第10回では、第9回に引き続き、投稿アプリSPAの機能を拡張していきます。第10回では、新規投稿のコードを通じて、Dioxusの状態管理やイベントハンドラの理解を進めます。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
前回は、投稿一覧を表示する機能を実装しました。今回は、新規投稿機能を実装します。
その前に、投稿一覧の更新機能を実装します。SPA(Single Page Application)では、自身の操作による更新は随時ブラウザ画面に反映できますが、他のクライアントにおける操作は反映できません。そのため、プッシュ通知を実装したり、定期的に一覧を取得したりする必要があります。ここでは簡略版として、ユーザーの指示で一覧表示を再読み込みする機能を実装します。イベントハンドラの基本的な記述方法を解説します。
「再読み込み」ボタンはUI部品なので、Appコンポーネントにおいて投稿一覧をレンダリングする部分にリスト1のように追加します。この指定で、投稿一覧の上に右寄せでボタンが表示されるようになります。
- …略…
- filtered_posts.sort_unstable_by(|a, b| b.cmp(a));
- rsx! {
- div { (1)
- p { (2)
- class: "text-end mb-2",
- button { (3)
- class: "btn btn-primary",
- "再読み込み",
- }
- }
- }
- {
- filtered_posts.iter().map(|id|
- …略…
(1)~(3)が、div要素、p要素、button要素に相当するRSX構文です。続くclass:はHTML属性の指定であることは、第9回で紹介した通りです。
このボタンのクリックで、再読み込みを実行するイベントハンドラをリスト2のように追加します。場所は、buttonブロックの内部です。
- …略…
- button {
- class: "btn btn-primary",
- onclick: move |_| { (1)
- posts.write().clear(); (2)
- posts_source.restart(); (3)
- },
- "再読み込み",
- }
- …略…
初出としてonclick属性の指定があります(1)。ここには、クリック(タップ)イベントに対応するためのイベントハンドラをクロージャで記述します。クロージャでは、引数のイベントデータに加えて、クロージャ外の変数を使用することができます(キャプチャー)。今回の例では、postsとposts_sourceが該当しますが、クロージャ呼び出し時にこれらが「生きて」いる必要があるため、moveキーワードによって所有権を移動させる必要があります。moveキーワードが不要なのは、クロージャが環境をキャプチャーしないときのみです。
クリックイベントのクロージャには、引数を1個(Event<MouseData>構造体)与える必要があります。構造体名から分かるように、イベントデータを保持する構造体です。通常は、イベントの種類に応じたデータ(ここではマウス操作のMouseData)が入りますが、今回はクリックイベントであることが分かればいいので、引数は使用せずワイルドカード「_」としています。なお、MouseDataからは、クリック位置の座標(coodinates)、クリックしたボタン(trigger_button)などを取得できます。
クロージャの中身は、(2)の投稿データのフックのクリア、(3)の投稿一覧API呼び出しフックの再呼び出しとなっています。(2)については問題ないでしょう、posts.write()にてハッシュマップを更新用で取得し、clearメソッドで全消去します。全消去するのは、投稿一覧をAPIで取得した際に、投稿データを再構築するためです。(3)はUseResourceフックのrestartメソッドの呼び出しで、フックの関数(全件取得API《index》の呼び出し)を再度呼び出します。
これで、「再読み込み」ボタンで投稿一覧を更新する機能を実装できました。APIサーバ(配布サンプルのactix_posts)を起動し、本アプリもdx serveコマンドでビルド、実行して、図1のように表示されれば成功です。
ここでは、clickイベントに対応するonclickイベントハンドラを使用しましたが、Dioxusにおけるイベントとイベントハンドラの概要を参考として紹介します。
clickイベントは、数あるイベントの中の一つであり、JavaScriptと同様に多くのイベントに対して処理を実行できます。Dioxusでは、イベントレファレンスで定めるイベントの一部(onfocus、onkeydownなど)を指定できます。一覧は、dioxus_html::eventsで確認できます。
なお、イベントハンドラに関わる共通の話題として、「イベントの伝播(でんぱ)」「既定動作のキャンセル」「カスタムイベントとカスタムデータ」について補足しておきます。
(1)イベントの伝播
子要素で発生したイベントは、親要素に伝播します。これは、子要素でイベントが発生すれば、親要素にも同じイベントが発生するということです。例えば、親要素と子要素にクリックイベントのハンドラが設定されている場合、子要素のクリックで双方にイベントハンドラの呼び出しが発生します。このとき、親要素のイベントハンドラを呼び出したくない場合には、イベントの伝播をキャンセルするstop_propagationメソッドを、子要素のイベントハンドラで呼び出す必要があります。以下のリストは、親要素と子要素のクリックでそれぞれフックを書き換えるケースで、子要素クリックでの書き換えを親要素が上書きしてしまうのを防ぐ例です。
- let mut hook = use_signal(|| 0);
- …略…
- div { // 親要素
- onclick: move |_| *hook.write() = 0, // hookを0にセット
- button { // 子要素
- "ボタン",
- onclick: move |e| {
- *hook.write() = 1; // hookを1にセット
- // イベントの伝播をキャンセルする。これがないと親要素の
- // イベントハンドラも起動してhookが0に戻ってしまう
- e.stop_propagation();
- }
- }
- }
(2)既定の動作のキャンセル
また、prevent_default属性の値にイベント名("onclick"など)を指定して、イベントの既定の動作を禁止することもできます。以下のリストでは、アンカータグのクリックでリンク先に遷移しないようになります(イベントハンドラは呼び出される)。
- a {
- href: "https://dioxuslabs.com/",
- onclick: |_| tracing::info!("Clicked link"),
- prevent_default: "onclick",
- "Go to Dioxus"
- }
(3)カスタムイベントとカスタムデータ
カスタムイベントを定義し、カスタムデータ(イベントに独自に付加するデータ)を与えて呼び出す(発火させる)ことができます。カスタムイベントにより、例えばモーダルウィンドウの表示やログへの記録などを、関数呼び出しによらずに任意のタイミングで実施できます。カスタムイベントは、EventHandler<T>構造体のインスタンスとして作成します。型パラメーターTには任意の型を指定可能です。イベントの発火は、callメソッドです。以下のリストでは、ログ出力のイベントを作成し、子コンポーネントのonclickイベントハンドラ内でカスタムデータとともに発火しています。
- // カスタムデータ構造体
- struct CustomData(i32);
- // ログ出力のカスタムイベントloggingを作成して子コンポーネントに渡す
- Child { logging: move |e: CustomData| tracing::info!("From child {0}", e.0) }
- // 子コンポーネントのイベント処理でカスタムイベントloggingを発火する
- #[component]
- fn Child(logging: EventHandler<CustomData>) -> Element {
- rsx! {
- button {
- onclick: move |_| {
- let custom_data = CustomData(100); // カスタムデータ
- logging.call(custom_data); // イベントの発火
- },
- }
- }
- }
このようにDioxusにおいては、イベントとイベントハンドラをJavaScriptとほぼ同様に取り扱えます。
今回の配布サンプルにおけるAPIサーバには、CORS(Cross-Origin Resource Sharing)対応の処理を付加しています。具体的には、HTTP OPTIONメソッドへの応答処理です。このメソッドへは、通常のハンドラー関数では応答できません。HttpServerインスタンスの生成クロージャに、actix_cors::Corsインスタンスを生成してwrapメソッドでアプリケーションに追加する処理を、リスト3のように追記しています。
- HttpServer::new(move || {
- let tera = Tera::new("templates/**/*.html").unwrap();
- let cors = Cors::default()
- .allowed_origin_fn(|origin, _req_head| {
- true
- })
- .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
- .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
- .allowed_header(header::CONTENT_TYPE)
- .supports_credentials()
- .max_age(3600);
- App::new()
- …略…
- .wrap(cors)
- })
続けて、新規投稿機能を実装します。新規投稿機能は、「新規投稿」ボタンのクリックで入力フォームが現れ、投稿送信後に閉じるという流れになります。大まかに以下のコードを実装していきます。
これまでに実装したAPI呼び出し関数は一覧表示(index)のみでしたので、新規投稿(create)のAPI呼び出し関数をリスト4のように作成します。
- pub async fn call_create(message: &Message) -> Result<ApiResponse, reqwest::Error> { (1)
- let url = format!("{}/create", BASE_API_URL); (2)
- let client = reqwest::Client::new(); (3)
- client.post(&url) (4)
- .json(message)
- .send()
- .await?.json::<ApiResponse>().await
- }
新規投稿はHTTP POSTによって送信するため、call_index関数と異なる書き方になっています。
(1)のように、call_create関数は引数にMessage構造体の参照を受け取って、API呼び出し結果をResult構造体で返すという仕様になっています。Result構造体の型パラメーターであるApiResponseについては第6回を参照してください。
(2)はエンドポイントURLの生成です。APIの仕様の通り、BASE_API_URLに対して/createを付加したものとなります。
(3)では、reqwestクレートのClientオブジェクトを生成しています。Clientオブジェクトで、HTTPクライアントとしてのさまざまな処理を実行します。
(4)以降は、postメソッドによるリクエストの発行、JSONデータの送信、処理待ち、レスポンスであるJSONデータの受け取りとなります。
「新規投稿」ボタンのクリックで入力フォームを開き、入力中はフォームを開いたままにするために、作業モードを保持するフックをAppコンポーネントに作成します。モードは、この時点では「投稿表示のみ」(Mode::None)と「投稿作成中」(Mode::New)とし、列挙体(enum)で定義します。列挙体とするのは、後ほど更新機能のためのモードも実装するためです。
- #[derive(PartialEq)]
- enum Mode {
- None, // 投稿一覧を表示するだけのモード
- New, // 新規投稿のフォームを表示するモード
- }
- #[component]
- fn App() -> Element {
- …略…
- let mut mode = use_signal(|| Mode::None); (1)
(1)のように、モードの保持にはuse_signal関数を使います。
サーバサイドでページをレンダリングするアプリと異なり、SPAではクライアントサイドでページを随時更新しています。後述のフォーム入力内容を保持するフックも該当しますが、レンダリングの前後でコンポーネントの状態を保持したい場合には、必ずフックを使います。SPAでは再レンダリング時にページの内容を復元できる必要があるからです。フックを使わない場合、再レンダリングによってページ内容が初期状態に戻ってしまいます。
「新規投稿」ボタンをリスト6のように設置します。場所は、「再読み込み」ボタンの左側とします。
- p {
- class: "text-end mb-2",
- button {
- class: "btn btn-primary me-2",
- onclick: move |_| { (1)
- *mode.write() = Mode::New; (2)
- },
- "新規投稿",
- }
- …「再読み込み」ボタンの記述なので省略…
- }
(1)は、ボタンクリックのイベントハンドラです。内容は(2)のみで、作業モードをMode::New(新規投稿状態)に切り替えるだけです。この設定は、後述のレンダリング時に参照されます。
投稿フォームにおけるデータ保持のために、投稿者と投稿内容を保持するフックを作成します。それぞれ、sender、contentとしてuse_signal関数で作成します。
- let mut sender = use_signal(String::new);
- let mut content = use_signal(String::new);
投稿フォームは、投稿一覧の上にリスト8のように追加します。投稿フォームは、投稿者、投稿内容、投稿ボタンからなります。
- …略…
- rsx! {
- …ボタンの設置部分なので省略…
- if *mode.read() == Mode::New { (1)
- div {
- class: "mb-4",
- input { (2)
- class: "d-block mb-2",
- placeholder: "お名前をどうぞ",
- value: "{sender.read()}", (3)
- oninput: move |e| sender.set(e.value().clone()), (4)
- autofocus: "true",
- }
- textarea { (5)
- class: "d-block w-100 mb-2",
- placeholder: "メッセージをどうぞ",
- value: "{content.read()}", (6)
- oninput: move |e| content.set(e.value().clone()), (6)
- }
- button { (8)
- r#type: "button", (9)
- class: "btn btn-primary",
- onclick: move |_| { (10)
- …投稿ボタンがクリックされたときに実行する処理…
- },
- "投稿",
- }
- }
- }
(1)では、前述の作業モード(modeフック)を参照して、新規投稿状態(Mode::New)であるときのみレンダリングを実施しています。
(2)(5)(8)が、それぞれ投稿者(input要素)、投稿内容(textarea要素)、投稿ボタン(button要素)です。
(3)と(6)のvalue属性の指定は、それぞれ投稿者と投稿内容の初期値となります。値はフックであるsenderとcontentを展開したものです。レンダリングされても内容が保持されるように、フックに値を保持しているのは前述の通りです。
(4)と(7)は、要素のoninputイベントに対する反応です。何か入力されたら、その内容(Event<FormData>)をフックにセットしています。前述のイベントハンドラと異なり、引数eを受け取って、そこから値をvalueメソッドで取り出してフックにセットしています。
(9)はinput要素に対するtype属性の指定ですが、第9回で紹介したようにRustの予約語に相当する場合は、r#を前置すれば使うことができます。
(10)は、ボタンクリック時の処理です。具体的な内容は、次で説明します。
必要であればここでアプリをdx serveコマンドでビルド、実行して、期待する画面となるかどうか確認してもよいでしょう。
上記(10)の処理内容はリスト9の通りです。
- …略…
- onclick: move |_| {
- if !sender.read().is_empty() && !content.read().is_empty() { (1)
- spawn( async move { (2)
- let message = Message { (3)
- id: 0,
- posted: "".to_string(),
- sender: sender.read().clone(),
- content: content.read().clone(),
- };
- let res = data::call_create(&message).await.unwrap(); (4)
- match &res.result { (5)
- ResponseContent::Item(item) => {
- posts.write().insert(
- item.id,
- item.clone()
- );
- },
- _ => {}
- };
- content.set("".to_string()); (6)
- });
- *mode.write() = Mode::None; (7)
- }
- },
- …略…
少々長いですが、アプリからのデータ更新方法を含む内容となっています。
(1)は、投稿者と投稿内容が、いずれも空でないときのみ処理するガードです。
(2)は、Dioxusのspawnメソッドの呼び出しです。spawnメソッドは、非同期でブロック(クロージャではない)を実行できるメソッドです。処理による戻り値が必要ない、イベントハンドラの処理のようなケースに便利に使うことができます。
(3)は、投稿するMessage構造体を生成しています。idフィールドとpostedフィールドはAPI側で自動的に割り当てるので、senderフィールドとcontentフィールドのみを設定しています。
(4)は、APIを呼び出すcall_create関数にMessage構造体を渡して新規投稿処理を実行させています。(5)で、正常に処理された、すなわち戻り値がResponseContent::Item型であれば、その内容を用いて投稿一覧フックpostsにinsertメソッドで追加します。これによって再レンダリングが発生し、新規投稿が画面に反映されます。
(6)は後処理であり、投稿内容を消去して次の投稿に備えます。投稿者が変わることは少ないと思われるので、そのまま内容を維持します。
全ての処理が終われば、(7)で作業モードをリセット(Mode::None)しています。これで再レンダリングによって入力フォームが閉じられます。
これで、新規投稿機能を実装できました。APIサーバを起動、本アプリもdx serveコマンドでビルド、実行し、図2のように入力フォームの表示と投稿内容が反映されれば成功です。
今回は、新規投稿のコードを通じて、Dioxusの状態管理やイベントハンドラの理解を深めました。
次回は、引き続き投稿アプリへの編集、削除機能の実装を通じて、DioxusによるSPA開発の、より実践的な手順を学びます。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・X: @WingsPro_info(https://x.com/WingsPro_info)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.
Coding Edge 髫ェ蛟�スコ荵斟帷ケ晢スウ郢ァ�ュ郢晢スウ郢ァ�ー