2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WASM ComponentでWEBフロントエンドを実装する

Last updated at Posted at 2025-12-01

はじめに

この記事はviteでプロジェクトを生成した場合に作成されるカウンターアプリをWASM componentベースに魔改造するもの。
通常のWASM(Core WASM)の場合、rustで実装可能なcrateはいくつかあるが、なーぜーか、WASM componentベースは見つけることができなかったのでチャレンジしてみたといういきさつ。

TR;DL

補足3でrun_scopeの実行を1回にできたことで、increment/decrement実行のたびに削除できないSignal等が溜まり続ける問題が解決できたので、
いいから全体のコード見せろというせっかちさんの方用のレポジトリも公開しておきます。

事前準備

記事作成時点での最新バージョンを選択している。

  • rust 1.91.1
    • rustupで事前にwasm32-wasip2のターゲットを追加しておく
  • wit-bindgen-cli 0.48.1
    • WASM componentのインターフェース定義であるWITからソースコードの雛形を作るツール
    • cargo (b)install でインストール可能
    • binstallでもソースビルドになるので、ちと時間がかかる
  • wac-cli 0.8.1
    • 複数のWASM component間のインポートを解決して新たなWASMとして出力してくれるツール
    • cargo (b)install でインストール可能

今回はフロントエンドのパッケージマネージャとしてpnpmを使用している。必須ではないのでnpmや他のパッケージマネージャの場合は読み替えてくだされ。

UIフレームワークの選定

rustで実装可能なhookもしくはfine-grained reactivityベースな仮装DOMについて雑に調べたところ、以下のUIフレームワークが見つかった。

  • momenta
    • DSLはjsx likeな書き味
      • fine-grained reactivityベースで構築
    • wasm-bindgen/web-sysはfeature flagにより切り替え可能
  • dioxus
    • DSLはrustの初期化構文likeな書き味
      • Virtual DOMベースで構築
    • wasm-bindgen/web-sysはfeature flagにより切り替え可能
  • cycamore
    • DSLはMarkaby/DSLっぽい書き味
      • fine-grained reactivityベースで構築
    • APIレベルでどっぷりwasm-bindgen/web-sysに依存
  • yew
    • DSLはjsx likeな書き味
      • Hookベースで構築
    • APIレベルでどっぷりwasm-bindgen/web-sysに依存
  • leptos
    • DSLはjsx likeな書き味
      • fine-grained reactivityベースで構築
    • APIレベルでどっぷりwasm-bindgen/web-sysに依存
  • iced
    • Elm Architectureを採用
    • DSLでのUI構築はサポートしてなさそう?
  • sauron
    • Elm Architectureを採用
    • DSLはjsx likeな書き味
    • wasm-bindgen/web-sysはfeature flagにより有効化
      • と見せかけてfeature flagを無効化するとビルドできないため、実質どっぷり依存

wasm-bindgen/web-sysWASM Component ModelCannonical ABIとは非互換なため、これらにがっつり依存するcrateは最優先で除外。
残った中で、

  • icedDSLがなさそうだったので除外
  • sauronwasm-bindgen/web-sysfeatureを無効にするとビルド不能となりお話にならないお粗末さだったので除外

dioxusmomentaの頂上決戦。
dioxusDSLwell-known以外の属性(data-xxxx)は文字列埋め込みでちょっと直感的じゃなかったので、momentaを選定した。

全体構成

全体のフォルダ構成は以下の通り

/
├── crates
│   ├── core
│   │   ├── api_core
│   │   └── core_types
│   └── wasi
│       ├── types
│       └── view_counter
├── examples
│   └── counter_app
└── wit

momentafine-grained reactivityを採用してはいるが、外部からlibcrateとして使用する場合、構築結果のキャッシュを使用することはできず、毎回いちからDSLからの再構成となる。

そのためアプローチとして、

  • momentaで仮想DOMを構築
  • mt-domクレートで差分を抽出
  • 差分からパッチセットを導出して、DOM APIで描画

とする。

これを踏まえて、

  • coreWASMターゲットとしてビルドするクレートに依存として加えるサブクレート
    • core/core_typesはパッチセットの型定義
    • core/api_coremomentaのコンポーネントからパッチセットの導出を担わせる
  • wasi/typesもまたWASMターゲットとしてビルドするクレートに依存として加えるサブクレート
    • core/core_typesで定義されたパッチセットの型からWASM Componentとして外部に後悔する型への変換を担わせる
  • wasi/view_counterWASMターゲットとしてカウンターコンポーネントを実装
  • examples/counter_appviteを介してフロントエンドを実装

とする。

WIT定義

まずはインターフェース定義であるWITwitフォルダに作成する。

1. wit/render.witにファイルを作成し、以下の内容で編集する。

package ritalin:enterprise-counter-app@0.0.1;

interface render-types {
    record element {
        tag: string,
        ns: option<string>,
    }

    record attr {
        name: string,
        ns: option<string>,
        value: string,
    }

    record remove-attr {
        name: string,
        ns: option<string>,
    }

    record event-handler {
        event: string,
        handler: string,
        bubbling: bool,
        context-name: string,
        for: option<string>,
    }

    record selector {
        value: string,
    }

    record child-indexes {
        indexes: list<u32>,
    }

    record pop-count {
        value: u32,
    }

    variant patch {
        //
        // Query element
        //

        // Set mount root as current element specifid the selector.
        find-mount-root(selector),
        // Set as element specified the path indexes.
        find-child(child-indexes),

        //
        // Element management
        //

        // Create new element node.
        new-element(element),
        // Create new text node.
        new-content(string),
        // Pop from stack specified numbers, and apend as child element to the parent element.
        append-as-children(pop-count),
        // Pop from stack specified numbers, and replace the children of the parent element.
        replace-node(pop-count),

        //
        // Attribute management
        //

        // Create new attribute.
        new-attribute(attr),
        // Drop specified attribute
        remove-attribute(remove-attr),
        // Pop from stack specified numbers, and apply attributes for the parent element.
        apply-attributes(pop-count),

        //
        // Event management
        //

        // Create new event handler.
        new-event-listener(event-handler),
        // Pop from stack specified numbers, and add as event listerner.
        add-event-listeners(pop-count),
    }
}

interface renderer {
    use render-types.{patch};

    render: func(patches: list<patch>);
}
  • このファイルはパッチの型と外部からインポートするrender関数のインターフェースを定義する

2. wit/view.witにファイルを作成し、以下の内容で編集する。

package ritalin:enterprise-counter-app@0.0.1;

interface view {
    use render-types.{patch};

    resource counter {
        constructor(init: u32, selector: string, context: string);
        increment: func();
        decrement: func();
        snapshot: func() -> list<patch>;
    }
}
  • このファイルはカウンターコンポーネントのインターフェースを定義する

3. wit/app.witにファイルを作成し、以下の内容で編集する。

package ritalin:enterprise-counter-app@0.0.1;

interface app {
    enum command {
        increment,
        decrement,
        render,
    }

    use view.{counter};

    resource counter-dispatcher {
        constructor(counter: counter);
        dispatch-all: func();
        push: func(command: command);
    }

    create-counter: func(init: u32, selector: string, context: string) -> counter;
}
  • このファイルはDispatcherのインターフェースを定義する

