[Rust] YewでWebAssemblyを使ったWebアプリのフロントを作ってみる

2021.11.25

Introduction

最近巷で話題のWebAssembly(Wasm)。
ブラウザで動く比較的新しいタイプのコードですが、 Rustなどプログラミング言語のコンパイル対象となり、ウェブ上で実行することができます。
Rust + Wasmについてはyewseedpercyなど
フレームワークもいろいろでてきいます。

本稿ではWasmでWebアプリケーションのフロントを作成できるRust用フレームワーク、yewを使ってみます。

WebAssembly?

WebAssembly(Wasm)は、Webブラウザなどの環境で効率よく実行できる、
安全&コンパクトなバイナリ形式の低レベルアセンブリ風言語です。
WasmはJavaScriptと並行して動くようになっており、連携させることも可能です。

WasmはすでにW3Cがコア仕様をW3C勧告として公開しています。
HTML、CSS、JavaScriptに続く、Webブラウザで実行できる
プログラムを記述できる第4の言語となっています。

なお、Wasmは人間が直接読んだり記述することを想定していません。
C++やRustなどの言語でコンパイルされ、バイナリ(.wasm拡張子)で出力されるように設計されています。
wat-wasmなどのツールを使えばwasmからwat(WasmのS式ベーステキスト表現)
へ変換することは可能です。

WebAssemblyはまだ新しめの技術であり、事例はまだ多くありませんが、
Web/serverless/暗号系/科学技術計算/ゲーム系などで採用が進むと予想されているみたいです。

Yew?

YewはWebAssemblyをつかってマルチスレッドなWebアプリのフロントを開発できるフレームワークです。
Rustを使って開発できる、Reactのようなコンポーネントベースのフレームワークです。
JavaScriptとの互換があったり既存のjsモジュールと統合したりすることが可能です。
他にも仮想DOMを使った効率のいいレンダリングが可能だったり、
JSXみたいなHTMLマクロが使えたりします。
ドキュメントも用意されているので、
とりあえず動かすくらいは簡単にできます。

Environment

以下の環境で試しました。 rustc/cargoはすでに実行可能な前提です。

  • OS : MacOS 10.15.7
  • Rust: 1.56.1

Setup

TrunkはRust用WASM Webアプリのbundlerです。
HTMLファイルを介してWASMやjsファイルやアセットをbuild&bundleしてくれます。
cargoでインストールしておきましょう。

% cargo install --locked trunk

また、Cargo.tomlを編集するのが面倒なので、cargo-editをインストールします。

% cargo install cargo-edit

プロジェクトを作成し、yewやその他必要なcrateをインストールします。

% cargo new --lib yew-app && cd yew-app
% cargo add yew
% cargo add wasm-bindgen
% cargo add wasm-logger

Create Yew App

ではYewを使ってシンプルなTodoアプリをつくってみます。
フィールドに値を入力してボタンを押すとそれが画面に表示されるだけのプログラムです。

HTMLを作成

アクセスする対象のhtmlファイル(yew-app/index.html)を作成します。
wasm.jsをimportして初期化している以外は何もないファイルです。

<!doctype html>
<html lang="ja">
    <head>
        <meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
        <title>Yew Sample App</title>
        <script type="module">
            import init from "./wasm.js"
            init()
        </script>
    </head>
    <body></body>
</html>

Rustプログラムを作成

yew-app/src/main.rsを下記内容で作成します。

/// main.rs

use std::fmt::{Debug};
use yew::{html, Component, ComponentLink, Html, ShouldRender,InputData};



#[derive(Clone, Debug, Default)]
pub struct Todo {
    pub title: String,
    pub msg: String,
}

impl Todo {
    pub fn render(&self) -> Html {
        html! {
            <div>
                <p>{ format!("title: {}, msg : {}", self.title ,self.msg) }</p>
            </div>
        }
    }
}

struct Model {
    link: ComponentLink<Self>,
    todos: Vec<Todo>,
    title:String,
    msg:String
}

#[derive(Debug)]
enum Msg {
    PostTodo,
    SetTitle(String),
    SetMsg(String)
}

impl Component for Model {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        let todos: Vec<Todo> = Vec::new();
        Self {
            link,
            todos: todos ,
            title:String::new(),
            msg:String::new()}
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        log::info!("Update: {:?}", msg);
        match msg {
            Msg::PostTodo => {
                let todo = Todo {
                    title: self.title.clone(),
                    msg: self.msg.clone(),
                };
                self.todos.push(todo);
                self.title.clear();
                self.msg.clear();

            }
            Msg::SetTitle(value) => {
                log::info!("value: {:?}",value);
                self.title = value;
            }
            Msg::SetMsg(value) => {
                log::info!("value: {:?}",value);
                self.msg = value;
            }

        }
        true
    }

    fn view(&self) -> Html {
        html! {
               <div>
                   <h1>{"Todo App"}</h1>
                       {"title :"}  <input type="text" value=self.title.clone()  oninput=self.link.callback(|e:InputData| Msg::SetTitle(e.value))/><br/>
                       {"msg :"}  <input type="text" value=self.msg.clone() oninput=self.link.callback(|e:InputData| Msg::SetMsg(e.value))/><br/>
                       <button onclick=self.link.callback(|_| Msg::PostTodo)>{ "Post Todos" }</button>
                   <div>
                       { for self.todos.iter().map(Todo::render) }
                   </div>
               </div>
        }
    }
}

