JavaScript
Rust
Web
WebAssembly

Rust+WebAssemblyのフレームワークを作ってみた

はじめに

クライアントサイドでのWebアプリケーション開発の発展は留まるところを知りません。しかし、JavaScriptは

  • 型安全ではない
  • ビルドのための環境構築が大変

といった課題をずっと抱えています。前者はTypeScriptでかなり改善されていますが、ベースがJavaScriptなので当初から型をもつ言語には及びません。後者もWebpackでかなり改善されましたが、依然としてビルドの設定は一番苦労するところです。WebAssemblyが主要ブラウザでサポートされた今、これらのソリューションとしてRust+WebAssemblyのフレームワークを提案します。

フレームワークの要件

フレームワークは既存の課題を解決するだけでなく、使いやすいことが重要だと思います。今回フレームワークを設計するにあたり、4つの要件を定義しました。

  • 型安全
  • ビルドツール不要
  • 標準HTMLに近いテンプレート記法
  • コンポーネント指向

Why Rust?

数ある言語の中からRustを選んだ理由は以下の2つです。

  • 型安全であること(安全すぎるかも)
  • 強力なマクロをサポートしていること(現段階ではnightly版のみ)

Rustは静的型付けの言語でジェネリクスや型推論もサポートしており、型安全の点に関しては申し分ありません。さらに、Rustが持つ強力なマクロ機能を活用することで、ビルドツールを不要にします。

テンプレートマクロ

テンプレートマクロは今回のフレームワークの肝とも言える部分です。テンプレートマクロにより、標準HTMLに近いテンプレート記法とビルドツール不要の開発を両立しています。

html!("
    <div>
        <h1 bind:class='state.class_name'>
            Hello World!
        </h1>
        <p>
            {state.text}
        </p>
        <p>
            count: {state.count.to_string()}<br />
        </p>
        <button on:click='handle_click'>click me!</button>
    </div>
")
}

マクロhtml!(...)に与えられた文字列はHTMLテキストとしてコンパイル前にRustコードに変換されます。この変換にはnightly Rustのプラグインマクロを利用しています。テンプレート内では、{...}による式展開やbind:属性名=による動的な属性、on:イベント名="..."によるイベントハンドリングが可能です。

テンプレートのレンダリング

レンダリングに関しては既存のJavaScriptフレームワークと同様です。VirtualDOMを返す関数(createElementhと呼ばれることが多い)によってVirtualDOMを生成し、VirtualDOMからDOMを生成する流れになっています。

関数によるテンプレート定義

html!(...)の部分は関数h(dom_type: DOMType, children: Vec<VirtualDOM>, attributes: Vec<Attribute>) -> VirtualDOMによる表現に展開されます。

h(DOMType::Element("document"), vec![
    h(DOMType::Element("div"), vec![
        h(DOMType::Element("h1"), vec![
            h(DOMType::Text(format!("Hello World!")), vec![], vec![])
        ], vec![Attribute::String{name:"class", value:state.count.to_string()}]),
        h(DOMType::Element("p"), vec![
            h(DOMType::Text(format!("{}", state.text)), vec![], vec![])
        ], vec![]),
        h(DOMType::Element("p"), vec![
            h(DOMType::Text(format!("count: {}", state.count.to_string())), vec![], vec![]),
            h(DOMType::Element("br"), vec![], vec![])
        ], vec![]),
        h(DOMType::Element("button"), vec![
            h(DOMType::Text(format!("click me!")), vec![], vec![])
        ], vec![Attribute::EventHandler(handle_click)])
    ], vec![])
], vec![])

hはVue.jsやReactでいうcreateElementです。概念的にはReactのJSX→JavaScriptの変換と同様ですが、マクロで処理されるのでビルドツールは要りません。

VirtualDOMの生成

hを実行することで、VirtualDOMが生成されます。VirtualDOMの実体はシンプルな構造体で定義しています。

pub struct VirtualDOM {
    name: String,
    dom_type: DOMType,
    children: Vec<VirtualDOM>,
    attributes: Vec<Attribute>,
    dom_id: Option<u64>
}

DOMの操作

VirtualDOMが出来たら、あとはリアルなDOMを生成するだけです。と言いたいところですが、果たしてWebAssemblyからDOMを操作できるのでしょうか。ここは一番読者の皆さまにとって一番気になるところでしょう。結論としてはWebAssemblyからDOMを操作することは一応可能です。ただし、WebAssemblyから直接DOMを操作するAPIはありません。WebAssemblyからJavaScriptのメソッドを呼び出す仕組みがあるので、それを活用してDOMを操作していきます。このあたりを自力で書くのは不可能ではないですが、結構面倒です。今回はstdwebというライブラリを使いました。stdwebを使うと、JavaScriptライクな書き方でDOMを操作することができます。

let parent_dom = document().get_element_by_id("parent").unwrap();
let child_dom = document().create_element("div");
parent_dom.append_child(&child_dom);

コンポーネント

