はじめに
この記事ではRustとWebAssemblyを使って簡単なウェブアプリとしてカウンターを作成します。
RustとWebAssemblyそのものについては紹介しません。Rustは公式ドキュメント、WebAssemblyはMDNがわかりやすいです。
さらに、rustupとNodejsがインストールされていることを前提としています。
プロジェクトの準備
この記事ではRust WasmグループがRust×webpackのテンプレートとして提供しているrust-webpack-templateを用いてプロジェクトを準備します。
このテンプレートは以下のコマンドで生成します。
npm init rust-webpack counter
counterの部分は生成するフォルダ名です。フォルダ名がない場合は現在のディレクトリに生成されます。
生成物の解説
Cargo.tomlはRustを管理、package.jsonはNode.jsを管理するためのファイルです。
jsフォルダにはwebpackのエントリーファイルが置かれています。基本的には変更しません。
srcフォルダにはRustのコードが置かれます。
staticフォルダにはビルド後のフォルダにそのまま移行させたいファイルが置かれます。
testsフォルダはRustのコードに対するテストですが、今回は利用しません。
webpack.config.jsファイルはwebpackの設定が書かれたコードです。基本的には変更しません。
動作の確認
Node.jsが依存するパッケージをpackage.jsonを元にインストールします。
npm i
次に開発サーバーを起動して動作を確認します。Nodeのバージョンが17以上の場合は
NODE_OPTIONS=--openssl-legacy-provider npm run start
と入力してください。17未満の場合は
npm run start
でlocalhost:8080に開発サーバーが立ち上がります。
wasm-packを導入していないことによるエラーが発生する場合は
cargo install wasm-pack
で導入してください。
最終的にlocalhost:8080にアクセスするとページには何も表示されずに開発ツールを確認するとconsoleにHello world!と表示されているはずです(その他にもWDSからのメッセージが表示されます)。これで準備は完了です。
ファイルをビルドしたいときは以下のコマンドでdistに対して結果を吐き出させられます。
// Nodejs v17以上
NODE_OPTIONS=--openssl-legacy-provider npm run build
// Nodejs v17未満
npm run build
同時にtargetフォルダとpkgフォルダも生成されるはずです(この2つはnpm run startの段階で生成されます)。
targetフォルダはRustコンパイラによって生成されたバイナリやライブラリです。
pkgフォルダはRustから生成したWebAssemblyのバイナリをJavaScriptから呼び出すために必要なファイル群です。実際にwebpackのエントリーポイントとなるjs/index.jsもpkgフォルダを参照しているはずです。
distフォルダはそれらを複合してwebpackから吐き出された成果物です。staticフォルダにあったファイルとwebpackによって生成されたJavaScriptのファイル、WebAssemblyのファイルが置かれます。
Rustファイルの解説
src/lib.rsファイルについてみていきます。このファイルは先ほど見たようにconsoleに対する出力を行なっています。
use wasm_bindgen::prelude::*;
use web_sys::console;
// When the `wee_alloc` feature is enabled, this uses `wee_alloc` as the global
// allocator.
//
// If you don't want to use `wee_alloc`, you can safely delete this.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
// This is like the `main` function, except for JavaScript.
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue> {
// This provides better error messages in debug mode.
// It's disabled in release mode so it doesn't bloat up the file size.
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
// Your code goes here!
console::log_1(&JsValue::from_str("Hello world!"));
Ok(())
}
wee_allocはRustのWebAssemblyバイナリをより小さくするためのグローバルアロケータとしてデフォルトで使用されているものです。
#[wasm_bindgen(start)]はmain_jsをwasm-bindgenマクロを使用して、WebAssemblyモジュールのエントリーポイントを定義しています。
main_jsの返り値はResult<(), JsValue>となっています。JsValueはJavaScriptで扱う値を意味しており、失敗した時はJavaScriptで扱える値で返す必要があるということです。
#[cfg(debug_assertions)]
console_error_panic_hook::set_once();
この部分はRust側で生じたpanicに関する処理を記したものです。console_error_panic_hook::set_once();が宣言されていれば、Rust内でpanicが生じた場合はスタックトレースをエラーと共にconsoleに表示します。宣言されていなければRuntimeError: Unreachable completedのような単調なメッセージが表示されます。
#[cfg(debug_assertions)]のように書くことで、開発中はスタックトレースまで表示し、本番では簡素なメッセージを出すように出し分けを行なっています。
console::log_1(&JsValue::from_str("Hello world!"));
そして、この部分ではweb_sysのconsoleモジュールを呼び出してlog_1を使うことで、JavaScriptのconsole.log関数を呼び出しています。
&JsValue::from_str("Hello world!")はRustの文字列"Hello world!"をJavaScriptの文字列に変換しています。
そしてlogではなくlog_1となっているのはJavaScriptに実装されたconsole.log関数の引数は可変長であるのに対して、Rustでは可変調な引数を定義できないのでconsole.logの引数に合わせてlog_1、log_2のように定義されているからです。
カウンターの作成
プロジェクトの準備と解説を行ったので早速カウンターの作成を行います。
カウンターはとても単純なものでボタンを押すとボタンの持つ数値が1ずつ増えていくようなものを作ります。
まずは、ボタンを表示するようにstaticフォルダにあるindex.htmlにbutton要素を追加します。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My Rust + Webpack project!</title>
</head>
<body>
<script src="index.js"></script>
<button id="counter">0</button>
</body>
</html>
Rust側で扱いやすいようにidにcounterを与えました。
次にそのbuttonをRustで扱えるようにします。buttonが持つテキストを取得してconsoleに出力させてみましょう。
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let counter = document
.get_element_by_id("counter")
.unwrap()
.dyn_into::<web_sys::HtmlButtonElement>()
.unwrap();
console::log_1(&counter.inner_text().into());
consoleにHello World!と吐き出していた部分を上記のコードに置き換えます。
このコードではweb_sysのwindowメソッドを用いてwindowを取り出し、そこからdocumentメソッドでdocumentを取り出しています。
buttonはそのdocumentからget_element_by_idメソッドを用いてを指定したidのボタンを取得してdyn_intoでweb_sysに定義されたHtmlButtonElementへ型を変換しています。
最後にそのボタンが持つテキストをconsoleに出力しています。
このままコードを貼り付けるだけではコードは動きません。Cargo.tomlを開いてweb-sysのfeturesに利用するbrowser apiを記述します。
[dependencies.web-sys]
version = "0.3.22"
features = ["console", "Window", "Document", "HtmlButtonElement"]
このようにすることで画面にテキストが0のボタンが表示され、コンソールにも0が表示されたはずです。

