Rust + Leptos = WebAssembly でかんばんライクなタスク管理アプリを作ってみました。

はじめに

LeptosはRustで書かれたフロントエンド向けのフレームワークです。 書かれたコードはWebAssembly (WASM) に変換されて、ブラウザ上で実行することができます。

同様のフレームワークとしてYewが有名ですが、LeptosはVirtual DOMを使わずSolidJSのようなシグナルなどを用いたリアクティブプリミティブでコンポーネント内部の変更を制御することで高いUIパフォーマンスを実現しています。

フロントエンドフレームワークのベンチマークの結果では、よい結果を得られており、そのあたりのことをSolidJSの作者であるRyanがツイートしたことで一時期話題になりました (ツイート1ツイート2)。

というわけで、以前、このブログで書いた「Rust + Yew = WebAssembly でかんばんライクなタスク管理アプリを作ってみました。」という記事を参考に、見ためがほとんど同じようなかんばんライクなタスク管理アプリ (タスクボードアプリ) をRust + Leptosで作ってみました。 以前の記事とのコードの違いなどをお楽しみください。

なお、執筆時のLeptosのバージョンは0.0.17で、今後仕様が変更される可能性もありますのでご理解ください。

パッケージの作成とTrunkのセットアップ

まず、cargoを使ってタスクボードアプリの雛形をつくります。

cargo init taskboard && cd taskboard/

続いてWASMでの開発を楽にするためにTrunkと、それに必要なwasm-bindgen-cliを導入します。

rustup target add wasm32-unknown-unknown
cargo install --locked trunk
cargo install --locked wasm-bindgen-cli

インストールしたら試しに起動させてみましょう。trunkではビルドに必要な設定はindex.htmlに書くようになっており、index.htmlが最低限存在する必要があります。

touch index.html
trunk serve --open --watch .

コンパイル後にブラウザが起動して、ブランクのページが表示されるはずです。今回はこのtrunkを起動させたままにしておきましょう。ウォッチでこのディレクトリ上を監視しているので変更があったら自動でコンパイルされてブラウザも自動でリロードされるようになります。

Leptosの導入

trunkを起動させたまま別のターミナルを開いて次のコマンドを実行します。

cargo add leptos --features=stable

これでLeptosが導入できたので、main.rsを次のコードに置き換えます。

use leptos::*;

fn main() {
    mount_to_body(|cx| view! { cx, <p>"Hello, world!"</p> })
}

ブラウザのページがリロードされて次のようなページが表示されます。

LeptosでHello, worldをしてみました

view!マクロ内ではJSXに似たRSXと呼ばれる記法でDOMを表現することができます。文字列はクォートで囲う必要があります。

カウンターアプリを作ってみる

まずは、試しに簡単なカウンターアプリを作ってみましょう。main.rsを次のコードに置き換えます。

use leptos::*;

fn main() {
    mount_to_body(|cx| view! { cx, <Counter /> })
}

#[component]
fn Counter(cx: Scope) -> Element {
    let (counter, set_counter) = create_signal(cx, 0);
    view! { cx, <button on:click=move |_|{ set_counter.update(|v| *v += 1) }>{move || counter.get()}</button> }
}

これでボタンに数値が表示されていて、これをクリックすると1増えるアプリができました。

ボタンクリックでカウントが1ずつ増えていきます

カウンターアプリのコード解説

このソースコードを上から簡単に解説します。

