RustとDioxusで投稿アプリのSPAに一覧表示機能を実装しようWebアプリ実装で学ぶ、現場で役立つRust入門(9)

第9回では、第8回の続きとして、投稿アプリSPAの機能を拡張していきます。第9回では、一覧表示機能のコードを通じて、Dioxusのコンポーネントの理解を進めます。

» 2024年06月13日 05時00分 公開

この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。

「Webアプリ実装で学ぶ、現場で役立つRust入門」のインデックス

連載:Webアプリ実装で学ぶ、現場で役立つRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。


Dioxusにおけるコンポーネント指向開発

 第7回で、DioxusはReactの影響を強く受けたフレームワークであることに触れました。これは公式サイトでも明記されていますが、Dioxusの利用においてはReactのコンポーネント指向開発の理解があればスムーズなのは間違いありません。今回、一覧表示機能を実装するに当たって、コンポーネント指向開発の基本をあらかじめ示しておくことにします(図1)。

図1 コンポーネント指向開発 図1 コンポーネント指向開発

レンダリングの単位としてコンポーネントを作る

 コンポーネント指向開発では、レンダリング(HTMLの生成)の単位を「コンポーネント」として設計します。前回はAppコンポーネントを示しましたが、これはヘッダとフッタを除く部分、すなわちアプリのメイン部分に相当するコンポーネントです。役割は、APIサーバからのレスポンスを、JSON形式でそのままレンダリングするというものでした。

 今回は一覧表示機能を実装していきます。このとき、Appコンポーネントでは投稿データを逐一レンダリングしていってもよいのですが、役割分担という観点から、ここでは投稿データのレンダリングに特化したPostEntryコンポーネントを導入します。役割ごとにコンポーネントを分離することで、Appコンポーネントの肥大化を防げますし、コンポーネントが再利用しやすくなります。

 Appコンポーネントからは、投稿の数だけPostEntryコンポーネントを呼び出すことで、結果として一覧表示を達成できます。このとき、Appコンポーネントは親コンポーネント、PostEntryコンポーネントは子コンポーネントという関係になります。

コンポーネントの状態はフックで管理する

 前回の記事で、コンポーネントの状態(ステート)は「フック」(hook)という仕組みで管理することを示しました。フックには値、参照、関数を割り当てることができ、コンポーネントが有効な間だけ保持されます。

 また、フックの変更は、そのままコンポーネントが再レンダリングされるトリガーとなります。つまり、フックを書き換えることで、その内容は即座に表示にも反映されます(反映のための特別な指示は必要ありません)。

子コンポーネントにはPropsでデータを渡す

 親コンポーネントから子コンポーネントへは、Propsという仕組みでデータを渡せます。名前から想像が付くと思いますが、PropsはProp(プロパティ)の集合です。コンポーネントに対する引数とも言えるでしょう。Propsによって、コンポーネントの汎用(はんよう)性を上げて再利用しやすくしています。

 この例であれば、PostEntryコンポーネントがレンダリングする投稿データは、AppコンポーネントからProps経由で渡されています。

 以上、コンポーネント、フック、Propsといった概念を押さえられたところで、具体的なアプリに実装していきます。

取得した投稿データのランダムアクセス化

 前回は、API呼び出しで取得した投稿データ(JSON)をAppコンポーネント内にそのまま表示する処理を実装しました。これを、レンダリングして表示するように改良します。まず、JSONデータそのままでは扱いにくいので、ランダムアクセスが可能なハッシュマップを生成する処理を実装します。

Message構造体の使用とハッシュマップの型を定義する

 投稿データの構造体は、これまで通りMessageです。第7回で、dataクレートにMessage構造体の定義を記述しました。これをmainクレートでも使えるようにリスト1のようにuse文を修正します。

mod data;
use data::{Message, ResponseContent};		// Messageを追加
リスト1:src/main.rs(use文)

 ハッシュマップは、im_rc::HashMap構造体として作成します。このハッシュマップは、キーをi32型、値をMessage構造体とします。キーには、Message構造体のidフィールドの値をそのまま使います。これにより、idフィールドによるランダムアクセスを可能にするわけです。また、型名が長くなるので、type文によって型エイリアスPostsをリスト2のように定義します。