最後にこのボタンに対して加算処理を加えます。通常のJavaScriptと同じようにクリックイベントに追加します。
let counter_clone = counter.clone();
let click_handler = Closure::wrap(Box::new(move || {
let next_count = counter_clone.inner_text().parse::<u32>().unwrap_or(0) + 1;
counter_clone.set_inner_text(&next_count.to_string());
}) as Box<dyn FnMut()>);
counter.add_event_listener_with_callback("click", click_handler.as_ref().unchecked_ref())?;
click_handler.forget();
consoleの箇所を削除して、counterのクリックイベントにボタンの値を前回の値+1の値に設定するようなメソッドを追加しています。
まずはcounterをクローンしてきます。これはクリックイベントに渡すコールバック関数に所有権を譲るための変数です。
let counter_clone = counter.clone();
次にボタンの値を前回の値+1の値にする関数です。
let click_handler = Closure::wrap(Box::new(move || {
let next_count = counter_clone.inner_text().parse::<u32>().unwrap_or(0) + 1;
counter_clone.set_inner_text(&next_count.to_string());
}) as Box<dyn FnMut()>);
Box::newメソッドを使用してClosureを作成し、moveを使用してcounter_clone変数をClosureに移動します。次にcounter_cloneのinner_textメソッドを使用してボタンのテキストを取得し整数型に変換します(変換できない場合は0にします)。最後にcounter_cloneのset_inner_textメソッドを使用してボタンのテキストを更新します。
JavaScriptの文脈で実行させる関数を扱う場合はこのようにRust側でClosureを用いて定義することが多いです。
そして、counterに対して先ほど作成したClosureをクリックイベントとして登録します。as_refとunchecked_refメソッドはClosureからJavaScriptのFunction型に変換しています。
counter.add_event_listener_with_callback("click", click_handler.as_ref().unchecked_ref())?;
最後にClosureオブジェクトがドロップされた時のメモリーリークを防ぐために登録したClosureを忘却させます。
click_handler.forget();

