Open6

Reactチュートリアルの○✗ゲームをDioxusで作ってみる

えとあるえとある

概要

DioxusとはReactっぽい書き方ができるRustのUIフレームワークである。「Reactっぽい書き方ができる」とのことなので、Reactのチュートリアルにある○✗ゲーム (tic-tac-toe) を作りながらDioxusの使い方と開発体験を楽しんでみようと思う。このスクラップでは進捗と学んだことを開発ログとして記録していく。

https://github.com/etoal83/agented-tic-tac-toe

これを書いてるのはどんな人?

  • Rustを趣味で1年ちょっとくらい書いてる人
    • 習作アプリを1つ作ったことがあるくらい
    • システムプログラミングは勉強中でほぼ素人
    • 非同期、並行性、スマートポインタなどのRustの高度な使途についての理解はまだまだ浅い
  • Reactは仕事で1件、趣味では何件か使ったことある人
    • 関数コンポーネントが出てきて以降に使い始めたのでclassベースの記法には不慣れ
    • 状態管理はReactフックまたはRecoilで対応してきた ← Reduxは未経験
    • クロスプラットフォーム (ReactNative) やパフォーマンス・クリティカルな案件は未経験

想定読者

  • ある程度ReactとRustの基礎知識がある方
  • Dioxusの使い方を学ぼうと思っている方

関連リンク

Dioxus

React

えとあるえとある

まずはDioxusでHello world

以下のドキュメントを参考にdioxusとリポジトリのセットアップを行う。dioxusではマルチプラットフォームをターゲットとして指定可能だが、今回はReactチュートリアルを進めるのでReactのデフォルトであるwebをプラットフォームとして選択する。


リポジトリを初期化。リポジトリ名はagented-tic-tac-toeとした。

cargo new --bin agented-tic-tac-toe
cd agented-tic-tac-toe

dependenciesにdioxusを追加

cargo add dioxus --features web

dioxus/webはWebAssembly (wasm32-unknown-unknown) へコンパイルされる。このwasmをwebブラウザ上のUIとして展開させるには、wasmをマウントするHTML要素が必要となる。そこでリポジトリにエントリーポイントとなる最小限のhtmlファイルを用意する。

dioxusのドキュメントではwebターゲットへのビルド/バンドル用CLIに trunk を利用しているようなので、それに倣ってリポジトリのルートに index.html という名前のhtmlファイルを置く。これでtrunk コマンドが自動的にindex.htmlをSPAのエントリーポイントとして認識してくれるようになる。

index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <div id="main"> </div>
  </body>
</html>

そして src/main.rs を以下の内容に書き換える。

main.rs
use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

fn app(cx: Scope) -> Element {
    cx.render(rsx!{
        div { "hello, wasm!" }
    })
}

以上のファイルを変更・保存した後、trunkコマンドで開発サーバーを立ち上げる。

trunk serve 

ブラウザで http://localhost:8080 にアクセスした結果↓

ちゃんと動いた🎉

ブラウザの開発ツールでページのHTML構造を見てみると、index.html では空だった <div id="main"> 要素の中身がRust側の rsx! マクロの中身に相当する要素になっていることが分かる。

今日は commit: 93789ba まで。

えとあるえとある

Reactチュートリアルの「スターターコード」の状態を作る

状態目標スターターコード (codepen.io)

学んだこと

  • class名の書き方
react
return (
  <div className="hoge">
  </div>
)
dioxus
cx.render(rsx!(
    div { class: "hoge",
    }
))
  • コンポーネントとProps
    • dioxusのコンポーネントはPropsを引数にとってElementを返す関数である
      • 引数PropsPropsトレイトを実装した単一の struct として渡す
        • Propsトレイトは継承 #[derive(Props)] で簡単に実装できる
        • Propsには2種類ある
          • Owned props: コンポーネント自身に主に紐づくプロパティ。Copyが容易な単純なデータの場合もこちら。PartialEqが要求される
          • Borrowed props: 他コンポーネントから借用しているプロパティ。
          • スターターコードを作る段階ではまだowned propsのみで十分
      • [感想]propsがちょっとしかない場合でもいちいちstructを定義しなきゃいけないのは少々面倒くさいかも?
    • コンポーネントの使い方
      • Propsがない場合はComponentName()Propsがある場合はComponentName { prop1: value1, ... }のように書く
react(関数コンポーネントに書き換えてある)
const Square = () => {
  return (
    <button className="square">
    </button>
  );
};

const Board = () => {
  const renderSquare = i => {
    return (
      <Square />
    );
  };

  const status = 'Next player: X';

  return (
    <>
      <div className="board-row">
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div className="board-row">
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div className="board-row">
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </>
  );
};
dioxus
fn Square(cx: Scope) -> Element {
    cx.render(rsx!(
        button { class: "square",
        }
    ))
}

fn Board(cx: Scope) -> Element {
    #[derive(Props, PartialEq)]
    struct RenderSquareProps {
        nth: i32,
    }

    fn RenderSquare(cx: Scope<RenderSquareProps>) -> Element {
        cx.render(rsx!{
            Square()
        })
    }
    
    let status = "Next Player: X";

    cx.render(rsx!(
        div { class: "status", "{status}" },
        div { class: "board-row",
            RenderSquare { nth: 0 },
            RenderSquare { nth: 1 },
            RenderSquare { nth: 2 },
        },
        div { class: "board-row",
            RenderSquare { nth: 3 },
            RenderSquare { nth: 4 },
            RenderSquare { nth: 5 },
        },
        div { class: "board-row",
            RenderSquare { nth: 6 },
            RenderSquare { nth: 7 },
            RenderSquare { nth: 8 },
        },
    ))
}

コンポーネントの関数名はCamelCaseで定義する必要がある?[要出典]

  • dioxusではCamelCaseで命名された関数を自動的にdioxus_htmlと呼ばれるクレートに登録し、rsxマクロで利用可能なコンポーネントにしているっぽい[1]
  • 登録されていないコンポーネントをrsxマクロ内で使おうとすると以下のようなコンパイルエラーが出る
    • dioxus_elementsとはdioxus_htmlのエイリアス名[2]
error[E0425]: cannot find value `renderSquare` in crate `dioxus_elements`
  --> src/main.rs:35:13
   |
35 |             renderSquare { nth: 0 },
   |             ^^^^^^^^^^^^ not found in `dioxus_elements`
  • Propsがないコンポーネントであればsnake_caseでも大丈夫っぽい? が、Propsがあるコンポーネントだと引数の渡し方の整合性を気にする必要がある
    • 大人しくコンポーネント関数名をCamelCaseで定義する方が楽
    • CamelCaseを使うことでコンパイラのwarningが鬱陶しい場合には、ファイルの先頭に#![allow(non_snake_case)]と書いておくと警告を黙らせることが出来る

その他、作業ログ

index.html
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+   <title>Agented Tic-tac-toe</title>
+   <link data-trunk rel="css" href="public/index.css" />
  </head>

成果物

スターターコードと同じ状態になった🥳

今日は commit: f57f097 まで。

脚注
  1. dioxus_html - Docs.rs ↩︎

  2. dioxus::prelude - Docs.rs ↩︎

えとあるえとある

Props経由でデータを渡す

状態目標この時点でのコード全体を見る (codepen.io)

学んだこと

  • 前回の投稿 でもちょっと書いたけど、dioxusではpropsを渡すには明示的に専用のstruct型を用意する必要がある
    • Reactでは全てのコンポーネントが暗黙的にpropsを引数として持っているので、受け取る側でdestructuringすればpropsの存在をそこまで意識せずともデータの受け渡しができる(改めてReactよく出来てるなと思った)
  • 渡されたpropsは構造体Scopeのフィールドに格納されている[1]
    • アクセスするときはcx.props.{渡す側で定義したkey}のように書く

Props経由でデータを受け取る側

react
- const Square = () => {
+ const Square = ({ value }) => {  // { value } = props でdestructuringして受け取る 
    return (
      <button className="square">
+       {value}
      </button>
    );
  };
dioxus
+ #[derive(Props, PartialEq)]
+ struct SquareProps {
+    value: i32,
+ }

- fn Square(cx: Scope) -> Element {
+ fn Square(cx: Scope<SquareProps>) -> Element {
      cx.render(rsx!(
          button { class: "square",
+            "{cx.props.value}"
          }
      ))
  }

Props経由でデータを渡す側

react
  const renderSquare = i => {
    return (
-     <Square />
+     <Square value={i} />
    );
  };
dioxus
    #[derive(Props, PartialEq)]
    struct RenderSquareProps {
        nth: i32,
    }

    fn RenderSquare(cx: Scope<RenderSquareProps>) -> Element {
        cx.render(rsx!{
-           Square()
+           Square { value: cx.props.nth }
        })
    }

成果物

Reactチュートリアルの この時点でのコード全体を見る (codepen.io) と同じ状態になった🥳

今日は commit: 38e558f まで。

脚注
  1. Scope in dioxus::prelude - Docs.rs ↩︎

えとあるえとある

インタラクティブなコンポーネントを作る

状態目標この時点でのコード全体を見る (codepen.io)

学んだこと

  • コンポーネントを使うとき、classなどの属性やイベントリスナはviewに使われるテキストや子コンポーネントよりも前に書く必要がある[1]
  • use_stateの使い方
    • reactのuseState()フックは状態のgetterとsetterをセットで返すが、dioxusのuse_state()は状態を参照・操作するメソッドが生えたUseState structを返す[2]
react
const Square = ({ value }) => {
  const [state, setState] = useState(null);

  return (
    <button className="square" onClick={() => setState('X')}>
      {state}
    </button>
  );
};
dioxus
fn Square(cx: Scope<SquareProps>) -> Element {
    let state = use_state(&cx, || "");

    cx.render(rsx!(
        button { class: "square",
            onclick: move |_| state.set("X"),  // クロージャの引数`event`は今は使わないので`_`で塞ぐ
            "{state}",
        }
    ))
}
  • Reactのコード例に倣ってdioxusでもstateの型を&str || Noneの何れかの値を取るUseState<Option<&str>>にしようとしてみたが、Option型はstd::fmt::Displayトレイトを実装していないのでrsx!マクロ内で呼び出せなかった
    • rsxマクロ内でデバッグ表示"{:?}"できるんだろうか?

成果物

Reactチュートリアルの この時点でのコード全体を見る (codepen.io) と同じ状態になった🥳

今日は commit: c7c7daa まで。

脚注
  1. dioxus#Elements & your first component - Docs.rs ↩︎

  2. UseState in dioxus::hooks - Docs.rs ↩︎