type Posts = im_rc::HashMap<i32, Message>;
リスト2:src/main.rs(型エイリアスPostsの定義)

 このハッシュマップが変更されたらコンポーネントを再レンダリングしたいので、まさにフックの出番です。前回のuse_resource関数ではなく、値を保持するuse_signal関数でフックとして登録します。初期値はPosts::default、つまり、空のハッシュマップです。ハッシュマップを更新するので、mutでミュータブルとしています。

let posts_source = use_resource(|| data::call_index());
let mut posts = use_signal(Posts::default);			// 追加
リスト3:src/main.rs(Appコンポーネント)

 これで、Appコンポーネントで保持する投稿データの格納場所が準備できました。

【補足】im_rcクレート

 ハッシュマップに使用しているim_rcクレートは、前回でプロジェクトに追加したクレートで、不変(イミュータブル)のコレクションを提供します。本来のイミュータブルとは、データの変更ができないことを意味しますが、ここでいうイミュータブルとは、コレクションの変更ができないことを意味しません。ある変更操作に対して、元のデータを変更せずに、変更後のデータを返すようなコレクションになります。意図しないデータの変更を防止できるという利点がありますが、データの複製を伴うので、性能上は不利に働きます。im_rcクレートでは、参照カウンタを用いた複製をすることで、複製のオーバーヘッドを最小限に抑えて性能の劣化を防いでいます。

JSONデータをハッシュマップに置き換える

 API呼び出しで取得した投稿データ(JSONのベクター)を、上記で作成したハッシュマップに格納します。JSONデータを表示していた箇所を、リスト4のように書き換えます。

…略…
Some(Ok(res)) => {
    if posts.read().is_empty() {	(1)
        match &res.result {
            ResponseContent::Items(items) => {
                for item in items {			(2)
                    posts.write().insert(item.id, 	(3)
                        Message {
                            id: item.id,
                            posted: item.posted.clone(),
                            sender: item.sender.clone(),
                            content: item.content.clone(),
                        }
                    );
                }
                rsx! { div { "データ読み込みを終了しました" } }		(4)
            },
            ResponseContent::Item(item) =>
                rsx! { div { "{serde_json::to_string(&item).unwrap()}" } },
            ResponseContent::Reason(reason) => rsx! { div { "{reason}" } },
            ResponseContent::None => rsx! { div {} },
        }
    } else {
        rsx! { div { "ここに投稿データを表示します" } }		(5)
    }
},
…略…
リスト4:src/main.rs(ハッシュマップの生成)

 API呼び出しのフックである、posts_sourceの実行が正常に終了した(Some(Ok(res))が返ってきた)ときの処理です。

 (1)では、is_emptyメソッドによってハッシュマップ(posts)が空のときは未生成、空でないときは生成済みであるとして処理を分けています。これは、何度も生成処理を実行しないためです。投稿データがないときもハッシュマップは空となりますが、その場合は生成処理も行われないため、処理を簡略にするために条件を単純化しています。

 (2)から(4)が、前回の処理内容の置き換えです。(2)以降で、全ての投稿データに対してMessage構造体のインスタンスを生成し、(3)でハッシュマップに挿入しています。ここで使っているposts.write().insert()という書式は、フックにアクセスするときの定型的な書き方です。フックに対するアクセスは、readメソッドかwriteメソッドを介することになっています。

  • readメソッド:フックに読み出し専用でアクセスする
  • writeメソッド:フックに書き込み可能でアクセスする

 ここでは、postsの更新用の参照をwriteメソッドで取得し、その参照からinsertメソッドを呼び出してハッシュマップに挿入するという意味となります。

 (4)では、最後にデータ読み込み済みのメッセージをレンダリングしています。ただし、(3)においてフックが変更されているので、すぐにコンポーネントが再レンダリングされて消えてしまうメッセージです。

 (5)は、(1)が不成立のときの処理です。ハッシュマップが生成済みなので、仮のメッセージをレンダリングしています。この部分には、後ほど投稿データのレンダリング処理を実装します。

 ここまでで、API呼び出しで取得した投稿データをハッシュマップ(フック)に格納できました。APIサーバ(前回配布サンプルのactix_posts)を起動、本アプリもdx serveコマンドでビルド、実行し、図2のように表示されれば成功です。

図2 ハッシュマップ生成成功 図2 ハッシュマップ生成成功

投稿表示のコンポーネントの作成

 個々の投稿は、そのためのPostEntryコンポーネントを作成し、レンダリングします。

コンポーネントの入力となるPropsを定義する

 コンポーネントの作成に先立ち、その入力となるPropsの定義をPostEntryProps構造体として、リスト5のように追加します。Propsの内容としては、Posts型のSignal構造体set_posts、そして投稿のIDであるidです。

#[derive(Props, Clone, PartialEq)]	(1)
struct PostEntryProps {
    set_posts: Signal<Posts>,		(1)
    id: i32,
}
リスト5:src/main.rs(PostEntryProps構造体)

 Propsには、構造体がPropsとして振る舞えるようにするため、(1)のようにderiveマクロにてメソッドなどを実装します。

 (2)のSignal構造体は、フックの定義であるuse_signalメソッドが返すデータ型です。

 Dioxusの旧バージョンでは、Propsにフックを渡す場合にはその参照を構造体に含めて、ライフタイムを適切に設定する必要がありました。バージョン0.5では、構造体をクローンしてコンポーネントに渡すため、参照を渡す必要がなくなりライフタイム注釈も不要になります。

コンポーネントを作成する

 個々の投稿をレンダリングするコンポーネントを、PostEntry関数としてリスト6のように定義します。基本的にはAppコンポーネントと同様の構造で、スコープを受け取ってElement型を返します。

#[component]
fn PostEntry(props: PostEntryProps) -> Element {	(1)
    let posts = props.set_posts.read();		(2)
    let post = &posts[&props.id];
    rsx! {	(3)
        div {
            class: "card mb-3",		(4)
            div {
                div {
                    class: "card-header",
                    "{post.sender} {post.posted}"	(5)
                }
                div {
                    class: "card-body",
                    p {
                        class: "card-text",
                        "{post.content}"
                    }
                }
            }
        }
    }
}
リスト6:src/main.rs(PostEntry関数)

 (1)はPostEntry関数の宣言です。引数のProps構造体をそのまま引数として指定することができます。

 (2)のprops.set_posts.read()は、Propsである引数のフィールドset_postsをreadメソッドによる読み出しで参照する、という意味です。続く&posts[&props.id]で、idに一致する投稿のみを取得してレンダリングに使用します。

 (3)からはコンポーネントのレンダリングで、(4)はRSX構文における属性の記述です。RSX構文では、「属性名: 属性値」の形式で属性を指定できます。指定しているclass属性は、Bootstrapのカードコンポーネントのためのクラスです。また、(5)はdiv要素のコンテンツであり、中カッコ{}を使った値の埋め込みも可能です。

Props構造体を省略する記法

 本稿では、Propsとコンポーネント関数の関係を説明するために、Props構造体を明示的に定義しました。#[component]属性を付与されたコンポーネント関数は、実は構造体定義を省略できます。上記の例では、以下のように記述しても同様に動作します。

#[component]
fn PostEntry(id: i32, set_posts: Signal<Posts>) -> Element {	(1)
    let posts = set_posts.read();		(2)
    let post = &posts[&id];
…略…

 (1)から分かるように、構造体のフィールドをそのまま関数の引数として記述するだけです。(2)も、引数を直接参照して値を取り出しています。構文が別にあるわけではなく、#[component]属性によって最終的には構造体を使う形に展開されます。

[参考]RSX構文

 PostEntryコンポーネントでは、基本的なRSX構文が使用されています。ここに示したシンプルな記法をはじめ、RSX構文には便利な記法が多く用意されていますので、その概要を参考として紹介します。詳細は、「Dioxus | An elegant GUI library for Rust」を参照してください。

  • タグは「タグ名 { }」の形式で指定し、入れ子にできる

 リスト6のような記述です。div { div { p { … } } }で<div><div><p>…</p></div></div>といった構造を記述できます。

  • コンテンツ(テキスト)は文字列リテラルで指定する

 既出の、"Hello, World!!"などです。属性と並べるときは、カンマで区切ります。文字列中に{ … }を含ませると、リスト6の"{post.content}"のようにRustの式を展開できます。なお、dangerous_inner_html: "<strong>…</strong>"などとしてコンテンツにHTMLを含めることができます。ただし、セキュリティ上の脆弱(ぜいじゃく)性の原因となりますので、HTMLが安全かどうか確認して使う必要があります。

  • 属性は「属性名: 属性値」の形式で指定する

 リスト6のclass: "card mb-3"のような記述です。この場合、class="card mb-3"と展開されます。属性名は、dioxus-htmlクレートで定義されているように、スネークケースで指定します(スネークケースはキャメルケースに変換される)。また、inputタグのtype属性のようにRustの予約語とかぶる場合には、r#typeのようにr#指定子を前置します。

 コンテンツと同様に、{ … }でRustの式を展開できます。コンテンツと並べたり、属性が複数ある場合にはカンマで区切ります。

  • Rustの式はメソッド呼び出しやイテレータも記述できる

 {text.to_uppercase()}や{(0..10).map(|i| rsx!{ "{i}" })}というように、メソッドを呼び出したり、イテレータで簡単な繰り返しを記述したりすることができます。

  • if文やfor文を記述できる

 条件分岐のif文や、イテレータを使わずにfor文で繰り返しを記述できます。if文とfor文の中身はRSX構文でなくてはなりません。

if true {
    div { "真" }
}
for i in 0..9 {
    div { "{i} " }
}

コンポーネントの呼び出し

 最後は、作成したコンポーネントを呼び出して一覧表示を完成させます。呼び出しのコードは、リスト6の(5)の部分を置き換えて(コメントアウトして)リスト7のように記述します。

…略…
} else {
    //rsx! { div { "ここに投稿データを表示します" } }		リスト6の(5)
    let mut filtered_posts = posts.read()		(1)
        .iter()
        .map(|f| *f.0)
        .collect::<Vec<_>>();
    filtered_posts.sort_unstable_by(|a, b| b.cmp(a));	(2)
    rsx! {
        {
            filtered_posts.iter().map(|id| 	(3)
                rsx! { PostEntry { id: *id, set_posts: posts }}
            )
       }
    }
}
…略…
リスト7:src/main.rs(Appコンポーネント)

 (1)から始まる一連のメソッド呼び出しは、投稿IDのみからなるベクターを作成する処理です。ハッシュマップでは表示順は保証されないので、表示順を保持するために使用します。メソッドチェーンが重なるので少し複雑な処理に見えますが、以下のようになっています。コレクション間の変換のための定型的な処理と言えます。

  • read():ハッシュマップの参照を取得
  • iter():それに対してイテレータを適用
  • map():ハッシュマップの各要素から*f.0で1番目の要素(すなわちid)のみを取得
  • collect::<Vec<_>>():idのみからなるベクターとして再構成

 (2)は、(1)で作成したベクターを、id順に並び替える処理です。これで、確実にid順(ここでは降順)で投稿が表示されるようになります。今回は割愛しますが、(1)(2)のコードを改変し、投稿をフィルタリングしたり、表示順を変更したりすることもできるでしょう。

 (3)からは、作成したベクターの各要素に対して、PostEntryコンポーネントの呼び出しを実行しています。この処理にて、全ての投稿データがレンダリングされ、表示されます。コンポーネントには、属性値としてPropsを渡すだけなので、記述は非常にシンプルです。

 ここで、APIサーバを起動、本アプリもdx serveコマンドでビルド、実行して図3のように表示されれば、一覧表示機能の実装は成功です。

図3 投稿データの一覧表示成功 図3 投稿データの一覧表示成功

まとめ

 今回は、前回の続きとして、投稿アプリSPAの一覧表示機能のコードを通じて、Dioxusのコンポーネントの利用方法を紹介しました。

 次回は、アプリに更新機能を追加して状態管理やイベントハンドラの記述方法を紹介します。

筆者紹介

WINGSプロジェクト

有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。

サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/
RSS(https://wings.msn.to/contents/rss.php
X: @WingsPro_info(https://x.com/WingsPro_info
Facebook(https://www.facebook.com/WINGSProject


Copyright © ITmedia, Inc. All Rights Reserved.

スポンサーからのお知らせPR

注目のテーマ

Microsoft & Windows最前線2025
AI for エンジニアリング
ローコード/ノーコード セントラル by @IT - ITエンジニアがビジネスの中心で活躍する組織へ
Cloud Native Central by @IT - スケーラブルな能力を組織に
システム開発ノウハウ 【発注ナビ】PR
あなたにおすすめの記事PR

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。