counter resourceはコンストラクタを持つように定義されているが、これはWASM Componentの実装内でのみ使用可能で、外部からはnew counter()のように呼び出すことはできない。
これは、WASM ComponentのアーキテクチャとしてWASMのリニアメモリーへの直接のアクセスを禁止しているから。そうでもしないとCannonical ABIを担保できないわけだし。
そのためcreate-counterというファクトリをインターフェースに付け加えた。

4. wit/world.witにファイルを作成し、以下の内容で編集する。

package ritalin:enterprise-counter-app@0.0.1;

world view-world {
    export view;
}

world render-types-world {
    export render-types;
}

world render-world {
    include render-types-world;
    export render-types;
    export renderer;
}

world app-world {
    import renderer;
    import view;
    export app;
}
  • このファイルは全体のwit定義をとりまとめている
    • WASM ComponentはWorldが最小単位となる

実装

witとして定義したインターフェースについて、以下の対応付けで実装していく。

  • render-types-world:
    • crates/core/core_typesでパッチセットの型をenumで定義
    • crates/wasi/typesでFrom traitを実装して外部公開の方に変換
  • view-world:
    • crates/wasi/view_counterでコンポーネントを実装
  • render-world:
    • examples/counter_apptypescriptでコンポーネントを実装
  • app-world:
    • examples/counter_apppkg/appと名付けたrustのクレートを用意しそこに実装

ワークスペースとしてrustの実装を行うため、ルートフォルダにCargo.tomlを作成し、以下のように編集しておく

[workspace]
members = []
resolver = "2"

[workspace.dependencies]
wit-bindgen = "0.48.0"
mt-dom = "0.59.2"

# momentaはwasm-bindgen/web-sys依存を外しておく
momenta = { version = "0.2.3", default-features = false, features = ["dom", "full-reactivity"] }

[profile.release]
# ダウンロード時間最小化の設定
opt-level = "z"
codegen-units = 1

パッチ定義の実装(内部)

1. crates/core/core_typesにクレートを作成する。

cargo new --lib --vcs none crates/core/core_types
  • 別クレートの依存はないため、crates/core/core_types/Cargo.tomlの編集は不要

2. crates/core/core_types/src/lib.rsを編集する。

#[derive(Debug)]
pub enum PatchAction {
    // query
    FindMountRoot { selector: String },
    FindChild { path: Vec<u32> },
    // element
    NewElement { tag: String, ns: Option<String> },
    NewContent { text: String },
    AppendAsChildren { pop_count: usize },
    ReplaceNode { pop_count: usize },

    // attribute
    NewAttribute { name: String, value: String, ns: Option<String> },
    RemoveAttribute { name: String, ns: Option<String> },
    ApplyAttributes { pop_count: usize },

    // event
    NewEventListener { event: String, handler: String, bubbling: bool, context_name: String, target_for: Option<String> },
    AddEventListeners { pop_count: usize },
}
  • 一つのDOM APIが一つのパッチとなるようにしている

パッチ定義の実装(外部)

1. crates/wasi/typesにクレートを作成する。

cargo new --lib --vcs none crates/wasi/types

2. crates/wasi/types/cargo.tomlを編集する。

[dependencies]
+ core_types = { path = "../../core/core_types" }
+ wit-bindgen = { workspace = true }

3. render-types-worldからバインディングの実装を生成する。

wit-bindgen rust \
    --out-dir crates/wasi/types/src/bindings/ \
    --world render-types-world \
    --default-bindings-module "types::bindings::render_types_world" \
    --pub-export-macro \
    ./wit
  • witフォルダに複数のファイルに分割してwitを定義した場合、witフォルダのパスを指定する必要があることに注意
  • 作成されるmod名はworld名のスネークケースとなる
  • このworldは関数定義をしていないため、exportマクロによる公開は不要なのだが、実行しないと使用マクロとしてRust Analyzerが警告を発するため、pubとして公開しこれを回避する
  • 別のクレートに依存として参照させるため、--default-bindings-moduleオプションは、crate::〜ではなくtypes::〜と指定する必要があることに注意

3. crates/wasi/types/src/binding.rsを作成し、Fromトレイトを実装する。

use core_types::PatchAction;

mod render_types_world;

pub use render_types_world::exports::ritalin::enterprise_counter_app::render_types;

impl From<PatchAction> for render_types::Patch {
    fn from(value: PatchAction) -> Self {
        match value {
            PatchAction::FindMountRoot { selector } => render_types::Patch::FindMountRoot(render_types::Selector{value: selector}),
            PatchAction::FindChild { path } => render_types::Patch::FindChild(render_types::ChildIndexes{indexes: path}),
            PatchAction::NewElement { tag, ns } => render_types::Patch::NewElement(render_types::Element{ tag, ns }),
            PatchAction::NewContent { text } => render_types::Patch::NewContent(text),
            PatchAction::AppendAsChildren { pop_count } => render_types::Patch::AppendAsChildren(render_types::PopCount{value: pop_count as u32}),
            PatchAction::ReplaceNode { pop_count } => render_types::Patch::ReplaceNode(render_types::PopCount{value: pop_count as u32}),
            PatchAction::NewAttribute { name, value, ns } => render_types::Patch::NewAttribute(render_types::Attr{ name, ns, value }),
            PatchAction::RemoveAttribute { name, ns } => render_types::Patch::RemoveAttribute(render_types::RemoveAttr { name, ns }),
            PatchAction::ApplyAttributes { pop_count } => render_types::Patch::ApplyAttributes(render_types::PopCount{value: pop_count as u32}),
            PatchAction::NewEventListener { event, handler, bubbling, context_name, target_for } => {
                render_types::Patch::NewEventListener(render_types::EventHandler{ event, handler, bubbling, context_name, for_: target_for })
            }
            PatchAction::AddEventListeners { pop_count } => render_types::Patch::AddEventListeners(render_types::PopCount{value: pop_count as u32}),
        }
    }
}

pub use render_types_world::export as export_render_types;

4. APIとして公開するため、crates/wasi/types/src/lib.rsを編集する。

mod bindings;

pub mod render {
    pub use super::bindings::render_types::*;
    pub use super::bindings::export_render_types;
}
  • export_render_typesを公開しているのは警告回避のため

差分パッチ出力の実装

1. crates/core/core_apiにクレートを作成する。

cargo new --lib --vcs none crates/core/core_api

2. crates/core/core_api/Cargo.tomlを編集する。

[dependencies]
+ core_types = { path = "../core_types" }
+ momenta = { workspace = true }
+ mt-dom = { workspace = true }

3. crates/core/core_api/src/virtual_dom.rsを作成し、以下を定義する。

  • 属性分類のための型
  • monentaのコンポーネント定義に値をバインドしてNodeを構築する関数
  • monentaNode型をmt-domの型に変換する関数

4. 属性分類のための型定義

#[derive(PartialEq, Clone, Debug)]
pub enum Attr {
    // イベント名(click等)の格納先
    Event {
        name: String,
        allow_event_bubbling: bool,
    },
    // Chrome / Direfoxで使用可能となっているcommandのcommandforの組み合わせの格納先
    Command {
        name: String,
        target: Option<String>,
    },
    // data-xxxxの格納先
    Data {
        name: String,
    },
    // 組み込みの属性名の格納先
    Builtin {
        name: String,
    },
    // ReactJS等にもあるキー定義
    // 今回はmt-domでの差分構築での指定のために用意
    Keyed(Option<String>),
}

5. monentaのコンポーネント定義に値をバインドしてNodeを構築する関数の定義。

