🔄

wasm-bindgen を使った Rust と JavaScript のデータ共有

2024/03/09に公開

Rust は WebAssembly ターゲットに対応しているため、Rust コードをブラウザ上で JavaScript から呼び出せる形でコンパイルすることが簡単にできます。さらに wasm-bindgen を使うと、Rust と JavaScript の間のデータのやり取りも簡潔に行えるようになります。

ここではビルドを wasm-pack を使って行う前提で説明します。

データの持ち方

wasm-bindgen では、オブジェクトを Rust 側で確保するか JavaScript 側で確保するかの二通りがあります。いずれのオブジェクトも Rust 側、JavaScript 側双方でアクセスすることが可能ですが、アクセス時のコストなどに差があります。

Rust 側に置いたオブジェクトの扱い

struct の宣言に #[wasm_bindgen] を付けると、その struct は JavaScript 側に持ち出してアクセスできるようになります。

#[wasm_bindgen]
pub struct Foo {
    pub x: i32,
    pub y: i32,
}

関数についても同様に #[wasm_bindgen] を付けることで JavaScript 側からアクセスできるようになります。

#[wasm_bindgen]
pub fn gen_foo(a: i32) -> Foo {
    Foo {
        x: 42,
        y: a,
    }
}
> pkg = require('./path/to/generated/package')
{
  ...
}
> f = pkg.gen_foo(100)
Foo { __wbg_ptr: 1114152 }
> f.x
42
> f.y
100

struct を引数として渡すこともできます。

#[wasm_bindgen]
pub fn receive_foo(f: Foo) -> i32 {
    f.x
}

この宣言では receive_foo の呼び出し時に引数 f の所有権が移動します。JavaScript 側から呼び出す場合も、引数として渡した Foo オブジェクトは以後無効となります。なお、これは FooCopy であっても変わりません。もちろん、receive_foo&Foo を受け取る場合は引数として渡した Foo オブジェクトは無効とはなりません。

> f = pkg.gen_foo(100)
Foo { __wbg_ptr: 1114120 }
> pkg.receive_foo(f)
42
> f
Foo { __wbg_ptr: 0 }
> pkg.receive_foo(f)
Uncaught Error: null pointer passed to rust
...

Rust 側のオブジェクトを JavaScript 側で作る

#[wasm_bindgen(constructor)] をつけたメソッドがあれば JavaScript 側でも new でオブジェクトを作ることができます。

#[wasm_bindgen]
impl Foo {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Foo {
        Foo { x: 0, y: 0 }
    }
}
> f = new pkg.Foo()
Foo { __wbg_ptr: 1114120 }
> f.x = 100
100
> f.x
100
> f.y
0

一方で、{ x: number, y: number } 型のものを直接 Foo のインスタンスとして扱うことはできません。

> pkg.receive_foo({x: 10, y: 20})
Uncaught Error: expected instance of Foo
...

JavaScript 側とやりとりできるもの

次のようなものは #[wasm_bindgen] をつけた関数の引数、戻り値、struct のフィールドとして JavaScript 側とやりとりできます(完全な一覧ではありません)。

  • 数値型 (i32, u64 など)
  • bool
  • String
  • T が JavaScript 側とやりとりできるとき、Option<T>
    • TypeScript の型としては (T に対応する型) | undefined となります
  • T が JavaScript 側とやりとりできるとき、Vec<T>
  • T が数値型のとき、Box<[T]>

struct のフィールドに pub (getter / setter が自動で用意されます) かつ Copy でないものが存在する場合には #[wasm_bindgen(getter_with_clone)] が必要です。

ドキュメント には、Vec<T> は JavaScript の通常の配列 (Array) となる一方、Box<[T]>Int32Array などに変換されると書いてあります。ところが、手元で試した限りは Vec<i32>Int32Array として扱われるようでした。加えて T が数値型でなくても Box<[T]>Vec<T> と同様に配列として変換されるようで、このあたりはよくわかりません。

パフォーマンス上の注意

Rust 側のオブジェクトについてもプロパティのアクセスは普通のオブジェクトのようにできるように見えますが、wasm-pack の生成した JavaScript コードを見てもわかるようにこのプロパティへのアクセスは setter / getter を介して行われます。stringInt32Array などにアクセスする場合には、getter の呼び出しで内容全体がコピーされるため、注意しないと思いがけず計算量が増大してしまう場合があります。

例えば

#[wasm_bindgen(getter_with_clone)]
pub struct A {
    pub a: Vec<i32>,
}

#[wasm_bindgen]
pub fn get_a() -> A {
    A { a: vec![0; 100000] }
}

のようなコードを Rust 側で書いた場合、JavaScript 側で

obj = pkg.get_a()
for (let i = 0; i < obj.a.length; ++i) {
  ...
}

のようにするとループの各繰り返しで obj.a のコピーが走るため、obj.a.length の 2 乗に比例した時間がかかってしまい、想定外に遅くなることが考えられます。この場合は、const a = obj.a; のようにプロパティの内容を変数に持っておくことで、プロパティに直接アクセスする回数を減らすことが有効です。

JavaScript 側に置いたオブジェクトの扱い

前述の方法では、JavaScript のオブジェクト {x: 5, y: 10} などを直接 Rust 側に渡すことはできません。例えば関数が多数のオプションを取る場合、それを個別の引数で指定するのは間違えやすいため、オブジェクトとして渡せると便利であると考えられます。

obj.drawRect({
  leftX: 100,
  topY: 200,
  width: 50,
  height: 200,
  fillColor: "red",
  borderColor: "black",
  borderWidth: 2,
  cornerRadius: 5
});

Rust の関数の引数や戻り値を JavaScript のオブジェクトとしたい場合、serde および tsify を使うと簡潔に記述することができます。

use serde::{Deserialize, Serialize};
use tsify::Tsify;

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Foo {
    x: i32,
    y: i32,
}

#[wasm_bindgen]
pub fn update_foo(a: Foo) -> Foo {
    Foo { x: a.y, y: a.x }
}
> a = { x: 5, y: 10 }
{ x: 5, y: 10 }
> pkg.update_foo(a)
{ x: 10, y: 5 }

このとき、TypeScript の型宣言も自動で生成されます。

export interface Foo {
    x: number;
    y: number;
}

#[serde(rename = "name")] を付けることで、JavaScript 側でのフィールド名を変更することもできます。

Option 値の使用

struct のフィールドとして Option<T> を使用することは可能ですが、デフォルトでは TypeScript での型は T | null となります。

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Foo {
    x: i32,
    y: i32,
    z: Option<i32>,
}
export interface Foo {
    x: number;
    y: number;
    z: number | null;
}

フィールドを省略できるようにするには、この型を T | undefined とする必要があります。このためには #[tsify(optional)] を付けるとよいです。

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct Foo {
    x: i32,
    y: i32,
    #[tsify(optional)]
    z: Option<i32>,
}
export interface Foo {
    x: number;
    y: number;
    z?: number;
}

Discussion