LoginSignup
13
6

More than 1 year has passed since last update.

Sycamoreを使ったRustでのウェブフロントエンド開発入門

Last updated at Posted at 2022-12-02

これは EDOCODE Advent Calendar 2022 12月2日の記事です。

VDOMを使わないタイプのSPAフレームワークであるSolidJSのRust版といえるSycamoreを使ったウェブフロントエンド開発を今回はやっていきたいと思います。

注意事項として、この記事はSycamoreを使ってどの様にコードを書いていくのかその雰囲気が分かる事を目的としています。
しっかりと学びたい方はSycamore公式のドキュメントを参照することをオススメします。

この記事の章立てに合わせてコミットをしたGitリポジトリを用意してあります。
https://github.com/fujitayy/hello-sycamore
読み進めていて全体像が分からなくなった時等にお使いください。

Sycamoreとは?

SycamoreSolidJSと同じ様なVDOMを使わないタイプのSPAフレームワークです。
SolidJSのコンセプトを踏襲しておりSignalとEffectでアプリケーションの状態を更新していきます。

SignalというのはReactのuseStateみたいなもので、具体的な値を持っているオブジェクトです。
または値を伝搬する為のパイプと見ることもできます。

EffectとはSignalを参照するコールバック関数の様なものです。

Signalが更新された時にそのシグナルを必要とするEffectだけが再実行される事で必要最小限の処理だけが実行されます。

実行速度はSvelteやSolidJSと同じくらいです

今回のゴール

hello-sycamore-anim-3.gif
実際にブラウザで動くもの 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 にアクセスしてみましょう。

Hello World!をブラウザで表示

上手くできていればこんな感じに表示されるはずです。

SVGで動くFerrisを1体描画します。

Hello Worldが出来たら次はFerris1体が画面内を動き回るようにしましょう。

Ferris

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 で実行してみましょう。

Ferrisが画面内を動ている画像

こんな感じで動くはずです。

もし上手くいかない場合などは以下URLでプロジェクトの全体像が確認できます。
https://github.com/fujitayy/hello-sycamore/tree/e7c4a829019b145affb39942d645c1feb0ff7013

イベントハンドラを追加してFerrisにクリックしたら姿が変わる動的な要素を加えます

次はイベントハンドラーの取り扱い方を見ていきましょう。

Ferrisをクリックすると姿が変わるようにしてみましょう。
Extra-cute Ferris

実装としてはonclickハンドラが呼ばれる度に画像を切り替えるようにします。

まずは次のコマンドでFerrisの別の姿の画像をダウンロードします。

curl -sSL https://rustacean.net/assets/cuddlyferris.svg > assets/images/ferris2.svg

次に src/main.rsview! マクロで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 で実行しましょう。

上手く出来ていれば次の画像の様に動くはずです。

hello-sycamore-anim-2.gif

非同期でリソースを取得して複数の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 で実行しましょう!
上手く出来ていたら以下の様な感じで動くはずです。

hello-sycamore-anim-3.gif

ではこのコードで行っている事のうち、非同期でのリソース取得の部分について見ていきます。

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 では、エンジニア・デザイナー・プロダクトマネジャーを募集しています。
ご興味のある方は、こちらの採用ページも是非ご覧ください。

13
6
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
13
6