pub fn create_view_node<C, S>(state: S) -> mt_dom::Node<String, String, Vec<String>, Attr, String> 
where 
    C: momenta::nodes::Component, 
    S: Into<C::Props>,
    C::Props: Send + 'static
{
    let props = state.into();
    let node = momenta::signals::run_scope(move || C::render(&props), |_| {});
    to_vdom(&node)
}
  • rustのAPIとして、From<T> for Tがブラケット実装されているため、S: Into<C::Props>のジェネリック境界で安全に変換できる

6. monentaNode型をmt-domの型に変換する関数の定義

Counterコンポーネントを実装するうえで必要最低限のみ実装しています。あしからず。


pub type VNode = mt_dom::Node<String, String, Vec<String>, Attr, String>;
pub type VElement = mt_dom::Element<String, String, Vec<String>, Attr, String>;
pub type VAttribute = mt_dom::Attribute<String, Attr, String>;
pub type Patch<'a> = mt_dom::Patch<'a, String, String, Vec<String>, Attr, String>;

pub fn to_vdom(parent: &momenta::nodes::Node) -> VNode {
    match parent {
        momenta::nodes::Node::Element(element) => {
            let children = eval_children(element.children());
            let attrs = eval_attr(element.attributes());
            let self_closing = element.children().is_empty();

            mt_dom::element_ns(None, element.tag().into(), attrs, children, self_closing)
        }
        momenta::nodes::Node::Text(s) => mt_dom::leaf(vec![s.into()]),
        momenta::nodes::Node::Fragment(nodes) => mt_dom::fragment(eval_children(nodes)),
        momenta::nodes::Node::Comment(_) | momenta::nodes::Node::Empty => mt_dom::fragment(vec![]),
    }
}

fn eval_children(nodes: &[momenta::nodes::Node]) -> Vec<VNode> {
    let mut children = vec![];
    let mut iter = nodes.iter().peekable();
    while let Some(node) = iter.next() {
        match node {
            momenta::nodes::Node::Text(s1) => {
                let mut s = vec![s1.into()];
                while let Some(momenta::nodes::Node::Text(s2)) = iter.peek() {
                    s.push(s2.clone());
                    iter.next();
                }
                children.push(mt_dom::leaf(s));
            }
            _ => {
                children.push(to_vdom(node));
            }
        }
    }
    children
}

fn eval_attr(attrs_raw: &BTreeMap<String, String>) -> Vec<VAttribute> {
    let mut attrs: Vec<VAttribute> = Vec::with_capacity(attrs_raw.len());
    let mut events = HashMap::new();
    let mut commands = HashMap::new();
    let mut allow_event_bubbling = false;
    let mut command_target = None;

    for (name, value) in attrs_raw {
        match name {
            name if name.starts_with("on_") => {
                events.insert(&name[3..], value);
            }
            name if name == "data-bubbling" => {
                allow_event_bubbling = true;
            }
            name if name == "data-command" => {
                commands.insert(&name[5..], value);
            }
            name if name == "data-commandfor" => {
                command_target = Some(value.as_str());
            }
            name if name == "data-key" => {
                attrs.push(mt_dom::attr_ns(None, Attr::Keyed(Some(value.into())), "".into()));
            }
            name if name.starts_with("data-") => {
                attrs.push(mt_dom::attr_ns(
                    None,
                    Attr::Data { name: name[5..].into() },
                    value.into(),
                ));
            }
            _ => {
                attrs.push(mt_dom::attr_ns(
                    None,
                    Attr::Builtin { name: name.clone() },
                    value.into(),
                ));
            }
        };
    }

    for (name, value) in events {
        attrs.push(mt_dom::attr_ns(
            None,
            Attr::Event {
                name: name.into(),
                allow_event_bubbling,
            },
            value.into(),
        ));
    }
    for (name, value) in commands {
        attrs.push(mt_dom::attr_ns(
            None,
            Attr::Command {
                name: name.into(),
                target: command_target.map(String::from),
            },
            value.into(),
        ));
    }

    attrs
}
  • momentaNodemt-domNodeを1対1の対応で変換しているだけ
  • 後のパッチに変換のためにここで属性を分類している
  • 本当のところはライフサイクルパラメータを指定して、&'a strとして組み立てたかったが、WASM Componentを実装するためのトレイトがstaticのライフサイクルを養成するため、泣く泣くStringで組み立ててる

7. crates/core/core_api/patch.rsを作成し、mt-domNodeからパッチ生成する関数を実装を定義する。

use core_types::PatchAction;
use super::virtual_dom;

type Patch<'a> = mt_dom::Patch<'a, String, String, Vec<String>, virtual_dom::Attr, String>;

pub fn make_patch_action(selector: &str, context_name: &str, view: &virtual_dom::VNode, old_view: Option<&virtual_dom::VNode>) -> Vec<core_types::PatchAction> {
    if old_view.is_none() {
        let empty = Some(mt_dom::node_list(std::iter::empty()));
        return make_patch_action(selector, context_name, view, empty.as_ref());
    }

    let mut actions = vec![
        PatchAction::FindMountRoot { selector: selector.into() }
    ];

    let old_view = mt_dom::element_ns(None, "div".into(), std::iter::empty(), old_view.into_iter().cloned(), false);
    let new_view = mt_dom::element_ns(None, "div".into(), std::iter::empty(), [view.clone()], false);

    let patches: Vec<Patch> = mt_dom::diff::diff_recursive(
        &old_view,
        &new_view,
        &mt_dom::TreePath::new([]),
        &virtual_dom::Attr::Keyed(None),
        &|_, _| false,
        &|_, _| false,
    );
    
    for p in patches {
        expand_patch_action_internal(context_name, p, &mut actions);
    }

    actions
}

fn expand_patch_action_internal(context_name: &str, patch: Patch, actions: &mut Vec<PatchAction>) {
    if ! patch.patch_path.is_empty() {
        actions.push(PatchAction::FindChild { path: patch.patch_path.path.iter().map(|&p| p as u32).collect() });
    }

    match patch.patch_type {
        mt_dom::PatchType::InsertBeforeNode { nodes:_ } => todo!(),
        mt_dom::PatchType::InsertAfterNode { nodes:_ } => todo!(),
        mt_dom::PatchType::AppendChildren { children: raw_children } => {
            let mut children = Vec::with_capacity(raw_children.len());
            let mut pop_count = 0;
            expand_children_patch_actions(context_name, raw_children.iter().cloned(), &mut children, &mut pop_count); 
            if pop_count > 0 {
                actions.extend(children);
                actions.push(PatchAction::AppendAsChildren { pop_count });
            }
        }
        mt_dom::PatchType::RemoveNode => todo!(),
        mt_dom::PatchType::MoveBeforeNode { nodes_path:_ } => todo!(),
        mt_dom::PatchType::MoveAfterNode { nodes_path:_ } => todo!(),
        mt_dom::PatchType::ReplaceNode { replacement } => {
            let mut children = Vec::with_capacity(replacement.len());
            let mut pop_count = 0;
            expand_children_patch_actions(context_name, replacement.iter().cloned(), &mut children, &mut pop_count); 
            'children: {
                actions.extend(children);
                actions.push(PatchAction::ReplaceNode { pop_count });
                break 'children;
            }
        }
        mt_dom::PatchType::AddAttributes { attrs: raw_attrs } => {
            expand_attributes_patch_actions(context_name, raw_attrs.iter().cloned(), actions);
        }
        mt_dom::PatchType::RemoveAttributes { attrs:_ } => todo!(),
    }
}

fn expand_children_patch_actions<'a>(context_name: &'a str, raw_children: impl Iterator<Item = &'a virtual_dom::VNode>, children: &mut Vec<PatchAction>, pop_count: &mut usize) {
    for child in raw_children {
        match child {
            mt_dom::Node::Element(element) => {
                children.push(PatchAction::NewElement{ ns: element.namespace.clone(), tag: element.tag.clone() });
                *pop_count += 1;

                // attrs
                expand_attributes_patch_actions(context_name, element.attributes().iter(), children);
                // children
                let mut grand_children = Vec::with_capacity(element.children.len());
                let mut grand_child_count = 0;
                expand_children_patch_actions(context_name, element.children().iter(), &mut grand_children, &mut grand_child_count);
                if grand_child_count > 0 {
                    children.extend(grand_children);
                    children.push(PatchAction::AppendAsChildren { pop_count: grand_child_count });
                }
            }
            mt_dom::Node::NodeList(_nodes) => todo!(),
            mt_dom::Node::Fragment(_nodes) => todo!(),
            mt_dom::Node::Leaf(texts) => {
                children.push(PatchAction::NewContent { text: texts.join("") });
                *pop_count += 1;
            }
        }
    }
}

fn expand_attributes_patch_actions<'a>(context_name: &'a str, raw_attrs: impl Iterator<Item = &'a virtual_dom::VAttribute>, actions: &mut Vec<PatchAction>) {
    let mut attrs = Vec::with_capacity(raw_attrs.size_hint().0);
    let mut events = vec![];

    for attr in raw_attrs {
        let name = match &attr.name {
            virtual_dom::Attr::Event { name, allow_event_bubbling } => {
                events.push(PatchAction::NewEventListener { 
                    event: name.into(), 
                    handler: attr.value.join(" "), 
                    bubbling: *allow_event_bubbling, 
                    context_name: context_name.into(),
                    target_for: None,
                });
                continue;
            }
            virtual_dom::Attr::Command { name, target } => {
                if let Some(target) = target {
                    attrs.push(PatchAction::NewAttribute { name: "commandfor".into(), value: target.into(), ns: None });
                };
                if let Some(handler) = attr.value.first() && handler.starts_with("--") {
                    events.push(PatchAction::NewEventListener { 
                        event: "command".into(), 
                        handler: handler.clone(), 
                        bubbling: false, 
                        context_name: context_name.into(), 
                        target_for: target.clone(),
                    });
                };
                name.into()
            }
            virtual_dom::Attr::Data { name } => format!("data-{name}"),
            virtual_dom::Attr::Builtin { name } => name.into(),
            virtual_dom::Attr::Keyed(_) => continue,
        };
        match &attr.value {
            v if v == &["false"] => {
                attrs.push(PatchAction::RemoveAttribute{ name, ns: attr.namespace.clone() });
            }
            _ => {
                attrs.push(PatchAction::NewAttribute { name, value: attr.value.join(" "), ns: attr.namespace.clone() });
            }
        }
    }

    'attrs: {
        let pop_count = attrs.len();
        if pop_count > 0 {
            actions.extend(attrs);
            actions.push(PatchAction::ApplyAttributes { pop_count });
        }
        break 'attrs;
    }
    
    'events: {
        let pop_count = events.len();
        if pop_count > 0 {
            actions.extend(events);
            actions.push(PatchAction::AddEventListeners { pop_count });
        }
        break 'events;
    }
}
  • 新旧のmt-domNodeから差分を収集し、core/core_typesで定義したPatchActionに変換している

8. crates/core/core_api/lib.rsを編集し、モジュールを公開する

pub mod virtual_dom;
pub mod patch;

Counterコンポーネントの実装

1. crates/wasi/view_counterにクレートを作成する。

cargo new --lib --vcs none crates/wasi/view_counter

2. crates/wasi/view_counter/Cargo.tomlを編集する。

+ [lib]
+ crate-type = ["cdylib"]

[dependencies]
+ types = { path = "../types" }
+ core_api = { path = "../../core/core_api" }
+ wit-bindgen = { workspace = true }
+ momenta = { workspace = true }
  • crate-typeとして共有ライブラリを含めておくこと
    • これを忘れると成果物としてwasmが作成されない(N敗)

3. view-worldからバインディングの実装を生成する。

wit-bindgen rust \
    --out-dir crates/wasi/view_counter/src/bindings/ \
    --world view-world \
    --default-bindings-module "crate::bindings::view_world" \
    --with "ritalin:enterprise-counter-app/render-types@0.0.1=types::render" \
    --generate-all \
    wit
  • render-typesインターフェースについては、事前に生成したwasi/typesクレートを参照するため--withオプションで指定している
    • このオプションのキーはバージョンも含めてインターフェースの完全名を指定する必要がある
    • --with="render-types=types::render"のように指定すると、「そんなインターフェースねぇ」ってエラーが出る
  • --generate-allwit-bindgen-cliのヘルプでも明記されているようにwithオプションで指定したインターフェースをスキップして生成してくれる
  • 別クレートから依存として参照されるわけではないため、--default-bindings-moduleオプションはcrate::〜となっていることに注意

4. counter resourceの実装

生成されたバインディングにFooGuestという名前のトレイトが定義されている。今回ならCounterGuest
resourceの場合このトレイトの実装を与える必要がある。

crates/wasi/view_counter/src/bindings.rsを作成し、以下の定義を行う。

  • counter resourceの実装
  • 状態とmonentaのUIコンポーネントの実装
  • WASM Componentとしての公開

5. counter resourceの実装

mod view_world;

use std::cell::RefCell;

use core_api::virtual_dom::VNode;
use momenta::nodes::rsx;
use view_world::exports::ritalin::enterprise_counter_app::view;

struct CounterImpl {
    inner: RefCell<CountState>,
    selector: String,
    context: String,
    old_view: RefCell<Option<VNode>>,
}

impl view::GuestCounter for CounterImpl {
    fn new(init: u32,selector: String,context: String,) -> Self {
        Self {
            inner: RefCell::new(CountState::new(init, &context)),
            selector,
            context,
            old_view: RefCell::new(None),
        }
    }

    fn increment(&self,) -> () {
        self.inner.borrow_mut().increment();
    }

    fn decrement(&self,) -> () {
        self.inner.borrow_mut().decrement();
    }
    
    fn snapshot(&self,) -> Vec::<types::render::Patch> {
        let state = self.inner.borrow();

        let new_view: VNode = core_api::virtual_dom::create_view_node::<Counter, CountState>(state.clone());
        let old_view = self.old_view.replace(Some(new_view.clone()));

        core_api::patch::make_patch_action(&self.selector, &self.context, &new_view, old_view.as_ref())
        .into_iter()
        .map(From::from)
        .collect()
    }
}
  • resourceを実装する際の注意点として、メソッドのレシーバーは共有参照(&self)であるということ
    • resourceの状態を変更する場合は内部可変性で行う必要がある
    • 内部状態の変更を行うため、resourceの内部状態を保持する型を別途用意し、RefCellでラップしてフィールドに持たせている

6. 状態とmonentaのUIコンポーネントの実装

momentaのドキュメントにあったものを改変して使用

