フルスタックWeb
Leptos はフルスタックWebフレームワークです。最初のレシピでは、サーバーのみで HTML をレンダリングします。2 つ目ではハイドレーションを追加し、クライアントが同じコンポーネントを WebAssembly として再実行してインタラクティブ性を提供できるようにします。
フィルタリングした結果を HTML として返す
このクレートは、ssr フィーチャーを有効にした leptos に加えて、leptos_router、leptos_axum、axum、tokio を使用します。leptos には resolver = "3" が必要なため、メインのワークスペースの外にあります。
ルーターを定義する
Router は、ブラウザーがアプリにアクセスしたときにどのページを表示するかを担当します。path! マクロは、どの URL にマッチするかを定義するために使われます。:query はテンプレートで、任意の値にマッチし、その値をコンポーネントが後で読み取れるよう query として保存します。
#[component]
fn App() -> impl IntoView {
view! {
<LeptosRouter>
<nav><A href="/">"Home"</A>" | "<A href="/filter/serde">"serde"</A>" | "<A href="/filter/tokio">"tokio"</A></nav>
<main>
<Routes fallback=|| "Not found.">
<Route path=path!("") view=HomePage />
<Route path=path!("/filter/:query") view=FilterPage />
</Routes>
</main>
</LeptosRouter>
}
}
<A> はリンクのアンカーとなり、<Routes fallback=...> はデフォルトを提供するために使われます。
詳しくは Leptos book の <Routes/> の定義 と ネストされたルーティング を参照してください。
パラメーターを読み取り、サーバーで取得する
use_params は Router からの値を保持します。Resource::new は、非同期に取得するためにクロージャを使います。このクロージャはサーバーで実行され、<Suspense> はサーバーが処理中であることをユーザーに伝えます。
#[derive(Params, PartialEq, Clone, Debug)]
struct FilterParams {
query: Option<String>,
}
#[component]
fn FilterPage() -> impl IntoView {
let params = use_params::<FilterParams>();
let query = move || {
params
.read()
.as_ref()
.ok()
.and_then(|p| p.query.clone())
.unwrap_or_default()
};
let results = Resource::new(query, filter_crates);
view! {
<h1>"Results"</h1>
<Suspense fallback=|| view! { <p>"Loading..."</p> }>
{move || results.get().map(|r| match r {
Ok(items) => view! { <CrateList items /> }.into_any(),
Err(err) => view! { <p>{format!("error: {err}")}</p> }.into_any(),
})}
</Suspense>
}
}
filter_crates は、サーバーで実行される単純な async fn です。データファイルは env!("CARGO_MANIFEST_DIR") を介して解決されるため、バイナリをどこから実行してもパスが機能します。
async fn filter_crates(query: String) -> Result<Vec<String>, String> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/crates.txt");
let text = tokio::fs::read_to_string(path)
.await
.map_err(|e| e.to_string())?;
let q = query.to_lowercase();
Ok(text
.lines()
.filter(|line| line.to_lowercase().contains(&q))
.map(String::from)
.collect())
}
詳しくは Leptos book の パラメーターとクエリ、Resource を使ったデータの読み込み、Suspense を参照してください。
再利用可能なコンポーネントを書く
コンポーネントは #[component] が付いた関数です。Props は通常の関数引数です。
#[component]
fn CrateList(items: Vec<String>) -> impl IntoView {
view! {
<p>{format!("{} match(es)", items.len())}</p>
<ul>
{items.into_iter().map(|name| view! { <li>{name}</li> }).collect::<Vec<_>>()}
</ul>
}
}
詳しくは Leptos book の コンポーネントと Props と コンポーネントに Children を渡す を参照してください。
Axum を組み込む
generate_route_list(App) は Routes ツリーをたどって、ルーターが認識しているすべてのパスを列挙します。leptos_routes はそれらのパスを、各リクエストでシェルをレンダリングする Axum の GET ハンドラーとして登録します。
fn shell(_options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>"Crate Filter"</title>
</head>
<body><App /></body>
</html>
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: std::net::SocketAddr = "127.0.0.1:3000".parse()?;
let conf = LeptosOptions::builder()
.output_name("web_leptos")
.site_addr(addr)
.build();
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&conf, routes, {
let opts = conf.clone();
move || shell(opts.clone())
})
.with_state(conf);
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("listening on http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
詳しくは Leptos book の ページロードのライフサイクル と cargo-leptos(多くの実アプリが最終的に移行するツール)を参照してください。
実行:
cargo run --manifest-path crates/web_leptos/Cargo.toml
# http://127.0.0.1:3000/
# http://127.0.0.1:3000/filter/serde
# http://127.0.0.1:3000/filter/tokio
コンポーネントの状態をサーバーと同期する
最初のレシピと同じ data/crates.txt を使いますが、ユーザーはテキストボックスに入力し、結果は入力に応じて更新されます。ページの再読み込みはありません。ファイルはサーバー上に置かれたままで、クエリはサーバー関数を経由して往復します。
このレシピでは、ビルドツールとして cargo-leptos、ターゲットとして wasm32-unknown-unknown を使います:
cargo install cargo-leptos
rustup target add wasm32-unknown-unknown
feature flag で ssr と hydrate を分ける
1 つのクレートで、ビルドは 2 種類です。ssr はサーバー側の依存関係を取り込み、hydrate は WASM クライアントを有効にします。cargo-leptos はその両方を [package.metadata.leptos] から制御します。
crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = "0.8.19"
leptos_axum = { version = "0.8.9", optional = true }
leptos_router = "0.8.13"
axum = { version = "0.8", optional = true }
tokio = { version = "1.42", features = ["rt-multi-thread", "macros"], optional = true }
tower-http = "0.6"
wasm-bindgen = "=0.2.118"
console_error_panic_hook = "0.1"
[dev-dependencies]
cargo-leptos = "0.3.6"
[features]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:leptos_axum",
"dep:axum",
"dep:tokio",
"leptos/ssr",
"leptos_router/ssr",
]
[package.metadata.leptos]
output-name = "web_leptos_hydrate"
site-root = "target/site"
site-pkg-dir = "pkg"
site-addr = "127.0.0.1:3000"
reload-port = 3001
bin-features = ["ssr"]
フィルター処理をサーバー関数にする
#[server] は filter_crates をクライアントから呼び出せる関数にします。本体は ssr ビルドにのみコンパイルされ、hydrate ではマクロがサーバーに POST するスタブを残します。tokio::fs は、これまでと同様にサーバー上でファイルを読み取ります。
#[server]
pub async fn filter_crates(query: String) -> Result<Vec<String>, ServerFnError> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("data/crates.txt");
let text = tokio::fs::read_to_string(path).await?;
let q = query.to_lowercase();
Ok(text
.lines()
.filter(|line| line.to_lowercase().contains(&q))
.map(String::from)
.collect())
}
入力をリソースにバインドする
App コンポーネントは、ssr にも hydrate にも分岐しない、通常のリアクティブな Leptos コンポーネントです。signal がクエリを保持し、event_target_value が各キーストロークから文字列を取り出し、Resource::new がクエリが変わるたびに filter_crates を再実行します。<Suspense> は結果、または「検索中…」のフォールバックを描画します。
#[component]
pub fn App() -> impl IntoView {
let (query, set_query) = signal(String::new());
let results = Resource::new(
move || query.get(),
|q| async move { filter_crates(q).await },
);
view! {
<h1>"Crate search"</h1>
<input
type="text"
placeholder="Type to filter..."
prop:value=query
on:input=move |ev| set_query.set(event_target_value(&ev))
/>
<Suspense fallback=|| view! { <p>"Searching..."</p> }>
{move || results.get().map(|r| match r {
Ok(items) => view! {
<p>{format!("{} match(es)", items.len())}</p>
<ul>
{items.into_iter().map(|name| view! { <li>{name}</li> }).collect::<Vec<_>>()}
</ul>
}.into_any(),
Err(err) => view! { <p>{format!("error: {err}")}</p> }.into_any(),
})}
</Suspense>
}
}
サーバー側: HydrationScripts でシェルをレンダリングする
<HydrationScripts> は、WASM バンドルを読み込む <script> タグを出力します。<AutoReload> は、cargo leptos watch の実行中に再読み込みスクリプトを提供します。
#[cfg(feature = "ssr")]
pub fn shell(options: leptos::config::LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HydrationScripts options />
<title>"Crate search"</title>
</head>
<body><App /></body>
</html>
}
}
クライアント側: body を hydrate する
hydrate_body はブラウザで同じ App 関数を実行し、
それをサーバーでレンダリングされた DOM にアタッチします。#[wasm_bindgen] は、
生成された JS が呼び出すエントリポイントとしてこれを示します。
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}
ssr フィーチャーの背後で Axum を接続する
get_configuration は
[package.metadata.leptos] セクションを読み取ります。leptos_routes は
サーバー関数のエンドポイントも自動的に登録します。
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
use axum::Router;
use leptos::config::get_configuration;
use leptos_axum::{LeptosRoutes, generate_route_list};
use std::path::Path;
use tower_http::services::ServeDir;
use web_leptos_hydrate::{App, shell};
let conf = get_configuration(None)?;
let options = conf.leptos_options;
let addr = options.site_addr;
let routes = generate_route_list(App);
let app = Router::new()
.leptos_routes(&options, routes, {
let opts = options.clone();
move || shell(opts.clone())
})
.nest_service("/pkg", ServeDir::new(Path::new(&*options.site_root).join("pkg")))
.with_state(options);
let listener = tokio::net::TcpListener::bind(&addr).await?;
println!("listening on http://{addr}");
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(not(feature = "ssr"))]
fn main() {}
実行します:
cd crates/web_leptos_hydrate
cargo leptos watch
# http://127.0.0.1:3000/
詳細は Leptos book の Server Functions と
cargo-leptos を参照してください。