fn main() {
    wasm_logger::init(wasm_logger::Config::default());
    yew::start_app::<Model>();
}

このテンプレートはルートにyewのComponentを設定し、
Modelと呼ばれる要素(入力フィールドとボタンで構成)を作ります。
main関数に記述しているyew::start_app::()がyewアプリを起動し、
htmlのbodyタグをマウントします。

動作確認

では動かしてみましょう。
プロジェクトのルートでtrunk serveとすればビルドしてWebサーバを起動してくれます。
* デフォルトでは8080ポートで起動

% trunk serve
Nov 25 10:20:26.583  INFO 📦 starting build
Nov 25 10:20:26.586  INFO spawning asset pipelines
Nov 25 10:20:27.119  INFO building yew-app
・・・
Nov 25 10:20:31.008  INFO 📡 server listening at 0.0.0.0:8080
Nov 25 10:24:21.702  INFO applying new distribution
Nov 25 10:24:21.707  INFO ✅ success

ブラウザでアクセスして動かしてみてください。
ログの出力もされています。

コード説明

コード上のポイントを説明します。  

ログ

このアプリではwasm_loggerをつかってログを出力しています。
ブラウザコンソールで簡単にログを表示させることができます。

wasm_logger::init(wasm_logger::Config::default());

// Logging
log::info!("Some info");
log::error!("Error message");

詳細はここらへんを確認。

モデルとコンポーネント

ModelはComponentに含まれるデータや
ComponentLink(コールバックやメッセージを管理)を持っています。
この構造体に対してComponentを実装します。

ComponentはYewを構成するブロックです。
状態を管理し、Component自身をDOMへレンダリング可能です。
Componentトレイトを実装することで作成できます。
メソッドはいくつかありますが、
今回はComponent作成時のcreate、メッセージ受信時にそれを処理するupdate、
自身のレイアウトを定義するviewを実装しています。

struct Model {
    link: ComponentLink<Self>,
    todos: Vec<Todo>,
    title:String,
    msg:String
}

impl Component for Model {
    type Message = Msg;
    type Properties = ();

    fn create(_props: Self::Properties, link: ComponentLink<Self>) -> Self {
        let todos: Vec<Todo> = Vec::new();
        Self {
            link,
            todos: todos ,
            title:String::new(),
            msg:String::new()}
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::PostTodo => {
              ・・・
            }
            Msg::SetTitle(value) => {
              ・・・
            }
            Msg::SetMsg(value) => {
              ・・・
            }
        }
        true
    }

    fn view(&self) -> Html {
        html! { #HTML要素を記述 }
    }
}

Componentについての詳細はここを確認してください。

html!マクロでHTML記述

Componentのviewメソッドで実行しているhtml!マクロを使うと、
JSXのようにHTMLコードを宣言的に記述できます。
マクロ内では{ }をつかってRustコードを書けます。

・・・
    fn view(&self) -> Html {
        html! {
               <div>
                   <h1>{"Todo App"}</h1>
                       {"title :"}  <input type="text" value=self.title.clone()  oninput=self.link.callback(|e:InputData| Msg::SetTitle(e.value))/><br/>
                       {"msg :"}  <input type="text" value=self.msg.clone() oninput=self.link.callback(|e:InputData| Msg::SetMsg(e.value))/><br/>
                       <button onclick=self.link.callback(|_| Msg::PostTodo)>{ "Post Todos" }</button>
                   <div>
                       { for self.todos.iter().map(Todo::render) }
                   </div>
               </div>
        }
    }
・・・

要素の描画

ModelではTodo構造体をVecで持っています。
Todo構造体はhtml!マクロを使って自身を描画する
renderメソッドを実装しています。

#[derive(Clone, Debug, Default)]
pub struct Todo {
    pub title: String,
    pub msg: String,
}

impl Todo {
    pub fn render(&self) -> Html {
        html! {
            <div>
                <p>{ format!("title: {}, msg : {}", self.title ,self.msg) }</p>
            </div>
        }
    }
}

ComponentのviewでVecをiterateして表示しています。

{ for self.todos.iter().map(Todo::render) }

Sumarry

今回はYewフレームワークをつかってRustでWasmプログラムを動かしてみました。
Yewでは既存jsを動かしたりもできるので、それも試してみたいと思います。

References