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

これは [EDOCODE Advent Calendar 2022](https://adventar.org/calendars/7645) 12月2日の記事です。

VDOMを使わないタイプのSPAフレームワークである[SolidJS](https://www.solidjs.com)のRust版といえる[Sycamore](https://crates.io/crates/sycamore)を使ったウェブフロントエンド開発を今回はやっていきたいと思います。

注意事項として、この記事はSycamoreを使ってどの様にコードを書いていくのかその雰囲気が分かる事を目的としています。  
しっかりと学びたい方は[Sycamore公式のドキュメント](https://sycamore-rs.netlify.app/docs/getting_started/installation)を参照することをオススメします。

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

## Sycamoreとは？

[Sycamore](https://crates.io/crates/sycamore)は[SolidJS](https://www.solidjs.com)と同じ様なVDOMを使わないタイプのSPAフレームワークです。  
SolidJSのコンセプトを踏襲しておりSignalとEffectでアプリケーションの状態を更新していきます。

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

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

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

[実行速度はSvelteやSolidJSと同じくらいです](https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html#eyJmcmFtZXdvcmtzIjpbImtleWVkL3NvbGlkIiwia2V5ZWQvc3ZlbHRlIiwia2V5ZWQvc3ljYW1vcmUiXSwiYmVuY2htYXJrcyI6WyIwMV9ydW4xayIsIjAyX3JlcGxhY2UxayIsIjAzX3VwZGF0ZTEwdGgxa194MTYiLCIwNF9zZWxlY3QxayIsIjA1X3N3YXAxayIsIjA2X3JlbW92ZS1vbmUtMWsiLCIwN19jcmVhdGUxMGsiLCIwOF9jcmVhdGUxay1hZnRlcjFrX3gyIiwiMDlfY2xlYXIxa194OCIsIjIxX3JlYWR5LW1lbW9yeSIsIjIyX3J1bi1tZW1vcnkiLCIyM191cGRhdGU1LW1lbW9yeSIsIjI1X3J1bi1jbGVhci1tZW1vcnkiLCIyNl9ydW4tMTBrLW1lbW9yeSIsIjMxX3N0YXJ0dXAtY2kiLCIzNF9zdGFydHVwLXRvdGFsYnl0ZXMiXSwiZGlzcGxheU1vZGUiOjEsImNhdGVnb3JpZXMiOlsxLDIsMyw0LDVdfQ==)。

## 今回のゴール

![hello-sycamore-anim-3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/4559324c-3eb8-8ee4-6342-6584a0ee24e7.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](https://www.rust-lang.org/learn/get-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](https://trunkrs.dev)というツールをインストールしましょう。

`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`を次の様に編集します。

```rust
// 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 { "(属性名=値,...)は省略可能" }
}
```

この様なフォーマットで書いていくことが出来ます。

マクロは嫌だという人の為に[普通のメソッドチェーンで書いていく事も出来るようになっています](https://sycamore-rs.netlify.app/docs/v0.8/basics/view#builder-syntax)。

Rustのコードが書けたら次はWasmを実行するページのテンプレートを用意します。  
`Cargo.toml` と同じディレクトリに `index.html` を次の内容で作成します。

```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!をブラウザで表示](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/6829ea32-0e3b-0e88-c74f-62d9f14fefaf.png)

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

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

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

<img alt="Ferris" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/711118dd-1900-5480-3c7a-2c4e1bd75e7f.png" width="400">

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>` 内に追加します。

```html
<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](https://crates.io/crates/gloo) crateで定義されています。  
`gloo` crateをDependenciesに追加する為に次のコマンドを実行します。

`cargo add gloo@0.8`

次に `src/main.rs` を以下の様に編集します。

```rust
// 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をどう使っているか見ていきましょう。

```rust
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です。

```rust
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側で自動的に判別していい感じに処理してくれます。

```rust
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が画面内を動ている画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/66ae8f19-d7fe-6637-4c8a-a1d6a97dd18b.gif)

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

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

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

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

Ferrisをクリックすると姿が変わるようにしてみましょう。  
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/27c0ac68-8291-f7c4-4f3c-3d6592061fbc.png" alt="Extra-cute Ferris" width="400">

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

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

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

次に `src/main.rs` の `view!` マクロでUI定義している所の前辺りに次のコードを追加します。

```rust
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属性の追加をします。

```rust
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/381b71e5-714c-3fab-b5a6-bd8759267013.gif)

## 非同期でリソースを取得して複数のFerrisを一度に動かします

最後に非同期でリソースを取得してからレンダリングする方法を見ていきましょう。

ここに[複数の初期位置と初期ベクトルを記述したファイル](https://raw.githubusercontent.com/fujitayy/hello-sycamore/main/ferris_initial_params.json)を用意してあります。  
こちらのデータを最初にロードして、複数の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` の全体を載せます。  
下記の通りに書き換えましょう。

```rust
// 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/195271/4559324c-3eb8-8ee4-6342-6584a0ee24e7.gif)

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

```rust
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公式のドキュメント](https://sycamore-rs.netlify.app/docs/getting_started/installation)を読むのをおススメします。

また、現在 EDOCODE では、エンジニア・デザイナー・プロダクトマネジャーを募集しています。  
ご興味のある方は、こちらの採用ページも是非ご覧ください。
- https://go.edocode.co.jp/jobs
- https://edocode.notion.site/Jobs-at-EDOCODE-c9108ee1f3b1474fa07e328f51e2f29e