📘

RustのフロントエンドフレームワークYewに入門してみた

2022/05/31に公開

作ったもの

https://main--jhk-computer-simulator.netlify.app/
https://github.com/kana-rus/computer-simulator
↓一応 (ちなみに Mac で矢印は全角 z + k,l,j,h で出せます)
https://yew.rs/ja/

経緯

コンピュータの仕組みを簡単に説明する授業の中で、「メモリ」「レジスタ」といった構成要素を図解するために使われていた
https://www.i.h.kyoto-u.ac.jp/users/tsuiki/vma/index.html
というページが元ネタです (基本的に仕様はこれに準拠しているので、そのあたりは reference として元ページの説明を参照する形で手を抜きました)。このページを見た頃ちょうど Yew に興味を持ち始めていて、

  • これくらいシンプルなものなら、初めて Rust でフロントエンドを書く練習にちょうどよさそう
  • このページはレスポンシブデザインについては考慮されていないので、それを改善する意図で作り直す意味はありそう

と思ったのが作り始めたきっかけです。小規模かつ http リクエストなども扱わないぶん、そこまで実用性のある内容にはなっていないかもしれませんが、まだまだ少ない Yew 開発の一例として、少しでも Yew を触ってみたい人の助けになればと思って記事を書いています。

ディレクトリ構成・開発の流れ

今回はシンプルに

.
├── dist
├── src
│   ├── components
│   ├── utils
│   └── components.rs, utils.rs, main.rs
├── target
└── .gitignore, Cargo.lock, Cargo.toml, index.html

というディレクトリ構成をとりました。開発環境の構築は
https://yew.rs/ja/docs/tutorial
https://zenn.dev/azukiazusa/articles/rust-base-web-front-fremework-yew
がわかりやすく、これらに倣えば問題ないです。

  • 保存するたびに target 以下にファイルがどんどん生成される
  • 同時に dist 以下に最新のビルド結果 (html, js, wasm の3ファイル) が入ってホットリロードで即反映される
  • デプロイ時は dist をそのまま公開すれば OK

という流れになります (dist というディレクトリ名や index.html の場所は Trunk.toml で変えられる) 。
JSX ライクな HTML in Rust で書けるし、コンポーネントに渡す props や use_stateuse_effect などの hooks も、clonemove の感覚にさえ慣れれば React とおよそ同じように使えます。
デバッグまわりは、Yew はコンポーネント志向なので Rust 標準の test が使いやすいし、web-sysconsole モジュールでコンソールデバッグもできるので、今回の開発では特に困った場面はなかったです。

スタイリング

レスポンシブデザインに関わる部分だけ index.html の <style></style>、固定でいい部分は html の style 属性に直書きしています。今後もしもっと大きいサイトや Web アプリを Yew で作るとなったらスタイリングフレームワークを探すかもしれません。
工夫した点としては、一部コンポーネントで、 style が長くなって読みにくい場合に

process_buttons.rs
use web_sys::MouseEvent;
use yew::{function_component, html, Properties, Callback};

#[derive(Properties, PartialEq)]
pub struct ProcessCallbacks {
    pub handle_step: Callback<MouseEvent>,
    pub handle_go_through: Callback<MouseEvent>
}

#[function_component(ProcessButtons)]
pub fn process_buttons(prop: &ProcessCallbacks) -> Html {
    let button_style = "
        width: 42px;
        height: 42px;
        border-color: white;
        border-radius: 21px;
        font-size: 15px;
        padding: 0;
    ";

    html!{
        <span style="margin-right: 4%;">
          <button
            class="process-buttons" disabled=true
            style={button_style} onclick={prop.handle_step.clone()}
          >{"step"}</button>
          <button
            class="process-buttons" disabled=true
            style={button_style} onclick={prop.handle_go_through.clone()}
          >{"go"}</button>
        </span>
    }
}

