はじめに
この記事では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();