Rust
+
WebAssembly
実践チュートリアル
Rust のコードをブラウザで動かす WASM の基本を、ゼロから実装しながら学ぶチュートリアルです。
wasm-bindgen を使った JS ↔ Rust のデータ受け渡しから DOM 操作、ミニプロジェクトまで一気に体験できます。
概要・仕組み
WebAssembly(WASM)はブラウザ上で動くバイナリフォーマットの仮想命令セットです。
Rust は WASM への公式サポートが充実しており、wasm-bindgen というクレートを使うことで
JavaScript と Rust のコードを簡単に連携できます。
全体のデータフロー
登場する主要ツール・クレート
| 名前 | 役割 |
|---|---|
wasm-pack | Rust → WASM のビルドツール。npm パッケージとして出力もできる |
wasm-bindgen | JS と Rust の型変換・バインディングを自動生成するクレート |
web-sys | Web API(DOM、fetch、canvas 等)の Rust バインディング集 |
js-sys | JS の組み込みオブジェクト(Array、Promise 等)の Rust バインディング |
このチュートリアルでは Rust の基礎構文の説明は省略し、WASM 固有の概念・ツールの使い方に集中します。
#[wasm_bindgen] マクロや web-sys の使い方など WASM ならではの部分を重点的に解説します。
環境構築
まず必要なツールをインストールします。rustup は既にある前提です。
① wasm-pack のインストール
Rust を WASM にビルドして npm パッケージとして出力してくれる公式ツールです。
$ cargo install wasm-pack
# インストール確認
$ wasm-pack --version
wasm-pack 0.13.1
② WASM ターゲットの追加
wasm-pack が内部的に使いますが、念のため手動でも追加しておきましょう。
$ rustup target add wasm32-unknown-unknown
③ Node.js と http-server
ビルドした WASM を動かすローカルサーバーが必要です。
# Node.js が入っているか確認(18 以上推奨)
$ node --version
# 軽量な静的サーバーをグローバルインストール
$ npm install -g http-server
WASM ファイルは file:// プロトコルでは CORS エラーで読み込めません。
必ず http://localhost から配信する必要があります。
プロジェクト作成
① プロジェクトの雛形を生成
$ wasm-pack new hello-wasm
$ cd hello-wasm
生成されるディレクトリ構造:
② Cargo.toml の確認と編集
生成された Cargo.toml を確認し、後で使う web-sys を追加しておきます。
[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
# ★ WASM 用には必ず crate-type = ["cdylib"] を指定する
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
# web-sys: DOM・ブラウザ API にアクセスするためのクレート
# features に使いたい API を列挙する(tree shaking のため)
[dependencies.web-sys]
version = "0.3"
features = [
"console", # console.log を使うため
"Window", # window オブジェクト
"Document", # document オブジェクト
"Element", # DOM 要素
"HtmlElement", # HTML 要素の型
"HtmlButtonElement",
"HtmlInputElement",
"EventTarget", # イベントリスナーの登録
"MouseEvent", # クリックイベント
"Node", # appendChild 等
]
[dev-dependencies]
wasm-bindgen-test = "0.3"
web-sys は Web API が非常に多いため、使うものだけを features に列挙する仕組みになっています。
使いたい API の型名を features に追加しないとコンパイルエラーになります。
どんな API が使えるかは docs.rs/web-sys で確認できます。
Hello WASM
まずはブラウザのコンソールに文字を出力するところから始めましょう。
① lib.rs を書く
// wasm_bindgen::prelude は必ずインポートする
use wasm_bindgen::prelude::*;
// console.log を Rust から呼び出すためのマクロを定義
#[macro_export]
macro_rules! console_log {
($($t:tt)*) => (
web_sys::console::log_1(&format!($($t)*).into())
)
}
// ★ #[wasm_bindgen] を付けた関数は JS から呼び出せるようになる
#[wasm_bindgen]
pub fn greet(name: &str) {
// console.log を呼び出す
console_log!("こんにちは、{} さん!Rust から WASM 経由で送ります 🦀", name);
}
// JS に文字列を返す関数の例
#[wasm_bindgen]
pub fn get_greeting(name: &str) -> String {
format!("こんにちは、{} さん!", name)
}
② ビルドする
# --target web = ブラウザで ES Modules として使う形式でビルド
$ wasm-pack build --target web
# 成功すると pkg/ ディレクトリに以下が生成される
pkg/
├── hello_wasm_bg.wasm # 本体バイナリ
├── hello_wasm.js # wasm-bindgen が自動生成したグルーコード
├── hello_wasm.d.ts # TypeScript の型定義
└── package.json
③ HTML から使う
プロジェクトルートに index.html を作成します。
<!-- ES Modules で .wasm ファイルを読み込む -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Hello WASM</title>
</head>
<body>
<h1>Rust + WASM</h1>
<script type="module">
// pkg/hello_wasm.js から default export(init 関数)と greet をインポート
import init, { greet, get_greeting } from './pkg/hello_wasm.js';
async function main() {
// ★ init() を必ず最初に呼ぶ(.wasm ファイルを fetch して初期化する)
await init();
// Rust 関数を呼ぶ(コンソールに出力される)
greet('Rust WASM 入門者');
// 戻り値を受け取る
const msg = get_greeting('世界');
document.body.innerHTML += `<p>${msg}</p>`;
}
main();
</script>
</body>
</html>
④ 確認する
# http-server でローカルに配信(デフォルトは 8080 ポート)
$ http-server . -c-1
# ブラウザで http://localhost:8080 を開いて
# DevTools の Console に以下が表示されれば成功!
# → こんにちは、Rust WASM 入門者 さん!Rust から WASM 経由で送ります 🦀
&str や String などの型変換は wasm-bindgen が自動で行います。
JS の文字列 ↔ Rust の UTF-8 文字列の変換コードを自分で書く必要はありません。
生成された pkg/hello_wasm.js を覗いてみると、メモリ操作のグルーコードが自動生成されているのが分かって面白いです。
JS ↔ Rust のデータ受け渡し
wasm-bindgen でサポートされている型の範囲と、より複雑な値を扱うときの方法を学びます。
基本的な型マッピング
| Rust 型 | JS 型 | 備考 |
|---|---|---|
i32 / u32 / f64 | number | そのまま使える |
bool | boolean | そのまま使える |
&str / String | string | 自動変換(コピーが発生) |
Vec<u8> | Uint8Array | バイト列のやり取りに使う |
JsValue | any | 型安全でない万能型 |
Option<T> | T | undefined | None → undefined に変換 |
Result<T, JsValue> | T (エラー時は JS 例外) | エラーハンドリングに使う |
数値・演算の例
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// Option を返すと JS 側では undefined になる
#[wasm_bindgen]
pub fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 {
None // JS 側では undefined
} else {
Some(a / b)
}
}
// Result を返すと JS 側で try/catch できる
#[wasm_bindgen]
pub fn parse_number(s: &str) -> Result<f64, JsValue> {
s.parse::<f64>()
.map_err(|e| JsValue::from_str(&e.to_string()))
}
JS 側からの呼び出し
await init();
// 数値の足し算
const sum = add(3, 4);
console.log(sum); // → 7
// Option: 0 除算は undefined が返る
const r1 = safe_divide(10, 2); // → 5
const r2 = safe_divide(10, 0); // → undefined
// Result: エラー時は JS 例外として飛んでくる
try {
const n = parse_number('abc');
} catch (e) {
console.log('エラー:', e); // → "invalid float literal"
}
構造体を JS に公開する
#[wasm_bindgen] は構造体にも付けられます。JS からはクラスのように扱えます。
// ★ #[wasm_bindgen] を構造体に付けると JS クラスとして公開できる
#[wasm_bindgen]
pub struct Counter {
count: i32,
}
#[wasm_bindgen]
impl Counter {
// コンストラクタは new() という名前の関数で定義する
#[wasm_bindgen(constructor)]
pub fn new() -> Counter {
Counter { count: 0 }
}
pub fn increment(&mut self) {
self.count += 1;
}
pub fn value(&self) -> i32 {
self.count
}
// JS 側での free() を自動呼び出しにしたい場合は
// drop() を使うか、wasm-bindgen の自動 GC に任せる
}
import init, { Counter } from './pkg/hello_wasm.js';
await init();
// JS クラスのように扱える
const counter = new Counter();
counter.increment();
counter.increment();
console.log(counter.value()); // → 2
// ★ WASM 側のメモリを解放する(重要!)
counter.free();
WASM の構造体インスタンスは JS の GC 管理外のメモリに確保されます。
使い終わったら必ず .free() を呼ぶか、try-finally で解放してください。
解放しないと WASM のメモリリークになります。
DOM 操作
web-sys クレートを使って Rust からブラウザの DOM を操作します。
これが一番「WASM らしい」使い方になります。
document を取得して要素を操作する
use wasm_bindgen::prelude::*;
use web_sys::{window, Document, Element};
// window → document を取得するヘルパー(よく使うパターン)
fn get_document() -> Document {
window()
.expect("window が取得できない")
.document()
.expect("document が取得できない")
}
#[wasm_bindgen]
pub fn add_list_item(text: &str) -> Result<(), JsValue> {
let doc = get_document();
// id="list" の要素を取得(JS の getElementById と同じ)
let list = doc
.get_element_by_id("list")
.ok_or_else(|| JsValue::from_str("#list が見つからない"))?;
// <li> 要素を作成してテキストを設定
let item = doc.create_element("li")?;
item.set_text_content(Some(text));
// リストに追加
list.append_child(&item)?;
Ok(())
}
イベントリスナーを Rust で登録する
イベントリスナーは少し複雑で、Closure という型を使います。
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::{HtmlButtonElement, MouseEvent};
#[wasm_bindgen]
pub fn setup_button() -> Result<(), JsValue> {
let doc = get_document();
// id="my-btn" のボタンを取得して HtmlButtonElement にキャスト
let btn = doc
.get_element_by_id("my-btn")
.ok_or(JsValue::from_str("ボタンが見つからない"))?
// ★ dyn_into でダウンキャスト(JS の型システムの都合)
.dyn_into::<HtmlButtonElement>()?;
// ★ Closure::wrap でクロージャを JS が扱える形に変換
let closure = Closure::wrap(
Box::new(move |_e: MouseEvent| {
console_log!("ボタンがクリックされました!🦀");
}) as Box<dyn FnMut(MouseEvent)>
);
// イベントリスナーに登録
btn.add_event_listener_with_callback(
"click",
closure.as_ref().unchecked_ref(),
)?;
// ★ forget() しないとクロージャが即座にドロップされてしまう
// (メモリリークになるが、ページ存続中ずっと使う場合はこれでOK)
closure.forget();
Ok(())
}
Closure は Rust の所有権管理に従うため、スコープを抜けると解放されます。
イベントリスナー等「ページが存続する間ずっと生かしておきたい」クロージャは
.forget() でリークさせて JS 側に生存期間を委ねます。
逆にリスナーを解除したい場合は forget() せずに参照を保持してください。
ミニプロジェクト:フィボナッチ計算機
今まで学んだことを組み合わせて、JS 版と WASM 版の速度を比較できる フィボナッチ計算機を作ります。重い再帰計算で WASM の威力を体感しましょう。
① Rust 側の実装
use wasm_bindgen::prelude::*;
use web_sys::{window, Document};
#[macro_export]
macro_rules! console_log {
($($t:tt)*) => (
web_sys::console::log_1(&format!($($t)*).into())
)
}
fn get_document() -> Document {
window().unwrap().document().unwrap()
}
// 再帰版フィボナッチ(わざと遅くしてる)
fn fib_recursive(n: u64) -> u64 {
if n <= 1 {
return n;
}
fib_recursive(n - 1) + fib_recursive(n - 2)
}
// JS から呼ぶエントリーポイント:計算時間も一緒に返す
#[wasm_bindgen]
pub fn calc_fib(n: u32) -> String {
// performance.now() で計測(web-sys で window.performance を使う)
let perf = window().unwrap().performance().unwrap();
let start = perf.now();
let result = fib_recursive(n as u64);
let elapsed = perf.now() - start;
format!("結果: {} (計算時間: {:.2} ms)", result, elapsed)
}
// ページの初期化:ボタンにイベントリスナーを設定
#[wasm_bindgen(start)] // ← WASM 初期化時に自動実行される
pub fn on_load() -> Result<(), JsValue> {
use wasm_bindgen::JsCast;
use web_sys::{HtmlButtonElement, HtmlInputElement, MouseEvent};
let doc = get_document();
// Rust ボタンのクリック処理
let btn = doc
.get_element_by_id("rust-btn")
.unwrap()
.dyn_into::<HtmlButtonElement>()?;
let closure = Closure::wrap(Box::new(move |_: MouseEvent| {
let d = get_document();
// input から n の値を読む
let n_str = d
.get_element_by_id("n-input")
.unwrap()
.dyn_into::<HtmlInputElement>()
.unwrap()
.value();
let n: u32 = n_str.parse().unwrap_or(40);
let result = calc_fib(n);
// 結果を表示エリアに書き込む
d.get_element_by_id("rust-result")
.unwrap()
.set_text_content(Some(&result));
}) as Box<dyn FnMut(MouseEvent)>);
btn.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref())?;
closure.forget();
Ok(())
}
② HTML / JS 側の実装
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>フィボナッチ比較</title>
<style>
body { font-family: sans-serif; max-width: 500px; margin: 2rem auto; }
.row { display: flex; gap: 1rem; margin: 1rem 0; }
button { padding: 0.5rem 1rem; cursor: pointer; }
.result { margin-top: 0.5rem; font-weight: bold; min-height: 1.5rem; }
.rust-result { color: #e07b39; }
.js-result { color: #4a9eff; }
</style>
</head>
<body>
<h1>🦀 Rust WASM vs JavaScript</h1>
<p>フィボナッチ数列の n 番目を再帰で計算して速度比較します。</p>
<div class="row">
<label>n = <input id="n-input" type="number" value="42" min="1" max="50"></label>
</div>
<div class="row">
<button id="rust-btn">🦀 Rust で計算</button>
<button id="js-btn">📜 JS で計算</button>
</div>
<div class="result rust-result" id="rust-result">(未計算)</div>
<div class="result js-result" id="js-result" >(未計算)</div>
<script type="module">
import init from './pkg/hello_wasm.js';
// WASM を初期化(#[wasm_bindgen(start)] が自動実行される)
await init();
// JS 版のフィボナッチ(比較用)
function fibJs(n) {
if (n <= 1) return n;
return fibJs(n - 1) + fibJs(n - 2);
}
document.getElementById('js-btn')
.addEventListener('click', () => {
const n = +document.getElementById('n-input').value;
const t0 = performance.now();
const result = fibJs(n);
const ms = (performance.now() - t0).toFixed(2);
document.getElementById('js-result').textContent =
`結果: ${result} (計算時間: ${ms} ms)`;
});
</script>
</body>
</html>
③ ビルドして動かす
$ wasm-pack build --target web
$ http-server . -c-1
# http://localhost:8080 を開いて n=42 あたりで両方計算してみよう
# Rust WASM の方が 2〜10 倍速いことが体感できるはず!
n = 42〜48 くらいで顕著に差が出ます。WASM は整数演算・ループ・再帰が特に速く、 JS エンジンの JIT コンパイルとの速度差が出やすいです。 DOM 操作は JS の方が得意なので、「計算処理は Rust、UI 操作は JS」というのが WASM の典型的な使い方です。
次のステップ
このチュートリアルで学んだ内容をベースに、以下を試してみると WASM の理解がより深まります。
発展テーマ
- Canvas API —
web-sysのHtmlCanvasElementを使って Rust からピクセルを描画する(Game of Life が定番) - wasm-bindgen-futures —
async/awaitを WASM で使い、fetch API と連携する - wasm-pack test —
wasm-bindgen-testでブラウザ上の単体テストを書く - Trunk —
trunk serveで Hot Reload 付きの開発環境を作る(wasm-pack より便利な場面も多い) - Yew — React 相当のフロントエンドフレームワーク。Rust だけで SPA を作れる
- Leptos / Dioxus — 最近台頭してきた Rust 製フロントエンドフレームワーク
参考リソース
- The Rust and WebAssembly Book — 公式チュートリアル(Conway's Game of Life を作る)
- wasm-bindgen ドキュメント
- web-sys ドキュメント — 使える Web API の一覧はここを見る
コメント
コメントを投稿