Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

フルスタックWeb

Leptos はフルスタックWebフレームワークです。最初のレシピでは、サーバーのみで HTML をレンダリングします。2 つ目ではハイドレーションを追加し、クライアントが同じコンポーネントを WebAssembly として再実行してインタラクティブ性を提供できるようにします。

フィルタリングした結果を HTML として返す

leptos-badge leptos-router-badge leptos-axum-badge axum-badge tokio-badge cat-net-badge

このクレートは、ssr フィーチャーを有効にした leptos に加えて、leptos_routerleptos_axumaxumtokio を使用します。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_paramsRouter からの値を保持します。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

コンポーネントの状態をサーバーと同期する

leptos-badge leptos-axum-badge axum-badge tokio-badge wasm-bindgen-badge cat-net-badge

最初のレシピと同じ data/crates.txt を使いますが、ユーザーはテキストボックスに入力し、結果は入力に応じて更新されます。ページの再読み込みはありません。ファイルはサーバー上に置かれたままで、クエリはサーバー関数を経由して往復します。

このレシピでは、ビルドツールとして cargo-leptos、ターゲットとして wasm32-unknown-unknown を使います:

cargo install cargo-leptos
rustup target add wasm32-unknown-unknown

feature flag で ssrhydrate を分ける

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 Functionscargo-leptos を参照してください。