第14回では、第13回の続きとして、TODO投稿アプリSPAの機能を拡張していきます。タスクの編集、削除といった機能の実装を通じて、WebAssemblyにおけるより複雑なDOMの操作について理解します。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
今回は、タスクの編集機能を実装していきます。タスクの編集機能としては、済みフラグのON/OFF、タイトルと期限の修正の他に、削除機能も実装します。タスクの編集機能は、タスク一覧のクリックで呼び出されるイベントハンドラとして実装します。それに先立ち、前回に掲載したアプリの全体像を図1に再掲します。
今回は、図1の(3)の部分を作成します。
まずは、編集フォームを図2のようなイメージで作成します。フォームはindex.htmlファイルに[+]ボタンの後に埋め込んでしまい、初期状態としては不可視(display: none)としておきます。マークアップについては割愛するので、配布サンプルを参照してください。
編集フォームには、イベントのバブリング(イベントが発生した要素から親要素、さらにその親要素という具合にイベントが上位要素に伝わること)をキャンセルするイベントハンドラを設定しておきます。編集フォームは、編集時にはタスク一覧のホルダー要素の子要素となるので、クリックなどのイベントがホルダー要素に伝播(でんぱ)するのを防ぐためです。前回に紹介したinitial関数のブロック末尾に記述します。ブロックで囲むのも同様です(リスト1)。
{ // (1)イベントデータを受け取る場合のクロージャの定義 let form_click_event = Closure::<dyn FnMut(_)>::new(move |e: MouseEvent| { // (2)イベントの伝播をキャンセル e.stop_propagation(); }); document.get_element_by_id("edit_form").unwrap() .dyn_ref::<HtmlElement>().unwrap() .set_onclick(Some(form_click_event.as_ref().unchecked_ref())); // clickイベントハンドラのセット form_click_event.forget(); // クロージャの管理をJavaScriptに委譲する }
注目すべきは、(1)のクロージャ定義です。ここでは、イベントデータを受け取るために、引数を1個受け取るFnMutトレイトによるクロージャ生成としています(前回のイベントハンドラでは受け取っていませんでした)。これにより、クロージャ定義の方でMouseEvent型などの引数を受け取ることができるようになります。なお、FnMutトレイトの引数の数と、クロージャの引数の数は一致する必要があります。
(2)のイベント伝播のキャンセルは、第10回でも紹介したstop_propagationメソッド(JavaScriptのstopPropagationメソッドに対応)を使います。その他の部分は、前回の知識で読みこなせるでしょう。
削除ボタン、更新実行ボタン、更新キャンセルボタンにもイベントハンドラが必要になりますが、それについては別途紹介します。
タスク一覧クリックのイベントハンドラは、タスク一覧全体、すなわちホルダー要素に対して設定します。リスト2は、イベントハンドラの全容です。
// (1)編集中のタスクIDを保持する共有変数 let current_id = Rc::new(RefCell::new(0)); …略… // (2)タスク一覧クリック時のイベントハンドラ { // 共有変数のクローン let document_clone = document.clone(); let storage = Rc::clone(&storage); let current_id = Rc::clone(¤t_id); // イベントハンドラ定義 let task_click_event = Closure::<dyn FnMut(_)>::new(move |e: MouseEvent| { // (3)クリックした要素から個別タスクの要素を検索 let mut target_element = e.target().unwrap() // クリックした要素 .dyn_ref::<HtmlElement>().unwrap() .clone(); let mut id = target_element.id(); // id属性の取得 while !id.starts_with("task") { // id属性が"task〜"を探す target_element = target_element.parent_element().unwrap() // 親要素を取得 .dyn_ref::<HtmlElement>().unwrap() .clone(); id = target_element.dyn_ref::<HtmlElement>().unwrap().id(); if id == "items".to_string() { // ホルダー要素にたどり着いたら中止 break; } } if id != "items".to_string() { // 必ず真となるが念のためのガード // (4)タスクIDからタスクデータを取得して編集フォームにセット // 最初の子要素の最初の子要素を取得 *current_id.borrow_mut() = target_element .first_element_child().unwrap() // 最初の子要素の取得 .first_element_child().unwrap() .dyn_ref::<HtmlInputElement>().unwrap() .value().parse().unwrap(); // value属性からタスクIDを取得 // データストレージから対応するタスクデータを取得 let item = storage.borrow().get(*current_id.borrow()).unwrap(); // 編集フォームの各input要素にセット document_clone.get_element_by_id("edit_completed").unwrap() .dyn_ref::<HtmlInputElement>().unwrap() .set_checked(item.completed); // チェックボックスへのセット document_clone.get_element_by_id("edit_title").unwrap() .dyn_ref::<HtmlInputElement>().unwrap() .set_value(item.title.as_str()); // テキストボックスへのセット document_clone.get_element_by_id("edit_period").unwrap() .dyn_ref::<HtmlInputElement>().unwrap() .set_value(item.period.as_str()); // テキストボックスへのセット // (5)編集フォームを移動して可視に、タスク表示を不可視に設定 let form_element = document_clone.get_element_by_id("edit_form").unwrap(); let items_element = document_clone.get_element_by_id("items").unwrap(); let _ = items_element.insert_before(&form_element, Some(&target_element)); // 第1引数の要素を第2引数の要素の前に移動 form_element .dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "block").unwrap(); // CSSプロパティのセット target_element .dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "none").unwrap(); // CSSプロパティのセット } }); document.get_element_by_id("items").unwrap() .dyn_ref::<HtmlElement>().unwrap() .set_onclick(Some(task_click_event.as_ref().unchecked_ref())); // clickイベントハンドラのセット task_click_event.forget(); // クロージャの管理をJavaScriptに委譲する }
かなり長いですが、基本的なコードは前回のリスト5と同じです。異なるポイントを見ていきます。
イベントハンドラはタスク一覧のホルダー要素に設定されているので、クリックした要素から個別のタスク要素を割り出す必要があります。それは、各要素の関係が以下のような階層をなしているからです。
タスク一覧のホルダー要素 > 個別のタスク要素(複数) > クリックした要素
タスク一覧のホルダー要素は余白もボーダーも持たないので、直接クリックイベントは発生しません。これを踏まえて、(3)からのコードを見てみます。
割り出しは、大まかに以下のようなステップとなります。図3も併せて見てください。
targetメソッド、parent_elementメソッドともにHtmlElement構造体のメソッドで、それぞれイベントを発生させた要素、親要素を取得します(JavaScriptのtargetメソッド、parentElementメソッドに対応)。idメソッドも同様で、個別のタスク要素を表す"task〜"であるか、タスク一覧のホルダー要素を表す"items"になるまで祖先をたどるプロセスとなります。
なお、targetメソッドによく似たcurrent_targetメソッド(JavaScriptのcurrentTargetメソッド)がありますが、これはイベントをキャッチした要素を返します。この場合は必ずタスク一覧のホルダー要素となって目的を果たさないので、両者の違いを理解して使い分けましょう。
個別のタスク要素の割り出しが成功したら、(4)のように対応するタスクデータを取得して編集フォームにセットします。タスクIDは、要素に"task〜"というid属性でも設定されていますが、タスクのレンダリング時に配下のhidden要素のvalue属性にタスクIDを埋め込んでいるので、こちらを利用します。これは、タスクIDを得る際の文字列解析をシンプルにするためです。
hidden要素の取得は、HTMLの構造に依存する形になっていますが、個別のタスク要素の最初の子要素、さらに最初の子要素と決め打ちして、first_element_childメソッドで取得しています。hidden要素のvalue属性からの取り出しとなるので、input要素を表すHtmlInputElement構造体にdyn_refメソッドで型変換していることに注意してください。valueメソッドの戻り値はString型なので、parseメソッドによって数値(この場合はi32型)に変換して最終的なタスクIDとしています。
タスクIDからは、データストレージから対応するタスクデータをgetメソッドで取得し、済みフラグ、タイトル、期限の各input要素にセットしています。ここで、済みフラグはチェックボックスであるのでset_checkedメソッドを、それ以外はテキストなのでset_valueメソッドを、それぞれ使用しています。
タスク表示のクリックによって、ホルダー外にあった編集フォームをタスク表示の直前に移動し、クリックされたタスク表示は不可視とします。これにより、ユーザーにはクリックによってタスク表示が編集フォームに変化したように見えます(図4)。
(5)からが、その処理になります。重要なのは、前回に存在だけ紹介したinsert_beforeメソッドです。このメソッドは、第1引数で指定される要素を、第2引数で指定される要素の前に挿入します。すでにDOMにある要素を第1引数に指定すると、移動になります。ここでは、編集フォームの要素を当該タスクの要素の前に移動しています。その後、編集フォームを可視に(display: block)、当該タスク表示を不可視に(display: none)にそれぞれstyleメソッドで指定することで、目的の処理が達成できるわけです。
編集フォームからは、タスクを編集、削除します。編集フォームに設置したボタンに、それぞれイベントハンドラを設定し、データストアの操作、タスク一覧への反映という処理の流れはこれまでと変わりません。そこで、ここではそれぞれの処理で特徴的な部分をピックアップして解説します。全容は、配布サンプルを参照してください。
編集フォーム右端にある緑色の[○]をクリックすると、フォームの内容によるタスクの更新となります。
{ …共有変数のクローンは略… let ok_button_event = Closure::<dyn FnMut(_)>::new(move |e: MouseEvent| { // (1)編集フォームを不可視に document_clone.get_element_by_id("edit_form").unwrap() .dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "none").unwrap(); // CSSプロパティのセット // (2)当該タスクを更新してレンダリング let mut item = storage.borrow().get(*current_id.borrow()).unwrap(); item.completed = document_clone.get_element_by_id("edit_completed").unwrap() .dyn_ref::<HtmlInputElement>().unwrap().clone().checked(); // チェックボックスの状態の取得 item.title = document_clone.get_element_by_id("edit_title").unwrap() .dyn_ref::<HtmlInputElement>().unwrap().clone().value(); // テキストボックスの内容の取得 item.period = document_clone.get_element_by_id("edit_period").unwrap() .dyn_ref::<HtmlInputElement>().unwrap().clone().value(); // テキストボックスの内容の取得 storage.borrow_mut().update(item.clone()); // データストアを更新 let item_rendered = template::render_item(&item); // レンダリング // (3)更新後のタスク表示を挿入し、更新前のタスク表示を削除 let item_element = document_clone.get_element_by_id(&format!("task{}", *current_id.borrow())).unwrap(); item_element .insert_adjacent_html("afterend", &item_rendered).unwrap(); // HTMLを当該タスク後に挿入 item_element.remove(); // 当該タスクをDOMから削除 e.stop_propagation(); // イベントバブリングのキャンセル }); …イベントハンドラの登録は同様なので略… }
タスク更新の流れは、
となっています。(2)はリスト2のタスク編集フォームの表示の逆の処理となっていて、編集フォームの入力要素から値を取り出し、当該タスクを更新後、レンダリングしています。入力要素からの取得になるので、
を使用している点が異なります。
(3)は、実質的には更新前のタスク表示を更新後のもので置き換える処理ですが、既存のHTMLをset_inner_htmlメソッド(JavaScriptのinnerHtmlフィールドへの書き込みに相当)などで直接書き換えるのではなく、更新後のタスクをレンダリングしたHTMLを挿入後、元のタスク表示を消去するという処理として簡略化しています。
(3)で使用しているremoveメソッドは、JavaScriptの同名メソッドに相当し、呼び出した要素を削除します。
編集フォーム左端にある赤い[×]をクリックすると、タスクの削除となります。
{ …共有変数のクローンは略… let delete_button_event = Closure::<dyn FnMut(_)>::new(move |e: MouseEvent| { // (1)編集フォームを不可視に document_clone.get_element_by_id("edit_form").unwrap() .dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "none").unwrap(); // CSSプロパティのセット // (2)当該タスクをデータストアから削除 storage.borrow_mut().remove(*current_id.borrow() as i32); // (3)当該タスクをホルダーから削除 document_clone.get_element_by_id(&format!("task{}", *current_id.borrow())).unwrap().remove(); // 当該タスクをDOMから削除 if storage.borrow().list.len() == 0 { …タスクなし表示を可視にする処理なので略… } e.stop_propagation(); // イベントバブリングのキャンセル }); …イベントハンドラの登録は同様なので略… }
タスク削除の流れは、
となっています。これ以上の説明は不要と思われますが、(3)について使用しているremoveメソッドは、JavaScriptの同名メソッドに相当し、呼び出した要素を削除します。
編集フォーム右端にある緑色の[×]をクリックすると、編集フォームのキャンセルとなります。編集フォームは閉じられ、当該タスク表示が復活します。
{ …共有変数のクローンは略… let cancel_button_event = Closure::<dyn FnMut(_)>::new(move |e: MouseEvent| { // (1)編集フォームを不可視に document_clone.get_element_by_id("edit_form").unwrap() .dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "none").unwrap(); // CSSプロパティのセット // (2)当該タスク表示を表示 document_clone.get_element_by_id(&format!("task{}", *current_id.borrow())).unwrap().dyn_ref::<HtmlElement>().unwrap() .style().set_property("display", "block").unwrap(); // CSSプロパティのセット e.stop_propagation(); // イベントバブリングのキャンセル }); …イベントハンドラの登録は同様なので略… }
編集フォームのキャンセルは、
となっています。いずれも、displayプロパティの書き換えだけなので、詳細は割愛します。
今回は、TODOアプリSPAへのタスクの編集、削除といった機能の実装を通じて、WebAssemblyにおけるより複雑なDOMの操作を紹介しました。これで、Actix Web、Dioxus、Rust and WebAssemblyによるWebアプリの開発はひとまず終了です。
次回は、これまでテキストファイルなどを使ってきたデータの恒久化を、データベースで行う方法を紹介します。
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.