Rust and WebAssemblyでSPAに一覧表示と追加機能を実装しようWebアプリ実装で学ぶ、現場で役立つRust入門(13)

第13回では、第12回の続きとして、TODO投稿アプリSPAの機能を拡張していきます。第13回では、タスク表示、追加といった機能の実装を通じて、WebAssemblyにおけるDOMの操作やイベントハンドラの記法、Web Storageの利用方法について理解します。

» 2024年10月17日 05時00分 公開

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

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

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

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


実装前の準備

 各種機能の実装に備えて、幾つか準備をしておきます。

既定のコードを無効化する

 前回までの作業では、既定の関数greetを呼び出すようになっているので、これをリスト1のように無効にします。なお、替わりに呼び出す関数の記述は不要です。次節で触れるように、モジュール読み込み時に自動で呼び出す関数の指定ができるためです。

import * as wasm from "todo-app";
//wasm.greet();				無効にする
リスト1:site/index.js

 必須ではありませんが、不要になった関数定義(alert関数、greet関数)もコメントアウトして無効にしましょう。Rustでは、C言語のような/* 〜 */形式の複数行コメントも利用可能なので活用してみてください(配布サンプルを参照)。

クレートとfeatureを追加する

 以降の作業で必要になるクレートとfeatureを追加しておきます。以下のコマンドで、chrono、js-sysクレートを追加してください。それぞれ、カレンダー機能、JavaScriptライブラリへのアクセス機能に相当します。

% cargo add chrono js-sys

 [dependencies.web-sys]セクションのfeatures項目に、幾つかのfeaturesをリスト2のように追加してください。内容は、コメントを参照してください。

[dependencies.web-sys]
features = [
  …略…
  'CssStyleDeclaration',		# CSSの読み出しや設定
  'HtmlDivElement',			# div要素の使用
  'HtmlInputElement',			# input要素の使用
  'MouseEvent',				# MouseEventの使用
]
リスト2:Cargo.toml

タスク一覧機能の実装

 ここから、TODO投稿アプリの中核となる機能を実装していきます。アプリの全体像を図1に示します。今回は、この図の(1)(2)(4)の部分を実装していきます。

図1 アプリの全体像 図1 アプリの全体像

 まずは、図の(1)に対応する、タスクの一覧表示機能を実装します。一覧表示機能は、リスト3のように新しい関数initialとして記述します。この関数は、まずはタスク一覧を表示しますが、後ほどイベントハンドラなどを実装していく、アプリの編集機能の基点となります。

// (1)JavaScriptから呼び出す関数の定義
#[wasm_bindgen(start)]	// start引数でモジュール読み込み時に自動的に実行
pub fn initial() {
    // データストアを作成して成功時のみ実行
    if let Some(storage) = TaskStore::new("rust_todo_app") {
        // (2)共有変数を定義する
        let window = web_sys::window().unwrap();	// Windowの取得
        let document = Rc::new(window.document().unwrap());	// Documentの取得
        let mut maximum_id = 0;		// 最大IDの初期化
        let storage = Rc::new(RefCell::new(storage));	// データストアの取得
        // (3)タスク一覧を作成する
        let item_element = document.get_element_by_id("items").unwrap();
        for item in storage.borrow().list.iter() {
            // タスクをレンダリングしてホルダー要素に挿入する
            let item_rendered = template::render_item(&item);	// レンダリング
            let _ = item_element
                .insert_adjacent_html("afterbegin", &item_rendered);	// 挿入
            if item.id > maximum_id {		// 最大IDを更新する
                maximum_id = item.id;
            }
        }
        // (4)タスクが存在する場合にタスクなし表示を不可視化
        if maximum_id > 0 {	// タスクが存在したときのみ
            let _ = document.get_element_by_id("no_task").unwrap()
                .dyn_ref::<HtmlElement>().unwrap()
                .style().set_property("display", "none");	// display: none
        }
    }
}
リスト3:src/lib.rs

