wasmのEventListenerの実装をコードとともに見てみる

今回のゴール

本来、jsで実装されているaddEventListenerwasmで登録して使えるようにするところまでを簡単に説明できればと思います。

web_sys の ライブラリを見ていく

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Node.html#method.add_event_listener_with_callback

rustのwasm crateである wasm-bindgen には web_sysというものがある。これはRustからDOM APIを触るためのラッパー(ライブラリ)を担っている

This is a procedurally generated crate from browser WebIDL which provides a binding to all APIs that browser provide on the web.

new-wasm-bindgen-architecture.png

web_sysを読んでいくと以上のように書かれている。ブラウザがWebで提供するすべてのAPIへのバインディングを提供していて、図のようにwasm-bindgenとグルーコードのWebIDLを使用してweb_sysを提供している。

web_sysを使ったtutorialで実際に実装しながら学べるのでオススメです。

コードを見る

painting-sample (1).gif

今回は実装されたものは wasm-bindgenのtutorialにもある、簡単なお絵かきができるwebアプリですhttps://paint-wasm-sample.netlify.com/ に飛んで実際に試してみてください!

また、paint-wasmソースコードをおいているので見たいかたはリンクを飛んで見てみてください。

見てみる

実際のコードをみると

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>paint-wasm</title>
    <style>
      html, body {height: 100%; width:100%; margin: 0;}
    </style>
  </head>
  <body>
    <script type="module">
      import { start_app, default as init } from './pkg/paint_wasm.js';
        async function run() {
          await init('./pkg/paint_wasm_bg.wasm');
          start_app();
        }
        run();
    </script>
    <canvas id="draw"></canvas>
  </body>
</html>

今回は、そこまで大きな開発でもないため、bundlerはなしで実装しています。そのため、index.html内では script type="module" を設定して wasm-pack のbuildも wasm-pack build --target web でしています。

ちなみに、bundlerなしでwasm開発するためのtemplateを作ったのでよければどうぞ https://github.com/poccariswet/rust-wasm-template-without-bundler (⚠︎ 開発仕立てのため、バグがあるかもしれないのでよろしければissueをください!)

fn get_body_dimensions(body: &HtmlElement) -> (u32, u32) {
    let width = body.client_width() as u32;
    let height = body.client_height() as u32;

    (width, height)
}

#[wasm_bindgen]
pub fn start_app() -> Result<(), JsValue> {
    let document = window()
        .unwrap()
        .document()
        .expect("Could not find `document`");

    let body = document.body().expect("Could not find `body` element");

    let canvas = document.get_element_by_id("draw").unwrap();
    let canvas: HtmlCanvasElement = canvas
        .dyn_into::<HtmlCanvasElement>()
        .map_err(|_| ())
        .unwrap();

    let (w, h) = get_body_dimensions(&body);
    // offset solid space
    canvas.set_width(w - 10);
    canvas.set_height(h - 10);
    canvas.style().set_property("border", "5px solid")?;

    let context = canvas
        .get_context("2d")
        .expect("Could not get context")
        .unwrap()
        .dyn_into::<CanvasRenderingContext2d>()
        .unwrap();

    let drawing_ok = Rc::new(Cell::new(false));
    {
        let context = context.clone();
        let drawing_ok = drawing_ok.clone();
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            context.begin_path();
            context.move_to(event.offset_x() as f64, event.offset_y() as f64);
            drawing_ok.set(true);
        }) as Box<dyn FnMut(_)>);
        canvas.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }
    {
        let context = context.clone();
        let drawing_ok = drawing_ok.clone();
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            if drawing_ok.get() {
                context.line_to(event.offset_x() as f64, event.offset_y() as f64);
                context.stroke();
                context.begin_path();
                context.move_to(event.offset_x() as f64, event.offset_y() as f64);
            }
        }) as Box<dyn FnMut(_)>);
        canvas.add_event_listener_with_callback("mousemove", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }
    {
        let context = context.clone();
        let drawing_ok = drawing_ok.clone();
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            drawing_ok.set(false);
            context.line_to(event.offset_x() as f64, event.offset_y() as f64);
            context.stroke();
        }) as Box<dyn FnMut(_)>);
        canvas.add_event_listener_with_callback("mouseup", closure.as_ref().unchecked_ref())?;
        closure.forget();
    }

    Ok(())
}

