目次
はじめに
先日話題になった「Webの将来はサーバサイドレンダリング(SSR)に回帰していく」という記事によると、Isomorphic /Universal JavaScriptのような、単一のコードでサーバーとクライアントの両方で実行できるアプリケーションを構築する手法が再び注目を集めているようです。そこで、本記事では以前書いたタスクボードアプリを題材として、Isomorphic RustなフロントエンドフレームワークであるLeptosを使ったサーバーサイドレンダリング (SSR) とハイドレーションを行う方法を紹介します。
なお、ソースコードはGitHubに上げていますが、前回のブログ記事からLeptosのバージョンが上がっており、前回から多少ソースコードを変更しています (本記事ではLeptos v0.2.4を使っています)。
前回のおさらいと今回の概要
前回作ったアプリはクライアント側だけで動作するタスクボードアプリで、trunkがWASMとHTMLを配信していました。これを表したのが次の図です。
タスクボードの状態はブラウザ上にあり、レンダリングもブラウザで行っています。これは、Leptosのフィーチャーフラグcsrを有効にさせてtaskboardアプリをつくり、できたWASMバイナリをそれをtrunk経由でブラウザに送信していました。
今回はまず、タスクボードの状態とそれを操作するAPIを持ったサーバーをつくります。
サーバ側のアプリでは、Leptosのフィーチャーフラグssrを有効にさせ、leptos_axumを使うことでシリアライズ・デシリアライズなどのコードを書く必要がなくなります。単一のソースコードを条件コンパイルでクライアント側のWASMバイナリとサーバー側のバイナリを出すようにします。サーバー側ではLeptosのssrの機能を使いつつも、この時点ではまだサーバーサイドレンダリング(SSR)を行っていません。
そして、ここからさらにコードを書き換えて、サーバーサイドレンダリングでタスクボードを描画したものを送信し、クライアント側でイベントを付ける (Hydrate) ようにします。
Leptosは標準でSSRとhydrateをサポートしているのでコードを少し書き換えるだけで済みます。
フィーチャーフラグを導入する
まずCargo.toml内のleptosの行を次のように書き換えてleptosのdefault-featuresをfalseにします。
leptos = { version = "0.2.1", default-features = false, features = ["stable"] }
さらに、featuresセクションを追加して次のような設定を書きます。
[features] default = [] csr = ["leptos/csr"] hydrate = ["leptos/hydrate"] ssr = ["leptos/ssr"]
こうすることで、コンパイル時にtaskboardのfeaturesを指定したときにleptosのfeaturesを間接的に指定することができるようになります。
Webフレームワークを導入してサーバー側アプリをコンパイルする
ではサーバー側のアプリを書いていきましょう。WebフレームワークのAxumと、main関数をasyncで書きたいのでTokioを導入します。
cargo add axum --optional cargo add tokio --optional --features=rt-multi-thread,macros
導入したこれらのパッケージはサーバー側でのみ必要なパッケージなので、パッケージはoptinalとして追加します。cargo.toml内のfeaturesセクションではssrにのみ依存関係を追記することで、フィーチャーssr(サーバー側)が指定されたときのみこれらのパッケージが使われるようにします。
[features] default = [] csr = ["leptos/csr"] hydrate = ["leptos/hydrate"] ssr = ["leptos/ssr","dep:axum","dep:tokio"]
main.rsを次のコードに置き換えてください。
use std::net::SocketAddr; use axum::{routing::get, Router, response::Html}; #[tokio::main] async fn main() { let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); async fn root() -> Html<&'static str> { Html( r#"<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"/> </head> <body> </body> </html>"#) } let app = Router::new() .route("/", get(root)); println!("listening on http://{}", &addr); axum::Server::bind(&addr) .serve(app.into_make_service()) .await .unwrap(); }
その後、次のコマンドでサーバーを起動させると真っ白のページが表示されます。
cargo watch -- cargo run --features=ssr --release
サーバーからWASMとスタイルファイルを返せるようにする
サーバー側でリクエストを受け付けて、WASMとスタイルファイルを返すようにしていきます。まず、
Axumでファイル読み込むために、Axumが内部で利用しているTowerのミドルウェアであるtower-httpを導入します。同時にHTTPステータスコードを扱うためにhttpも導入します。
cargo add http --optional cargo add tower-http --optional --features=fs
先ほどと同様に、optinalで追加してssrでのみ利用するようにCargo.tomlを修正します。
ssr = ["leptos/ssr", "dep:axum", "dep:tokio", "dep:http", "dep:tower-http"]
次に、main.rsを変更していきます。まず、次のuse宣言を追加してください。
use axum::error_handling::HandleError; use axum::handler::HandlerWithoutStateExt; use tower_http::services::{ServeFile, ServeDir}; use http::StatusCode;
ファイルを扱うためのTowerのサービスを作ります。style.cssのためにサービスを追加するついでに、後でwasmのために利用するpkgというディレクトリのためにもサービスを追加します。そして、エラーハンドリングのためのメソッドを追加します。
let pkg_service = ServeDir::new("assets").not_found_service(handle_file_error.into_service()); let style_service = ServeFile::new("style.css"); async fn handle_file_error() -> (StatusCode, &'static str) { (StatusCode::NOT_FOUND, "File Not Found") }
そして、これを使うために次のコードを追加してください。
let app = Router::new() .nest_service("/pkg", pkg_service) // 追加 .nest_service("/style.css", style_service) // 追加 .route("/", get(root));
そして、HTMLを書いている箇所の<HEAD>タグ内にスタイルを追加してください。
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
最後にHTMLを書いている箇所にWASMを読み込むためのコードを追加してください。
<script type="module">import init, { main } from './pkg/taskboard.js'; init().then(main);</script>
wasm-packでWASMバイナリをつくる
サーバー側の準備ができましたので、クライアント側WASMを作りましょう。残念ながらtrunkで作ったwasmバイナリでは上手く動かないので、今回は別のWASMのツールwasm-packでWASMバイナリを作ります。まず、wasm-packをインストールし、必要なパッケージであるwasm-bindgenも追加します。
cargo install wasm-pack cargo add wasm-bindgen
次にCargo.tomlにwasm-packでのコンパイルに必要な次のセクションを追加します。
[lib] crate-type = ["cdylib", "rlib"]
続いて、lib.rsに次のコードを追加します。#[cfg(feature = “csr”)]は条件コンパイルで、その次に書かれた式がクライアントアプリ(csr) のみコンパイルされるコードになります。
#[cfg(feature = "csr")] use wasm_bindgen::prelude::wasm_bindgen; #[cfg(feature = "csr")] #[wasm_bindgen] pub fn main() { mount_to_body(|cx| view! { cx, <Board /> }) }
これで、準備が整いましたのでwasm-packでコンパイルします。
wasm-pack build --target=web --features=csr --release
pkgディレクトリが作られて、中にWASMバイナリができています。
$ ls pkg package.json taskboard.d.ts taskboard_bg.wasm snippets/ taskboard.js taskboard_bg.wasm.d.ts
これでブラウザをリロードさせるとAxumサーバーから配信されたタスクボードアプリが実行されるようになります (キャッシュのせいでWASMバイナリの変更が反映されないことがあるのでCtrl + F5やCommand + Shift + Rなどでハードリフレッシュさせてください)。
サーバー側にタスク管理アプリの状態を持たせるようにする
この時点ではサーバー側のBoardコンポーネントでタスクボードの状態を初期化しているのでアクセスする度に初期状態を返すだけの状態です。そこで、サーバー側にグローバルなタスク管理アプリの状態を持たせるようにします。
まず、後ほど必要となるシリアライズとデシリアライズのライブラリserdeとserde-jsonを追加し、uuidのfeaturesにserdeを追加します。
cargo add serde cargo add serde-json cargo add uuid --features=v4,js,serde cargo add once_cell --optional
Cargo.tomlの依存関係も更新しておきます。
ssr = ["leptos/ssr", "dep:axum", "dep:tokio", "dep:http", "dep:tower-http", "dep:once_cell"]
ここからlib.rsを変更していきます。まず次のようにuse宣言を追加します。
use serde::{Serialize, Deserialize};
タスクカードのモデルであるTaskとボードのモデルであるTasksのderiveにSerialize, Deserializeを追加します。
#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Tasks(Vec<Task>); #[derive(Serialize, Deserialize, Clone, Eq, PartialEq, Debug)] pub struct Task {
そして、サーバー側にタスク管理アプリの状態を持たせるように次のコードを追加します。
#[cfg(feature = "ssr")] use once_cell::sync::Lazy; #[cfg(feature = "ssr")] use std::sync::Mutex; #[cfg(feature = "ssr")] static BOARD: Lazy<Mutex<Tasks>> = Lazy::new(|| { Mutex::new(Tasks::new()) });
サーバー側にアクションを追加する
サーバー側で実行するアクションとして、
- タスクボードの現在の状態を返すアクション
- タスクカードを移動させるアクション
- タスクカードを追加するアクション
の3つを作りましょう。
次のコードをlib.rsに追加してください。
#[server(GetBoardState, "/api")] pub async fn get_board_state() -> Result<Tasks, ServerFnError> { let board = BOARD.lock().unwrap(); Ok(board.clone()) } #[server(AddTask, "/api")] pub async fn add_task(name: String, assignee: String, mandays: u32) -> Result<(), ServerFnError> { let mut board = BOARD.lock().unwrap(); board.add_task(&name, &assignee, mandays); Ok(()) } #[server(ChangeStatus, "/api")] pub async fn change_status(id: Uuid, delta: i32) -> Result<Uuid, ServerFnError> { let mut board = BOARD.lock().unwrap(); board.change_status(id, delta); Ok(id) }
そしてこれらのアクションをまとめて登録させるためのメソッドregister_server_functionsを追加します。
#[cfg(feature = "ssr")] pub fn register_server_functions() -> Result<(), ServerFnError> { GetBoardState::register()?; AddTask::register()?; ChangeStatus::register()?; Ok(()) }
アクションを処理するハンドラをサーバー側に書く
先ほど作ったアクションを処理するハンドラーをサーバー側に書いていきます。これはleptos_axumを使うと比較的簡単にできます。まずleptos_axumを導入します。
cargo add leptos_axum --optional
Cargo.tomlの依存関係も更新しておきます。
ssr = ["leptos/ssr", "dep:axum", "dep:tokio", "dep:http", "dep:tower-http", "dep:once_cell", "dep:leptos_axum"]
そして、main.rsを変更していきます。まずuse宣言を追加します。
// ↓ postを追加 use axum::{error_handling::HandleError, routing::{get, post}, Router, response::Html}; use taskboard::*; // 追加
そして、サーバー起動時にアクションを登録するために、main関数の先頭あたりに次のコードを追加してください。
register_server_functions().unwrap();
最後にserviceを記述する部分に次のコードを追加すれば完了です。
let app = Router::new() .route("/api/*fn_name", post(leptos_axum::handle_server_fns)) // 追加 :
これでサーバー側にAPIが完成しましたので、ターミナルで次のようなコードを実行させてみましょう。ボードの状態が返されるはずです。
$ curl -s -X POST 'http://localhost:3000/api/get_board_state' | jq [ { "id": "e6d61eec-9939-4331-9ce9-76d2c667e618", "name": "Task 1", "assignee": "🐱", "mandays": 3, "status": 1 }, { "id": "f26dabf5-d5d9-49a0-b4ea-555349b3ad07", "name": "Task 2", "assignee": "🐶", "mandays": 2, "status": 1 }, { "id": "21d4a38e-e8b0-4ae5-b9f5-1483c3b6ad8e", "name": "Task 3", "assignee": "🐱", "mandays": 1, "status": 3 }, { "id": "0afb3787-3ec7-42f0-986d-a9820a753108", "name": "Task 4", "assignee": "🐹", "mandays": 3, "status": 2 } ]
(解説) handle_server_fnsが行なっている処理
(解説なので次の節まで読み飛ばしてもらっても構いません) Actix用のhandle_server_fnsのコードはLeptosのドキュメントに書かれています。このコードが何をしているのかをざっくりと表わしたのが次の図です。
クライアント側でchange_statusなどが呼ばれたときは、そのコードは上で書かれたコードを実行しません。その代わりに、マクロで展開されたコードが実行されます。具体的にはパラメータを組み立ててからcall_server_fnを呼ぶことでサーバーの指定されたエンドポイントにx-www-form-urlencodedでパラメータをPOSTして戻り値をJSONとして受け取ります。
ですので、サーバー側ではそれを受け取って、server_fn_by_pathにパスを渡して登録されたアクションかどうかを調べます。server_fn_by_pathが関数を返したなら、その関数にbodyをそのまま渡すと、それをデシリアライズして実際のコードであるchange_statusが呼ばれ、結果をシリアライズしたものをレスポンスとして返す…という処理をしています。
クライアントからサーバーの初期状態を得られるようにする
クライアント側からAPIを呼び出してタスクボードの状態を得られるようにします。
create_resource経由でアクションget_board_stateを呼び出すようにすると、Leptosが内部でHTTP通信を行い、ボードのデータを取得するようになるのでそのように変更していきます。
まず、Boardコンポーネント内のコードを書き換えます。
pub fn Board(cx: Scope) -> impl IntoView { let (tasks, set_tasks) = create_signal(cx, Tasks::new()); provide_context(cx, set_tasks); let filtered_tasks = move |status: i32| tasks.with(|tasks| tasks.filtered(status));
となっている3行を次のコードに置き換えてください。
let filtered_tasks = { let tasks = create_resource( cx, move || (), |_| get_board_state(), ); #[cfg(feature = "csr")] let filtered = move |status: i32| tasks .read(cx) .unwrap_or(Ok(Tasks::new())) .map(|tasks| tasks.filtered(status)) .expect("none error"); #[cfg(feature = "ssr")] let filtered = move |status: i32| vec![]; filtered };
条件コンパイルの記述を少なくするために、RustのBlock式を使って次のようにまとめています。#[cfg(any(feature = “csr”, feature = “ssr”))]とすることでクライアント側とサーバー側両方の処理となります (後にhydrateの処理を書き分けたいのでこうしています)。
このままcsrでコンパイルすると削除したprovide_contextを使っているuse_contestの箇所で実行時エラーになってしまうので、ひとまずコンパイルが通るように書き換えます。
Controlコンポーネントのset_tasksなどの3行を次のように書き換えてください。
let add_task = move |_| {};
同様にCardコンポーネントもmove_decのあたりの3行を次のように書き換えてください。
let (move_dec, move_inc) = (move |_| {}, move |_| {});
そして、wasm-packを実行し、ブラウザをリロードしてください。
wasm-pack build --target=web --features=csr --release
Devtoolのネットワークタブを開くとサーバーにアクセスしていることが確認できます。
UIから操作はできませんが、JavaScriptコンソール上で下記のようにしてサーバーのAPIを直接実行させることができます。それからブラウザをリロードすると状態を変更することが確認できます (idは適宜実際のものに置き換えてください)。
await fetch("http://localhost:3000/api/change_status", {"method": "POST", "body": "id=4dc7f3b2-d7a8-4e3e-aaa5-912c16e62258&delta=1"})
クライアントからサーバーの状態を更新できるようにする
残りのアクションを使えるようにしていきます。まず provide_contextで値を提供するときとuse_contextで値を取り出すときに型を合わせるために型宣言を追加します。
type AddTaskAction = Action<(String, String, u32), Result<(), ServerFnError>>; type ChangeStatusAction = Action<(Uuid, i32), Result<Uuid, ServerFnError>>;
Boardコンポーネントではこの型を使ったアクションを2つ定義し、下位コンポーネントで使えるようにprovide_contextします。
let filtered_tasks = { // ↓ 2行追加 let create_card: AddTaskAction = create_action(cx, |input: &(String, String, u32)| add_task(input.0.clone(), input.1.clone(), input.2)); let move_card: ChangeStatusAction = create_action(cx, |input: &(Uuid, i32)| change_status(input.0, input.1)); let tasks = create_resource( cx, move || (create_card.version().get(), move_card.version().get()), // 追加 |_| get_board_state(), ); #[cfg(any(feature = "csr")] let filtered = move |status: i32| tasks .read(cx) .unwrap_or(Ok(Tasks::new())) .map(|tasks| tasks.filtered(status)) .expect("none error"); #[cfg(any(feature = "ssr")] let filtered = move |status: i32| vec![]; // ↓ 2行追加 provide_context(cx, create_card); provide_context(cx, move_card); filtered };
create_resourceのsourceの部分に定義したアクションのversionを指定しています。これはActionに付いているシグナルでAPIの呼び出しに成功したときに値が1増えます。つまりここでは、create_cardかmove_cardのいずれかのアクションが成功したときにタスクボードの状態を取り直すようになります。
あとはControlコンポーネントとCardコンポーネントのそれぞれで適切なアクションを呼ぶように変更するだけです。
let add_task = { let create_card = use_context::<AddTaskAction>(cx).unwrap(); move |_| { create_card.dispatch((name.get(), assignee.get(), mandays.get())); } };
Cardコンポーネントも同様の部分を次のコードに変更します。
let (move_dec, move_inc) = { let move_card = use_context::<ChangeStatusAction>(cx).unwrap(); let move_dec = move |_| move_card.dispatch((task.id, -1)); let move_card = use_context::<ChangeStatusAction>(cx).unwrap(); let move_inc = move |_| move_card.dispatch((task.id, 1)); (move_dec, move_inc) };
これで再度wasm-packでビルドします。
wasm-pack build --target=web --features=csr --release
ブラウザでリロードさせると状態更新やタスク作成がAPI経由でサーバー側で実行されるようになります。
SSRを行い、クライアント側でhydrationさせる
最後にサーバー側でサーバーサイドレンダリングさせ、クライアント側でそれをhydrateさせてみましょう。
まず、後でleptosで<style>タグをレンダリングする必要があるので、それを使えるようにするためにleptos-metaを導入します。
cargo add --no-default-features leptos-meta
そして、フィーチャーcsr, hydrate, ssrそれぞれで、leptos-metaのフィーチャーcsr, hydrate, ssrを使うようCargo.tomlを修正します。
csr = ["leptos/csr", "leptos_meta/csr"] hydrate = ["leptos/hydrate", "leptos_meta/hydrate"] ssr = ["leptos/ssr", "leptos_meta/ssr", "dep:axum", "dep:tokio", "dep:http", "dep:tower-http", "dep:once_cell", "dep:leptos_axum"]
続いて、後で使う関数get_configurationで読み込ませるためのサーバーの設定をCargo.tomlに追加します。
[package.metadata.leptos] output-name = "taskboard" env = "DEV" site-root = "/pkg" site-pkg-dir = "pkg" site-addr = "127.0.0.1:3000" reload-port = 3001
続いてmain.rsを修正していきます。まず次のuse宣言を追加します。
use leptos::*; use leptos_axum::*; use std::sync::Arc;
leptos_axum::render_app_to_streamを使うのに必要なLeptosOptionsをCargo.tomlから読み込むようにコードを追加します。
let conf = get_configuration(Some("Cargo.toml")).await.unwrap(); let leptos_options = conf.leptos_options; let addr = leptos_options.site_addr.clone();
leptos_axum::render_app_to_streamを使うように修正します。これを使うことで、これまで自前で行なっていたサーバー側でタスクボードのレンダリングを代わりにleptos_axumが行うようになります。関数rootは使わなくなったので削除して構いません。
let app = Router::new() : //.route("/", get(root)); // ← 削除 .fallback(leptos_axum::render_app_to_stream(leptos_options, |cx| view! { cx, <Board /> })); // 追加
続いてlib.rsを書き換えていきます。まず、関数mainの名前をhydrateに変更します。
#[cfg(feature = "hydrate")] #[wasm_bindgen] pub fn hydrate() { mount_to_body(|cx| view! { cx, <Board /> }) }
またlib.rs内のfeature = “csr”になっている他の2箇所もfeature = “hydrate”に置き換えます。
#[cfg(feature = "csr")] // すべての ↑ を ↓ に #[cfg(feature = "hydrate")]
そして、Boardコンポーネントではサーバー側でもResouceオブジェクトから読み込むようにします。サーバー側でもResourceを読み込むようにすることで、レンダリング時に値を取得してシリアライズしたものをHTML内に含めて、hydrate時にそれを使うようになっています。
#[cfg(feature = "ssr")] let filtered = move |status: i32| tasks .read(cx) .unwrap_or(Ok(BOARD.lock().unwrap().clone())) .map(|tasks| tasks.filtered(status)) .expect("none error");
最後にサーバー側で<style>を埋め込まなくしたのでそれに対応します。まず、lib.rsにuse宣言を追加して、
use leptos_meta::*;
Boardコンポーネント内に次のコードを追加すれば終了です。
provide_meta_context(cx); // 追加 view ! { cx, <> // ↓ 2行追加 <Stylesheet href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" /> <Stylesheet href="/style.css" />
次のコマンドでコンパイルしてブラウザをリロードしてください (featuresの指定をhydrateに変っている点にご注意ください)。
wasm-pack build --target=web --features=hydrate --release
このページのソースを表示すると、レンダリングされた内容が表示されていることがわかります。
Transitionを使って処理をまとめる
Board内の条件コンパイルをしている変数filteredがssrとhydrateでだいたい同じであることに気が付いたでしょうか? 実はこの処理はまとめることができます…と言いたいのですが、単純にhydrateの処理に寄せて次のようにまとめてしまうと、サーバー側とクライアント側で初期状態が異なるようになってhydrate時にpanicを起こしてしまいました。
move |status: i32| tasks .read() .unwrap_or(Ok(Tasks::new())) .map(|tasks| tasks.filtered(status)) .expect("none error")
軽くコードを読んだところ (Leptos v0.1.3時点での話です)、サーバー側ではリソースの取得を待たずに描画が行なった後でリソース取得まで待ち、そのデータも含めて送信している一方、クライアント側では受信したデータに含まれているリソースを使って描画しており、結果としてサーバー側とクライアント側のそれぞれで作られたDOMツリーが一致しないことが原因のようでした。サーバー側ではリソースが常にNoneになるのでunwrap_orされてTasks:new()が常に描画される一方で、クライアント側ではソース内の__LEPTOS_RESOURCE_RESOLVERSのデータを使ってボードの最新の状態で描画するようなかんじです。
そしてさらに調べたところ、サーバー側でリソースの取得まで描画を待たせるようにするにはSuspenseを使う必要がありそうでした。
そこで Transition (Suspenseみたいなものだがfallbackするのは初回のロードのみ) を使ってみましょう。Boardコンポーネントを次のコードに置き換えてください。
#[component] pub fn Board(cx: Scope) -> impl IntoView { let tasks = { let create_card: AddTaskAction = create_action(cx, |input: &(String, String, u32)| add_task(input.0.clone(), input.1.clone(), input.2)); let move_card: ChangeStatusAction = create_action(cx, |input: &(Uuid, i32)| change_status(input.0, input.1)); provide_context(cx, create_card); provide_context(cx, move_card); create_resource( cx, move || (create_card.version().get(), move_card.version().get()), |_| get_board_state(), ) }; provide_meta_context(cx); view ! { cx, <> <Stylesheet href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" /> <Stylesheet href="/style.css" /> <div class="container"> <Control /> </div> <Transition fallback=|| view! { cx, "Loading..." }> {move || tasks.read(cx).map(|tasks| match tasks { Err(e) => view! { cx, <div class="item-view">{format!("Error: {}", e)}</div> }.into_any(), Ok(ts) => { let t1 = ts.clone(); let t2 = ts.clone(); view! { cx, <section class="section"> <div class="container"> <div class="columns"> <Column text="Open" tasks=Signal::derive(cx, move || t1.filtered(1)) /> <Column text="In progress" tasks=Signal::derive(cx, move || t2.filtered(2)) /> <Column text="Completed" tasks=Signal::derive(cx, move || ts.filtered(3)) /> </div> </div> </section> }.into_any() } })} </Transition> </> } }
これで、Boardコンポーネント内に条件コンパイルするコードがなくなってすっきりしました。
おわりに
LeptosでにIsomorphicなウェブアプリをつくってみました。最終的なコードでは、その多くの部分をサーバ側とクライアント側で共有することができたと思います。とは言え、サーバー側かクライアント側のどちらでも使えるようにコードを書くのはLeptosの知識や経験がそれなりに必要そうに思えました。
サーバファンクションあたりの仕組みはマクロの魔術の力もあって、アクションハンドラを定義するだけで簡単にAPIが使えるようになるのはなかなか便利だと感じました。Rustにはserdeがあるので比較的楽に書けるとは思いますがサーバーとクライアントの両方のボイラープレートコードをお手軽にミスなく用意してくれるのはやはりありがたいです。
hydrationについては、やっていること自体は興味深いのですが、レンダリング結果や送信するデータを含めた「状態」をhydrate側とssr側がどう扱うのかを理解して、双方を同じ状態に合わせる必要があり、合わないときの原因の追求もなかなか難しいという印象を受けました (ssr時の挙動はv0.2.0のリリースノートに書かれており、これを読むことで理解が深まりました)。とは言え、上手くできたときはちょっとした達成感を感じました (ローカルで実行しているだけなのであまり恩恵を感じられませんが)。
今回紹介しませんでしたが、Leptosのfeaturesをssrのみにしてサーバー側だけで動くアプリを作ることもできます (サーバー側でのレンダリングでformを使ってPOSTする古き良きアプリになります)。
ちなみに最終版のリリースビルドにおけるWASMバイナリのサイズは490KBでした。Vueで書いたバージョンはおよそ30KBくらい (Vueのランタイムがおよそ27KBで、ソースコードはおよそ2KB) なのと比べるとかなり大きいですね。
この記事では使いませんでしたが、cargo-leptosというツールもあります。これはサーバーとクライアントの両方のバイナリをコンパイルをしてくれたりとLeptosを使うときに便利な様々な機能が提供されていますので、使ってみるのもよいでしょう (公式で用意されているテンプレートが、サンプルアプリを使うには豪華すぎるので使いませんでした)。