#[derive(Clone, Debug)]
struct CountState {
    count: u32,
    id: String,
}
impl CountState {
    pub fn new(init: u32, id: &str) -> Self {
        Self { count: init, id: id.into() }
    }

    pub fn increment(&mut self) {
        self.count += 1;
    }

    pub fn decrement(&mut self) {
        if self.count == 0 { return }

        self.count -= 1;
    }
}

#[momenta::nodes::component]
fn Counter(state: &CountState) -> momenta::nodes::Node {
    let count = momenta::signals::create_signal(state.count);
    let inc_button_id = format!("inc-{}", state.id);
    let dec_button_id = format!("dec-{}", state.id);

    rsx! {
        <div>
            <h1>"Counter: " {count}</h1>
            <button id={&inc_button_id} data:commandfor={&inc_button_id} data:command="--increment">
                "Increment"
            </button>
            <button id={&dec_button_id} disabled={count <= 0} data:commandfor={&dec_button_id} data:command="--decrement">
                "Decrement"
            </button>
        </div>
    }
}
  • commandcommandformomentaでは未定義だったため、dataプレフィックスをつけて宣言している
    • baselineに乗ってるのがChrome系とFirefoxだけだからね。しょうがないね

7. WASM Componentとしての公開

生成されたバインディング定義には、WASM Componentとしての公開するためのトレイトGuestも定義されている。
これの実装も必要。

struct Component;

impl view::Guest for Component {
    type Counter = CounterImpl;
}

view_world::export!(Component);
  • 外部に公開するものでもないためprivateの可視性でOK
  • 生成されたバインディング定義で用意されているexportマクロを実行する必要があることに注意
    • これを行わないと、wasmexportセクションに生えてこなくなる

8. crates/wasi/view_counter/src/lib.rsを編集する。

mod bindings;

フロントエンド

1. viteでパッケージを作成する。

mkdir examples && cd examples
pnpm create vite@latest 

対話モードを進めていくことでパッケージのプロジェクトが作成される。
以下の構成とした。

作成先: examples/counter_app
パッケージ名: counter_app
言語: typescript
フレームワーク: Vanilla

2. package.jsonを編集する。

{
  "scripts": {
    "dev": "vite",
+    "prebuild": "build:compose",
    "build": "tsc && vite build",
+    "build:compose": "./scripts/build.wasm.sh",
    "preview": "vite preview"
  },
+  "dependencies": {
+    "@bytecodealliance/preview2-shim": "^0.17.5"
+  },
  "devDependencies": {
+    "@bytecodealliance/jco": "^1.15.3",
+    "@types/node": "^24.10.1",
    "typescript": "~5.9.3",
    "vite": "^7.2.2"
  }
}
  • wasmの構成するのに数ステップのコマンドを叩く必要があるため、./scripts/build.wasm.sh(後述)を呼ぶbuild:composeを追加
  • WASM ComponentからJavascript/Typescriptのバインディングを生成するために、@bytecodealliance/jcoパッケージを追加
  • @types/nodevite.config.tsの編集のため
  • 作成されたWASM Componentwasi:cli等の入出力を行うための実装をインポートとして要求する
    • しかしブラウザは当然これらを実装していない
    • そのために用意されたのが@bytecodealliance/preview2-shim

3. scripts/build.wasm.shを作成し、shbangでの実行のため実行権限を与えておく。

mkdir scripts
touch scripts/build.wasm.sh
chmod +x scripts/build.wasm.sh

scripts/build.wasm.shを以下の内容で編集する。

#!/usr/bin/env bash
set -euo pipefail

cargo build --package view_counter --target wasm32-wasip2 --release
cargo build --package app --target wasm32-wasip2 --release

wac plug \
    --plug ../../target/wasm32-wasip2/release/view_counter.wasm \
    --output ../../target/wasm32-wasip2/release/app-m.wasm \
    ../../target/wasm32-wasip2/release/app.wasm 

pnpm exec jco transpile \
    --name counter \
    --out-dir ./pkg/_transpiled \
    --instantiation async \
    --no-nodejs-compat \
    ../../target/wasm32-wasip2/release/app-m.wasm

  • スクリプトでは以下のことを行なっている
    • counter-worldを実装したwasmapp-worldを実装したwasm(後述)をビルド
    • wac-cliでこれらを合成
    • jco transpileを実行し、フロントエンドから呼ぶためのバインディングを生成
      • jcoによって制されるバインディングはデフォでインスタンス化されたwasmからエクスポートされた関数をesmとして公開する形で作成される
      • 今回、DOM APIを実行するために、TypescriptWASM Componentを実装し、インポートとして提供する必要がある
      • そのため--instantiation asyncオプションを指定している
        • これによりエクスポートされた関数ではなくwasmをインスタンス化する関数が公開される

4.\ examples/counter_app/tsconfig.jsonを編集する。

{
  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "counter": ["./pkg/_transpiled/counter"],
+      "counter/app-types": ["./pkg/_transpiled/interfaces/ritalin-enterprise-counter-app-app"],
+      "counter/render-types": ["./pkg/_transpiled/interfaces/ritalin-enterprise-counter-app-render-types"],
+      "counter/renderer": ["pkg/_transpiled/interfaces/ritalin-enterprise-counter-app-renderer"]
+    },
    "target": "ES2022",
}
  • jco transpileesmとしてバインディングを生成するが、package.jsonまでは作成しない
  • ソースからの呼び出しを簡易化するため、パスのエイリアスを設定している

5. examples/counter_app/vite.config.tsを作成する。

import { defineConfig } from 'vite';
import * as path from 'node:path';

export default defineConfig({
    resolve: {
        alias: {
        counter: path.resolve(__dirname, './pkg/_transpiled/counter'),
        },
    },
});
  • トランスパイル先のパスを解決するだけ

Dispatcherの実装

この節では、cargoworkspaceルートから実行していることに注意。

フロントエンドからの呼び出しの入り口となるapp-worldを実装する。

1. examples/counter_app/pkg/appにクレートを作成する。

cargo new --lib --vcs none examples/counter_app/pkg/app

2. examples/counter_app/pkg/app/Cargo.tomlを編集する

+ [lib]
+ crate-type = ["cdylib"]

[dependencies]
+ types = { path = "../../../../crates/wasi/types" }
+ core_api = { path = "../../../../crates/core/core_api" }
+ wit-bindgen = { workspace = true }
  • wasmとしてビルドするため、crate-typeに共有ライブラリとしての生成を加える

3. app-worldからバインディング定義を生成する。