余談はさておき、lib.rs 内のコードを見ていきましょう

// 1
let drawing_ok = Rc::new(Cell::new(false));
    {
        let context = context.clone();
        let drawing_ok = drawing_ok.clone();
        // 2 Closure::wrapでRustのクロージャをJSのクロージャにする
        let closure = Closure::wrap(Box::new(move |event: web_sys::MouseEvent| {
            context.begin_path();
            context.move_to(event.offset_x() as f64, event.offset_y() as f64);
            drawing_ok.set(true); // drawing_okの値をset
        }) as Box<dyn FnMut(_)>);
        canvas.add_event_listener_with_callback("mousedown", closure.as_ref().unchecked_ref())?;
        // 3
        closure.forget();
    }
    ...

1 Rc::new(Cell::new())

まず、drawing_okの初期化です。今回は MouseEvent の状態によって挙動が変わるものなのでシングルスレッド間で状態を共有できるRc(参照カウントポインター)を使用します。また、MouseEvent には、up, down があるように可変な状態を持ちます。その際に使うのが内部可変性コンテナである Cell (ただCellにはmemcpyでコピーできなければならないといったような制約がある) シングルスレッドかつ複数のクロージャ間内での状態共有をしたいためにRcまたCellでget, setの恩恵を受けています。

この辺について深く知りたいかたは @qnighyさん の記事 と @wada314さん の[記事] (https://qiita.com/wada314/items/24249418983312795c08)を読むことをオススメします!

2 Closure::wrap

コードに書いてあるように、JSでのaddEvenntListenerの実装のようにクロージャ(Event発火時の処理)を実装していきます。 ここでは、rustのクロージャをjsのクロージャにするために、Closure::wrap を使用します。

Closure.html#method.wrapに書いてある通り、wrap methodを使用するには注意点があります。

  • Fn or FnMut のtraitの実装が必要 : この記事が詳細です
  • no stack references => move を使う: これはクロージャに環境の所有権を取得することを強制する
  • 最大で7つの引数を使用可能
  • 引数と戻り値はすべてJSで共有できるものとする

3 forget()

これが今回自分が面白いなと思ったポイントで

といったように、ここではあえてメモリリーク(スコープ抜けてもDropしない)するような実装になっています。 理由としては、ここにも書いてある通り、「このクロージャをリークして、プログラム全体の期間中有効にする」というようにJSのイベントリスナーのように常にイベントの発火を受け付けるようにするためです。

メモリ安全が担保されているrustであえてメモリリークされるような実装があるのは面白いですよね。 でも、メモリリークされていることが明示的にわかることで結局、メモリ安全が担保されているような気がします。

まとめ

今回は、単純なお絵かきアプリを元に、wasmでのEventListenerの実装を見ていきました。 コードとともに解説した3つのことを覚えれば誰でも簡単にEventListenerの実装が可能だと思いますので、みなさんもぜひ rustでwasmをしましょう!

JSを書かずにWebアプリを作る

※ 他サービスで書いたエントリの再掲になります。 前に、自分はrustでAPNGのライブラリを作りました。(良ければみてください 記事) また、別のweb appを作った記事もここにあります。

poccariswet.hatenablog.com

今回、作ったライブラリの活用手段として、Webアプリ化と自分の中で考え、Webアプリを作ろうと思いました。

続きを読む

Rustでwasmを使ってWebアプリを作る

もうすぐ年末?!

早いもので、年末まであと2週間ですね。大学生活最後の冬休みになる...はずなので、今回の正月は思う存分、寝正月をしながらゲーム発展国++やろうと思っています。


さて、この記事はCyberAgent20新卒エンジニアAdvent Calendar 2019の11日目の記事です。

adventar.org

前の人は、Shotaro Taoさんで、次の人はAyumuさんです。

ところで、2021年度新卒採用 エンジニアコースが始まったそうなので良ければどうぞ

www.cyberagent.co.jp

続きを読む