のようにスタイルを &str 変数として切り出して style 属性に渡すという書き方をしています。やろうと思えば

  • prop として &'static str を渡すことで、そのコンポーネントの style (または style の一部) を親コンポーネントから与える
  • style 文字列をスタイリング用のモジュールに切り出して pub const (あるいは、引数によってパターンを切り替えるスタイルなら pub fn 〜() -> &'static str ) とし、複数のコンポーネントで使い回す

といったこともできます。

困った点

  • html! 内の HTML タグ関係はほぼ補完が効かない


  • move を含むコールバック関数を渡すと (?) クリックしてもラジオボタンの見た目が切り替わらなくなる
not_move.rs
use web_sys::{MouseEvent, console::log_1};
use wasm_bindgen::{JsValue};
use yew::{function_component, html};

#[function_component(App)]
fn app() -> Html {
    // move しない
    let onclick_left = |_:MouseEvent| {
        log_1(&JsValue::from("left clicked!"));
    };
    let onclick_right = |_:MouseEvent| {
        log_1(&JsValue::from("right clicked!"));
    };

    html!(
        <>
          {"Yew"}
          <input
            type="radio" name="yew" checked=true
            onclick={onclick_left}
          />
          <input
            type="radio" name="yew"
            onclick={onclick_right}
          />
        </>
    )
}

fn main() {
    yew::start_app::<App>();
}

move.rs
use web_sys::{MouseEvent, console::log_1};
use wasm_bindgen::{JsValue};
use yew::{function_component, html, use_state, Callback};

#[function_component(App)]
fn app() -> Html {
    // move する
    let count = use_state(|| 0_i8);

    let onclick_left = {
        let count = count.clone();
        Callback::from(move|_:MouseEvent| {
            count.set(*count + 1);
            log_1(&JsValue::from(*count));
        })
    };
    let onclick_right = {
        let count = count.clone();
        Callback::from(move|_:MouseEvent| {
            count.set(*count - 1);
            log_1(&JsValue::from(*count));
        })
    };

    html!(
        <>
          {"Yew"}
          <input
            type="radio" name="yew" checked=true
            onclick={onclick_left}
          />
          <input
            type="radio" name="yew"
            onclick={onclick_right}
          />
        </>
    )
}

fn main() {
    yew::start_app::<App>();
}


  • Callback! 内で、move する前に unwrapexpectElement を取り出す操作をすると (?) ホワイトアウトする
unwrap_in_move_block.rs
use web_sys::{MouseEvent, window};
use yew::{function_component, html, use_state, Callback};

#[function_component(App)]
fn app() -> Html {
    let count = use_state(|| 0_i8);
    let onclick = {
        let count = count.clone();
        Callback::from(move |_:MouseEvent| {

            // 意味はまったくないが Element を取得してみる
            window().unwrap() //: Window
                .document().unwrap() //: Document
                .get_element_by_id("count") //: Option<Element>
                .unwrap();
                
            count.set(*count + 1);
        })
    };

    let button_style = "
        width: 42px;
        height: 42px;
        border-radius: 21px;
        border-color: white;
        padding: 0;
    ";

    html!(
        <div style="text-align: center;">
          <p>{*count}</p>
          <button style={button_style} {onclick}>{"inc"}</button>
        </div>
    )
}

fn main() {
    yew::start_app::<App>();
}


gifのコマ数の問題で、押してないのにインクリメントされてるように見えるところがありますが気にしないでください

unwrap_outside_move_block.rs
use web_sys::{MouseEvent, window};
use yew::{function_component, html, use_state, Callback};

#[function_component(App)]
fn app() -> Html {
    let count = use_state(|| 0_i8);
    let onclick = {
        let count = count.clone();

        // 意味はまったくないが Element を取得してみる
        window().unwrap() //: Window
            .document().unwrap() //: Document
            .get_element_by_id("count") //: Option<Element>
            .unwrap(); // ← この unwrap があるとホワイトアウトする

        Callback::from(move |_:MouseEvent| {
            count.set(*count + 1);
        })
    };

    let button_style = "
        width: 42px;
        height: 42px;
        border-radius: 21px;
        border-color: white;
        padding: 0;
    ";

    html!(
        <div style="text-align: center;">
          <p id="count">{*count}</p>
          <button style={button_style} {onclick}>{"inc"}</button>
        </div>
    )
}

fn main() {
    yew::start_app::<App>();
}


特に2つ目 (ラジオボタン問題) は、今回作ったものにラジオボタンが含まれるため、割とクリティカルな問題でした。詳しくはリポジトリの mode_radio.rsmain.rs を見ていただけると分かると思うのですが、多少無理やり突破しています。

感想など

全体としては「意外とできる」という感じで、
https://zenn.dev/mayo_dev/articles/rust-yew-tailwind-app
のようにブログくらいなら普通に作れそうですが、現状は、同じく React 系列の JS・TS フレームワークたちに比べるとまだまだ微妙なところがあり、フロントエンド開発においてメインで使いたいとは思えません。とはいえ、TS に比べ厳密な型システム、分かりやすい(異論は認める)モジュールシステム、コンポーネント志向と相性のよさそうな標準の test 機構などなど期待できる部分が多々あると感じたので、今後も注目していこうと思います。

GitHubで編集を提案

Discussion