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

YewはRustで書かれたフロントエンド向けのフレームワークです。 書かれたコードはWebAssemblyに変換されて、ブラウザ上で実行することができます。プロジェクトのREADMEに「ElmとReactに影響を受けた」と書かれているように、JSX風のHTMLを書けるようなマクロが用意されていて、Rustのコード内にHTMLっぽく表現でビューを書くことができます。 また、Elmアーキテクチャのように、モデルとビューがあり、メッセージによって状態の更新がされるようになっています。

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

また、ElmやElmアーキテクチャについては、ヌーラボのElmマスターLeoの記事を参照ください。

rustupによるRustの導入

今なら、Rustツールチェインのインストーラーであるrustupを使えばRustを簡単に導入できます。

https://rustup.rs/ に書かれているスクリプト curl https://sh.rustup.rs -sSf | sh をコンソールで実行してください。スクリプトの内容が気になる方はコードをダウンロードして中身を確認の上、実行してもよいでしょう (実際のところ対応するCPUやOSに応じたrustup-initというバイナリを落としてきてそれを実行させているだけです)。

このスプリプトを実行すると、Rustのコンパイラであるrustcやパッケージマネージャーのcargoも一緒にインストールされます。$HOME/.cargo/bin にパスを通す (もしくは source $HOME/.cargo/env を実行する) と、パスが通ってこれらのコマンドが使えるようになります。

$ source $HOME/.cargo/env
$ rustc --version
rustc 1.31.1 (b6c32da9b 2018-12-18)
$ cargo --version
cargo 1.31.0 (339d9f9c8 2018-11-16)

nightly版Rustのインストール

残念ながら、現時点ではyewが依存しているコンポーネント (具体的にはstdweb-derive) がnightly版のRustでないとコンパイルできませんので、nightly版のRustをインストールします。

$ rustup install nightly

これで、nightly版のrustcとcargoが使えるようになりました。ただし、このままだと、PATHに通っているコマンドは安定版(stable) のrustcとcargoのままなので、rustup runを使ってnightly版を明示的に呼ぶ必要があります。

$ rustup run nightly rustc --version
rustc 1.33.0-nightly (03acbd71c 2019-01-14) 
$ rustup run nightly cargo --version
cargo 1.33.0-nightly (2b4a5f1f0 2019-01-12)

毎回rustup run nightlyとするのが煩わしく感じるのであれば、rustup default nightlyでデフォルトツールチェインをnightly版にすることもできます。

$ rustup default nightly 
$ rustup toolchain list
stable-x86_64-apple-darwin
nightly-x86_64-apple-darwin (default) 
$ rustc --version 
rustc 1.33.0-nightly (03acbd71c 2019-01-14) 
$ cargo --version
cargo 1.33.0-nightly (2b4a5f1f0 2019-01-12)

これ以降では、デフォルトのツールチェインをnightly版に変更しているものとします。stable版のままお使いの方は、cargoコマンドの前にrustup run nightlyを付けてください。

wasmターゲットの追加とcargo-webのインストール

RustでWebAssemblyを出力としてコンパイルできるようにするために、rustupでターゲットwasm32-unknown-unknownを追加しましょう。

$ rustup target add wasm32-unknown-unknown

さらに、フロントエンドの開発に便利なサブコマンドをcargoコマンドに追加するcargo-webを導入します。インストール後に、cargoにサブコマンドwebが追加されます。

$ cargo install cargo-web
$ cargo web
cargo-web 0.6.23

USAGE:
    cargo-web 

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

SUBCOMMANDS:
    build                 Compile a local package and all of its dependencies
    check                 Typecheck a local package and all of its dependencies
    deploy                Deploys your project so that its ready to be served statically
    help                  Prints this message or the help of the given subcommand(s)
    prepare-emscripten    Fetches and installs prebuilt Emscripten packages
    start                 Runs an embedded web server serving the built project
    test                  Compiles and runs tests

