第13回では、第12回の続きとして、TODO投稿アプリSPAの機能を拡張していきます。第13回では、タスク表示、追加といった機能の実装を通じて、WebAssemblyにおけるDOMの操作やイベントハンドラの記法、Web Storageの利用方法について理解します。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
各種機能の実装に備えて、幾つか準備をしておきます。
前回までの作業では、既定の関数greetを呼び出すようになっているので、これをリスト1のように無効にします。なお、替わりに呼び出す関数の記述は不要です。次節で触れるように、モジュール読み込み時に自動で呼び出す関数の指定ができるためです。
import * as wasm from "todo-app"; //wasm.greet(); 無効にする
必須ではありませんが、不要になった関数定義(alert関数、greet関数)もコメントアウトして無効にしましょう。Rustでは、C言語のような/* 〜 */形式の複数行コメントも利用可能なので活用してみてください(配布サンプルを参照)。
以降の作業で必要になるクレートと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の使用 ]
ここから、TODO投稿アプリの中核となる機能を実装していきます。アプリの全体像を図1に示します。今回は、この図の(1)(2)(4)の部分を実装していきます。
まずは、図の(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 } } }
(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の付いた非同期関数 …略…
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メソッドを持たないからです。
これらもweb-sysクレートのメソッドで、それぞれ型を明示した参照の取得、型を明示した所有権の取得が役割です。上記の例では、実体がHtmlElement型であるにもかかわらずElement型として取得した値に対して、プログラマーの責任でHtmlElement型として参照を取得しています。実体が異なるなど不整合があれば、実行時にpanicとなります。型に厳格なRustでDOMを扱うために必須のメソッドと言えます。
(2)では、DocumentオブジェクトとTaskStoreオブジェクトをRc構造体(下記NOTE参照)でラップしています。後ほど紹介するように、イベントハンドラはクロージャとして定義しますが、クロージャには所有権が移動するので、イベントハンドラ間で共有したい変数は複製した方の所有権が移動するようにするのです。
Rc構造体はスマートポインタの一つで、参照カウンタを使った複製をサポートします。所有権を移動させたくない、ディープコピーもできないというケースで有用です。今回のサンプルでは、イベントハンドラでもアクセスできるようにするために使用しています。
RefCell構造体は、参照の制約チェックを実行時に行うための参照ラッパーです。通常、参照の制約はコンパイル時にチェックされますが、プログラマーの責任でこの制約を回避して実行時に委ねるためにRefCell構造体を使います。実行時のチェックで制約違反となればpanicとなるので、好き放題に可変の参照を使えるというわけではありません。
(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メソッドの内容です。その他のメソッドとしては、以下に挙げるものを実装しました。具体的なコードは配布サンプルを参照してください。
// (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(()) }
(1)のnewメソッドの内容については前回で解説済みです。異なるのは(2)で初期データを読み出している点、(3)以降のダミーデータの挿入の無効化です。
(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文字列はキーのない配列となっているので、読み込み側もそれを踏まえた構成とする必要があります。
(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式のブロックに追記していきます。イベントハンドラの実装の基本は、
の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(); // 所有権の放棄 }
(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.