これは EDOCODE Advent Calendar 2022 12月2日の記事です。
VDOMを使わないタイプのSPAフレームワークであるSolidJSのRust版といえるSycamoreを使ったウェブフロントエンド開発を今回はやっていきたいと思います。
注意事項として、この記事はSycamoreを使ってどの様にコードを書いていくのかその雰囲気が分かる事を目的としています。
しっかりと学びたい方はSycamore公式のドキュメントを参照することをオススメします。
この記事の章立てに合わせてコミットをしたGitリポジトリを用意してあります。
https://github.com/fujitayy/hello-sycamore
読み進めていて全体像が分からなくなった時等にお使いください。
Sycamoreとは?
SycamoreはSolidJSと同じ様なVDOMを使わないタイプのSPAフレームワークです。
SolidJSのコンセプトを踏襲しておりSignalとEffectでアプリケーションの状態を更新していきます。
SignalというのはReactのuseStateみたいなもので、具体的な値を持っているオブジェクトです。
または値を伝搬する為のパイプと見ることもできます。
EffectとはSignalを参照するコールバック関数の様なものです。
Signalが更新された時にそのシグナルを必要とするEffectだけが再実行される事で必要最小限の処理だけが実行されます。
今回のゴール
実際にブラウザで動くもの https://fujitayy-hello-sycamore.netlify.app/
コード https://github.com/fujitayy/hello-sycamore
こんな感じに動作するWebアプリを1から順に作っていきます。
Rustのインストールから始めますのでRustが分からないという方でも写経すれば作れます!
このアプリを作りながらSycamoreをどう使ってSPAを構築していくのか見ていきましょう。
開発環境の準備
まずは開発に必要なツールをインストールしていきます。
これ以降CLIコマンドの実行はLinux環境を想定して説明していきます。
Windowsユーザーの方はWSLの利用をオススメします。
Macユーザーの方は必要に応じて適切にコマンドを読み替えて下さい(基本的にそのままいけるとは思います)。
Rustのインストール
まずはRust処理系をインストールしましょう!
Rustを既にインストールしている方はここを飛ばして次へ進んで下さい。
RustのインストールはLinuxやMac等のUnix-likeな環境では次のコマンドでインストールできます。
rootではなく普段使いのユーザーで実行します(HOMEの中にインストールされます)。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
※その他のインストール方法を希望する場合やこの記事が古くなっている場合はRust公式サイトのGetting Startedへアクセスしてインストール方法を確認して下さい。
上記コマンドを実行すると色々聞かれますが、デフォルトっぽい選択肢を選んでいけば最新の安定版がインストールされます。
初めてインストールした方は完了時点ではまだRust処理系へのパスが通っていないと思いますので、一番簡単な反映方法であるターミナルの起動し直しをおススメします。
インストール時に表示されたメッセージから何をすればパスが通るか分かる方は適切な操作をする事でパスを通すのでも大丈夫です。
rustc --version
を実行して rustc 1.65.0 (897e37553 2022-11-02)
の様な出力がされていればインストールは成功しています。
wasm32-unknown-unknownのインストール
Rust処理系がインストールできたら次はRustコードをWasmにコンパイルする為に必要なツール等をインストールしましょう。
rustup target wasm32-unknown-unknown
を実行してインストールします。
trunkのインストール
最後にWasmアプリケーションのビルドを楽にしてくれるtrunkというツールをインストールしましょう。
cargo install --locked trunk
でインストールできます。
プロジェクトのディレクトリの作成
それでは実際にコードを書いていくプロジェクトを次のコマンドで作成しましょう。
cargo new hello-sycamore
プロジェクトを作ったら cd hello-sycamore
をしてカレントディレクトリを変更しておきましょう。
これ以降のCLI操作は全てこのプロジェクトディレクトリ内で実行します。
Cargo.tomlのdependenciesにSycamoreを追加
cargo add sycamore@0.8
とコマンドを実行して追加します。
とりあえずHello Worldします
まずは肉付けしていく土台になる最低限動くコードを用意しましょう。
src/main.rs
を次の様に編集します。
// src/main.rs
use sycamore::prelude::*;
fn main() {
sycamore::render(|cx| {
view! { cx,
p { "Hello World!" }
}
});
}
まず use sycamore::prelude::*;
を書いてsycamoreを使うのに必要な基本的な型や関数等をインポートしています。
次にmain関数で sycamore::render
を実行する事でUIのレンダリングを行います。
UIの定義は view!
マクロを使って行うのが簡単です。
タグ名(属性名=値,...) {
子要素1 { "テキスト" }
子要素2 { "(属性名=値,...)は省略可能" }
}
この様なフォーマットで書いていくことが出来ます。
マクロは嫌だという人の為に普通のメソッドチェーンで書いていく事も出来るようになっています。
Rustのコードが書けたら次はWasmを実行するページのテンプレートを用意します。
Cargo.toml
と同じディレクトリに index.html
を次の内容で作成します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Hello Sycamore!</title>
</head>
<body></body>
</html>
このindex.htmlをtrunkが読み込んで最終的なWasmをロードするHTMLファイルが作成されます。
ここまででHello Worldするコードが書けたので、早速実行してブラウザで表示してみましょう。
trunk serve
で実行します。
ビルドに成功して実行されると server listening at http://127.0.0.1:8080
の様なメッセージが出力されるので http://127.0.0.1:8080 にアクセスしてみましょう。
上手くできていればこんな感じに表示されるはずです。
SVGで動くFerrisを1体描画します。
Hello Worldが出来たら次はFerris1体が画面内を動き回るようにしましょう。
FerrisとはRustのマスコットキャラクターです。
https://rustacean.net
FerrisのSVG画像のダウンロード
画像等を置くディレクトリをプロジェクトに作成します。
mkdir -p assets/images
続けてFerrisのSVG画像をダウンロードします。
curl -sSL https://rustacean.net/assets/rustacean-flat-happy.svg > assets/images/ferris.svg
画像をtrunkの出力に含める為の設定をindex.htmlに追加
assets/images
ディレクトリ内の画像をtrunkでビルドした成果物に含める為の設定を index.html
に追加します。
次のタグを index.html
の <head>
内に追加します。
<link data-trunk rel="copy-dir" href="assets/images">
trunkに対してはこの様な形で様々な設定を行うことができます。
https://trunkrs.dev/assets/
動くFerrisを表示するコードをmain.rsに追加
アニメーションの為に window.requestAnimationFrame()
をRustから使えるようにします。
window.requestAnimationFrame()
をRustから呼ぶ為のAPIはgloo crateで定義されています。
gloo
crateをDependenciesに追加する為に次のコマンドを実行します。
cargo add gloo@0.8
次に src/main.rs
を以下の様に編集します。
// src/main.rs
use gloo::render::request_animation_frame;
use sycamore::prelude::*;
fn main() {
sycamore::render(|cx| {
let ferris_wh = 64;
let ferris_r = ferris_wh / 2;
let area_wh = 1000;
let frame_handle = create_rc_signal(None);
let tick = create_rc_signal(0u64);
let ferris = create_signal(cx, (area_wh / 2, area_wh / 2, 7, -3));
create_effect(cx, move || {
let _ = tick.get();
let (x, y, v_x, v_y) = *ferris.get();
let (x, v_x) = move_1_axis(x, v_x, ferris_r, 0, area_wh);
let (y, v_y) = move_1_axis(y, v_y, ferris_r, 0, area_wh);
ferris.set((x, y, v_x, v_y));
let tick = tick.clone();
let h = request_animation_frame(move |t| tick.set(t as u64));
frame_handle.set(Some(h));
});
view! { cx,
svg(
xmlns = "http://www.w3.org/2000/svg",
viewBox = format!("0 0 {area_wh} {area_wh}"),
style = "width:90vmin; height:90vmin; border:1px solid #000",
) {
image (
href = "/images/ferris.svg",
width = ferris_wh,
height = ferris_wh,
x = (ferris.get().0 - ferris_r),
y = (ferris.get().1 - ferris_r),
) {}
}
}
});
}
fn move_1_axis(pos: i16, v: i16, r: i16, lower: i16, upper: i16) -> (i16, i16) {
let new_pos = pos + v;
let upper = upper - r;
let lower = lower + r;
if new_pos >= upper {
(upper, v * -1)
} else if new_pos <= lower {
(lower, v * -1)
} else {
(new_pos, v)
}
}
Ferrisをアニメーションさせる為にsycamoreをどう使っているか見ていきましょう。
let tick = create_rc_signal(0u64);
let ferris = create_signal(cx, (area_wh / 2, area_wh / 2, 7, -3));
まずこの部分でフレーム毎に更新される値のSignalを作成しています。
tick
変数は request_animation_frame
でコールバック関数が呼ばれた事を検知する為に使うSignalです。
中身は何でもいいのですが、ここでは毎回違う値になるようにする為にコールバック関数に渡されるタイムスタンプを整数にキャストしたものを値としています。
ferris
変数はFerrisを描画する座標(画像の中心座標)と現在の速度を持つタプルを値として持つSignalです。
create_effect(cx, move || {
let _ = tick.get();
let (x, y, v_x, v_y) = *ferris.get();
let (x, v_x) = move_1_axis(x, v_x, ferris_r, 0, area_wh);
let (y, v_y) = move_1_axis(y, v_y, ferris_r, 0, area_wh);
ferris.set((x, y, v_x, v_y));
let tick = tick.clone();
let h = request_animation_frame(move |t| tick.set(t as u64));
frame_handle.set(Some(h));
});
次に create_effect
関数を使って tick
signalの更新に応じて再実行される処理をSycamoreに登録しています。
更新を受け取りたいSignalの値を get
メソッドを通して受け取るようにクロージャを書く事で、この処理がどのSignalに依存しているのかSycamore側で自動的に判別していい感じに処理してくれます。
view! { cx,
svg(
xmlns = "http://www.w3.org/2000/svg",
viewBox = format!("0 0 {area_wh} {area_wh}"),
style = "width:90vmin; height:90vmin; border:1px solid #000",
) {
image (
href = "/images/ferris.svg",
width = ferris_wh,
height = ferris_wh,
x = (ferris.get().0 - ferris_r),
y = (ferris.get().1 - ferris_r),
) {}
}
}
最後に view!
マクロの中からも ferris.get().0
等としてSignalを参照しています。
create_effect
と同様に view!
マクロの中でもSignalの値を get
メソッドで取得する事で、このView構築処理がどのSignalに依存しているのか、いつ再レンダリングを行うのかといった事をSycamore側でいい感じに処理してくれます。
実行してみる
コードが書けたら trunk serve
で実行してみましょう。
こんな感じで動くはずです。
もし上手くいかない場合などは以下URLでプロジェクトの全体像が確認できます。
https://github.com/fujitayy/hello-sycamore/tree/e7c4a829019b145affb39942d645c1feb0ff7013
イベントハンドラを追加してFerrisにクリックしたら姿が変わる動的な要素を加えます
次はイベントハンドラーの取り扱い方を見ていきましょう。
Ferrisをクリックすると姿が変わるようにしてみましょう。
実装としてはonclickハンドラが呼ばれる度に画像を切り替えるようにします。
まずは次のコマンドでFerrisの別の姿の画像をダウンロードします。
curl -sSL https://rustacean.net/assets/cuddlyferris.svg > assets/images/ferris2.svg
次に src/main.rs
の view!
マクロでUI定義している所の前辺りに次のコードを追加します。
let ferris_image_url_list = vec!["/images/ferris.svg", "/images/ferris2.svg"];
let ferris_image_index = create_signal(cx, 0);
更に view!
マクロで定義しているUI中のimageタグのhref属性の修正とon:click属性の追加をします。
image(
href = ferris_image_url_list[*ferris_image_index.get()],
// 省略
on:click = |_| { ferris_image_index.set( (*ferris_image_index.get() + 1) % 2); },
) {}
src/main.rs
のコードの全体はこちらから確認できます。
https://github.com/fujitayy/hello-sycamore/blob/8d26891ffb5c9f14fed86b5d1a1b689de6d4712e/src/main.rs
変更ができたら trunk serve
で実行しましょう。
上手く出来ていれば次の画像の様に動くはずです。
非同期でリソースを取得して複数のFerrisを一度に動かします
最後に非同期でリソースを取得してからレンダリングする方法を見ていきましょう。
ここに複数の初期位置と初期ベクトルを記述したファイルを用意してあります。
こちらのデータを最初にロードして、複数のFerrisを一度に動かしてみましょう。
データをサーバから取得してから描画するような非同期レンダリングをするには sycamore::futures::create_resource
関数を使います。
Sycamoreで非同期関係の機能を使えるようにする為にfeaturesにsuspenseを追加しましょう。次のコマンドで追加できます。
cargo add sycamore@0.8 --features suspense
またFuture関係の便利機能が欲しいのでfuturesもDependenciesに追加します。
cargo add futures@0.3.25
変更範囲が広いので src/main.rs
の全体を載せます。
下記の通りに書き換えましょう。
// src/main.rs
use futures::TryFutureExt;
use gloo::render::request_animation_frame;
use std::{cell::Cell, rc::Rc};
use sycamore::{futures::create_resource, prelude::*};
fn main() {
sycamore::render(|cx| {
let ferris_wh = 64;
let ferris_r = ferris_wh / 2;
let area_wh = 1000;
let ferrises = create_signal(cx, Vec::new());
create_resource(cx, async move {
let url = "https://raw.githubusercontent.com/fujitayy/hello-sycamore/main/ferris_initial_params.json";
ferrises.set(
gloo::net::http::Request::get(url)
.send()
.and_then(|r| async move { r.json().await })
.unwrap_or_else(|err| {
gloo::console::log!(format!(
"Failed to load ferris initial params json: {err}"
));
vec![(0, area_wh / 2, area_wh / 2, 7, -3)]
})
.await
.into_iter()
.map(|x| create_signal(cx, x))
.collect(),
);
});
let frame_handle = Rc::new(Cell::new(None));
let tick = create_rc_signal(0u64);
create_effect(cx, move || {
let _ = tick.get();
for ferris in ferrises.get().iter() {
let (image_index, x, y, v_x, v_y) = *ferris.get();
let (x, v_x) = move_1_axis(x, v_x, ferris_r, 0, area_wh);
let (y, v_y) = move_1_axis(y, v_y, ferris_r, 0, area_wh);
ferris.set((image_index, x, y, v_x, v_y));
}
let tick = tick.clone();
let h = request_animation_frame(move |t| tick.set(t as u64));
frame_handle.set(Some(h));
});
let ferris_image_url_list = Rc::new(vec!["/images/ferris.svg", "/images/ferris2.svg"]);
view! { cx,
svg(
xmlns = "http://www.w3.org/2000/svg",
viewBox = format!("0 0 {area_wh} {area_wh}"),
style = "width:90vmin; height:90vmin; border:1px solid #000",
) {
Indexed (
iterable = ferrises,
view = move |cx, ferris| {
let ferris_image_url_list = ferris_image_url_list.clone();
view! { cx,
image (
href = ferris_image_url_list[ferris.get().0],
width = ferris_wh,
height = ferris_wh,
x = (ferris.get().1 - ferris_r),
y = (ferris.get().2 - ferris_r),
on:click = move |_| {
gloo::console::log!("aaa");
let (index, x,y, v_x, v_y) = *ferris.get();
ferris.set(((index + 1) % 2, x, y, v_x, v_y));
},
)
}
},
)
}
}
});
}
fn move_1_axis(pos: i16, v: i16, r: i16, lower: i16, upper: i16) -> (i16, i16) {
let new_pos = pos + v;
let upper = upper - r;
let lower = lower + r;
if new_pos >= upper {
(upper, v * -1)
} else if new_pos <= lower {
(lower, v * -1)
} else {
(new_pos, v)
}
}
コードの変更ができたら trunk serve
で実行しましょう!
上手く出来ていたら以下の様な感じで動くはずです。
ではこのコードで行っている事のうち、非同期でのリソース取得の部分について見ていきます。
let ferrises = create_signal(cx, Vec::new());
create_resource(cx, async move {
let url = "https://raw.githubusercontent.com/fujitayy/hello-sycamore/main/ferris_initial_params.json";
ferrises.set(
gloo::net::http::Request::get(url)
.send()
.and_then(|r| async move { r.json().await })
.unwrap_or_else(|err| {
gloo::console::log!(format!(
"Failed to load ferris initial params json: {err}"
));
vec![(0, area_wh / 2, area_wh / 2, 7, -3)]
})
.await
.into_iter()
.map(|x| create_signal(cx, x))
.collect(),
);
});
Sycamoreでは create_resource
関数を使う事によってリソースの非同期取得を行うことが出来ます。
create_resource
関数はFutureを引数に取りますので、ここで行いたい非同期処理を行います。
上記のコードではFerrisの初期パラメータが入ったJSONをgithubから取得し、その後 ferrises
Signalに適切な加工をした上でセットしています。
create_resource
関数はFutureの戻り値を値とするRcSignalを返しますので、それをそのままViewに渡すこともできます。
最後に
今回はSycamoreでどの様にSPAを構築するのか、謎にFerrisが動き回るアプリを作る事を通して見ていきました。
話が長くならないように意図的にスコープから外した機能や細かい部分で説明を省略している機能が結構あります。
例えば以下の様なトピックについて説明していません。
- メモ化
- Viewで条件分岐や繰り返しをする方法
- データバインディング
- Component
- Router
- SSR, Hydration
興味がある方はSycamore公式のドキュメントを読むのをおススメします。
また、現在 EDOCODE では、エンジニア・デザイナー・プロダクトマネジャーを募集しています。
ご興味のある方は、こちらの採用ページも是非ご覧ください。