21
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WebAssemblyAdvent Calendar 2019

Day 9

Rust製Webフロントエンドフレームワーク「Kagura」の紹介

Last updated at Posted at 2019-12-08

概要

RustがWebAssemblyにコンパイルできるので、Rust製のWebフロントエンドフレームワークを開発してみました。この記事はそのフレームワークの紹介です。

参考:https://github.com/SoundRabbit/Kagura
Kaguraを使用して作成したWebページ:https://soundrabbit.github.io/

基本構成

KaguraはTEA(The Elm Architecture)をベースとしたコンポーネント指向のWebフロントエンドフレームワークです。

コンポーネントは状態(State)を持つことができ、その状態(State)をメッセージ(Msg)をもとにupdate関数により更新し、その状態(State)をもとにrender関数により描画します。これは以下の図のようなイメージで表すことができます。(ここではKaguraの外の世界をBrowserとしています。)

アセット 1.png

実際には、直接的に上のような動作を行うことができないので、Kaguraが間に入ることでページの描画・更新を行います。

アセット 4.png

たとえば、あるボタンを押すごとにそのボタンのテキストが1ずつ増加するだけのWebアプリを書いてみると以下のようになります。

extern crate kagura;
extern crate wasm_bindgen;

use kagura::prelude::*;
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() {
    kagura::run(Component::new(init, update, render), "app");
}

type State = u32;

enum Msg {
    CountUp,
}

struct Sub();

fn init() -> (State, Cmd<Msg, Sub>) {
    (0, Cmd::none())
}

fn update(state: &mut State, msg: Msg) -> Cmd<Msg, Sub> {
    match msg {
        Msg::CountUp => {
            *state = *state + 1;
            Cmd::none()
        }
    }
}

fn render(state: &State) -> Html<Msg> {
    Html::button(
        Attributes::new(),
        Events::new().on_click(|_| Msg::CountUp),
        vec![Html::text(state.to_string())],
    )
}

update には msg に基づく state の更新方法を記述し、 render には state をどのように描画するのかを記述しています。また on_click(|_| Msg::CountUp) があることで、ボタンがクリックされたときにKaguraは |_| Msg::CountUp を実行し、 Msg::CountUp というメッセージを取得します。そして、Kaguraはこのメッセージを State への可変参照と共に update に渡します。

実際にKaguraが関数を実行する順序は以下のようになります。

  1. Kaguraは init 関数を実行することで初期状態を取得します。今回の場合、初期状態は0になります。
  2. Kaguraは render 関数に初期状態を渡します。
  3. render 関数は渡された状態に基づいて描画する内容を返します。
  4. Kaguraは render 関数の戻り値に基づき描画を行います。
  5. 描画したボタンがクリックされると、Kaguraは on_click の引数として渡されたクロージャを実行し、その戻り値としてメッセージを取得します。
  6. Kaguraは取得したメッセージをその時の状態への可変参照と共に update 関数に渡します。
  7. update 関数はメッセージに基づき状態を更新します。

コンポーネントについて

コンポーネント( Component<Msg, State, Sub> )は initupdaterender により構成されます。コンポーネントは Component::new(init, update, render) により作成できます。

initは引数を取らず (State, Cmd<Msg, Sub>) を返す関数で、 状態の初期化を行います。

update は状態への可変参照(&mut State)とメッセージ(Msg)を引数に取りCmd<Msg, Sub> を返す関数で、状態の更新を行います。

render は状態への参照(&State)を引数に取りHtml<Msg>を返す関数で、状態の描画を行います。

コンポーネントを描画する

コンポーネントは Html::component(some_component) により Html<Msg> として描画できます。

コンポーネント間でメッセージをやり取りする

コンポーネントを作成する関数を作成し、その引数として子コンポーネントに何らかの値を渡すことができます。

Cmd::Sub(some_message)update の戻り値とすることで、親コンポーネントに Sub 型のメッセージを送ることができます。親コンポーネントでは、 child_component.subscribe によりそのメッセージを受け取り、親コンポーネント自身のメッセージにマップすることができます。

コンポーネントの描画とコンポーネント間のやり取りをする例を以下に記します。

extern crate kagura;
extern crate wasm_bindgen;

use kagura::prelude::*;
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() {
    kagura::run(Component::new(init, update, render), "app");
}

type State = bool;

enum Msg {
    Over32ed,
}

struct Sub();

fn init() -> (State, Cmd<Msg, Sub>) {
    (true, Cmd::none())
}

fn update(state: &mut State, msg: Msg) -> Cmd<Msg, Sub> {
    match msg {
        Msg::Over32ed => {
            *state = false;
            Cmd::none()
        }
    }
}