これで、ようやくYewでコードを書くための準備が整いました。

新しいパッケージの作成

通常のアプリ作成の手順と同じように、cargoを使ってタスクボードアプリの雛形をつくります。

$ cargo new yew-taskboard 
$ cd yew-taskboard && ls
Cargo.toml src/

作成されたCargo.tomlの[dependencies]セクションにyewを追加してください。

[dependencies]
yew = { git = "https://github.com/DenisKolodin/yew" }

Hello world

src/main.rsの内容を次のコードで上書きします。

#[macro_use]
extern crate yew;
use yew::prelude::*;

struct Model {
}

impl Component for Model {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink) -> Self {
        Model { }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        false
    }
}

impl Renderable for Model {
    fn view(&self) -> Html {
        html! {
{ "Hello, world!" }

        }
    }
}

fn main() {
    yew::initialize();
    App::::new().mount_to_body();
    yew::run_loop();
}

上記のコードを保存した後に、次のコマンドを実行してください。

$ cargo web start --target=wasm32-unknown-unknown --auto-reload
warning: debug builds on the wasm32-unknown-unknown are currently totally broken 
      forcing a release build
  Compiling proc-macro2 v0.4.20
    :
    :
  Compiling yew v0.5.0 (https://github.com/DenisKolodin/yew#504ce8be)
  Compiling yew-taskboard v0.1.0 (/Users/matsumoto/src/_samples/yew-taskboard)
  Finished release [optimized] target(s) in 1m 35s
  Garbage collecting "yew-taskboard.wasm"...
  Processing "yew-taskboard.wasm"...
  Finished processing of "yew-taskboard.wasm"!

If you need to serve any extra files put them in the 'static' directory in the root of your crate; they will be served alongside your application. You can also put a 'static' directory in your 'src' directory.

Your application is being served at '/yew-taskboard.js'. It will be automatically rebuilt if you make any changes in your code.

You can access the web server at http://127.0.0.1:8000.

必要なパッケージなどをダウンロードしてコンパイルした後に、我々が作ったパッケージであるyew-taskboardがコンパイルされます。コンパイル後には、プロジェクトのディレクトリ内にtarget/wasm32-unknown-unknown/release/が作成されて、 そこにyew-taskboard.wasmとyew-taskboard.jsが作成されます。

yew-taskboard.wasmはRustのコードをコンパイルしたもので、yew-taskboard.jsは yew-taskboard.wasmをロードして使えるようにするための起動用のJavaScriptです。WebAssemblyはJavaScriptのメソッドをインポートする機能を供えているので、 yew-taskboard.js内では yew-taskboard.wasmで使っているJavaScriptのメソッドをテーブルとして定義し、WebAssembly.Instanceの引き数として渡しています。

すべてのコードのコンパイルが終わった後に (コマンド自体は終了せずに) 、ウェブサーバーが起動しています。出力メッセージに書かれている http://127.0.0.1:8000 にブラウザでアクセスしてみましょう。作ったページを表示することができます。

「Hello, world!」が表示されました

cargo webの実行中は、main.rsを修正すると再コンパイルされます。--auto-reloadを付けているので、コンパイル後のブラウザのリロードも自動で行われます。

簡単なコード解説

先ほどのコードで注目していただきたいのは、html!の部分です。これはRustのマクロで、続いた { } 内にHTML風の記述をすることができます。 さらにその中の{ }内でRustのコードを書くことができます。

静的ファイルの配置

まずは、以前の記事で利用していたCSS frameworkのBulmaを利用できる状態にします。

先ほど実行したcargo web startで出力されたメッセージに次のようなことが書かれていました。

If you need to serve any extra files put them in the ‘static’ directory in the root of your crate; they will be served alongside your application.

cargo-webではcrate (Rustのパッケージ) のルートディレクトリにstaticディレクトリを作って、そこに静的ファイルを置くことができます。 そして、static/index.htmlがあると、それをドキュメントルートとして読み込んでくれます。

そこで、静的ファイルとして次のようなindex.htmlとstyles.cssをつくって、staticディレクトリ内に置きます。

index.html

<!doctype html>
<html lang=“ja”>
<head>
<meta charset=“utf-8”>
<title>Task Board</title>
<link rel=“stylesheet” href=“styles.css”>
<link rel=“stylesheet” href=“https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.0/css/bulma.min.css”>
<script src=“yew-taskboard.js”></script>
</head>
<body></body>
</html>

styles.css

.status-1 {
  background-color: #ed8077;
}
.status-2 {
  background-color: #4488c5;
}
.status-3 {
  background-color: #5eb5a6;
}
.card {
  margin-bottom: 5px;
}
.card-footer-item {
  padding-top: 0px; padding-bottom: 0px;	
}

コンパイルされたコードへのパスは /yew-taskboard.js にしています。これは、「アプリは /yew-taskboard.js にあるよ。コード変更すると自動でリビルドされるよ」と先ほどのメッセージに書かれているからです。

Your application is being served at ‘/yew-taskboard.js’. It will be automatically rebuilt if you make any changes in your code.

Bulmaは (前回の記事と同様に) CDNを使うことにしています。

残念ながら、staticディレクトリがない状態では監視されていないので、もし先ほど起動させた cargo web start が起動中であるなら、再度実行しなおす必要があります。 再起動した後に、ブラウザをリロードすると、フォントが変わったことが確認できるようになります。

CSSスタイルを適用しました (ほとんど変わったように見えませんが)

タスクボードの基礎の作成

ここからタスクボードアプリを作っていきます。 まずは、タスクボードの状態を示す列をひとつ作ってみましょう。

先ほどのコードの<div>{ "Hello, world!" } </div>の部分を次のコードに置き換えます。

        html! {
            <section class="section", id="board",>
                <div class="container",>
                    <div class="columns",>
                        <div class="column status-1",>
                            <div class="tags has-addons",>
                                <span class="tag",>{ "未処理" }</span>
                                <span class="tag is-dark",>{ 0 } </span>
                            </div>
                        </div>
                    </div>
                </div>
             </section>
        }

HTMLタグのプロパティごとに末尾に , が必要となっているのが残念ですが、HTMLのコードらしきものをRustのコード内に書いています。

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

タスクの状態を示す1列分ができました

複数の状態列の作成

1列だけで幅広く表示されてしまっているので、複数の列が表示されるようにしていきましょう。

まずは、先ほど追加したコードの中央部分を取り出して、それをメソッドとして使えるようにします。

fn view_column(status: u32, status_text: &str) -> Html<Model> {
    html! {
        <div class=format!("column status-{}", status),>
            <div class="tags has-addons",>
                <span class="tag",>{ status_text }</span>
                <span class="tag is-dark",>{ 0 }</span>
            </div>
        </div>
    }
}

今回は、状態を示す番号と、その状態の名前を引数として取るようなメソッドにしました。 タグのプロパティ部にはRustのコードがそのまま書けるので、Rustのマクロformat!を使っています。

そして、それらを呼び出すようにコードを修正します。

        html! {
            <section class="section", id="board",>
                <div class="container",>
                    <div class="columns",>
                        { view_column(1, "未対応") }
                        { view_column(2, "処理中") }
                        { view_column(3, "完了") }
                    </div>
                </div>
             </section>
        }

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

タスク状態が3列表示されました

タスクカードの作成

次に、タスクカードを表示できるようにしましょう。

モデルに状態として、Stateを持たせるようにします。

struct Model {
    state: State,
}

 

StateTaskをベクターとして持つようにしました。

struct State {
    tasks: Vec<Task>,
}
 
struct Task {
    name: String,
    assignee: String,
    mandays: u32,
    status: u32,
}

そして、初期状態として、モデルに4つのタスクを用意しておきます。

fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
  Model {
    state: State {
      tasks: vec! [
        Task { name: "Task 1".to_string(), assignee: "🐱".to_string(), mandays: 3, status: 1 },
        Task { name: "Task 2".to_string(), assignee: "🐶".to_string(), mandays: 2, status: 1 },
        Task { name: "Task 3".to_string(), assignee: "🐱".to_string(), mandays: 1, status: 2 },
        Task { name: "Task 4".to_string(), assignee: "🐹".to_string(), mandays: 3, status: 3 },
      ]
    }
  }
}