wasm_bindgen注釈でJavaScriptから呼び出す関数を定義する

 (1)では、Rust側で定義した関数をJavaScriptから呼び出せるように、wasm-bindgen-futureクレートのwasm_bindgenマクロで注釈します。既定のコードであるgreet関数でも同様に注釈されていますから、JavaScriptから呼び出したい関数にはwasm_bindgen注釈を付与すると覚えておきましょう。

 wasm_bindgen注釈には幾つかの引数を指定可能です。このうちstartを指定すると、(1)のようにモジュール読み込み時に自動的に実行される関数にできます。この指定により、index.jsファイルでは関数呼び出しのコードが不要だったわけです。

 なお、非同期関数を呼び出すなど、関数initialを非同期関数(async付き関数)としたいときには、RustのFurureトレイトをJavaScriptのPromiseオブジェクトに変換する、wasm-bindgen-futureクレートのwasm_bindgenマクロを使います。このためには、Cargo.tomlファイルの[dependencies]セクションにwasm-bindgen-futureを追加します(本サンプルでは使用していません)。

#[wasm_bindgen(start)]		// このままでOK(要wasm-bindgen-futureクレート)
pub async fn initial() {	// asyncの付いた非同期関数
…略…

web-sysクレートでDOMにアクセスする

 RustからDOM(Document Object Model)などのHTML側のリソースにアクセスするときに使うのが、web-sysクレートの構造体とメソッドです。web-sysクレートには、非常にたくさんの構造体とメソッドがあるので、その全容はこちらを参照していただくとして、ここではリスト3で使用したもの+αを紹介します。

 (2)で使われているwindowメソッドは、WindowオブジェクトをOption<Window>型で返します。Windowオブジェクトのdocumentメソッドは、DocumentオブジェクトをOption<Document>型で返します。Window型、Document型ともに、web-sysで定義されている構造体です。DOMの参照や操作には、Windowオブジェクトの取得、そこからのDocumentオブジェクトの取得といった定型の処理が基本になります。JavaScriptではWindowオブジェクトは暗黙のオブジェクトとして取得を省略できますが、Rustではこの手順をしっかり踏む必要があります。

 (3)で使われているget_element_by_idメソッドは、ElementオブジェクトをOption<Element>型で返します。このように、web-sysクレートのメソッドはJavaScriptのメソッド名をスネークケースに変換したものとなっています(この場合はgetElementByIdメソッド)。引数で指定するid属性の要素を取得しますが、戻り値がElement型であるのがポイントです。JavaScriptでは、getElementByIdメソッドの戻り値で実際の要素の型に合わせて柔軟にメソッドとプロパティを駆使できますが、型に厳格なRustではそうもいきません。このため、(4)ではdyn_refメソッド(下記NOTE参照)で明示的にHtmlElement型へキャストし、styleメソッドを利用しています。これは、Element型はstyleメソッドを持たないからです。

【補足】dyn_refメソッドとdyn_intoメソッド

 これらもweb-sysクレートのメソッドで、それぞれ型を明示した参照の取得、型を明示した所有権の取得が役割です。上記の例では、実体がHtmlElement型であるにもかかわらずElement型として取得した値に対して、プログラマーの責任でHtmlElement型として参照を取得しています。実体が異なるなど不整合があれば、実行時にpanicとなります。型に厳格なRustでDOMを扱うために必須のメソッドと言えます。

Rcスマートポインタでイベントハンドラと変数を共有する

 (2)では、DocumentオブジェクトとTaskStoreオブジェクトをRc構造体(下記NOTE参照)でラップしています。後ほど紹介するように、イベントハンドラはクロージャとして定義しますが、クロージャには所有権が移動するので、イベントハンドラ間で共有したい変数は複製した方の所有権が移動するようにするのです。

【補足】Rc構造体とRefCell構造体

 Rc構造体はスマートポインタの一つで、参照カウンタを使った複製をサポートします。所有権を移動させたくない、ディープコピーもできないというケースで有用です。今回のサンプルでは、イベントハンドラでもアクセスできるようにするために使用しています。

 RefCell構造体は、参照の制約チェックを実行時に行うための参照ラッパーです。通常、参照の制約はコンパイル時にチェックされますが、プログラマーの責任でこの制約を回避して実行時に委ねるためにRefCell構造体を使います。実行時のチェックで制約違反となればpanicとなるので、好き放題に可変の参照を使えるというわけではありません。

リストを作成してDOMへ挿入する

 (3)以降は、データストアからタスクを読み込み、リストとしてDOMに反映しています。処理としては、リストのホルダーであるdiv要素の取得、各タスクについてHTML文字列にレンダリングし、div要素に挿入するというものです。

 div要素への挿入には、ここではinsert_adjacent_htmlメソッドを使っています(JavaScriptのinsertAdjacentHtmlメソッドに相当)。このメソッドは、HTML文字列をHTMLノードに変換して挿入するもので、引数の"afterbegin"は最初の子要素の前を意味します。つまり、タスクは順番に上に挿入されていくことになります。挿入場所を指定する文字列には、"afterbegin"を含めて表1に挙げるものも利用可能です。

文字列 挿入場所
"beforebegin" 要素の前。親要素がある場合にのみ有効
"afterbegin" 要素の最初の子要素の前
"beforeend" 要素の最後の子要素の後
"afterend" 要素の後。親要素がある場合にのみ有効
表1:insert_adjacent_htmlメソッドの引数

 この他、DOMに要素を挿入するメソッドには、表2に挙げるものがあります。JavaScriptのメソッドに対して細かく分かれているのは、Rustでは引数の型と個数が明確に決まっている必要があるためです。

JavaScriptのメソッド Rust(web-sys)のメソッド
afterメソッド 要素の後に挿入。ノード配列を挿入するafter_with_nodeメソッド、ノードの個数に応じた挿入のafter_with_node_n(n:0〜7)メソッド、文字列の配列を挿入するafter_with_strメソッド、文字列の個数に応じた挿入のafter_with_str_n(n:0〜7)メソッドがある
beforeメソッド 要素の前に挿入。メソッドの種類はafterメソッドと同様(before_with_nodeメソッドなど)
appendメソッド 子要素の最後に挿入。メソッドの種類はafterメソッドと同様(append_with_nodeメソッドなど)
prependメソッド 子要素の最初に挿入。メソッドの種類はafterメソッドと同様(prepend_with_nodeメソッドなど)
appendChildメソッド 子要素の最後に追加(append_childメソッド)
insertBeforeメソッド 指定する子要素の前に挿入(insert_beforeメソッド)
表2:DOMに要素を挿入するメソッド

 ここで、前回の手順でアプリをビルド、起動してください。ブラウザからアクセスして図1(1)のように表示されれば、ここまでの手順は成功です。

データストアへのメソッドの実装

 ここからの更新処理に備えて、データストアにデータ挿入などのメソッドを実装します。実装に伴い、newメソッド内のダミーデータの登録コードはコメントアウトなどで無効にします。また、初期データの保存のsync_dataメソッドと読み込みのfetch_dataメソッドも実装します。リスト4は、このうちnewメソッド、sync_dataメソッド、fetch_dataメソッドの内容です。その他のメソッドとしては、以下に挙げるものを実装しました。具体的なコードは配布サンプルを参照してください。

  • タスク検索:get(&self, id: i32) -> Option<Task>
  • タスク挿入:insert(&mut self, item: Task)
  • タスク更新:update(&mut self, item: Task)
  • タスク削除:remove(&mut self, id: i32)
// (1)TaskStoreの生成と初期化
pub fn new(name: &str) -> Option<TaskStore> {
    let window = web_sys::window()?;
    if let Ok(Some(local_storage)) = window.local_storage() {
        let mut store = TaskStore {
            local_storage,
            name: String::from(name),
            list: Vec::new(),
        };
        store.fetch_data();	(2)
        /*					(3)
        let dummy = Task {id: 1, title: "生活費を口座に入金する".to_string(), 
            period: "2024-09-20".to_string(), completed: false};
        …略…
         */
        Some(store)
    } else {
        None
    }
}
// (4)データ保存
fn sync_data(&mut self) {
    let array = Array::new();	// 保存用Arrayの初期化
    for item in self.list.iter() {	// ベクターの全要素に対して処理
        let child = Array::new();	// 各要素のArrayを初期化
        child.push(&JsValue::from(item.id));		// idを追加
        child.push(&JsValue::from(&item.title));	// titleを追加
        child.push(&JsValue::from(&item.period));	// periodを追加
        child.push(&JsValue::from(item.completed));	// completedを追加
        array.push(&JsValue::from(child));
    }
    // JSON文字列化に成功したときのみLocal Storageに保存
    if let Ok(storage_string) = JSON::stringify(&JsValue::from(array)) {
        let storage_string: String = storage_string.into();
        self.local_storage.set_item(&self.name, &storage_string).unwrap();
    }
}
// (5)初期データ読み込み
fn fetch_data(&mut self) -> Option<()> {
    let mut item_list = Vec::<Task>::new();	// リスト(ベクター)の初期化
    // Local Storageからのデータ取得に成功した場合のみ
    if let Ok(Some(value)) = self.local_storage.get_item(&self.name) {
        let data = JSON::parse(&value).ok()?;	// JSON文字列をオブジェクト化
        let iter = js_sys::try_iter(&data).ok()??;	// イテレータの生成
        for item in iter {	// 全要素を取得
            let item = item.ok()?;
            let item_array: &Array = JsCast::dyn_ref(&item)?;	// Arrayを取得
            let id = item_array.shift().as_f64()?;	// idを取得
            let id = id as i32;
            let title = item_array.shift().as_string()?;	// titleを取得
            let period = item_array.shift().as_string()?;	// periodを取得
            let completed = item_array.shift().as_bool()?;	// completedを取得
            item_list.push(Task {id, title, period, completed,});	// リストに追加
        }
    }
    self.list = item_list;	// リストを格納
    Some(())
}
リスト4:src/store.rs

 (1)のnewメソッドの内容については前回で解説済みです。異なるのは(2)で初期データを読み出している点、(3)以降のダミーデータの挿入の無効化です。

js-sysクレートでリストをJSON文字列に変換する

 (4)からは、現在の内容をLocal Storageに保存するメソッドsync_dataの定義です。sync_dataの内容は、RustのベクターをJSON文字列に変換して、Local Storageに保存します。保存用のArrayオブジェクトを作成、listフィールドの内容を1要素ずつ別のArrayオブジェクトとして、追加しています。出来上がったArrayオブジェクトはJSON::stringifyメソッドで文字列化し、set_itemメソッドでLocal Storageに保存して終了です。

 RustでJSONを扱う場合、これまではSerdeクレートを使ってきましたが、ここではjs-sysクレートから機能JSON::parseメソッドを使ってデシリアライズしています。Arrayも同様で、Rustの機能ではなくJavaScriptの機能を使うことで、コンパイル後のバイナリの肥大を防ぐことができます。web-sysほどではありませんが、js-sysもたくさんの構造体とメソッドを備えるので、全容はこちらを参照してください。

 これから分かるように、保存されるJSON文字列はキーのない配列となっているので、読み込み側もそれを踏まえた構成とする必要があります。

js-sysクレートでJSON文字列からリストを作成する

 (5)からは、初期データをLocal Storageから読み出すメソッドfetch_dataの定義です。このメソッドの戻り値はOption型であり、エラーが発生したときにはNoneが返るようにしています。この関数の内容としてはLocal Storageから読み出したJSON文字列を、Rustのベクターに変換することです。

 Local Storageからの読み出しは、get_itemメソッドです。JSONオブジェクトに対しては、try_iterメソッドでイテレータを取得、各要素をArrayオブジェクトに変換、その値を順番にshiftメソッドで取り出して、Task構造体を生成してベクターに追加するという流れです。これは、JSONオブジェクトがキーを持たないためです。

 なお、Arrayから値を取り出すとき、as_f64メソッド、as_stringメソッド、as_boolメソッドを使用していますが、それぞれJavaScriptのNumber、String、Boolean型に相当します。as_f64メソッドで取得した値はf64型なので、必要に応じてi32型などにキャストします。

タスク追加機能の実装

 データストアができたので、タスクの追加機能を実装していきます。具体的には、[+]ボタンのクリックで呼び出されるイベントハンドラの実装になります。追加機能の内容としては、仮のタスク(タイトルは"新規タスク"、期限は追加時の1週間後)を登録するのみとして、タスク内容の書き換えは更新機能に委ねることでシンプルにします。

 タスク追加のイベントハンドラは、リスト3の(2)のif式のブロックに追記していきます。イベントハンドラの実装の基本は、

  1. 定義と登録を独立したブロックとする
  2. イベントハンドラはClosure型のクロージャとして定義する
  3. クロージャを要素にイベントハンドラとして登録する

の3つです。リスト5は、イベントハンドラの全容です。

// (1)全体をブロック化
{
    // 共有変数のクローン(ブロック内でのみ有効)
    let document_clone = document.clone();
    let storage = storage.clone();
    // (2)クロージャの定義
    let new_button_event = Closure::<dyn FnMut()>::new(move || {
        // (3)既定のタスクの作成
        maximum_id += 1;	// 最大IDの更新
        let now = Local::now();		// 現在日時の取得
        let period = now + Duration::days(7);	// 7日後の日付の作成
        let item = store::Task { id: maximum_id, title: "新規タスク".into(), 
            period: period.format("%Y-%m-%d").to_string(), completed: false };
        storage.borrow_mut().insert(item.clone());	// タスクをストアに追加
        // (4)タスクをレンダリングしてホルダー要素に挿入
        let item_rendered = template::render_item(&item);	// レンダリング
        let _ = document_clone.get_element_by_id("items").unwrap()
            .insert_adjacent_html("afterbegin", &item_rendered);	// 追加
        // (5)タスクなし表示の不可視化
        let _ = document_clone.get_element_by_id("no_task").unwrap()
            .dyn_ref::<HtmlElement>().unwrap()
            .style().set_property("display", "none");	// display: none
    });
    // (6)イベントハンドラの登録
    document.get_element_by_id("new_button").unwrap()
        .dyn_ref::<HtmlElement>().unwrap()
        .set_onclick(Some(new_button_event.as_ref().unchecked_ref()));
    new_button_event.forget();	// 所有権の放棄
}
リスト5:src/lib.rs

ブロックを作り共有変数をクローンする

 (1)のように、イベントハンドラとその登録部分は、独立したブロックとしています。ブロックとするのは、共有変数のクローンの有効範囲を明確にするためです。クロージャにはmoveキーワードによって所有権が移動するので、他のイベントハンドラと共有したい変数はcloneメソッドで複製し、移動しても問題ないようにします。このために、リスト3では変数をRc構造体でラップしたわけです。

イベントハンドラのクロージャを定義する

 (2)からは、イベントハンドラであるクロージャの定義です。クロージャは、wasm-bindgenクレートのClosure型のオブジェクトとして定義します。ここでクロージャ本体にはmoveが付いているように、クローンした共有変数の所有権はクロージャ内に移動します。(3)以降で既定のタスクの作成とストアへの挿入、(4)でタスクのレンダリングとホルダー要素への挿入、(5)でタスクなし表示の不可視化を、それぞれ実施しています。ホルダー要素への挿入は、リスト3の(3)と同じくinsert_adjacent_htmlメソッドを使っています。タスクなし表示の不可視化は、リスト3の(4)と同じです。

イベントハンドラを要素へ登録する

 (5)からは、定義したイベントハンドラを、ボタンに登録しています。ここで使っているget_element_by_idメソッド、dyn_refメソッドについてはすでに紹介した通りですが、新しくset_onclickメソッドを使っています。このメソッドはclickイベントに対応するセッターで、web-sysクレートにはほとんどのイベントに対してon_xxxxメソッドが用意されています。代表的なものを表3に挙げます(カッコ内はゲッターに相当するメソッド)。

メソッド 概要
set_onclick(onclick) マウスボタンがクリックされた
set_onchange(onchange) 入力フィールドの値が変更された
set_onfocus(onfocus)、set_onblur(onblur) フィールドがフォーカスされた、フォーカスが外れた
set_onmouseover(onmouseover)、set_onmousemove(onmousemove)、set_onmouseout(onmouseout) マウスポインタが乗った、移動した、外れた
set_onkeydown(onkeydown)、set_onkeyup(onkeyup) キーが押された、離された
表3:イベントハンドラの主なセッターメソッドとゲッターメソッド

 引数はイベントハンドラへの参照で、Option<&Function>型の値を受け取ることになっています。この場合はSome(…)なので有効なハンドラの設定になります。Noneを与えるとイベントハンドラを無効にできます。なお、クロージャの参照を受け取ることになっているので、as_refメソッドで参照を取得しています。as_refメソッドはRustで共通して使えるメソッドですが、ここのas_refメソッドはClosure型に合わせてJsValue型の参照を返すことになっています。

 最後に、ここが重要なのですが、forgetメソッドによりClosureオブジェクトの所有権を放棄しています。これはinto_js_valueメソッドの別名で、クロージャの管理をJavaScriptに委ねて、スコープから抜けてもイベントハンドラが消失しないようにするための処置です。通常であれば、作成したClosureオブジェクトはスコープ終了とともに消失してしまうので、イベント発火時にエラーとなってしまうためです。

 ここで、アプリをビルド、起動してください。ブラウザからアクセスして図1(2)のように表示されれば、タスク追加は成功です。

まとめ

 今回は、TODO投稿アプリSPAへの一覧表示、追加機能の実装を通じて、DOMの操作やイベントハンドラの記法、Web Storageの利用方法について紹介しました。

 次回は、このアプリに更新削除機能を実装していく過程で、より複雑なDOMの操作方法を紹介していきます。

筆者紹介

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

注目のテーマ

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

RSSについて

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

メールマガジン登録

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