fn render(state: &State) -> Html<Msg> {
    if *state {
        Html::div(
            Attributes::new(),
            Events::new(),
            vec![Html::component(some_component::new(16).subscribe(
                |sub| match sub {
                    some_component::Sub::Over32 => Msg::Over32ed,
                },
            ))],
        )
    } else {
        Html::text("over 32 !")
    }
}

mod some_component {
    use kagura::prelude::*;

    pub fn new(initial_number: u32) -> Component<Msg, State, Sub> {
        Component::new(|| (initial_number, Cmd::none()), update, render)
    }

    pub type State = u32;

    pub enum Msg {
        CountUp,
    }

    pub enum Sub {
        Over32,
    }

    fn update(state: &mut State, msg: Msg) -> Cmd<Msg, Sub> {
        match msg {
            Msg::CountUp => {
                *state = *state + 1;
                if (*state > 32) {
                    Cmd::sub(Sub::Over32)
                } else {
                    Cmd::none()
                }
            }
        }
    }

    fn render(state: &State) -> Html<Msg> {
        Html::button(
            Attributes::new(),
            Events::new().on_click(|_| Msg::CountUp),
            vec![Html::text(state.to_string())],
        )
    }
}

Cmd::taskで非同期処理

Cmd::taskFnOnce(Resolver<Msg>)を引数に取ります。Resolver<Msg> の型は Box<FnOnce(Msg)> です。JavaScriptのPromiseだとresolveとrejectを使っていたものがresolveだけになったと考えればわかりやすいと思います。

アセット 5.png

実際に使っている例を見た方が分かりやすいと思うので、まず以下に例を記します。

extern crate kagura;
extern crate wasm_bindgen;
extern crate web_sys;

use kagura::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

#[wasm_bindgen(start)]
pub fn main() {
    kagura::run(Component::new(init, update, render), "app");
}

type State = String;

enum Msg {
    ChangeMessage(String),
}

struct Sub();

fn init() -> (State, Cmd<Msg, Sub>) {
    (
        "Hello".to_string(),
        Cmd::task(|resolver| {
            let a = Closure::once(Box::new(|| {
                resolver(Msg::ChangeMessage("World".to_string()));
            }));
            web_sys::window()
                .unwrap()
                .set_timeout_with_callback_and_timeout_and_arguments_0(
                    a.as_ref().unchecked_ref(),
                    3000,
                );
            a.forget();
        }),
    )
}

fn update(state: &mut State, msg: Msg) -> Cmd<Msg, Sub> {
    match msg {
        Msg::ChangeMessage(message) => {
            *state = message;
            Cmd::none()
        }
    }
}

fn render(state: &State) -> Html<Msg> {
    Html::text(state)
}

3000ms(3秒)後に「Hello」が「World」に変わります。resolver(Msg::ChangeMessage("World".to_string())); が実行されたタイミングでupdateが走って再描画が行われます。Cmd<Msg,Sub>update の戻り値だけではなく、init の戻り値にも設定できるので、このようにコンポーネントが作成されたタイミングで Cmd::task を実行することができます。

Component::batch で外部からイベントを発生させる

Cmd::task を使えば1回のリクエストにつき1回の応答が来るHttpリクエストなどもできます。しかし、Server-Sent Eventsや setinterval などのようなことには対応できません。なぜなら resolver は1度しか呼び出せないからです。そこでKaguraにはbatchという機能があります。batchは FnOnce(FnMut(Msg)) を引数に取ります。

アセット 6.png

以下に setInterval を使用する例を記します。

extern crate kagura;
extern crate wasm_bindgen;
extern crate web_sys;

use kagura::prelude::*;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;

#[wasm_bindgen(start)]
pub fn main() {
    kagura::run(
        Component::new(init, update, render).batch(|mut handler| {
            let a = Closure::wrap(Box::new(move || {
                handler(Msg::CountUp);
            }) as Box<dyn FnMut()>);
            web_sys::window()
                .unwrap()
                .set_interval_with_callback_and_timeout_and_arguments_0(
                    a.as_ref().unchecked_ref(),
                    500,
                );
            a.forget();
        }),
        "app",
    );
}

type State = u32;

enum Msg {
    CountUp,
}

struct Sub();

fn init() -> (State, Cmd<Msg, Sub>) {
    (0, Cmd::none())
}

fn update(state: &mut State, msg: Msg) -> Cmd<Msg, Sub> {
    match msg {
        Msg::CountUp => {
            *state = *state + 1;
            Cmd::none()
        }
    }
}

fn render(state: &State) -> Html<Msg> {
    Html::text(state.to_string())
}

500ms おきに1ずつ値が増加していきます。

今後について

開発開始から約半年がたち、一応ではありますがWebSocketやXMLHttpRequestにも対応できました。ただ、Cmd::taskとComponent::batchの実装については個人的に気に入らない点があるので今後修正の必要があるようにも思えます。今後はWebAudioAPIへの対応を目指して開発をしていく予定です。

21
9
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
21
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?