続いて、タスクを表示するメソッドを用意します。

fn view_task((idx, task): (usize, &Task)) -> Html<Model> {
    html! {
        <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",>
              <a class="card-footer-item",>{ "◀︎" }</a>
              <a class="card-footer-item",>{ "▶︎︎" }</a>
            </footer>
          </div>
    }
}

後でmapを使うときに楽をするためにタプルを使っています。idxstateの内のベクターtasksでのインデックスです。後ほどカードを移動させるときに、どのカードかを指定するために使います。

先ほど作ったメソッドview_columnを修正します。第3引数としてタスクを追加して、その状態のタスク数とタスクを表示させるようにします。

fn view_column(status: u32, status_text: &str, tasks: &Vec<Task>) -> Html<Model> {
    html! {
        <div class=format!("column status-{}", status),>
            <div class="tags has-addons",>
                <span class="tag",>{ status_text }</span>
                <span class="tag is-dark",>{ tasks.iter().filter(|e| e.status == status).count() }</span>
            </div>
            { for tasks.iter().enumerate().filter(|e| e.1.status == status).map(view_task) }
        </div>
    }
}

 

最後に、メソッドview_columnを呼ぶ側でも引数に&self.state.tasksを追加すればOKです。

                        { view_column(1, "未対応", &self.state.tasks) }
                        { view_column(2, "処理中", &self.state.tasks) }
                        { view_column(3, "完了"  , &self.state.tasks) }

