wasm-bindgen経由で新しめのWeb APIを使う


wasm-bindgenを使うとWeb APIを手軽に使うことができますが、wasm-bindgenにまだ取り込まれていない新しいAPIを使うのはやや面倒です。本稿ではwasm-bindgenがサポートしていないAPIをwasm-bindgenっぽく使う方法を紹介したいと思います。

wasm-bindgenがどうやってAPIバインディングを生成しているのかを見るところから始めます。

Web APIはどのブラウザでも同じインタフェースで使えることが期待されています。そのためWeb APIはWeb IDLという特定の実装に依存しない言語で定義されます。wasm-bindgenはこのWeb IDLを「コンパイル」して、Rust (Wasm) とブラウザをつなぐバインディングを生成します。

1つ例を見てみます。appendChild()はWeb IDLで以下のように定義されています。

interface Node {
  Node appendChild(Node child);
};

wasm-bindgenはこの定義を元に以下のようなバインディングを生成します。

pub struct Node { /* omit */ }
impl Node {
  pub fn append_child(&self, node: &Node) -> Result<Node, JsValue> {
    // Rust <-> JS (glue code) の変換およびappendChild()の呼び出し
  }
}

ではこの生成は誰がやっているのでしょうか。答えはweb-sysクレートのビルドスクリプトです。wasm-bindgenでWeb APIを使うときはweb-sysのfeaturesに必要なインターフェースを記述すると思います。web-sysのビルドスクリプトはその情報を使って必要最低限のバインディングを生成します。かいつまんで説明すると、

  • web-sysのwebidlsディレクトリ配下にあるWeb IDLファイルの中身をすべて読み込む
  • featuresで指定されたインタフェースのリストを取得する
  • Web IDLの定義と取得したインタフェースのリストをwasm_bindgen_webidl::compile()に渡してバインディングを生成する

という流れになります。

このステップが含意するのは、web-sysのwebidlsに定義のないAPIは使えない、ということです。新しかったりブラウザ間での合意がとれていなかったりするAPIはweb-sysには含まれていないことがあります。この状況をworkaroundしたい、というのが本稿の趣旨です。

なお、ここで説明する手段は一時的な対処です。APIの仕様がある程度固まって各ブラウザの実装がそろってきたらwasm-bindgen本体に取り込まれるよう働きかけるのが良いと思います。公式ドキュメントに手順が詳しく書いてありますので参考にしてみてください。

では本題に。手順を要約すると以下になります。

  • バインディング用に個別のクレートを作る
  • そのクレートにWeb IDLファイルとweb-sysのビルドスクリプトを置く
  • 別のクレートからバインディングを含むクレートを使う

(クレートが2つ必要なのはwasm-bindgenの内部仕様による制限です。ちゃんと追っていませんが、内部で後処理に使っているハッシュ値を別々にしておく必要があるようです。)

今回はお題としてasync clipboard APIを使います。コードはGitHubに置きました。Async clipboard APIは今のところChromeでしか使えませんのであしからず。

ChromeのWeb IDLを元に簡単なメソッドを実装してみます。Clipboard.readText()はクリップボードからテキストを読みだしてテキストをPromiseにくるんで返すメソッドです。次のようなWeb IDLを作ります。

interface Clipboard : EventTarget {
  Promise<DOMString> readText();
};

このWeb IDLをweb-sysと同じようなディレクトリ構造をもったクレートに置きます。

clipboard
├── Cargo.toml
├── build.rs
├── src
│   └── lib.rs
└── webidls
    └── clipboard.webidl

build.rsはweb-sysのビルドスクリプトをコピーして一部改変したものです。

lib.rsには基本的に生成したバインディングをincludeする記述を書くだけです。ただし、wasm-bindgenがWeb IDLのpartial interfaceを外部から定義するのを許していないのでちょっとした工夫を入れます。具体的にはnavigator.clipboardを手軽に使えるようにトレイトとその実装を加えます。

use js_sys::{Object, Reflect};

// 生成されたバインディングの取り込み
include!(env!("BINDINGS"));

// `navigator.clipboard`を使えるように
pub trait NavigatorClipboard {
    fn clipboard(&self) -> Option<Clipboard>;
}

impl NavigatorClipboard for web_sys::Navigator {
    fn clipboard(&self) -> Option<Clipboard> {
        match Reflect::get(self.as_ref(), &"clipboard".into()) {
            Ok(clipboard) => Some(clipboard.into()),
            Err(_) => None,
        }
    }
}

使ってみます。別のトレイトを用意してそこから使います。

// ...
use clipboard::NavigatorClipboard;

#[wasm_bindgen]
pub fn read_text_from_clipboard() -> JsValue {
    let window = web_sys::window().expect("window");
    let navigator = window.navigator();
    let clipboard = navigator.clipboard().expect("clipboard");
    let text = clipboard.read_text();
    text.into()
}

これでひと段落しました。read_text_from_clipboard()をJS側から呼ぶとクリップボードのテキストがPromiseにくるまれて返ってきます。

Async clipboard APIには画像をやり取りするAPIもあります。こちらはもう少し複雑で、別のインタフェース(ClipboardItem)を定義してあげる必要があります。ここでは説明しませんが、GitHubに置いたコードでは画像を扱う例も書いていますので興味があれば参考にしてみてください。