コンポーネント指向は今やフレームワークに不可欠です。今回のフレームワークでもコンポーネント指向を取り入れましたが、実装にはかなり苦労しました。Rustにおける変数の生存期間とコンポーネントのライフサイクルが一致しないため、内部でコンポーネントをstaticなHashMapで管理しています。その影響で、コンポーネントやステートへのアクセスごとに排他制御や参照カウントを考慮する仕様になりました。
ここではfizzbuzzカウンタの実装例を交えつつ、コンポーネントの仕様を説明していきます。

  • main.rs
#![feature(proc_macro)]
extern crate rju;
extern crate rju_macro;

use rju::*;
use rju_macro::{html};

mod components;

fn main() {
    Renderer::render("test", components::main_component::factory)
}
  • main_component.rs
#![feature(proc_macro)]
extern crate rju;
extern crate rju_macro;

use rju::*;
use rju_macro::{html};

use components;

struct State {
    count: i32,
    text: String
}

impl BaseState for State {
    fn as_any(&mut self) -> &mut Any {
        self
    }
}

fn render(c: Arc<Mutex<Component>>) -> VirtualDOM {
    let mut component = c.lock().unwrap();
    let mut s = component.state.lock().unwrap();
    let mut state = s.as_any().downcast_mut::<State>().unwrap();
    html!("
        <div>
            <h1>
                Hello World!
            </h1>
            <p>
                {state.text}
            </p>
            <p>
                count: {state.count.to_string()}<br />
            </p>
            <button on:click='handle_click'>click me!</button>
            <component:child_component />
        </div>
    ")
}

fn handle_click(c: Arc<Mutex<Component>>) {
    let mut component = c.lock().unwrap();
    let mut s = component.state.lock().unwrap();
    let mut state = s.as_any().downcast_mut::<State>().unwrap();
    state.count = state.count + 1;
    state.text = fizzbuzz(state.count);
    component.update();
}

fn fizzbuzz(n: i32) -> String {
    match n {
        n if n % 5 == 0 && n % 3 == 0 => "fizzbuzz".to_string(),
        n if n % 5 == 0 => "buzz".to_string(),
        n if n % 3 == 0 => "fizz".to_string(),
        _ => n.to_string()
    }
}

pub fn factory() -> InitialComponent {
    InitialComponent {
        render: render,
        state: Arc::new(Mutex::new(State {
            count: 0,
            text: String::from("")
        }))
    }
}

fn child_component() -> InitialComponent {
    components::child::factory()
}

ファクトリー関数

コンポーネントはモジュール外から生成できる必要があります。そのため、コンポーネント構造体を返すファクトリー関数をコンポーネントのモジュールに定義しておく必要があります。

pub fn factory() -> InitialComponent {
    InitialComponent { // コンポーネント初期化用の構造体
        render: render, // VirtualDOMを生成する関数
        state: Arc::new(Mutex::new( // 参照カウントと参照ロック
            State { // 状態を管理する構造体
                count: 0,
                text: String::from("")
            }
        ))
    }
}

ここで返すInitialComponent構造体にはVirtualDOMを生成する関数と状態を管理する構造体を与える必要があります。それぞれに関しては後述します。

VirtualDOMを生成する関数

VirtualDOMを生成する関数ではコンポーネントから状態を取得したのち、VirtualDOMを生成します。

fn render(c: Arc<Mutex<Component>>) -> VirtualDOM {
    // Component構造体の参照カウントと参照ロック
    let mut component = c.lock().unwrap();
    // State構造体の参照カウントと参照ロック
    let mut s = component.state.lock().unwrap();
    // State構造体をダウンキャスト
    let mut state = s.as_any().downcast_mut::<State>().unwrap();
    html!("
        <div>
            <h1>
                Hello World!
            </h1>
            <p>
                {state.text}
            </p>
            <p>
                count: {state.count.to_string()}<br />
            </p>
            <button on:click='handle_click'>click me!</button>
        <component:child_component /><!-- 子コンポーネント -->
        </div>
    ")
}

{...}bind:属性名=の属性値はRustのコードとして展開されます。on:イベント名="..."の属性値にはイベントをハンドリングする関数名を与えます。また、子コンポーネントをレンダリングする場合はcomponent:子コンポーネントのファクトリ関数名の要素を定義すればよいです。

状態を管理する構造体

状態を管理する構造体はBaseStateを実装すれば良いです。as_any関数はダウンキャストにやむを得ず必要な関数です(無くしたい・・・)。

struct State {
    count: i32,
    text: String
}

impl BaseState for State {
    fn as_any(&mut self) -> &mut Any {
        self
    }
}

サンプル

http://nobuhito.ibaraki.jp/rju_example/

ソースコード

https://github.com/niba1122/rju にあります。
辛うじてデモが動く程度の最小限の実装しか出来ていません。コミット大歓迎です。

これからやりたいこと

  • DOMの差分更新
  • ディレクティブ(条件や繰り返し。Vue.jsのv-ifとかv-for的な)
  • イベントオブジェクトを扱えるようにする
  • CSS対応

まとめ

テンプレート、コンポーネント、状態というフレームワークに不可欠な最低限の機能をなんとかRustで実装してみました。フレームワーク自体はまだまだ未完成ですが、このフレームワークのコンセプトがWebAssembly時代のアプリケーション開発の先駆けになればと思っています。