初期状態でタスクが表示されるようになりました

状態変更処理の作成

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

まず、クリックイベントで送信するためのメッセージを追加します。

enum Msg {
  IncreaseStatus(usize),
  DecreaseStatus(usize),
}

 

そして、ボタンが押されたときのメッセージが投げられるように、ボタンにonclickを追加します。

              <a class="card-footer-item", onclick=|_| Msg::DecreaseStatus(idx), >{ "◀︎" }</a>
              <a class="card-footer-item", onclick=|_| Msg::IncreaseStatus(idx), >{ "▶︎︎" }</a>

モデルがメッセージを処理できるようにするために、そのメッセージを impl Component 内でのメッセージとします。

impl Component for Model {
  type Message = Msg;
  type Properties = ();
    :

そして、メッセージが送られたときに呼ばれる更新メソッド update でメッセージに応じて状態を更新する処理を書きます。

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        Msg::IncreaseStatus(idx) => {
            self.state.increase_status(idx);
        }
        Msg::DecreaseStatus(idx) => {
            self.state.decrease_status(idx);
        }
        true
    }

Stateにメソッドを追加します。これらは、特定のTaskをget_mutで取って、移動可能ならstatusを変更する、ということをしているだけです。

impl State {
    fn increase_status(&mut self, idx: usize) {
        self.tasks.get_mut(idx).filter(|e| e.status < 3).map(|e| e.status = e.status + 1);
    }
    fn decrease_status(&mut self, idx: usize) {
        self.tasks.get_mut(idx).filter(|e| e.status > 1).map(|e| e.status = e.status - 1);
    }
}

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

タスクの追加機能の作成

最後に、フォームを追加して、そこから新しいタスクを追加できるようにしましょう。

まずヘッダーを表示するためのメソッドを用意します。