wit-bindgen rust \
    --out-dir examples/counter_app/pkg/app/src/bindings/ \
    --world app-world \
    --default-bindings-module "crate::bindings::app_world" \
    --generate-all \
    --with "ritalin:enterprise-counter-app/render-types@0.0.1=types::render" \
    wit
  • WASM Component内部から、render関数を呼ぶため、wasi/typesクレートで定義された@types::render`で参照するようリマップ

4. counter-worldの時と同様、resourceの実装とWASM Componentとしての公開するため、examples/counter_app/pkg/app/src/bindings.rsを作成する

mod app_world;

use std::{cell::RefCell, collections::VecDeque};
use app_world::exports::ritalin::enterprise_counter_app::app;

use crate::bindings::app_world::ritalin::enterprise_counter_app::renderer;

struct DispatcherImpl {
    counter: RefCell<app::Counter>,
    commands: RefCell<VecDeque<app::Command>>,
}

impl app::GuestCounterDispatcher for DispatcherImpl {
    fn new(counter: app::Counter,) -> Self {
        Self {
            counter: RefCell::new(counter),
            commands: RefCell::new(VecDeque::from([app::Command::Render])),
        }
    }

    fn dispatch_all(&self,) -> () {
        if self.commands.borrow().is_empty() { return };

        let commands = self.commands.take();

        for command in commands {
            match command {
                app::Command::Increment => {
                    self.counter.borrow_mut().increment();
                    self.push(app::Command::Render);
                }
                app::Command::Decrement => {
                    self.counter.borrow_mut().decrement();
                    self.push(app::Command::Render);
                }
                app::Command::Render => {
                    let patches = self.counter.borrow().snapshot();
                    renderer::render(&patches);
                }
            }
        }
    }

    fn push(&self,command: app::Command,) -> () {
        self.commands.borrow_mut().push_back(command);
    }
}

struct Component;

impl app::Guest for Component {
    type CounterDispatcher = DispatcherImpl;
    
    fn create_counter(init: u32,selector: String,context: String,) -> app::Counter {
        app::Counter::new(init, &selector, &context)
    }
}

app_world::export!(Component);

5. examples/counter_app/pkg/app/lib.rsを編集する。

mod bindings;

DOM APIを使ってrender関数を実装

現状のWASM Componentでは内部からDOM APIを呼ぶ方法は確立されていない。
通常のwasmで呼べているのはweb-sysクレートが気合いでインポート定義を実装してくれてるからにすぎない。
また、web-sysクレートはwasm-bindgenベースであり、Cannonical ABIに対応していない。
そのためJavascript/TypescriptWASM Componetを実装し、インポートとして提供する必要がある。

幸い、jcoは素のjs/tsの関数をWASM Componetの関数にリフトしてインポートできるようバインディングを生成してくれている。

ということで、examples/counter_app/src/main.tsの内容を以下のように刷新する。

  • innserHtmlのパートの微調整
  • wasm:cli等のshimのインポート
  • render-worldの実装
  • イベントループの実装

1. innserHtmlのパートの微調整

import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation'
import './style.css'
import typescriptLogo from './typescript.svg'
import viteLogo from '/vite.svg'
import { instantiate, type ImportObject } from 'counter'
import type { Patch } from 'counter/render-types'
import type { Command, CounterDispatcher } from 'counter/app-types'

document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
  <div>
    <a href="https://vite.dev" target="_blank">
      <img src="${viteLogo}" class="logo" alt="Vite logo" />
    </a>
    <a href="https://www.typescriptlang.org/" target="_blank">
      <img src="${typescriptLogo}" class="logo vanilla" alt="TypeScript logo" />
    </a>
    <h1>Vite + TypeScript</h1>
    <p class="read-the-docs">
      Click on the Vite and TypeScript logos to learn more
    </p>
    <div class="card"></div>
  </div>
`
  • 基本的にviteが生成したものを踏襲
    • <p class="read-the-docs"> <div class="card"></div>を改修したのみ

2. wasm:cli等のshimのインポート

const builtinImports = new WASIShim().getImportObject<"0.2.3">();
  • jcoにて、WASM Componentの関数をエクスポートさせる場合は、内部でいい感じにwasm:cli等のshimのインポートを行なってくれる
    • --instantiationオプションを指定する場合、これを明示的に行う必要がある
  • '@bytecodealliance/preview2-shimでは'@bytecodealliance/preview2-shim/instantiation'でWASIShim`が公開されている
    • これをつかって、インポート定義を取得できる

3. render-worldの実装

let dispatcher: CounterDispatcher;

interface AttrDecl { tag: 'attr-decl', ns?: string, name: string, value: string };
interface RemoveAttrDecl { tag: 'remove-attr-decl', ns?: string, name: string };
type EventDecl = { event: string, handler: string, context: string, bubbling: boolean, for?: string };

const conterImports = {
  'ritalin:enterprise-counter-app/render-types': {},
  'ritalin:enterprise-counter-app/renderer': {
    render(patches: Patch[]) {
      // dumpPatchAction(patches);

      const elements: (Element | Text)[] = [];
      const attrs: (AttrDecl | RemoveAttrDecl)[] = [];
      const events: EventDecl[] = [];
      let current: Element | Text | undefined = undefined;

      for (const p of patches) {
        switch (p.tag) {
          case 'find-mount-root': {
            const node = document.querySelector(p.val.value);
            if (!node) { throw new Error(`Can not find mount point (selector: ${p.val.value})`) }
            current = node;
            elements.push(current);
            break;
          }
          case 'find-child': {
            const parent = elements.at(-1);
            if (!(parent instanceof Element)) { throw new Error(`A parent has to be "Element" (current: ${parent})`) }

            const node = findDescendantNode(parent, p.val.indexes);
            if (!node) { throw new Error(`Can not find dscendant node (indexes: [${p.val.indexes}])`) }
            current = node;
            elements.push(current);
            break;
          }
          case 'new-element': {
            current = p.val.ns? document.createElementNS(p.val.ns, p.val.tag) : document.createElement(p.val.tag);
            elements.push(current);
            break;
          }
          case 'new-content': {
            elements.push(document.createTextNode(p.val));
            break;
          }
          case 'append-as-children': {
            const popCount = p.val.value;
            const children = elements.splice(elements.length - popCount, popCount);

            const parent = elements.at(-1);
            if (!(parent instanceof Element)) { throw new Error(`A parent has to be "Element" (current: ${parent})`) }

            current = parent;
            current?.append(...children);
            break;
          }
          case 'replace-node': {
            const popCount = p.val.value;
            const children = elements.splice(elements.length - popCount, popCount);

            const dest = elements.pop();
            if (!dest) { throw new Error("No replacing destination") }
            
            dest.replaceWith(...children);
            break;
          }
          case 'new-attribute': {
            attrs.push({ tag: 'attr-decl', ns: p.val.ns, name: p.val.name, value: p.val.value });
            break;
          }
          case 'remove-attribute': {
            attrs.push({ tag: 'remove-attr-decl', ns: p.val.ns, name: p.val.name });
            break;
          }
          case 'apply-attributes': {
            const parent = elements.at(-1);
            if (!(parent instanceof Element)) { throw new Error(`A parent has to be "Element" (current: ${parent})`) }
            
            for (const attr of attrs.splice(0)) {
              if (attr.tag === 'attr-decl') {
                attr.ns? parent.setAttributeNS(attr.ns, attr.name, attr.value) : parent.setAttribute(attr.name, attr.value);
              }
              else if (attr.tag === 'remove-attr-decl') {
                attr.ns? parent.removeAttributeNS(attr.ns, attr.name) : parent.removeAttribute(attr.name);
              }
            }
            
            break;
          }
          case 'new-event-listener': {
            events.push({
              event: p.val.event,
              handler: p.val.handler,
              context: p.val.contextName,
              bubbling: p.val.bubbling,
              for: p.val.for,
            });
            break;
          }
          case 'add-event-listeners': {
            for (const decl of events.splice(0)) {
              const target = findEventTarget(decl) ?? elements.at(-1);
              if (!(target instanceof Element)) { throw new Error(`A evebt target has to be "Element" (current: ${target})`) }

              target.addEventListener(decl.event, (ev: Event) => {
                const command = resolveCommand(decl, ev);
                if (command) {
                  dispatcher.push(command);
                }
                if (! decl.bubbling) {
                  ev.preventDefault();
                }
              });
            }
            break;
          }
          default: {
            const _exhaustiveCheck: never = p;
            void _exhaustiveCheck;
          }
        }
      }
    },
  },
};

function findDescendantNode(root: Element, indexes: Uint32Array): Element | Text | null {
  const queue = Array.from(indexes).reverse();
  let node: Node = root;
  let i: number | undefined;

  while ((i = queue.pop()) !== undefined) {
    if ((i < 0) || (i >= node.childNodes.length)) return null;

    node = node.childNodes[i];
  }

  if (node instanceof Text) {
    return node;
  }
  else if (node instanceof Element) {
    return node;
  }

  return null;
}

function findEventTarget(decl: EventDecl): Element | Document | null {
  if (decl.for) {
    return document.getElementById(decl.for);
  }
  if (decl.bubbling) {
    return document;
  }

  return null;
}

function resolveCommand(decl: EventDecl, _ev: Event): Command | undefined {
  switch (decl.handler) {
    case "--increment": return 'increment';
    case "--decrement": return 'decrement';
  }

  return undefined;
}
  • DOM APIとパッチを1対1に対応させているので単に呼ぶだけ
  • 注意点として、インポートのキーにはバージョンを含めてはならない
    • しかし、jco transpileで生成される型定義では、'ritalin:enterprise-counter-app/render-types@0.0.1'のようになっている
    • ここは泣く泣くanyでお茶を濁している

4. イベントループの実装

const defaultLoader = undefined as unknown as (path: string) => WebAssembly.Module;

let wasm = await instantiate(
  defaultLoader, { ...builtinImports, ...conterImports, } as unknown as ImportObject, WebAssembly.instantiate
);

const counter = wasm.app.createCounter(0, ".card", "counter");
dispatcher = new wasm.app.CounterDispatcher(counter);
runMain();

function runMain() {
  dispatcher.dispatchAll();

  requestAnimationFrame(runMain);
}
  • 事前に作成されたインポート定義を渡してwasmのインスタンス化 & rAFでイベントループを回してるだけ

実行

pnpm run build:compose && pnpm run devで実行できる。

以下の感じで置き換わってれば成功

image.png

興味本位でcommandとcommandforを使ってみたため、この属性をサポートしてないsafariではちゃんと動きません。あしからず。

複数のカウンターをマウントしてみる

1. htmlを編集し複数マウントポイントを用意する。

-    <div class="card"></div>
+    <div class="card-a"></div>
+    <div class="card-b"></div>
  </div>

2. コマンドのディスパッチ先を振り分ける。

まずディスパッチャーの宣言を変更する。

- let dispatcher: CounterDispatcher;
+ type Dispatchers = {
+  "counter-a": CounterDispatcher,
+  "counter-b": CounterDispatcher,
+ }
+ let dispatchers: Dispatchers;

次いで、イベントリスナーでコマンドのディスパッチ先を振り分ける。

-                   dispatcher.push(command);
+                   dispatchers[decl.context as keyof Dispatchers].push(command);                

最後、ディスパッチャの初期化

- const counter = wasm.app.createCounter(0, ".card", "counter");
- dispatcher = new wasm.app.CounterDispatcher(counter);
+ dispatchers = {
+   "counter-a": new wasm.app.CounterDispatcher(wasm.app.createCounter(0, ".card-a", "counter-a")),
+   "counter-b": new wasm.app.CounterDispatcher(wasm.app.createCounter(0, ".card-b", "counter-b")),
+ };
runMain();

function runMain() {
-  dispatcher.dispatchAll();
+  dispatchers["counter-a"].dispatchAll();
+  dispatchers["counter-b"].dispatchAll();

  requestAnimationFrame(runMain);
}

実行して以下のような表示になって、個別にハンドリングできれば成功

image.png

おわりに

viteのテンプレートとして用意されたカウンターアプリをWASM Componentを使用して無駄におおげさに実装することができました。

補足

実は現在の実装には致命的な欠陥がある。
それは、毎回momentarun_scopeをコールしているということ。
この関数は、scope_idを発行し、そのidに対してコンポーネントの描画関数をグローバルに登録している。
scope_idに関連づけられたSignalが発行されると、scope_idに関連づけられた描画関数が呼ばれて再描画が行う構造。

ボタンのクリックで異なるscope_idで描画関数が登録され続けるため、メモリを食い続けることが予期される。
この挙動を回避するためにはコンポーネントの外にSignalを持ち出し、run_scopeのコールを1度にする。
その上でボタンのクリックの際に該当するSignalに通知を送る必要があるが、持ち出す良い方法が思いつかなかった。

未検証ではあるが、以下のようにすればもちだせるかも・・・?

thread_local内にSignalセットをもたせ、

struct SignalSetA { ... }
struct SignalSetB { ... }

thread_local! {
    static component_a_signals: RefCell<OnceCell<SignalSetA>> = OnceCell::new();
    static component_b_signals: RefCell<OnceCell<SignalSetB>> = OnceCell::new();
}

コンポーネント内で生成するSignalをここに退避する。
run_scopeの呼び出しですべてのSignalを初期化後に回収する。
回収後は、

component_a_signals.with(|cell| *cell.borrow_mut() = OnceCell::new());
component_b_signals.with(|cell| *cell.borrow_mut() = OnceCell::new());

として、再度初期化すれば、異なるマウントポイントでの再利用も行えるかも(希望的観測)

補足2

Signalを回収できたのならば、Signal::set(...)はスコープ外からでも呼べる。
シグナルの発火後、再描画が行われ結果のNodeは、Signal生成時のrun_scopeの2つ目のクロージャーに到達する(今回は|| {}として潰してる)。

クロージャーの内外でmpsc channelを構築し、クロージャー内でchannelのSenderから送信、Signal::set(...)から抜けた後にchannelのReceiverからNodeを受け取れば、その後の差分評価は行えそう。
wasmだしシングルスレッドなので、std::sync::mpsc::channel()で十分そう。
試してないので爆死するかもだけど・・・。

補足3

補足1と補足2を踏まえて、以下のアプローチにより1回のrun_scopeの呼び出しを実現した。

  • momenta::signals::run_scope(render_fn, callback)において
    • render_fnで初回の呼び出しのみSignalを構築する
      • Signalには状態の全てを乗せる
      • 構築したSignalstd::sync::mpscのチャネル経由で回収する
    • render_fnSignalを構築した上で、加えてComponentの描画も行う
    • callbackで、描画された仮想DOMを同じくstd::sync::mpscのチャネル経由で送信する
    • ディパッチされたイベントに基づき、アクションを実行し、次いで回収したSignalに全状態を乗せて発火させる
    • 生成された仮想DOMをstd::sync::mpscチャネル経由で受け取り、差分パッチを作成して呼び出し元に返す

以下詳細。

シグナルの構築

core/core_apiクレートに以下の関数を追加する。

pub fn make_signal<C, S>(state: S, context: &str) -> Result<(Signal<C::Props>, Receiver<momenta::nodes::Node>), SignalError>
where
    C: momenta::prelude::Component,
    S: Into<C::Props>,
    C::Props: std::fmt::Debug + Clone + SignalValue + PartialEq + Send + 'static
{
    let (tx0, rx0) = std::sync::mpsc::channel();
    let (tx1, rx1) = std::sync::mpsc::channel();

    let guard = std::sync::atomic::AtomicBool::new(false);
    let props = state.into();

    momenta::signals::run_scope(
        move || { 
            let signal = momenta::signals::create_signal(props.clone());
            if ! guard.swap(true, std::sync::atomic::Ordering::SeqCst) {
                tx0.send(signal.clone()).expect("Faild to takeaway the signal");
            }
            let props = signal.get();
            C::render(&props)
        }, 
        move|node| {
            tx1.send(node.clone()).expect("Faild to takeaway the node");
        }
    );

    let signal = rx0.recv().map_err(|_| SignalError::Init(context.into()))?;

    Ok((signal, rx1))
}
  • 初めにSignalrenderされた仮想DOM回収用のstd::sync::mpscチャネルを用意する
  • Signalの作成を初回のrender_fnの呼び出しのみにするためAtomicBoolによるガードを用意する
    • これはrender_fnimpl FnMut() -> Node + Send + 'staticな制約を持つためAtomicBoolでしかガードできなかった
  • render_fn内にて
    • Signalを作成する
      • 毎回Signalを作成しているように見えるが、scope_idsignal_idの組でシングルトンとなっているため、scope_idsignal_idが変わらなければ毎回作成されることはない
      • scope_idsignal_idは内部的にはともに単調増加する数値で管理されており、signal_idrender後リセットされるため、順序さえ合っていれば常に同じ組の値となる
    • AtomicBoolによるガードがfalse、即ち初回の呼び出しのみstd::sync::mpscチャネル経由でSignalを送りつける
    • Signalが保持する状態を取り出し、コンポーネントを再描画する
  • callback内にて
    • std::sync::mpscチャネル経由で仮想DOMのノードを送りつける
  • render_fnで送りつけられたSignalを回収する
    • WASM Componentはシングルスレッドなので、このような暴挙が許される
  • Signalと仮想DOM受信用のチャネルを呼び出し元に返す

コンポーネント定義の修正

外部でSignalを初期化するため、そのあたりのコードを変更する

#[momenta::nodes::component]
fn Counter(state: &CountState) -> momenta::nodes::Node {
-    let count = momenta::signals::create_signal(state.count);
    let inc_button_id = format!("inc-{}", state.id);
    let dec_button_id = format!("dec-{}", state.id);

    rsx! {
        <div>
-            <h1>"Counter: " {count}</h1>
+            <h1>"Counter: " {state.count}</h1>
            <button id={&inc_button_id} data:commandfor={&inc_button_id} data:command="--increment">
                "Increment"
            </button>
-            <button id={&dec_button_id} disabled={count <= 0} data:commandfor={&dec_button_id} data:command="--decrement">
+            <button id={&dec_button_id} disabled={state.count <= 0} data:commandfor={&dec_button_id} data:command="--decrement">
                "Decrement"
            </button>
        </div>
    }
}

コンポーネントリソースの初期化

CounterImpl型を以下のように変更:

struct CounterImpl {
+    render_signal: RefCell<Signal<CountState>>,
+    render_channel: Receiver<momenta::nodes::Node>,
    inner: RefCell<CountState>,
    selector: String,
    context: String,
    old_view: RefCell<Option<VNode>>,
}

core_api::make_signal関数から返されたSignalReceiverをフィールドで保持するようにしただけ。

wit/view.witの定義を以下のように変更した上で

interface view {
    use render-types.{patch};

    resource counter {
-        constructor(init: u32, selector: string, context: string);
+        create: static func(init: u32, selector: string, context: string) -> result<counter, string>;
        increment: func();
        decrement: func();
        snapshot: func() -> list<patch>;
    }
}

ゲストコードの実装を変更:

-    fn new(init: u32,selector: String,context: String,) -> Self {
-        Self {
-            inner: RefCell::new(CountState::new(init, &context)),
+    fn create(init: u32,selector: String,context: String,) -> Result<view::Counter,String> {
+        let state = CountState::new(init, &context);
+        let (signal, channel) = core_api::virtual_dom::make_signal::<Counter, CountState>(state.clone(), &context).map_err(|err| err.to_string())?;
+
+        Ok(view::Counter::new(Self {
+            render_signal: RefCell::new(signal),
+            render_channel: channel,
+            inner: RefCell::new(state),
            selector,
            context,
            old_view: RefCell::new(None),
        }
    }

witの定義を静的関数に変更したため、明示的にview::Counter::newを呼ぶ必要があることに注意。

シグナルが保持できる値に関する注意点

momenta::signals::create_signal

pub fn create_signal<T, I>(init: I) -> Signal<T>
where
    T: SignalValue + PartialEq + 'static,
    I: Into<SignalInit<T>> { ... }

と定義されており、Signalが保持する値はInto<SignalInit<T>>を要請している。
数値や文字列等の単純な型は用意されているが、任意のstructは用意されていないため、自前で実装する必要がある。
以下のようにすればOK

impl<T: SignalValue> From<T> for SignalInit<T> {
    fn from(value: T) -> Self {
        SignalInit::Value(value)
    }
}

状態の更新

CounterImplincrementdecrementを修正する。

    fn increment(&self,) -> () {
-        self.inner.borrow_mut().increment();
+        let mut state = self.inner.borrow_mut();
+        state.increment();
+        println!("inc: {}", state.count);
+
+        let signal = self.render_signal.borrow();
+        signal.set(state.clone());
    }

    fn decrement(&self,) -> () {
-        self.inner.borrow_mut().decrement();
+        let mut state = self.inner.borrow_mut();
+        state.decrement();
+        println!("dec: {}", state.count);
+
+        let signal = self.render_signal.borrow();
+        signal.set(state.clone());
    }

状態変更後、回収したSignalに状態を乗せて発火させている。
これにより、Signalに結びつくscope_idrender_fncallbackが内部で自動的に呼ばれる。

差分パッチの構築

CounterImplsnapshotを修正する。

    fn snapshot(&self,) -> Vec::<types::render::Patch> {
-        let state = self.inner.borrow();
-        let new_view: VNode = core_api::virtual_dom::create_view_node::<Counter, CountState>(state.clone());
+        let Some(new_view) = self.render_channel.try_recv().ok() else { return vec![] };
+        let new_view = core_api::virtual_dom::to_vdom(&new_view);
        let old_view = self.old_view.replace(Some(new_view.clone()));

        core_api::patch::make_patch_action(&self.selector, &self.context, &new_view, old_view.as_ref())
        .into_iter()
        .map(From::from)
        .collect()
    }
}

ディスパッチされたアクションにより仮想DOMが構築されてチャネルに送信されるので、回収して差分パッチを更新している。

appクレートの修正

Counterリソースの初期化方法を変更したため、app.witも変更する

(app.wit)

-    create-counter: func(init: u32, selector: string, context: string) -> counter;
+    create-counter: func(init: u32, selector: string, context: string) -> result<counter, string>;

app::Guestの実装も変更

impl app::Guest for Component {
    type CounterDispatcher = DispatcherImpl;
    
-    fn create_counter(init: u32,selector: String,context: String,) -> app::Counter {
+    fn create_counter(init: u32,selector: String,context: String,) -> Result<app::Counter, String> {
-        app::Counter::new(init, &selector, &context)
+        app::Counter::create(init, &selector, &context)
    }
}
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?