Rust and WebAssemblyでSPAに更新削除機能を実装しようWebアプリ実装で学ぶ、現場で役立つRust入門(14)

第14回では、第13回の続きとして、TODO投稿アプリSPAの機能を拡張していきます。タスクの編集、削除といった機能の実装を通じて、WebAssemblyにおけるより複雑なDOMの操作について理解します。

» 2024年11月14日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Webアプリ実装で学ぶ、現場で役立つRust入門」のインデックス

連載:Webアプリ実装で学ぶ、現場で役立つRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


タスク編集機能の実装

 今回は、タスクの編集機能を実装していきます。タスクの編集機能としては、済みフラグのON/OFF、タイトルと期限の修正の他に、削除機能も実装します。タスクの編集機能は、タスク一覧のクリックで呼び出されるイベントハンドラとして実装します。それに先立ち、前回に掲載したアプリの全体像を図1に再掲します。

図1 アプリの全体像(再掲) 図1 アプリの全体像(再掲)

 今回は、図1の(3)の部分を作成します。

編集フォームを作成する

 まずは、編集フォームを図2のようなイメージで作成します。フォームはindex.htmlファイルに[+]ボタンの後に埋め込んでしまい、初期状態としては不可視(display: none)としておきます。マークアップについては割愛するので、配布サンプルを参照してください。

図2 編集フォーム 図2 編集フォーム

バブリングをキャンセルする

 編集フォームには、イベントのバブリング(イベントが発生した要素から親要素、さらにその親要素という具合にイベントが上位要素に伝わること)をキャンセルするイベントハンドラを設定しておきます。編集フォームは、編集時にはタスク一覧のホルダー要素の子要素となるので、クリックなどのイベントがホルダー要素に伝播(でんぱ)するのを防ぐためです。前回に紹介した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:src/lib.rs

 注目すべきは、(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(&current_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に委譲する
}
リスト2:src/lib.rs

 かなり長いですが、基本的なコードは前回のリスト5と同じです。異なるポイントを見ていきます。

目的のタスク要素を検索する

 イベントハンドラはタスク一覧のホルダー要素に設定されているので、クリックした要素から個別のタスク要素を割り出す必要があります。それは、各要素の関係が以下のような階層をなしているからです。

タスク一覧のホルダー要素 > 個別のタスク要素(複数) > クリックした要素

 タスク一覧のホルダー要素は余白もボーダーも持たないので、直接クリックイベントは発生しません。これを踏まえて、(3)からのコードを見てみます。

 割り出しは、大まかに以下のようなステップとなります。図3も併せて見てください。

  1. イベントデータからターゲット要素(主に図の[1])をtargetメソッドで取得する
  2. id属性が"task〜"であれば個別のタスク要素(図の[2])なので、割り出しを中止する
  3. それ以外ならparent_elementメソッドで親要素を取得する
  4. 親要素のidが"items"ならタスク一覧のホルダー要素(図の[3])なので割り出しを中止する
図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)。

図4 フォームの移動 図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();	// イベントバブリングのキャンセル
    });
    …イベントハンドラの登録は同様なので略…
}
リスト3:src/lib.rs

 タスク更新の流れは、

  • (1)編集フォームは用済みとして不可視にする
  • (2)編集フォームの内容で当該タスクをupdateメソッドで更新し、レンダリングする
  • (3)更新後のタスク表示を挿入し、更新前のタスク表示をremoveメソッドで削除する

となっています。(2)はリスト2のタスク編集フォームの表示の逆の処理となっていて、編集フォームの入力要素から値を取り出し、当該タスクを更新後、レンダリングしています。入力要素からの取得になるので、

  • チェックボックスからの取得はcheckedメソッド
  • テキストボックスからの取得はvalueメソッド

を使用している点が異なります。

 (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();	// イベントバブリングのキャンセル
    });
    …イベントハンドラの登録は同様なので略…
}
リスト4:src/lib.rs

 タスク削除の流れは、

  • (1)編集フォームは用済みとして不可視にする
  • (2)当該タスクをデータストアからremoveメソッドで削除する
  • (3)当該タスク表示をホルダーからremoveメソッドで削除する

となっています。これ以上の説明は不要と思われますが、(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();	// イベントバブリングのキャンセル
    });
    …イベントハンドラの登録は同様なので略…
}
リスト5:src/lib.rs

 編集フォームのキャンセルは、

  • (1)編集フォームを不可視にする(不可視にするので移動の必要はない)
  • (2)不可視となっていた当該タスク表示を復元する

となっています。いずれも、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.

スポンサーからのお知らせPR

注目のテーマ

4AI by @IT - AIを作り、動かし、守り、生かす
Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。