fn view_header(state: &State) -> Html<Model> {
    html! {
            <div class="container",>
                <input value=&state.new_task_name, oninput=|e| Msg::UpdateNewTaskName(e.value),/>
                <select value=&state.new_task_assignee, onchange=|e| Msg::UpdateNewTaskAssignee(e),>
                    <option value="🐱",>{ "🐱" }</option>
                    <option value="🐶",>{ "🐶" }</option>
                    <option value="🐹",>{ "🐹" }</option>
                </select>
                <input value=&state.new_task_mandays, oninput=|e| Msg::UpdateNewTaskMandays(e.value),/>
                <button onclick=|_| Msg::NewTask,>{ "追加" }</button>
                <hr/>
            </div>
    }
}

上のメソッドview_headerで使われているステートとメッセージをそれぞれStateMsgに追加します。

struct State {
  tasks: Vec<Task>,
  new_task_name: String,
  new_task_assignee: String,
  new_task_mandays: u32,
}

enum Msg {
  IncreaseStatus(usize),
  DecreaseStatus(usize),
  UpdateNewTaskName(String),
  UpdateNewTaskAssignee(yew::html::ChangeData),
  UpdateNewTaskMandays(String),
  NewTask,
}

 

そして、createメソッド内のstateの初期化に次のコードを追加します。

new_task_name: "".to_string(),
new_task_assignee: "".to_string(),
new_task_mandays: 0,

メソッドupdateでは新たに追加されたメッセージを処理するようにします。

fn update(&mut self, msg: Self::Message) -> ShouldRender {
  match msg {	
    Msg::UpdateNewTaskName(val) => {
      self.state.new_task_name = val;
    }
    Msg::UpdateNewTaskAssignee(val) => {
      if let yew::html::ChangeData::Select(v) = &val {
        self.state.new_task_assignee = v.raw_value();
      }
    }
    Msg::UpdateNewTaskMandays(val) => {
      if let Ok(v) = u32::from_str(&val) {
        self.state.new_task_mandays = v;
      }
    }
    Msg::NewTask => {
      self.state.add_new_task(self.state.new_task_name.clone(), self.state.new_task_assignee.clone(), self.state.new_task_mandays);
    }
    Msg::IncreaseStatus(idx) => {
      self.state.increase_status(idx);
    }
    Msg::DecreaseStatus(idx) => {
      self.state.decrease_status(idx);
    }
  }
  true
}

上のメッセージNewTaskの処理で呼んでいるメソッドadd_new_taskは、単純にVecにTaskをpushしているだけです。

impl State {
  fn add_new_task(&mut self, name: String, assignee: String, mandays: u32) {
    self.tasks.push(Task { name, assignee, mandays, status: 1 });
  }

最後にview_headerを適切な位置に表示できるように呼び出しを追加すればよいでしょう。

これで、ようやく課題を追加できるようになりました。

新しいタスクを作ることができるようになりました

まとめ

Rust + Yewを使った、簡単なかんばんライクなタスク管理アプリの作り方を紹介しました。 今回は使いませんでしたが、コンポーネントも使えるので、より複雑なアプリを作るときには今回のタスクカードなどもコンポーネント化すると、よりわかりやすいコードになるでしょう。

JSX風のマクロがあるので、RustとReactの知識があればコードも読めるのではないかと思います。 Yewを始めて使ってみた私でも、今回のタスクボードアプリをそれほど苦労なく書くことができました。ドキュメントが少ないのは残念ですが、公式リポジトリ内にサンプルが結構あるので参考になりました。

Yewで書くことのメリットは、Rustで書けてRustの資産を使うことができるという点が挙げられます。残念ながら、とあるJSフレームワークのベンチマーク結果では、速度面ではYewは他のJSフレームワークと比べて劣っている結果になっています (Rust + WebAssemblyという観点でも、Yewが使っているstdweb自体が素のJSよりやや劣っている)。もっとも、この点は今後のブラウザー上でのWebAssembly実装が進めば改善していくかもしれません。

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

開発メンバー募集中

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

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

製品をみる