#[component]
fn Counter(cx: Scope) -> Element {

#[component]を指定している関数CounterはLeptosのコンポーネントになります。

属性マクロcomponentを宣言しているので、メソッド名としてUpper-camelにしても警告が出ないようになっています。

  let (counter, set_counter) = create_signal(cx, 0);

create_signalはReactのuseStateやSolidJSのcreateSignalのように、初期値を引数に取り、ゲッターとセッターとなるReadSignalとWriteSignalのペアを返します。ここでは初期値0のシグナルを作っています。

on:click=move |_|{ set_counter.update(|v| *v += 1) }

ここではイベントハンドラを定義しています。LeptosではDOMのイベントハンドラには先頭にon:を付ける必要があります。その他のルールはドキュメントを参照してください。

ここでupdateメソッドを使っている理由は、Leptosでは値を変更するときにゲッターを呼ぶのが推奨されていないからです。代わりにWriteSignalが持つupdateメソッドが現在の値を引数に持つので、それを使って変更させます。

{move || counter.get()}

ここでは「導出されたシグナル (Derived Signal)」を使うことでリアクティブに値が変わるようになります。もしこの部分が、

{counter.get()}

だったときは、値として扱われるのでクリックしても初期値の0のまま値が変わりません。

ちなみにnightly版のLeptosではコンポーネントを次のように書くことができます。

#[component]
fn Counter(cx: Scope) -> Element {
    let (counter, set_counter) = create_signal(cx, 0);
    view! { cx, <button on:click=move |_|{ set_counter.update(|v| *v += 1) }>{counter}</button> }
}

オブジェクトをクロージャっぽく使えるようにするnightly版Rustの機能fn_traitsを使ってReadSignalをクロージャのように扱えるようにしています。これによって、niglty版Leptosでは

{counter}

と書いたときはシグナル、

{counter()}

と書いたときは値となり、より直感的にわかりやすくなります。

以上をまとめたものが次の図です。

コードによって値として扱われるかシグナルとして扱われるかの図

太字はnigltly版Leptosだけで使えるコードです。counter()は内部でcounter.get()を呼び出しているのでこれを矢印で表わしています。counter()の括弧を外したcounterは(nightlyでは)使えますが、counter.get()の括弧を外したcounter.getはコンパイルエラーになりますので、シグナルとして扱いたいときは右上のコードを使うことになります。

ボードコンポーネントの作成

それではタスクボードアプリを作っていきましょう。ファイルsrc/lib.rsを作成して、次のようなコードを書いてください。

use leptos::*;

#[component]
pub fn Board(cx: Scope) -> Element {
    view ! { cx,
        <div>
            <section class="section">
                <div class="container">
                    <div class="columns">
                        <div class="column">
                            <div class="tags has-addons">
                                <span class="tag">"Open"</span>
                                <span class="tag is-dark">0</span>
                            </div>
                        </div>
                    </div>
                </div>
             </section>
        </div>
    }
}

trunkが自動でコンパイルしてくれますが、これだけだと何も変わらないので、main.rsを次のコードに置き換えてください。

use leptos::*;
use taskboard::*;

fn main() {
    mount_to_body(|cx| view! { cx, <Board /> })
}

これでとりあえず表示はされましたが、このままだと見栄えが悪いのでスタイルを適用しましょう。

スタイルが適用されていないのでよくわからない状態になっています

スタイルの導入

ディレクトリboardの下にstyle.cssを作成し次のようなコードを書いてください。

.column:nth-child(1) {
  background-color: #ed8077;
}

.column:nth-child(2) {
  background-color: #4488c5;
}

.column:nth-child(3) {
  background-color: #5eb5a6;
}

.card {
  margin-bottom: 5px;
}

.card-footer-item {
  padding-top: 0; padding-bottom: 0;
}

そして、空のままであったindex.htmlを次のように変更します。

<!DOCTYPE html>
<html>
  <head>
    <link data-trunk rel="css" href="style.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
  </head>
  <body></body>
</html>

data-trunk属性の付いたlinkノードはTrunkのアセットとして扱われます。cssの場合は書かれた内容をそのままdistディレクトリにハッシュを付けて書き出すだけです。

これでタスクの状態を示す1列分に、Bulmaと自分で追加したスタイル (カラムの背景の色である赤) が適用されていることがわかります。

Bulmaと自作のスタイルを適用してみました

カラムコンポーネントの作成

複数の列が表示されるようにしていきます。まずは、先ほど追加したコードの中央部分を取り出して、それをコンポーネントColumnとして切り出します。

#[component]
fn Column(cx: Scope, text: &'static str) -> Element {
    view ! { cx,
        <div class="column">
            <div class="tags has-addons">
                <span class="tag">{ text }</span>
                <span class="tag is-dark">{ 0 }</span>
            </div>
        </div>
    }
}

これで簡単にカラムを追加できるようになりましたので、Columnコンポーネントを使うようにBoardコンポーネントを変更します。

                    <div class="columns">
                        <Column text="Open" />
                        <Column text="In progress" />
                        <Column text="Completed" />
                    </div>

これで、3つの状態列が表示されるようになりました。

3つのカラムが表示されている様子

タスクカードコンポーネントの作成

今度はタスクカードを表示できるようにしましょう。まず、タスクカードのモデルに必要なライブラリuuidを追加します。

cargo add uuid --features=v4,js

それを使うようにuse宣言します。

use uuid::Uuid;

タスクカードのモデルであるTaskとボードのモデルであるTasksを次のように定義します。

#[derive(Clone, Debug)]
pub struct Tasks(Vec<Task>);

#[derive(Clone, Eq, PartialEq, Debug)]
pub struct Task {
    id: Uuid,
    name: String,
    assignee: String,
    mandays: u32,
    status: i32,
}

初期状態として、モデルに4つのタスクを用意しておきます。そして、ついでにボード内の特定のstatusになっているTaskを返すメソッドfilteredも定義しておきます。

impl Tasks {
    fn new() -> Self {
        Self(vec![
            Task::new("Task 1", "🐱", 3, 1),
            Task::new("Task 2", "🐶", 2, 1),
            Task::new("Task 3", "🐱", 1, 2),
            Task::new("Task 4", "🐹", 3, 3),
        ])
    }


   fn filtered(&self, status: i32) -> Vec<Task> {
        self.0
                .iter()
                .filter(|t| t.status == status)
                .cloned()
                .collect()
    }
}

impl Task {
    fn new(name: &str, assignee: &str, mandays: u32, status: i32) -> Self {
            Self {
                id: Uuid::new_v4(),
            name: name.to_string(),
                   assignee: assignee.to_string(),
                mandays,
                status,
        }
    }
}

続いて、タスクを表示するCardコンポーネントを用意します。

#[component]
fn Card(cx: Scope, task: Task) -> Element {
    view ! { cx,
        <div class="card">
            <div class="card-content">
                { &task.name }
            </div>
            <footer class="card-footer">
                <div class="card-footer-item">
                    { &task.assignee }
                </div>
                <div class="card-footer-item">
                    { format!("💪 {}", &task.mandays) }
                </div>
            </footer>
            <footer class="card-footer">
                <button class="button card-footer-item">{ "◀︎" }</button>
                <button class="button card-footer-item">{ "▶︎︎" }</button>
            </footer>
          </div>
    }
}

Columnコンポーネントを修正します。第3引数としてタスクを追加して、その状態のタスク数とタスクを表示させるようにします。

#[component]
fn Column(cx: Scope, text: &'static str, tasks: Memo<Vec<Task>>) -> Element {
    view ! { cx,
        <div class="column">
            <div class="tags has-addons">
                <span class="tag">{text}</span>
                <span class="tag is-dark">{move || tasks.get().len()}</span>
            </div>
            <div>
                <For each=move ||tasks.get() key=|e| e.id>
                    { move |cx, t: &Task| view! { cx, <Card task=t.clone() /> } }
                </For>
            </div>
        </div>
    }
}

ここでは、引数にメモ化されたシグナルを使っています。Columnコンポーネントとしては特定の状態だけでフィルターされたTaskの配列が欲しいわけですが、先ほどからの説明の通り、Vec<Task>とするとリアクティブな値ではないので描画が更新されません。クロージャを使った「導出されたシグナル」をコンポーネントに値を渡すのも上手くできなかったためです(他によい方法がありましたら教えてください)

カウントの部分task.get().len()がクロージャになっているのは、同様にシグナルとして扱わせたいからです。

Forコンポーネントはkeyの同一性に基づいた子要素の更新処理を行います。

            <For each=move ||tasks.get() key=|e| e.name.clone()>
                    { move |cx, t: &Task| view! { cx, <Card task=t.clone() /> } }
            </For>

次のコードは上記のコードと同様の動作をしますが、更新があるたびに全ての子要素を削除して新たに追加することになります。

            { move || tasks
                    .get().iter()
                    .map(|t: &Task| view! { cx, <Card task=t.clone() /> })
                    .collect::<Vec<_>>()
            }

BoardコンポーネントにTasksのシグナルを追加して、それぞれの状態でフィルターしたメモを作成します。

    let (tasks, set_tasks) = create_signal(cx, Tasks::new());
    let filtered_tasks = move |status: i32| tasks.with(|tasks| tasks.filtered(status));

    let filtered_tasks1 = create_memo(cx, move |_| filtered_tasks(1));
    let filtered_tasks2 = create_memo(cx, move |_| filtered_tasks(2));
    let filtered_tasks3 = create_memo(cx, move |_| filtered_tasks(3));

最後に、このメモをColumnコンポーネントで使うようにします。

                    <div class="columns">
                        <Column text="Open"        tasks=filtered_tasks1 />
                        <Column text="In progress" tasks=filtered_tasks2 />
                        <Column text="Completed"   tasks=filtered_tasks3 />
                    </div>

これでタスクカードが表示されるようになりました。

コード内で生成された4つのカードが表示されている様子

状態変更処理の作成

状態変更のボタンが押されたときに、状態を変更して、タスクカードを移動させる処理を入れます。

まず、Tasksにメソッドを追加します。このメソッドは指定されたidを持つタスクを探して指定された数だけstatusを変化させます。

    fn change_status(&mut self, id: Uuid, delta: i32) {
        if let Some(card) = self.0.iter_mut().find(|e| e.id == id) {
            let new_status =  card.status + delta;
            if 1 <= new_status && new_status <= 3 {
                card.status = new_status
            }
        }
    }

Boardコンポーネントでset_taskをコンテキストとして提供します。これは書き方は違いますが、ReactやSolidJSのコンテキストプロバイダーのようにノードをまたいでデータを渡すことができます。

        provide_context(cx, set_tasks);

Cardコンポーネントではuse_contextでこれを取り出します。これがWriteSignal<Tasks>なのでupdate内でchange_statusを呼んで値を変更してやります。

    let set_tasks = use_context::<WriteSignal<Tasks>>(cx).unwrap();
    let move_dec = move |_| set_tasks.update(|v| v.change_status(task.id, -1));
    let move_inc = move |_| set_tasks.update(|v| v.change_status(task.id,  1));

最後にクリックイベントのリスナーとして上で作ったハンドラーを設定します。

                <button on:click=move_dec class="button card-footer-item">{ "◀︎" }</button>
                <button on:click=move_inc class="button card-footer-item">{ "▶︎︎" }</button>

これでクリックでのタスクの移動ができるようになりました。

カードの下部にあるボタンをクリックすることでカードが移動するようになった様子タスク追加機能の作成

最後に、フォームを追加して、そこから新しいタスクを追加できるようにしましょう。まずコントロールコンポーネントを用意します。

#[component]
fn Control(cx: Scope) -> Element {
    let (name, set_name) = create_signal(cx, "".to_string());
    let (assignee, set_assignee) = create_signal(cx, "🐱".to_string());
    let (mandays, set_mandays) = create_signal(cx, 0);

    view! { cx,
        <div>
            <input value=name.get() on:change=move |e| set_name.update(|v| *v = event_target_value(&e)) />
            <select value=assignee.get() on:change=move |e| set_assignee.update(|v| *v = event_target_value(&e)) >
                <option value="🐱">"🐱"</option>
                <option value="🐶">"🐶"</option>
                <option value="🐹">"🐹"</option>
            </select>
            <input value=mandays.get() on:change=move |e| set_mandays.update(|v| *v = event_target_value(&e).parse::<u32>().unwrap()) />
            <button>{ "Add" }</button>
        </div>
    }
}

次のコードをBoardのビューに追加します。

            <div class="container">
                <Control />
            </div>

Tasksにメソッドadd_taskを追加します。

        fn add_task(&mut self, name: &str, assignee: &str, mandays: u32) {
            self.0.push(Task::new(name, assignee, mandays, 1));
        }

Cardコンポーネントと同様に、Controlコンポーネントでもuse_contextでWriteSignal<Tasks>を取り出して、add_taskを呼ぶようなクロージャを作ります。

    let set_tasks = use_context::<WriteSignal<Tasks>>(cx).unwrap();
    let add_task = move |_| {
        set_tasks.update(|v| v.add_task(&name.get(), &assignee.get(), mandays.get()));
    };

最後にこれをクリックイベントのリスナーとして設定すれば完成です。

                <button on:click=add_task>"Add"</button>

上にあるコントロールから新しいタスクカードを作成できるようになりました

おわりに

Rust + Leptosを使った、簡単なかんばんライクなタスク管理アプリの作り方を紹介しました。

Leptosを始めて使ってみた私でも、今回のタスクボードアプリをそれほど苦労なく書くことができました…と言いたいところですが、コンポーネントをまたいだときのリアクティブ周りではカードが動いてくれずにちょっと苦労しました。

ただ、ドキュメントは頻繁に更新されている上に、公式リポジトリ内にサンプルがあるのでそれを読むのは非常に参考になりました。

なお、RustのフロントエンドフレームワークはLeptosやYew以外にもいくつかあります。興味のあるかたは、github/rust-web-framework-comparisonあたりを参考にするとよいでしょう。

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる