WebAssembly入門:RustからWasmをビルドしてJavaScriptと連携する
Wasmで何が速くなり何が遅くなるか。Rustからのビルド、JSとのデータ受け渡し、向くワークロードと向かないものを動くコードで整理。
「画像処理をWasmで速くした」と聞いて、僕も期待して同じことをやりました。Rustで1ピクセルずつ色を反転する関数を書いて、ブラウザから呼んだ。結果は——JavaScriptより遅い。
原因は計算ではありませんでした。1ピクセルごとにJavaScriptとWasmの間を往復していて、その往復自体が重かったんです。Wasm関数の中身は確かに速かった。でも、データを渡したり受け取ったりする「橋を渡る回数」が多すぎた。
WebAssembly(Wasm)は「入れれば速くなる魔法」ではありません。速くなる仕事と、むしろ遅くなる仕事がはっきり分かれている道具です。今日はそこを、RustからのビルドとJavaScriptとの受け渡しまで、コピペで動く形で整理します。
この記事の要点
- Wasmは「JSの置き換え」ではなく、画像処理・暗号・圧縮みたいな重い計算の塊だけを任せる道具。文字列いじりやDOM操作には向かない。
- 速さの敵は計算じゃなく境界コスト。JSとWasmの間を行き来するたびにデータコピーが起きる。小さい関数を何万回も呼ぶと逆に遅くなる。
- 作り方の本筋は3つ。Rustで関数を書く →
wasm-packでビルド → JSからinit()してから呼ぶ。 - 向く: 画像のRGBA一括処理、ハッシュ・暗号、CSVの数値集計、既存Rust/C++資産の移植。向かない: 細かい関数の大量呼び出し、DOM更新、ちょっとした文字列処理。
- 判断は体感じゃなく同じ入力で計測する。Full HD相当の配列で差が出始める、くらいが現実。
WebAssemblyは何者で、何のためにあるのか
WebAssembly、略してWasmは、RustやC++、AssemblyScriptなどで書いた処理を、ブラウザやNode.jsの上で速く動かすための実行形式です。.wasmという小さなバイナリにコンパイルして、JavaScriptから関数として呼びます。
ここで勘違いしやすいのが立ち位置です。WasmはJavaScriptの代わりではありません。JavaScriptの隣に座る相棒です。画面を描いたり、イベントを拾ったり、APIを叩いたりするのは引き続きJavaScriptの仕事。Wasmが引き受けるのは、CPUをガリガリ使う計算の塊だけ。
たとえるなら、JavaScriptが現場監督で、Wasmは「重い荷物だけ運ぶ屈強な人」。荷物が重いほど活躍するけど、軽い荷物を一個ずつ「これ運んで」「これも運んで」と渡すと、声をかける手間のほうが大きくなる。さっきの僕のピクセル事故は、まさにこれでした。
向いている仕事は、だいたい次の表のどれかに当てはまります。
| 仕事 | Wasmに向く理由 | 先に決めておくこと |
|---|---|---|
| 画像処理 | RGBA配列をまとめて処理でき、Canvasと相性がいい | コピー回数、Canvasの更新タイミング、計測方法 |
| 暗号・圧縮・独自コーデック | バイト列処理が中心で、既存Rust資産を流用しやすい | 監査済みライブラリを使うか、自作してよい範囲か |
| CSV・数値計算 | 集計や特徴量づくりなどループが多い | 入力サイズ、NaN処理、エラー時の戻り値 |
| 既存Rust/C++資産の移植 | ロジックを再利用でき、ブラウザ配布に向く | OS依存API、ファイルI/O、スレッド依存の有無 |
| ゲーム・物理計算 | 毎フレームの重い計算をまとめて回せる | フレーム内の呼び出し回数、メモリの持ち方 |
| ブラウザ内の機密処理 | サーバーに送れないデータを端末内で処理できる | 個人情報の扱い、初回ロード、端末性能 |
逆に向かないのは、軽い処理を細切れに何度も呼ぶ系です。ボタンの色を変える、ちょっとした文字列を整形する、DOMを直接書き換える——こういうのは素直にJavaScriptでやったほうが速いし、読みやすい。
RustからWasmをビルドする最小構成
道具を整理します。wasm-packは、RustをWasm向けにビルドして、JavaScriptから呼ぶための接着コードとTypeScript定義まで生成してくれるCLIです。wasm-bindgenは、Rustの関数や型をJavaScript側に公開する橋渡しのライブラリ。この2つで、Rust初心者でも「呼べるWasm」がすぐ作れます。
題材は3つにします。画像のRGBA反転、CSVの数値列合計、バイト列のハッシュ。どれも「配列やバイト列を一度にまとめて渡す」という、Wasmが得意な形です。
まずプロジェクトの設定ファイル。crate-type = ["cdylib"]が、Wasm用に動的ライブラリとして吐き出すための指定です。
# Cargo.toml
[package]
name = "wasm-lab"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
次に本体。#[wasm_bindgen]という目印を関数につけるだけで、JavaScriptから呼べるようになります。それぞれ「スライス(配列の一部を借りる型)を受け取って、まとめて処理する」形に寄せているのがポイントです。
// src/lib.rs
use wasm_bindgen::prelude::*;
// 画像のRGBAを反転する。1枚分の配列を一度に受け取る
#[wasm_bindgen]
pub fn invert_rgba(pixels: &mut [u8]) {
for chunk in pixels.chunks_exact_mut(4) {
chunk[0] = 255 - chunk[0]; // R
chunk[1] = 255 - chunk[1]; // G
chunk[2] = 255 - chunk[2]; // B
// chunk[3](アルファ)はそのまま
}
}
// CSV本文と列番号を受け取り、その列の数値を合計する
#[wasm_bindgen]
pub fn sum_csv_column(csv: &str, column: usize) -> f64 {
csv.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| line.split(',').nth(column))
.filter_map(|cell| cell.trim().parse::<f64>().ok())
.sum()
}
// バイト列を一度に受け取り、FNV-1aで32bitハッシュを返す
#[wasm_bindgen]
pub fn fnv1a32(bytes: &[u8]) -> u32 {
let mut hash = 0x811c9dc5u32;
for byte in bytes {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(0x01000193);
}
hash
}
ビルドはこの3行。最初の行でWasm向けのコンパイル先を追加し、wasm-packを入れて、pkgフォルダに成果物を吐き出します。
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
wasm-pack build --target web --out-dir pkg
ひとつ正直に注意を。このfnv1a32は暗号学的に安全なハッシュではありません。ファイルの変更チェックみたいな用途には十分速くて便利ですが、認証・署名・パスワード・決済が絡むなら、ブラウザ標準のWeb Crypto APIや監査済みライブラリを使ってください。ここでは「バイト列をまとめてWasmに渡すと何が起きるか」を見るための軽い題材です。Rust側の書き方をもっと詰めたい人はClaude CodeでRust開発入門も合わせてどうぞ。
JavaScript(Vite)から呼んで、データを受け渡す
wasm-pack build --target webを実行すると、pkg/wasm_lab.jsと型定義pkg/wasm_lab.d.tsが生成されます。JavaScript側で最初にやることは決まっていて、init()でWasmを読み込んでから、関数を呼ぶ。これを忘れると、環境によってだけ落ちる嫌なバグになります。
そして橋を渡る回数を減らすために、初期化は一度だけにします。下のラッパーではensureWasm()が初期化を1回に固定し、UIから直接Wasm関数を呼び散らかさないよう、薄い窓口にまとめています。
// src/wasm-client.ts
import init, {
fnv1a32,
invert_rgba,
sum_csv_column,
} from "../pkg/wasm_lab";
export type WasmClient = {
invertImage(imageData: ImageData): Promise<ImageData>;
sumCsvColumn(csv: string, columnIndex: number): Promise<number>;
checksum(bytes: Uint8Array): Promise<number>;
};
let initPromise: Promise<void> | undefined;
// 初期化は一度だけ。2回目以降は同じPromiseを使い回す
async function ensureWasm(): Promise<void> {
initPromise ??= init().then(() => undefined);
return initPromise;
}
export const wasmClient: WasmClient = {
async invertImage(imageData) {
await ensureWasm();
// ImageDataの中身を、コピーせず同じメモリを指すビューとして渡す
const pixels = new Uint8Array(
imageData.data.buffer,
imageData.data.byteOffset,
imageData.data.byteLength,
);
invert_rgba(pixels);
return imageData;
},
async sumCsvColumn(csv, columnIndex) {
await ensureWasm();
return sum_csv_column(csv, columnIndex);
},
async checksum(bytes) {
await ensureWasm();
return fnv1a32(bytes);
},
};
呼び出し側はこれだけ。CSVファイルを選んだら、3列目(添字2)を合計して表示します。
// src/main.ts
import { wasmClient } from "./wasm-client";
const fileInput = document.querySelector<HTMLInputElement>("#csv-file");
const output = document.querySelector<HTMLPreElement>("#output");
fileInput?.addEventListener("change", async () => {
const file = fileInput.files?.[0];
if (!file || !output) return;
const csv = await file.text();
const total = await wasmClient.sumCsvColumn(csv, 2);
output.textContent = `3列目の合計: ${total.toFixed(2)}`;
});
データの受け渡しでいちばん大事なのは「コピーが起きる場所」を意識することです。ImageDataのように大きな配列は、上のコードのようにメモリを指すビューとして一度だけ渡す。逆に、文字列やバイト列をループの中で何度も渡すと、その都度コピーが走ります。渡すのは大きく、回数は少なく。これがWasm連携の合言葉です。
Viteの設定は、まず標準のままで大丈夫です。.wasmを直接importする特殊な構成にするときだけ、Wasm用プラグインやトップレベルawaitの扱いを足します。最初はwasm-packの生成物をそのまま読み、init()が成功するか、型定義が効くか、.wasmのサイズはどのくらいか、を確認するほうが安全です。
速くなったか、同じ入力で計測する
「速くなった気がする」は当てになりません。RGBA反転をJavaScriptとWasmで、同じ配列を使って比べる小さなベンチを置きます。
// src/bench.ts
import { wasmClient } from "./wasm-client";
// 比較用に、同じ処理をJavaScriptでも書いておく
function invertJs(pixels: Uint8Array): void {
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 255 - pixels[i];
pixels[i + 1] = 255 - pixels[i + 1];
pixels[i + 2] = 255 - pixels[i + 2];
}
}
function cloneImageData(source: Uint8Array, width: number, height: number): ImageData {
return new ImageData(new Uint8ClampedArray(source), width, height);
}
export async function runBench(): Promise<void> {
const width = 1920;
const height = 1080;
const source = new Uint8Array(width * height * 4);
crypto.getRandomValues(source); // 同じ元データを用意
const jsPixels = new Uint8Array(source);
const wasmImage = cloneImageData(source, width, height);
const jsStart = performance.now();
invertJs(jsPixels);
const jsMs = performance.now() - jsStart;
const wasmStart = performance.now();
await wasmClient.invertImage(wasmImage);
const wasmMs = performance.now() - wasmStart;
console.table({
javascriptMs: Number(jsMs.toFixed(2)),
wasmMs: Number(wasmMs.toFixed(2)),
ratio: Number((jsMs / wasmMs).toFixed(2)), // 1より大きければWasmが速い
});
}
動かす手順はこれだけ。
wasm-pack build --target web --out-dir pkg
npm run typecheck
npm run dev
数値が伸びないときに疑うのは、Wasm関数の中身ではありません。配列のコピー、Canvasからの読み書き、開発ビルド(最適化が弱い)かどうか、このあたりが犯人になりがちです。計算時間だけでなく、渡す前後の変換まで含めて測るのが効きます。速度全体の見方はClaude Codeでパフォーマンス最適化にまとめてあるので、Wasm以外の手も合わせて検討してください。
僕の手元では、小さな入力だとJavaScriptのままでも十分速く、差はほとんど出ませんでした。Full HD相当の大きな配列や、複数列のCSV集計になって、ようやくWasmが前に出てくる。だから「とりあえずWasm化」ではなく、「大きな計算が確実にある」と分かってから入れるのが正解でした。
ハマった落とし穴と、その回避策
ここからは僕が実際に踏んだ地雷です。先に知っておくと、丸一日溶かさずに済みます。
1. init()を待たずに呼ぶ。 いちばん最初にやらかしました。読み込みが終わる前に関数を呼ぶと、速いマシンでは動いて遅いマシンでは落ちる、という再現しづらいバグになります。ensureWasm()で必ず待つ。これだけで消えます。
2. 境界を渡りすぎる。 冒頭のピクセル事故がこれです。1要素ずつ呼ぶのをやめて、配列を丸ごと一度だけ渡したら、あっさりWasmが速くなりました。
3. バンドルが太る。 便利そうなcrateをRust側に足すと、.wasmと接着コードが一気に膨らみます。最初は小さな関数だけにして、必要になってからwasm-optや機能フラグで削る。サイズの測り方はバンドル分析の自動化が参考になります。
4. WasmからDOMを触ろうとする。 画面更新やイベント、アクセシビリティ属性はJavaScript側に残す。Rust側は純粋な計算に寄せると、テストもしやすくなります。
5. セキュリティヘッダーで詰まる。 普通のWasmは主要ブラウザでそのまま動きます。ただしWasm threadsやSharedArrayBufferを使う場合は、COOPとCOEPによるcross-origin isolationが必要です。広告タグや外部iframeを載せたサイトでは、この設定が壊れやすいので早めに確認を。
これらは全部「機械で気づける」種類のミスです。だから僕はClaude Codeに実装をさせたあと、別ターンで「境界コスト、非同期初期化、メモリコピー、バンドルサイズだけを批判的にレビューして」と頼みます。実装役とレビュー役を分けると、見落としが減ります。レビューを定型化したいなら、CLAUDE.mdに「Wasmは計算だけ」「DOMはJS」「pkg生成物は手で編集しない」「ベンチなしにWasm化しない」と書いておくと出力が安定します。
よくある質問
Q. WasmはJavaScriptより常に速いですか? いいえ。中身の計算は速いことが多いですが、JSとの間を行き来する境界コストが乗ります。小さな処理を細かく呼ぶ用途では、JavaScriptのほうが速いこともよくあります。大きな計算をまとめて渡すときに効きます。
Q. Rustは必須ですか?AssemblyScriptやCでもいい?
Rust以外でもWasmは作れます。AssemblyScript(TypeScript風)はJS畑の人に入りやすく、CやC++はEmscriptenで既存資産を移植できます。エコシステムとツールの完成度では、いまはRust + wasm-packが無難です。
Q. JavaScriptとWasmで大きなデータをやり取りするとコピーされますか?
配列や文字列を渡すたびにコピーが起きる場合があります。ImageDataのようにメモリを指すビューを一度だけ渡すと抑えられます。回数を減らし、1回あたりを大きくするのが基本です。
Q. Wasmからネットワークやファイル、DOMを直接触れますか? 直接はできません。Wasmは計算に専念し、通信・ファイル・画面更新はJavaScript側のAPIを通します。ブラウザのサンドボックスの中で動く、と考えると安全です。
Q. 画像処理以外で効果が出やすいのは? ハッシュや暗号などのバイト列処理、圧縮、CSVや数値配列の集計、ゲームの物理計算です。共通点は「ループが多くて、入力をまとめて渡せる」こと。この形に当てはまるかを先に見極めると外しません。
まとめ:測ってから入れる、が結局いちばん速い
WebAssemblyは、JavaScriptを置き換える技術ではなく、重い計算の塊だけを任せる相棒です。作り方の本筋は、Rustで関数を書く → wasm-packでビルド → JSでinit()してから呼ぶ、の3ステップ。難しいのは文法より「どの処理を渡すか」の見極めのほうです。
僕の失敗は、速くなると信じて先に入れたことでした。いまは順番を逆にしています。まず同じ入力でJavaScriptを計測する。明らかに重い塊が見つかったら、そこだけWasmに切り出して、また計測する。境界コストまで含めて勝っていたら採用。負けていたらJavaScriptのまま磨く。遠回りに見えて、これがいちばん事故りませんでした。
公式情報も合わせて確認しておくと安心です。Wasmの基本はMDN WebAssembly、RustとJavaScriptの橋渡しはwasm-bindgen Guide、ビルドフローはwasm-packが一次情報です。チームでWasm導入の進め方を相談したいときは、Claude Code研修・相談で実リポジトリ前提の設計から話せます。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeのチーム利用でコストが読めない時に作る予算ログ
チーム導入前に、誰が何に使い、どの成果が出たかを見える化する予算ログの作り方。
コミット前の3分チェック: Claude Codeが触った範囲を確認してから確定する
Claude Codeが勝手に広げた変更を、コミット前に3分で見抜く確認手順。差分の範囲、検証ログ、ステージするファイルの絞り込みを順番に解説します。
Claude Codeをチーム導入する前に作る「リスク台帳」の中身
Claude Codeを個人実験で終わらせずチーム導入するための、権限・CI・公開の事故を防ぐリスク台帳の作り方を実例とコードで解説します。