概要
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としています。)
実際には、直接的に上のような動作を行うことができないので、Kaguraが間に入ることでページの描画・更新を行います。
たとえば、あるボタンを押すごとにそのボタンのテキストが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が関数を実行する順序は以下のようになります。
- Kaguraは
init
関数を実行することで初期状態を取得します。今回の場合、初期状態は0になります。 - Kaguraは
render
関数に初期状態を渡します。 -
render
関数は渡された状態に基づいて描画する内容を返します。 - Kaguraは
render
関数の戻り値に基づき描画を行います。 - 描画したボタンがクリックされると、Kaguraは
on_click
の引数として渡されたクロージャを実行し、その戻り値としてメッセージを取得します。 - Kaguraは取得したメッセージをその時の状態への可変参照と共に
update
関数に渡します。 -
update
関数はメッセージに基づき状態を更新します。
コンポーネントについて
コンポーネント( Component<Msg, State, Sub>
)は init
・ update
・ render
により構成されます。コンポーネントは 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::task
は FnOnce(Resolver<Msg>)
を引数に取ります。Resolver<Msg>
の型は Box<FnOnce(Msg)>
です。JavaScriptのPromiseだとresolveとrejectを使っていたものがresolveだけになったと考えればわかりやすいと思います。
実際に使っている例を見た方が分かりやすいと思うので、まず以下に例を記します。
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))
を引数に取ります。
以下に 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への対応を目指して開発をしていく予定です。