简介

本书旨在介绍 Leptos 这一 Web 框架。 它将介绍构建应用程序所需要的基本概念,将从一个使用浏览器完成渲染的简单的应用程序开始, 逐步构建成为一个带有服务端渲染和状态复用(hydration)的全栈应用。

在后文中会提到 hydration ,该功能是通过 WebAssembly 将服务器生成的 HTML 转换为动态交互式应用的过程,因此这里译为状态复用,也可以译为状态激活。

本指南并不要求您事先对响应式框架(fine-grained reactivity 亦译作“细粒度响应性”)或现代 Web 框架的细节有所了解, 但是您需要熟悉 Rust 编程语言、HTML、CSS、DOM和基本 Web API。

Leptos 与 Solid(JavaScript)和 Sycamore(Rust)等框架最为相似。 它与像 React(JavaScript)、Svelte(JavaScript)、Yew(Rust)和 Dioxus(Rust)这些框架有一些相似之处, 因此如果您对这些框架有所了解会让您更容易理解 Leptos。

您可以在 Docs.rs 上找到各个 API 的更详细的文档。

这本书的英文源代码在这里获取,本书是个人对该文档的中文翻译版本。 欢迎您提出见解与翻译指正。

入门

使用 Leptos 框架有下面两种基本途径:

  1. 使用 Trunk进行客户端渲染(CSR) - 如果您只想用 Leptos 快速创建一个轻量网站,或与已有的服务器或 API 一起工作,Trunk 是一个不错的选择。 在 CSR 模式下,Trunk 将您的 Leptos 应用程序编译为 WebAssembly (WASM),并像典型的 Javascript 单页面应用程序 (SPA) 一样在浏览器中运行。 Leptos 使用 CSR 的优势包括更快的构建时间和更快的迭代开发周期,以及更简单的开发过程和更多部署应用程序的选项。 CSR 应用程序具有以下的缺点: 与服务器端渲染方法相比,最终用户的初始加载时间较慢,并且Leptos CSR 应用会像传统 JS 单页应用模型那样在 SEO 方面带来更多的麻烦。 请注意,在底层,会使用自动生成的 JavaScript 代码片段来加载 Leptos 的 WASM 包,因此客户端设备上必须启用 JavaScript,您的 CSR 应用才能正确显示。正如所有软件工程一样,这里也存在需要权衡的取舍。

  2. 使用 cargo-leptos进行的全栈的服务端渲染(SSR) - 如果你想使用 Rust 来同时构建应用程序的前端和后端,那么 SSR 是构建 CRUD 风格网站和自定义 Web 应用程序的绝佳选择。 使用 Leptos 服务器渲染的方式,您的应用程序将在服务端被呈现为 HTML,并发送到浏览器来显示;然后,WebAssembly 用于检测 HTML,使应用变得具有交互性 - 此过程称为“hydration”。 在服务端, Leptos SSR 应用程序与您选择的 Actix-webAxum 服务器库紧密集成,所以您可以利用这些社区的 crate 来帮助构建您的 Leptos 服务端。 使用 Leptos 进行 SSR(服务端渲染)有以下优势:它可以帮助您的 Web 应用实现最快的初始加载时间和最佳的 SEO 效果。此外,SSR 应用通过 Leptos 的一个名为“服务器函数”(server functions)的特性,可以显著简化跨越服务器和客户端的交互。该特性允许您从客户端代码中透明地调用服务器上的函数(稍后会详细介绍此功能)。然而,全栈 SSR 并非尽善尽美——它的缺点包括较慢的开发迭代周期(因为在修改 Rust 代码时需要同时重新编译服务器和客户端),以及与状态复用(hydration)相关的一些额外复杂性。

读完本书后,您应该对根据项目需求做出哪些权衡以及采取哪条路线(CSR 还是 SSR)有一个很好的了解。

在本书的第1部分,我们将从使用客户端渲染的 Leptos 网站开始,构建响应式用户界面(UI),并使用 Trunk 将 JavaScript 和 WASM 包提供给浏览器。

在本书的第2部分,我们将介绍 cargo-leptos,全面讲解 Leptos 如何在全栈 SSR 模式下充分发挥其强大功能。

Note

如果您熟悉 JavaScript ,但对像客户端渲染(CSR)和服务端渲染(SSR)这样的术语感到陌生,那么理解它们差异的最简单方法是通过下面的类比:

Leptos 的客户端渲染(CSR)模式类似于使用 React(或类似 SolidJS 的“信号”框架),重点是生成一个客户端渲染的用户界面(UI),可以与任何服务器端技术栈配合使用。

使用 Leptos 的服务端渲染(SSR)模式类似于在 React 领域使用像 Next.js 这样的全栈框架(或 Solid 的 “SolidStart” 框架)。SSR 可以帮助您构建在服务器上渲染后发送到客户端的站点和应用程序。SSR 有助于提升网站的加载性能和可访问性,同时让开发者可以轻松地在客户端和服务器端共同进行开发,而无需在前后端不同的编程语言之间频繁切换。

Leptos 框架既可以以客户端渲染(CSR)模式使用,仅用于构建用户界面(类似于 React);也可以以全栈服务端渲染(SSR)模式使用(类似于 Next.js),让您可以使用一种语言——Rust,同时构建用户界面和服务器端。

准备好,进入 Leptos CSR 的世界

首先,请确保您已经安装了最新的Rust语言套件。 (查看 Rust 安装指南?).

如果你是第一次进行安装配置,你首先应该使用下面的命令来安装进行 Leptos CSR 页面客户端渲染的"Trunk"工具:

cargo install trunk

之后,创建一个基础的 Rust 工程项目:

cargo init leptos-tutorial

cd 进入新创建的 leptos-tutorial 项目中 然后添加 leptos 作为一个依赖项。

cargo add leptos --features=csr

确保您已添加 wasm32-unknown-unknown 构建目标(target),以便 Rust 能将您的代码编译为可在浏览器中运行的 WebAssembly。

rustup target add wasm32-unknown-unknown

创建一个最简单的 index.html 在项目 leptos-tutorial 的根路径下(./index.html)

<!DOCTYPE html>
<html>
  <head></head>
  <body></body>
</html>

添加一个最简单的显示 “Hello, world!” 的代码到您的 main.rs 代码中。

use leptos::prelude::*;

fn main() {
    leptos::mount::mount_to_body(|| view! { <p>"Hello, world!"</p> })
}

您目前的目录结构应该和下面所展示的一致:

leptos_tutorial
├── src
│   └── main.rs
├── Cargo.toml
├── index.html

现在从项目leptos-tutorial的根目录使用终端运行 trunk serve --open。 Trunk 会自动编译您的应用程序并在您的默认浏览器中打开它。 如果您对main.rs进行编辑,Trunk 将重新编译您的源代码并实时重新加载页面。

欢迎进入由 Rust 和 WebAssembly (WASM) 驱动的 UI 开发世界,由 Leptos 和 Trunk 提供支持!

Note

如果您正在使用Windows操作系统,trunk serve --open可能不会奏效。如果您使用--open无法自动打开默认浏览器, 那么只需要使用trunk serve来启动服务,然后手动打开浏览器即可。


在开始使用 Leptos 构建您的第一个实际应用之前,有一些事情您可能需要了解,这将帮助您更轻松地使用 Leptos。

优化 Leptos 开发体验

有几件事可以帮助您提升使用 Leptos 开发网站和应用的体验。您可能需要花几分钟时间来配置您的开发环境,以优化开发流程,特别是如果您计划跟随本书中的示例一起编写代码的话。

1) 设置console_error_panic_hook

默认情况下,当您的 WASM 代码在浏览器中运行时发生 panic,浏览器会抛出一条没有实际帮助的信息,例如Unreachable executed,并提供一个指向 WASM 二进制文件的堆栈跟踪。

使用console_error_panic_hook后,您将获得一个实际的 Rust 堆栈跟踪,其中包含 Rust 源代码中的具体行号。

设置非常简单:

  1. 在项目中运行cargo add console_error_panic_hook 命令。
  2. main函数中添加console_error_panic_hook::set_once();

如果您不太明白如何修改main函数,点击这里查看示例。

现在,您应该能在浏览器控制台中看到更清晰的 panic 错误信息!

2) #[component]#[server]代码块内部的自动补全

由于宏的特性(它们可以从任何东西扩展到任何东西,但前提是输入在那个时刻必须完全正确),这可能会让 rust-analyzer 很难提供适当的自动补全和其他支持。

如果在编辑器中使用这些宏时遇到问题,您可以显式告诉 rust-analyzer 忽略某些过程宏。对于#[server]宏尤其如此,它用于注解函数体,但实际上并不会修改函数体内部的内容,这时这种做法会非常有帮助。

Note

从 Leptos 版本 0.5.3 开始,#[component]宏得到了 rust-analyzer 的支持,但如果遇到问题,您可能需要将#[component]添加到宏忽略列表中(如下所示)。

请注意,这意味着 rust-analyzer 无法识别您的组件属性,这可能会在 IDE 中生成一组错误或警告。

VSCode settings.json:

"rust-analyzer.procMacro.ignored": {
	"leptos_macro": [
        // optional:
		// "component",
		"server"
	],
}

VSCode with cargo-leptos settings.json:

"rust-analyzer.procMacro.ignored": {
	"leptos_macro": [
        // optional:
		// "component",
		"server"
	],
},
// if code that is cfg-gated for the `ssr` feature is shown as inactive,
// you may want to tell rust-analyzer to enable the `ssr` feature by default
//
// you can also use `rust-analyzer.cargo.allFeatures` to enable all features
"rust-analyzer.cargo.features": ["ssr"]

neovim with lspconfig:

require('lspconfig').rust_analyzer.setup {
  -- Other Configs ...
  settings = {
    ["rust-analyzer"] = {
      -- Other Settings ...
      procMacro = {
        ignored = {
            leptos_macro = {
                -- optional: --
                -- "component",
                "server",
            },
        },
      },
    },
  }
}

Helix, in .helix/languages.toml:

[[language]]
name = "rust"

[language-server.rust-analyzer]
config = { procMacro = { ignored = { leptos_macro = [
	# Optional:
	# "component",
	"server"
] } } }

Zed, in settings.json:

{
  -- Other Settings ...
  "lsp": {
    "rust-analyzer": {
      "procMacro": {
        "ignored": [
          // optional:
          // "component",
          "server"
        ]
      }
    }
  }
}

SublimeText 3, under LSP-rust-analyzer.sublime-settings in Goto Anything... menu:

// Settings in here override those in "LSP-rust-analyzer/LSP-rust-analyzer.sublime-settings"
{
  "rust-analyzer.procMacro.ignored": {
    "leptos_macro": [
      // optional:
      // "component",
      "server"
    ],
  },
}

3) 设置leptosfmt配合 Rust Analyzer工作(可选)

leptosfmt是用于格式化 Leptos view!宏的工具(在该宏内部,您通常会编写 UI 代码)。由于view!宏采用类似 JSX 的 RSX 风格编写 UI,cargo-fmt 很难自动格式化该宏内部的代码。而leptosfmt是一个解决格式化问题的 crate,它能确保您的 RSX 风格 UI 代码保持整洁和美观!

leptosfmt 可以通过命令行或代码编辑器内进行安装和使用:

首先,使用命令cargo install leptosfmt来安装该工具。

如果您只想使用默认选项进行命令行格式化,只需要在项目根目录下运行leptosfmt ./**/*.rs,它将使用leptosfmt格式化所有的 Rust 文件。

如果您希望在编辑器中使用 leptosfmt,或者想自定义leptosfmt的使用体验,请参阅leptosfmt GitHub 仓库的 README.md 页面中的说明

需要注意的是,建议在每个工作区内单独设置编辑器与leptosfmt的集成为最佳效果。

Leptos 社区和leptos-*单元包(Crates)

社区

在我们开始使用 Leptos 构建应用之前,最后提醒一下:如果您还没有加入,欢迎加入日益壮大的 Leptos 社区,无论是在 Leptos 的 Discord 频道还是 Github 上。特别是我们的 Discord 频道,非常活跃且友好——我们很希望能在那儿见到您!

Note

如果在阅读 Leptos 书籍的过程中遇到不清楚的章节或解释,您可以在 "docs-and-education" 频道提到它,或在 "help" 频道提问,这样我们可以帮助澄清问题并更新书籍内容,方便其他人参考。

随着您在 Leptos 的学习进展,如果遇到关于“如何用 Leptos 做 'x'”的问题,可以先在 Discord 的 "help" 频道搜索,看看是否已经有人问过类似的问题,或者随时发布您的问题——社区非常友好且响应迅速,大家都会乐于提供帮助。

Github 上的 "Discussions" 也是一个非常好的提问和关注 Leptos 最新动态的地方。

当然,如果在使用 Leptos 开发时遇到任何 bug,或者想要提出功能请求(或贡献 bug 修复/新特性),请在 Github issue tracker上提交问题。

Leptos-* Crates

社区已经构建了越来越多与 Leptos 相关的 crates,它们将帮助您更快速地投入到 Leptos 项目的开发中。您可以在 Github 上的 Awesome Leptos 仓库中查看由社区贡献并基于 Leptos 构建的 crates 列表。

如果您想发现最新的、正在快速发展的 Leptos 相关 crates,可以查看 Leptos Discord 中的 “Tools and Libraries” 频道。在该频道中,有针对 Leptos view! 宏格式化工具(在 "leptosfmt" 频道)和实用库 "leptos-use" 的讨论频道;还有 UI 组件库 "leptonic" 的频道;以及一个 "libraries" 频道,讨论新发布的 leptos-* crates,这些 crates 会在进入 Awesome Leptos 的逐步完善的资源和 crates 列表之前进行讨论。

第1部分:构建用户界面

在本书的第一部分,我们将探讨如何使用 Leptos 在客户端构建用户界面。在底层,Leptos 和 Trunk 会打包一段 JavaScript 代码,这段代码将加载已编译为 WebAssembly 的 Leptos UI,并驱动您 CSR(客户端渲染)网站实现交互性。

第1部分将介绍构建由 Leptos 和 Rust 驱动的响应式用户界面所需的基本工具。在第1部分结束时,您应该能够: 构建一个流畅的同步网站,该网站在浏览器中渲染,并且可以部署到任何静态网站托管服务,例如 Github Pages 或 Vercel。

Info

为了充分利用本书的内容,我们鼓励您跟随提供的示例一起编写代码。 在入门优化 Leptos 开发体验章节中,我们向您展示了如何使用 Leptos 和 Trunk 设置一个基本项目,包括在浏览器中处理 WASM 错误的方式。 这个基础设置已经足以让您开始使用 Leptos 进行开发了。

如果您更倾向于使用一个功能更全面的模板来开始,它可以演示如何设置一些实际 Leptos 项目中的基础功能,例如路由(将在本书后面介绍)、向页面的 中注入 <Title><Meta> 标签,以及其他一些实用功能,那么您可以使用leptos-rs start-trunk 模板仓库快速启动您的项目。

start-trunk 模板要求您安装 Trunkcargo-generate,您可以通过命令cargo install trunkcargo install cargo-generate安装它们。

你只需要使用下面的命令来使用模板配置你的初始项目:

cargo generate --git https://github.com/leptos-community/start-csr

然后运行命令:

trunk serve --port 3000 --open

在新创建的应用程序目录中开始开发您的应用程序。Trunk 服务器会在文件发生更改时自动重新加载您的应用程序,使开发过程相对更加流畅。

基本组件

那个“Hello, world!”是一个非常简单的例子。让我们继续讨论更像普通应用程序的东西。

首先,让我们修改main函数,使其不再渲染整个应用程序,而只是渲染一个 <App/> 组件。组件是大多数 Web 框架中组合和设计的基本单位,Leptos 也不例外。在概念上,它们类似于 HTML 元素:它们表示 DOM 的某个部分,并具有自包含且定义明确的行为。与 HTML 元素不同的是,组件采用 PascalCase 命名方式,因此大多数 Leptos 应用程序通常会从一个 <App/> 组件开始。

use leptos::mount::mount_to_body;

fn main() {
    mount_to_body(App);
}

现在让我们来定义 App 组件自身。因为它相对简单,所以我先介绍一下整个过程,然后再逐行讲解。

use leptos::prelude::*;

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <button
            on:click=move |_| set_count.set(3)
        >
            "Click me: "
            {count}
        </button>
        <p>
            "Double count: "
            {move || count.get() * 2}
        </p>
    }
}

导入 Prelude

use leptos::prelude::*;

Leptos 提供了一个包含常用特征和函数的 prelude。如果您更喜欢使用单独的导入,请随意使用;编译器将为每个导入提供有用的建议。

组件签名

#[component]

与所有组件定义一样,它以 #[component] 宏开头。#[component] 注释一个函数,以便它可以用作 Leptos 应用程序中的组件。我们将在后面几章中看到此宏的一些其他功能。

fn App() -> impl IntoView

每个组件都是一个具有以下特征的函数

  1. 它接受零个或多个任意类型的参数。
  2. 它返回 impl IntoView,这是一个不透明类型,包含您可以从 Leptos view 返回的任何内容。

组件函数参数被聚集到一个单独的 props 结构中,该结构由 view 宏根据需要构建。

组件主体

组件函数的主体是只运行一次的设置函数,而不是多次重复运行的呈现函数。 您通常会用它来创建几个响应变量,定义响应这些值变化而运行的任何效果,并描述用户界面。

let (count, set_count) = signal(0);

signal 用于创建信号,这是 Leptos 中响应式变化和状态管理的基本单元。 它返回一个 (getter, setter) 元祖。要访问当前值,可以使用 count.get() (或者在 nightly Rust版本中,可以简写为 count())。要设置当前值,则需要调用 set_count.set(...) (或者在 nightly 版本中可以简写为 set_count(...))。

.get() 会克隆信号的值,而 .set() 会覆盖它。在许多情况下,使用 .with().update() 会更加高效。如果您想了解这些方法之间的权衡,可以查阅 ReadSignalWriteSignal 的文档。

视图

Leptos 通过 view 宏使用类似 JSX 的格式定义用户界面。

view! {
    <button
        // define an event listener with on:
        on:click=move |_| set_count.set(3)
    >
        // text nodes are wrapped in quotation marks
        "Click me: "

        // blocks include Rust code
        // in this case, it renders the value of the signal
        {count}
    </button>
    <p>
        "Double count: "
        {move || count.get() * 2}
    </p>
}

这段代码大致上是易于理解的:它看起来像 HTML,其中有一个特殊的 on:click 用于定义一个 click 事件监听器,还有一些看起来像 Rust 字符串的文本节点(text node),以及两个用大括号包裹的值: 第一个 {count} 很容易理解(就是我们信号的值),而另一个则是:

{move || count.get() * 2}

这是什么?

有人开玩笑说,他们在第一次使用 Leptos 开发应用程序时写的闭包比他们人生中写的所有闭包还多。确实如此。

将一个函数传递给视图(view)相当于告诉框架:“嘿,这是一个可能会发生变化的东西。”

当我们点击按钮并调用 set_count 时,count 信号会被更新。而这个 move || count.get() * 2 闭包的值依赖于 count 的值,因此会重新运行。 框架会对与此闭包相关的特定文本节点进行精准更新,而不会影响(touch)应用中的其他部分。正是这种机制实现了对 DOM 的极高效更新。

记住——这一点非常重要——只有信号和函数在视图中被视为响应式值。

这意味着 {count}{count.get()} 在视图中做的事情是非常不同的。
{count} 传递一个信号,告诉框架每当 count 发生变化时更新视图。
{count.get()} 只会访问一次 count 的值,并将一个 i32 值传递到视图中,进行一次性渲染,不会响应更新。

以同样的方式,{move || count.get() * 2}{count.get() * 2} 的行为也不同。
第一个是一个函数,因此它会响应式地渲染。第二个是一个值,所以它只会渲染一次,并且在 count 变化时不会更新。

你可以在下面的 CodeSandbox 中查看到不同之处。

让我们做最后一次修改。set_count.set(3) 对于点击处理来说是一个相当无用的操作。我们将“将这个值设为 3”替换为“将这个值增加 1”:

move |_| {
    *set_count.write() += 1;
}

你可以看到,在这里,set_count 只是设置值,而 set_count.write() 给我们一个可变引用,并在原地修改值。无论哪种方式都会触发 UI 中的响应式更新。

在本教程中,我们将使用 CodeSandbox 展示交互式示例。
悬停在任何变量上以显示 Rust-Analyzer 详细信息和文档,了解发生了什么。
随时可以folk这些示例,自己动手玩一下!

Live example

Click to open CodeSandbox.

To show the browser in the sandbox, you may need to click Add DevTools > Other Previews > 8080.

CodeSandbox 源代码
use leptos::prelude::*;

// The #[component] macro marks a function as a reusable component
// Components are the building blocks of your user interface
// They define a reusable unit of behavior
#[component]
fn App() -> impl IntoView {
    // here we create a reactive signal
    // and get a (getter, setter) pair
    // signals are the basic unit of change in the framework
    // we'll talk more about them later
    let (count, set_count) = signal(0);

    // the `view` macro is how we define the user interface
    // it uses an HTML-like format that can accept certain Rust values
    view! {
        <button
            // on:click will run whenever the `click` event fires
            // every event handler is defined as `on:{eventname}`

            // we're able to move `set_count` into the closure
            // because signals are Copy and 'static

            on:click=move |_| *set_count.write() += 1
        >
            // text nodes in RSX should be wrapped in quotes,
            // like a normal Rust string
            "Click me: "
            {count}
        </button>
        <p>
            <strong>"Reactive: "</strong>
            // you can insert Rust expressions as values in the DOM
            // by wrapping them in curly braces
            // if you pass in a function, it will reactively update
            {move || count.get()}
        </p>
        <p>
            <strong>"Reactive shorthand: "</strong>
            // you can use signals directly in the view, as a shorthand
            // for a function that just wraps the getter
            {count}
        </p>
        <p>
            <strong>"Not reactive: "</strong>
            // NOTE: if you just write {count.get()}, this will *not* be reactive
            // it simply gets the value of count once
            {count.get()}
        </p>
    }
}

// This `main` function is the entry point into the app
// It just mounts our component to the <body>
// Because we defined it as `fn App`, we can now use it in a
// template as <App/>
fn main() {
    leptos::mount::mount_to_body(App)
}

视图(view):动态类、样式和属性

到目前为止,我们已经了解了如何使用 view 宏来创建事件监听器,以及如何通过将函数(例如信号)传递到 view 来创建动态文本。

但当然,您可能还想在用户界面中更新其他内容。在本节中,我们将介绍如何动态更新类(classes)、样式(styles)和属性(attributes),并介绍派生信号(derived signal)的概念。

让我们从一个应该熟悉的简单组件开始:单击一个按钮来增加计数器。

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <button
            on:click=move |_| {
                *set_count.write() += 1;
            }
        >
            "Click me: "
            {count}
        </button>
    }
}

到目前为止,我们在上一章中已经介绍了代码中涉及到的所有内容。

动态类(class)

现在假设我想动态更新该元素列表上的 CSS 类。
例如,我想在 count 为奇数时添加 red 类。可以使用 class: 语法来实现这一点。

class:red=move || count.get() % 2 == 1

class: 属性接受:

  1. 类名,紧跟在冒号后面(如 red)。
  2. 一个值,可以是 bool 或一个返回 bool 的函数。

当值为 true 时,类会被添加。当值为 false 时,类会被移除。如果值是一个访问信号的函数,当信号变化时,类会响应性地更新。

现在,每次点击按钮时,随着数字在偶数和奇数之间变换,文本的颜色应在红色和黑色之间切换。

<button
    on:click=move |_| {
        *set_count.write() += 1;
    }
    // the class: syntax reactively updates a single class
    // here, we'll set the `red` class when `count` is odd
    class:red=move || count.get() % 2 == 1
>
    "Click me"
</button>

如果您正在跟随我们一起操作,请确保在您的 index.html 中添加以下内容:

<style>
  .red {
    color: red;
  }
</style>

某些 CSS 类名无法通过 view 宏直接解析,尤其是当它们包含破折号和数字或其他字符时。在这种情况下,您可以使用元组语法:class=("name", value) 仍可直接更新单个类。

class=("button-20", move || count.get() % 2 == 1)

元组语法还允许通过将数组作为第一个元组元素来指定在单个条件下应用多个类。

class=(["button-20", "rounded"], move || count.get() % 2 == 1)

动态样式(style)

可以使用类似的 style: 语法直接更新单个 CSS 属性。

let (count, set_count) = signal(0);

view! {
    <button
        on:click=move |_| {
            *set_count.write() += 10;
        }
        // set the `style` attribute
        style="position: absolute"
        // and toggle individual CSS properties with `style:`
        style:left=move || format!("{}px", count.get() + 100)
        style:background-color=move || format!("rgb({}, {}, 100)", count.get(), 100)
        style:max-width="400px"
        // Set a CSS variable for stylesheet use
        style=("--columns", move || count.get().to_string())
    >
        "Click to Move"
    </button>
}

动态属性(Attributes)

同样的规则也适用于普通属性。为属性传递一个字符串或基本值会赋予它一个静态值。而为属性传递一个函数(包括信号)会使它的值响应式地更新。让我们在视图中添加另一个元素:

<progress
    max="50"
    // 信号是函数,因此 `value=count` 和 `value=move || count.get()` 是完全相同的。
    value=count
/>

现在,每次设置 count 时,不仅 <button>class 会切换,<progress> 元素的 value 属性也会增加,这意味着进度条将向前移动。

派生信号(Derived Signals)

让我们再深入一层,体验一下。

你已经知道,只需将函数传递到 view 中就可以创建响应式界面。这意味着我们可以轻松地更改进度条的行为。例如,如果我们想让进度条移动速度加倍,可以这样写:

<progress
    max="50"
    value=move || count.get() * 2
/>

但是,假设我们想在多个地方复用这个计算结果。这时,可以使用 派生信号,也就是一个访问信号的闭包:

let double_count = move || count.get() * 2;

/* 插入其他视图内容 */
<progress
    max="50"
    // 这里使用一次
    value=double_count
/>
<p>
    "Double Count: "
    // 这里再次使用
    {double_count}
</p>

派生信号允许你创建响应式的计算值,可以在应用程序的多个地方使用,且开销极小。

注意:像这样使用派生信号意味着每次信号发生变化(当 count() 改变时)以及每次访问 double_count 时,计算都会运行一次。换句话说,计算会运行两次。由于这是一个非常廉价的计算,这样做没问题。
在后续章节中,我们会介绍 memos,它们专门用来解决高成本计算中的问题。

进阶话题:注入原始 HTML

view 宏支持一个额外的属性 inner_html,可以用来直接设置任何元素的 HTML 内容,这会清除该元素中已定义的其他子元素。需要注意的是,提供给 inner_html 的 HTML 不会被转义。
因此,你必须确保内容仅包含可信(trusted)输入,或者对任何 HTML 实体进行转义,以防止跨站脚本攻击 (XSS)。

let html = "<p>This HTML will be injected.</p>";
view! {
  <div inner_html=html/>
}

点击此处查看完整的 view 宏文档

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    // a "derived signal" is a function that accesses other signals
    // we can use this to create reactive values that depend on the
    // values of one or more other signals
    let double_count = move || count.get() * 2;

    view! {
        <button
            on:click=move |_| {
                *set_count.write() += 1;
            }
            // the class: syntax reactively updates a single class
            // here, we'll set the `red` class when `count` is odd
            class:red=move || count.get() % 2 == 1
            class=("button-20", move || count.get() % 2 == 1)
        >
            "Click me"
        </button>
        // NOTE: self-closing tags like <br> need an explicit /
        <br/>

        // We'll update this progress bar every time `count` changes
        <progress
            // static attributes work as in HTML
            max="50"

            // passing a function to an attribute
            // reactively sets that attribute
            // signals are functions, so `value=count` and `value=move || count.get()`
            // are interchangeable.
            value=count
        >
        </progress>
        <br/>

        // This progress bar will use `double_count`
        // so it should move twice as fast!
        <progress
            max="50"
            // derived signals are functions, so they can also
            // reactively update the DOM
            value=double_count
        >
        </progress>
        <p>"Count: " {count}</p>
        <p>"Double Count: " {double_count}</p>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

组件和属性(Props)

到目前为止,我们一直在单个组件中构建整个应用程序。对于非常小的示例来说,这种方式完全没问题,但在任何实际应用中,你都需要将用户界面拆分成多个组件,以便将界面分解为更小、可复用、可组合的部分。

让我们以进度条为例。假设你希望有两个进度条,而不是一个:一个每次点击前进一个刻度,另一个每次点击前进两个刻度。

你可以通过简单地创建两个 <progress> 元素来实现:

let (count, set_count) = signal(0);
let double_count = move || count.get() * 2;

view! {
    <progress
        max="50"
        value=count
    />
    <progress
        max="50"
        value=double_count
    />
}

但显然,这种方法并不具有很好的扩展性。如果你想添加第三个进度条,就需要再添加一份代码。而如果你想修改任何与进度条相关的内容,就需要在三个地方分别进行修改。

因此,我们可以创建一个 <ProgressBar/> 组件来解决这个问题。

#[component]
fn ProgressBar() -> impl IntoView {
    view! {
        <progress
            max="50"
            // 但是……这应该从哪里得到呢?
            value=progress
        />
    }
}

现在有一个问题:progress 尚未定义。那么它应该从哪里来呢?当我们手动定义所有内容时,我们直接使用了局部变量名。但现在,我们需要一种方法将参数传递给组件。

组件属性(Props)

我们通过组件属性(properties或称“props”)来实现这一点。如果你使用过其他前端框架,这个概念应该很熟悉。本质上,属性对于组件的作用,就像属性对于 HTML 元素的作用一样:它们允许你向组件传递额外的信息。

在 Leptos 中,可以通过为组件函数添加额外的参数来定义 props。

#[component]
fn ProgressBar(
    progress: ReadSignal<i32>
) -> impl IntoView {
    view! {
        <progress
            max="50"
            // 现在便正常工作了
            value=progress
        />
    }
}

现在我们可以在主要的 <App/> 组件的视图中使用我们的组件。

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);
    view! {
        <button on:click=move |_| *set_count.write() += 1>
            "Click me"
        </button>
        // now we use our component!
        <ProgressBar progress=count/>
    }
}

在视图中使用组件看起来和使用 HTML 元素非常相似。你会注意到,可以轻松区分元素和组件,因为组件的名称始终是 PascalCase 的(帕斯卡命名法)。你可以像设置 HTML 元素属性一样传递 progress 属性,非常简单。

响应式与静态 Props

在这个示例中,你会注意到 progress 接受的是一个响应式的 ReadSignal<i32>,而不是一个普通的 i32。这一点 非常重要

组件的 props 并没有附加任何特殊意义。组件本质上只是一个函数,用来初始化用户界面。而要让界面对变化做出响应,唯一的方法就是传递一个信号类型。因此,如果你的组件属性会随着时间变化,比如我们的 progress,它就应该是一个信号。

optional Props

目前,max 设置是硬编码的。我们也可以将它作为一个 prop 来传递。但是让我们将这个 prop 设置为可选。我们可以通过用 #[prop(optional)] 标注它来实现这一点。

#[component]
fn ProgressBar(
    // 将这个 prop 标注为可选
    // 使用 `<ProgressBar/>` 时可以指定该属性,也可以不指定
    #[prop(optional)]
    max: u16,
    progress: ReadSignal<i32>
) -> impl IntoView {
    view! {
        <progress
            max=max
            value=progress
        />
    }
}

现在,我们可以使用 <ProgressBar max=50 progress=count/>,或者省略 max 来使用默认值(即 <ProgressBar progress=count/>)。对于 optional 属性,其默认值是 Default::default() 值,对于 u16 类型来说,这个默认值是 0。但对于进度条来说,max0 的值并不太有用。

因此,我们可以为它设置一个特定的默认值。

default props

您可以简单地通过定义 #[prop(default = ...) 来指定一个默认值,而不是使用 Default::default() 自动确定。

#[component]
fn ProgressBar(
    #[prop(default = 100)]
    max: u16,
    progress: ReadSignal<i32>
) -> impl IntoView {
    view! {
        <progress
            max=max
            value=progress
        />
    }
}

泛型属性(Generic Props)

这是很棒的例子。但一开始我们使用了两个计数器,一个由 count 驱动,另一个由派生信号 double_count 驱动。让我们通过将 double_count 用作另一个 <ProgressBar/>progress 属性来重新创建它。

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);
    let double_count = move || count.get() * 2;

    view! {
        <button on:click=move |_| { set_count.update(|n| *n += 1); }>
            "Click me"
        </button>
        <ProgressBar progress=count/>
        // 添加第二个进度条
        <ProgressBar progress=double_count/>
    }
}

嗯……这个代码无法编译。这很容易理解原因:我们声明了 progress 属性接受 ReadSignal<i32> 类型,而 double_count 不是 ReadSignal<i32> 类型。正如 rust-analyzer 所提示的,它的类型是 || -> i32,即返回 i32 的闭包。

有几种方法可以解决这个问题。我们可以这样说:“嗯,我知道视图要实现响应式,需要接受一个函数或信号。我可以通过将信号包装成闭包来将信号转换成函数……也许我可以直接接受任何函数?”如果你很熟悉,你可能知道这些都实现了 Fn() -> i32 trait。因此,可以使用泛型组件:

#[component]
fn ProgressBar(
    #[prop(default = 100)]
    max: u16,
    progress: impl Fn() -> i32 + Send + Sync + 'static
) -> impl IntoView {
    view! {
        <progress
            max=max
            value=progress
        />
        // 添加换行符以避免重叠
        <br/>
    }
}

这样写是完全合理的:progress 属性现在可以接受任何实现了 Fn() trait 的值。

泛型属性也可以通过 where 子句指定,或者使用内联泛型,比如 ProgressBar<F: Fn() -> i32 + 'static>

泛型需要在组件的属性中某处使用。这是因为属性会被构建成一个结构体,因此所有泛型类型都必须在结构体中使用。这通常可以通过可选的 PhantomData 属性轻松实现。然后可以使用表示类型的语法 <Component<T>/>(而不是 turbofish 风格 <Component::<T>/>)在视图中指定泛型。

#[component]
fn SizeOf<T: Sized>(#[prop(optional)] _ty: PhantomData<T>) -> impl IntoView {
    std::mem::size_of::<T>()
}

#[component]
pub fn App() -> impl IntoView {
    view! {
        <SizeOf<usize>/>
        <SizeOf<String>/>
    }
}

请注意,这里有一些限制。例如,我们的视图宏解析器无法处理嵌套泛型,比如 <SizeOf<Vec<T>>/>

into 属性

还有一种方法可以实现上述功能,就是使用 #[prop(into)]。 这个属性会自动对传递给属性的值调用 .into() 方法,从而让你轻松传递不同类型的值作为属性。

在这种情况下,了解 Signal 类型会很有帮助。 Signal 是一种枚举类型,表示任何可读的响应式信号或普通值。在定义组件 API 时,如果希望组件能够接受不同类型的信号,Signal 会非常有用。

#[component]
fn ProgressBar(
    #[prop(default = 100)]
    max: u16,
    #[prop(into)]
    progress: Signal<i32>
) -> impl IntoView
{
    view! {
        <progress
            max=max
            value=progress
        />
        <br/>
    }
}

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);
    let double_count = move || count.get() * 2;

    view! {
        <button on:click=move |_| *set_count.write() += 1>
            "Click me"
        </button>
        // `.into()` 将 `ReadSignal` 转换为 `Signal`
        <ProgressBar progress=count/>
        // 使用 `Signal::derive()` 包装派生信号
        <ProgressBar progress=Signal::derive(double_count)/>
    }
}

Optional Generic Props

注意,您无法为组件指定可选的泛型属性。让我们看看如果尝试这样做会发生什么:

#[component]
fn ProgressBar<F: Fn() -> i32 + Send + Sync + 'static>(
    #[prop(optional)] progress: Option<F>,
) -> impl IntoView {
    progress.map(|progress| {
        view! {
            <progress
                max=100
                value=progress
            />
            <br/>
        }
    })
}

#[component]
pub fn App() -> impl IntoView {
    view! {
        <ProgressBar/>
    }
}

Rust 会给出下面有用的报错:

xx |         <ProgressBar/>
   |          ^^^^^^^^^^^ cannot infer type of the type parameter `F` declared on the function `ProgressBar`
   |
help: consider specifying the generic argument
   |
xx |         <ProgressBar::<F>/>
   |                     +++++

您可以通过 <ProgressBar<F>/> 语法为组件指定泛型(在 view 宏中不使用 turbofish)。然而,在这里指定正确的类型是不可能的,因为闭包和函数通常是无法命名的类型。编译器可能会用一种简写显示它们,但您无法直接指定它们。

不过,您可以通过使用 Box<dyn _>&dyn _ 提供具体类型来解决这个问题:

#[component]
fn ProgressBar(
    #[prop(optional)] progress: Option<Box<dyn Fn() -> i32 + Send + Sync>>,
) -> impl IntoView {
    progress.map(|progress| {
        view! {
            <progress
                max=100
                value=progress
            />
            <br/>
        }
    })
}

#[component]
pub fn App() -> impl IntoView {
    view! {
        <ProgressBar/>
    }
}

因为 Rust 编译器现在知道属性的具体类型,因此即使在 None 的情况下,它也知道内存中的大小,这样就可以正常编译了。

在本例中,&dyn Fn() -> i32 会引发生命周期问题,但在其他情况下,它可能是一个可行的选择。

为组件编写文档

这是本书中最非必要却又最重要的部分之一。
记录组件及其属性并非严格必要,但根据团队规模和应用程序的复杂性,这可能变得非常重要。而且,这很简单,并且可以立即见效。

要为组件及其属性编写文档,您只需为组件函数和每个属性添加文档注释即可:

/// 显示目标的进度。
#[component]
fn ProgressBar(
    /// 进度条的最大值。
    #[prop(default = 100)]
    max: u16,
    /// 显示的进度值。
    #[prop(into)]
    progress: Signal<i32>,
) -> impl IntoView {
    /* ... */
}

这就是您需要做的全部。这些注释的行为与普通的 Rust 文档注释相同,只不过您可以为单个组件的属性编写文档,而这在普通的 Rust 函数参数中是无法做到的。

这将自动生成组件的文档,包括其 Props 类型及用于添加属性的每个字段的文档。
直到您在组件名称或属性上悬停鼠标,看到 #[component] 宏结合 rust-analyzer 的强大功能,您才会真正理解这一点的强大之处。

将属性扩展到组件

有时候,您希望用户能够向组件添加额外的属性,例如,允许用户添加自己的 classid 属性以便进行样式设计或其他目的。

一种方法是为组件创建 classid 属性,并将它们应用到适当的元素。然而,Leptos 还支持将额外的属性“扩展”到组件上。添加到组件的属性将应用到其视图中返回的所有顶级 HTML 元素。

// 您可以使用 view 宏通过扩展 {..} 创建属性列表
let spread_onto_component = view! {
    <{..} aria-label="a component with attribute spreading"/>
};

view! {
    // 扩展到组件的属性将应用于组件视图中返回的 *所有* 元素。
    // 要将属性应用于组件的一部分,请通过组件属性传递它们
    <ComponentThatTakesSpread
        // 普通标识符用于组件属性
        some_prop="foo"
        another_prop=42

        // class:, style:, prop:, on: 语法与在 HTML 元素上使用时完全相同
        class:foo=true
        style:font-weight="bold"
        prop:cool=42
        on:click=move |_| alert("clicked ComponentThatTakesSpread")

        // 要传递普通的 HTML 属性,请加上 attr: 前缀
        attr:id="foo"

        // 或者,如果您想包含多个属性,而不是为每个属性添加 attr: 前缀,
        // 可以使用扩展 {..} 将它们与组件属性分开
        {..} // 此后的内容将被视为 HTML 属性
        title="ooh, a title!"

        // 我们可以添加上面定义的整个属性列表
        {..spread_onto_component}
    />
}

更多示例请参见 spread 示例

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

// Composing different components together is how we build
// user interfaces. Here, we'll define a reusable <ProgressBar/>.
// You'll see how doc comments can be used to document components
// and their properties.

/// Shows progress toward a goal.
#[component]
fn ProgressBar(
    // Marks this as an optional prop. It will default to the default
    // value of its type, i.e., 0.
    #[prop(default = 100)]
    /// The maximum value of the progress bar.
    max: u16,
    // Will run `.into()` on the value passed into the prop.
    #[prop(into)]
    // `Signal<T>` is a wrapper for several reactive types.
    // It can be helpful in component APIs like this, where we
    // might want to take any kind of reactive value
    /// How much progress should be displayed.
    progress: Signal<i32>,
) -> impl IntoView {
    view! {
        <progress
            max={max}
            value=progress
        />
        <br/>
    }
}

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    let double_count = move || count.get() * 2;

    view! {
        <button
            on:click=move |_| {
                *set_count.write() += 1;
            }
        >
            "Click me"
        </button>
        <br/>
        // If you have this open in CodeSandbox or an editor with
        // rust-analyzer support, try hovering over `ProgressBar`,
        // `max`, or `progress` to see the docs we defined above
        <ProgressBar max=50 progress=count/>
        // Let's use the default max value on this one
        // the default is 100, so it should move half as fast
        <ProgressBar progress=count/>
        // Signal::derive creates a Signal wrapper from our derived signal
        // using double_count means it should move twice as fast
        <ProgressBar max=50 progress=Signal::derive(double_count)/>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

迭代

在 Web 应用程序中,无论是列出待办事项、显示表格,还是展示产品图片,遍历列表项都是一种常见任务。处理不断变化的数据集合的差异,也是框架需要解决的最棘手的问题之一。

Leptos 支持两种不同的方式来遍历列表项:

  1. 对于静态视图: Vec<_>
  2. 对于动态列表: <For/>

使用 Vec<_> 创建静态视图

有时需要重复显示一个项目,但数据列表本身并不经常改变。在这种情况下,需要了解可以将任何 Vec<IV> where IV: IntoView 插入到视图中。换句话说,如果可以渲染 T,就可以渲染 Vec<T>

let values = vec![0, 1, 2];
view! {
    // 这将渲染为 "012"
    <p>{values.clone()}</p>
    // 或者将它们包裹在 <li> 标签中
    <ul>
        {values.into_iter()
            .map(|n| view! { <li>{n}</li>})
            .collect::<Vec<_>>()}
    </ul>
}

Leptos 还提供了一个 .collect_view() 辅助函数,它允许将任何实现了 T: IntoView 的迭代器收集到 Vec<View> 中。

let values = vec![0, 1, 2];
view! {
    // 这将渲染为 "012"
    <p>{values.clone()}</p>
    // 或者将它们包裹在 <li> 标签中
    <ul>
        {values.into_iter()
            .map(|n| view! { <li>{n}</li>})
            .collect_view()}
    </ul>
}

即使 列表 是静态的,界面仍然可以是动态的。你可以在静态列表中渲染动态项目。

// 创建一个包含 5 个信号的列表
let length = 5;
let counters = (1..=length).map(|idx| RwSignal::new(idx));

注意,这里没有调用 signal() 来获取包含 reader 和 writer 的元组,而是使用了 RwSignal::new() 来获取一个单独的读写信号。这在需要传递元组的情况下更方便。

// 每个项目管理一个响应式视图
// 但列表本身永远不会改变
let counter_buttons = counters
    .map(|count| {
        view! {
            <li>
                <button
                    on:click=move |_| *count.write() += 1
                >
                    {count}
                </button>
            </li>
        }
    })
    .collect_view();

view! {
    <ul>{counter_buttons}</ul>
}

也可以响应式地渲染一个 Fn() -> Vec<_>。但需要注意,这是一次非键控列表更新:它将复用现有的 DOM 元素,并按照新 Vec<_> 中的顺序更新它们的值。如果只是向列表末尾添加或移除项目,这种方式效果很好;但如果移动项目位置或在列表中间插入项目,浏览器将比正常工作进行更多的操作,并可能对输入状态和 CSS 动画产生意想不到的影响。(关于“键控”与“非键控”列表的区别以及一些实际示例,可以阅读这篇文章。)

幸运的是,也有一种高效的方式来进行键控列表迭代。

使用 <For/> 组件进行动态渲染

<For/> 组件是一个带键控的动态列表。它接受以下三个属性:

  • each:一个返回要迭代的项目 T 的响应式函数。
  • key:一个从 &T 中提取稳定且唯一键或 ID 的函数。
  • children:将每个 T 渲染为视图。

key 是这个组件的关键。你可以在列表中添加、移除和移动项目。只要每个项目的键在时间上是稳定的,框架就不需要重新渲染任何项目,除非是新增的项目,并且可以非常高效地添加、移除和移动这些项目。这使得在列表发生变化时,能够以极高的效率更新列表,且额外工作量极少。

创建一个好的 key 可能会有点棘手。通常 应该使用索引作为键,因为它并不稳定——当移除或移动项目时,它们的索引会改变。

一个很好的做法是,在生成每一行时为其生成一个唯一 ID,并将其用作键函数的 ID。

请参考下面的 <DynamicList/> 组件示例,了解具体用法。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

// Iteration is a very common task in most applications.
// So how do you take a list of data and render it in the DOM?
// This example will show you the two ways:
// 1) for mostly-static lists, using Rust iterators
// 2) for lists that grow, shrink, or move items, using <For/>

#[component]
fn App() -> impl IntoView {
    view! {
        <h1>"Iteration"</h1>
        <h2>"Static List"</h2>
        <p>"Use this pattern if the list itself is static."</p>
        <StaticList length=5/>
        <h2>"Dynamic List"</h2>
        <p>"Use this pattern if the rows in your list will change."</p>
        <DynamicList initial_length=5/>
    }
}

/// A list of counters, without the ability
/// to add or remove any.
#[component]
fn StaticList(
    /// How many counters to include in this list.
    length: usize,
) -> impl IntoView {
    // create counter signals that start at incrementing numbers
    let counters = (1..=length).map(|idx| RwSignal::new(idx));

    // when you have a list that doesn't change, you can
    // manipulate it using ordinary Rust iterators
    // and collect it into a Vec<_> to insert it into the DOM
    let counter_buttons = counters
        .map(|count| {
            view! {
                <li>
                    <button
                        on:click=move |_| *count.write() += 1
                    >
                        {count}
                    </button>
                </li>
            }
        })
        .collect::<Vec<_>>();

    // Note that if `counter_buttons` were a reactive list
    // and its value changed, this would be very inefficient:
    // it would rerender every row every time the list changed.
    view! {
        <ul>{counter_buttons}</ul>
    }
}

/// A list of counters that allows you to add or
/// remove counters.
#[component]
fn DynamicList(
    /// The number of counters to begin with.
    initial_length: usize,
) -> impl IntoView {
    // This dynamic list will use the <For/> component.
    // <For/> is a keyed list. This means that each row
    // has a defined key. If the key does not change, the row
    // will not be re-rendered. When the list changes, only
    // the minimum number of changes will be made to the DOM.

    // `next_counter_id` will let us generate unique IDs
    // we do this by simply incrementing the ID by one
    // each time we create a counter
    let mut next_counter_id = initial_length;

    // we generate an initial list as in <StaticList/>
    // but this time we include the ID along with the signal
    // see NOTE in add_counter below re: ArcRwSignal
    let initial_counters = (0..initial_length)
        .map(|id| (id, ArcRwSignal::new(id + 1)))
        .collect::<Vec<_>>();

    // now we store that initial list in a signal
    // this way, we'll be able to modify the list over time,
    // adding and removing counters, and it will change reactively
    let (counters, set_counters) = signal(initial_counters);

    let add_counter = move |_| {
        // create a signal for the new counter
        // we use ArcRwSignal here, instead of RwSignal
        // ArcRwSignal is a reference-counted type, rather than the arena-allocated
        // signal types we've been using so far.
        // When we're creating a collection of signals like this, using ArcRwSignal
        // allows each signal to be deallocated when its row is removed.
        let sig = ArcRwSignal::new(next_counter_id + 1);
        // add this counter to the list of counters
        set_counters.update(move |counters| {
            // since `.update()` gives us `&mut T`
            // we can just use normal Vec methods like `push`
            counters.push((next_counter_id, sig))
        });
        // increment the ID so it's always unique
        next_counter_id += 1;
    };

    view! {
        <div>
            <button on:click=add_counter>
                "Add Counter"
            </button>
            <ul>
                // The <For/> component is central here
                // This allows for efficient, key list rendering
                <For
                    // `each` takes any function that returns an iterator
                    // this should usually be a signal or derived signal
                    // if it's not reactive, just render a Vec<_> instead of <For/>
                    each=move || counters.get()
                    // the key should be unique and stable for each row
                    // using an index is usually a bad idea, unless your list
                    // can only grow, because moving items around inside the list
                    // means their indices will change and they will all rerender
                    key=|counter| counter.0
                    // `children` receives each item from your `each` iterator
                    // and returns a view
                    children=move |(id, count)| {
                        // we can convert our ArcRwSignal to a Copy-able RwSignal
                        // for nicer DX when moving it into the view
                        let count = RwSignal::from(count);
                        view! {
                            <li>
                                <button
                                    on:click=move |_| *count.write() += 1
                                >
                                    {count}
                                </button>
                                <button
                                    on:click=move |_| {
                                        set_counters
                                            .write()
                                            .retain(|(counter_id, _)| {
                                                counter_id != &id
                                            });
                                    }
                                >
                                    "Remove"
                                </button>
                            </li>
                        }
                    }
                />
            </ul>
        </div>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

使用 <For/> 迭代更复杂的数据

本章将更深入地探讨如何迭代嵌套的数据结构。它与关于迭代的其他章节相关联,但如果你目前想专注于更简单的主题,可以跳过本章,稍后再回来学习。

问题

刚才提到,框架不会重新渲染某行中的任何项,除非该行的键发生了变化。这一逻辑乍一看似乎合理,但实际上可能会让你踩坑。

让我们考虑一个示例,其中每一行的项都是某种数据结构。想象一下,这些项来自某个 JSON 数组,每个项都有一个键和值:

#[derive(Debug, Clone)]
struct DatabaseEntry {
    key: String,
    value: i32,
}

我们定义一个简单的组件,迭代显示每一行:

#[component]
pub fn App() -> impl IntoView {
    // 初始设置三行数据
    let (data, set_data) = signal(vec![
        DatabaseEntry {
            key: "foo".to_string(),
            value: 10,
        },
        DatabaseEntry {
            key: "bar".to_string(),
            value: 20,
        },
        DatabaseEntry {
            key: "baz".to_string(),
            value: 15,
        },
    ]);
    view! {
        // 点击时更新每一行的值,值翻倍
        <button on:click=move |_| {
            set_data.update(|data| {
                for row in data {
                    row.value *= 2;
                }
            });
            // 记录信号的新值
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 迭代显示每一行的值
        <For
            each=move || data.get()
            key=|state| state.key.clone()
            let(child)
        >
            <p>{child.value}</p>
        </For>
    }
}

注意这里的 let(child) 语法。在上一章中,我们通过 children 属性介绍了 <For/>。实际上,我们可以直接在 <For/> 组件的子节点中创建该值,而无需跳出 view! 宏。let(child)<p>{child.value}</p> 等价于:

children=|child| view! { <p>{child.value}</p> }

为方便起见,您还可以选择对数据模式进行重组:

<For
    each=move || data.get()
    key=|state| state.key.clone()
    let(DatabaseEntry { key, value })
>

当你点击 Update Values 按钮时……什么都没有发生。或者更确切地说:信号确实更新了,新值也被记录了,但每行的 {child.value} 并未更新。

我们来看一下:是不是忘记添加闭包来让它变为响应式了?让我们试试 {move || child.value}

……还是不行。

问题的根源在于:如前所述,每行只有在键发生变化时才会重新渲染。我们更新了每行的值,但没有更新任何行的键,因此没有触发重新渲染。而如果查看 child.value 的类型,它是一个普通的 i32,而不是响应式的 ReadSignal<i32> 或类似的东西。这意味着,即使我们在其外部包裹一个闭包,该行的值也永远不会更新。

我们有三种可能的解决方案:

  1. 修改 key,使其在数据结构发生变化时始终更新。
  2. 修改 value,使其变为响应式。
  3. 使用数据结构的响应式切片,而不是直接使用每行的数据。

方案 1:更改 Key

每一行只有在键(key)发生变化时才会重新渲染。上面的示例中行未重新渲染,是因为键没有变化。那么,为什么不强制键发生变化呢?

<For
    each=move || data.get()
    key=|state| (state.key.clone(), state.value)
    let(child)
>
    <p>{child.value}</p>
</For>

现在,我们将键包含了行的键和值。这意味着每当行的值发生变化时,<For/> 会将其视为一行全新的数据,并替换掉之前的内容。

优点

这非常简单。通过为 DatabaseEntry 派生 PartialEqEqHash,我们甚至可以简化为 key=|state| state.clone()

缺点

这是三种选项中效率最低的。 每当行的值发生变化时,都会丢弃之前的 <p> 元素,并用一个全新的元素替换它。换句话说,它没有进行细粒度的文本节点更新,而是每次都完全重新渲染整行内容。这种方式的开销与行的 UI 复杂性成正比。

此外,你会注意到我们需要克隆整个数据结构,以便 <For/> 可以保存键的副本。对于更复杂的数据结构,这种方式可能会很快变得不合适!

方案 2:嵌套信号

如果我们希望对值进行细粒度的响应式更新,可以将每行的 value 包装为一个信号。

#[derive(Debug, Clone)]
struct DatabaseEntry {
    key: String,
    value: RwSignal<i32>,
}

RwSignal<_> 是一个“读写信号”,将 getter 和 setter 结合在一个对象中。这里我使用它是因为它比单独的 getter 和 setter 更容易存储在结构体中。

#[component]
pub fn App() -> impl IntoView {
    // 初始设置三行数据
    let (data, set_data) = signal(vec![
        DatabaseEntry {
            key: "foo".to_string(),
            value: RwSignal::new(10),
        },
        DatabaseEntry {
            key: "bar".to_string(),
            value: RwSignal::new(20),
        },
        DatabaseEntry {
            key: "baz".to_string(),
            value: RwSignal::new(15),
        },
    ]);
    view! {
        // 点击时更新每一行的值,值翻倍
        <button on:click=move |_| {
            for row in &*data.read() {
                row.value.update(|value| *value *= 2);
            }
            // 记录信号的新值
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 迭代显示每一行的值
        <For
            each=move || data.get()
            key=|state| state.key.clone()
            let(child)
        >
            <p>{child.value}</p>
        </For>
    }
}

这个版本可以正常工作!如果你在浏览器的 DOM 检查器中查看,会发现与之前的版本不同,这个版本中只有单个文本节点被更新。将信号直接传递给 {child.value} 能正常工作,因为信号在传递到视图中时仍然保留了它的响应式特性。

注意,我将 set_data.update() 更改为 data.read().read() 是一种非克隆方式访问信号值。在这里,我们只更新了内部的值,而没有更新值的列表。由于信号自己维护状态,我们实际上不需要更新 data 信号,所以使用不可变的 .read() 就可以了。

实际上,这个版本没有更新 data,因此 <For/> 本质上是一个静态列表,类似于上一章中的内容。理论上,这里可以直接使用普通迭代器。但如果我们希望将来添加或移除行,<For/> 会更有用。

优点

这是最有效的选项,与框架的其他思维模型完美契合:随时间变化的值包装在信号中,以便界面能响应这些变化。

缺点

如果从 API 或其他你无法控制的数据源接收数据,嵌套的响应式结构可能会显得繁琐。你可能不希望为每个字段创建一个信号包装的结构体。

方案 3:Memo 化切片

Leptos 提供了一种原语 Memo,可以创建一个派生计算,仅在其值发生变化时触发响应式更新。

这允许你为较大数据结构的子字段创建响应式值,而无需将该结构的字段包装成信号。

大部分应用逻辑可以与初始(问题)版本保持一致,但 <For/> 部分需要更新为以下代码:

<For
    each=move || data.get().into_iter().enumerate()
    key=|(_, state)| state.key.clone()
    children=move |(index, _)| {
        let value = Memo::new(move |_| {
            data.with(|data| data.get(index).map(|d| d.value).unwrap_or(0))
        });
        view! {
            <p>{value}</p>
        }
    }
/>

以下是一些关键的不同点:

  • data 信号转换为一个带索引的迭代器。
  • 显式使用 children 属性,以便在运行 view! 代码之前可以运行其他代码。
  • 定义了一个 value memo,并在视图中使用它。这个 value 字段实际上并没有使用传递到每行的 child,而是通过索引回到原始 data 中获取值。

现在,每次 data 发生变化时,每个 memo 都会重新计算。如果其值发生变化,它会更新相应的文本节点,而不会重新渲染整行。

优点

我们可以获得与信号包装版本相同的细粒度响应式更新,而无需将数据包装成信号。

缺点

<For/> 循环中为每行设置 memo 的操作比使用嵌套信号要复杂一些。例如,你会注意到我们需要通过 data.get(index) 防止 data[index] 引发 panic,这是因为在移除行后,memo 可能会被触发重新运行一次。(这是因为每行的 memo 和整个 <For/> 都依赖于相同的 data 信号,而多个依赖于同一信号的响应式值的执行顺序并不保证一致。)

另外需要注意的是,虽然 memo 会缓存其响应式变化,但每次都需要重新运行相同的计算来检查值,因此嵌套信号在进行精准更新时仍然更高效。

方案 4:Stores(存储)

本部分内容与 全局状态管理章节 中关于 Stores 的内容有些重复。由于这两部分都是中级/可选内容,适当的重复并无大碍。

Leptos 0.7 引入了一种新的响应式原语,称为“Stores”。Stores 专为解决本章中描述的问题而设计。它们还属于实验性功能,因此需要在 Cargo.toml 中添加额外的依赖 reactive_stores

Stores 允许对结构体的单个字段和集合(如 Vec<_>)中的单个项目进行细粒度的响应式访问,而无需像上述选项那样手动创建嵌套信号或 Memo。

Stores 基于 Store 派生宏构建,该宏为结构体的每个字段创建一个 getter。调用该 getter 可以获得对特定字段的响应式访问。读取该字段时,只会跟踪该字段及其父级/子级;更新它时,也只会通知该字段及其父级/子级,而不会通知同级字段。换句话说,修改 value 字段不会通知 key 字段,反之亦然。

我们可以调整上面示例中的数据类型。

Store 的顶层必须是一个结构体,因此我们创建一个 Data 包装器,它有一个 rows 字段:

#[derive(Store, Debug, Clone)]
pub struct Data {
    #[store(key: String = |row| row.key.clone())]
    rows: Vec<DatabaseEntry>,
}

#[derive(Store, Debug, Clone)]
struct DatabaseEntry {
    key: String,
    value: i32,
}

rows 字段添加 #[store(key)] 注解,可以为 Store 字段提供键控访问功能,这在下面的 <For/> 组件中会非常有用。我们可以简单地使用 key,即在 <For/> 中将用到的键。

<For/> 组件的实现非常直观:

<For
    each=move || data.rows()
    key=|row| row.read().key.clone()
    children=|child| {
        let value = child.value();
        view! { <p>{move || value.get()}</p> }
    }
/>

由于 rows 是一个键控字段,它实现了 IntoIterator,因此我们可以直接将 move || data.rows() 用作 each 属性。这会像嵌套信号版本中的 move || data.get() 一样,响应 rows 列表的任何变化。

key 字段调用 .read() 以获取行的当前值,然后克隆并返回 key 字段。

children 属性中,调用 child.value() 可以获得该行 value 字段的响应式访问权限。如果行被重新排序、添加或移除,键控 Store 字段会保持同步,确保此 value 始终与正确的键关联。

在更新按钮的处理程序中,我们迭代 rows 中的条目并更新每一项:

for row in data.rows().iter_unkeyed() {
    *row.value().write() *= 2;
}

优点

我们可以获得与嵌套信号和 Memo 版本相同的细粒度响应式更新,而无需手动创建嵌套信号或 Memo 化切片。我们只需使用普通数据(结构体和 Vec<_>),并用派生宏注解即可,而不需要特殊的嵌套响应式类型。

个人认为,Stores 版本是这里最好的解决方案。这并不意外,因为它是最新的 API。经过几年的经验积累,Stores 综合了我们学到的一些经验教训。

缺点

另一方面,这个 API 是最新的。截至本文撰写时(2024 年 12 月),Stores 刚发布几周。我相信仍然有一些需要解决的 bug 或边界情况。

完整示例

以下是完整的 Store 示例。你可以在这里找到另一个更完整的示例,以及书中的更多讨论请见此处

#[component]
pub fn App() -> impl IntoView {
    // 创建一个 Store,用于存储 Data,而不是单独存储 rows
    let data = Store::new(Data {
        rows: vec![
            DatabaseEntry {
                key: "foo".to_string(),
                value: 10,
            },
            DatabaseEntry {
                key: "bar".to_string(),
                value: 20,
            },
            DatabaseEntry {
                key: "baz".to_string(),
                value: 15,
            },
        ],
    });

    view! {
        // 点击按钮时更新每一行的值,将其翻倍
        <button on:click=move |_| {
            // 允许遍历可遍历存储字段中的条目
            use reactive_stores::StoreFieldIterator;

            // 调用 rows() 访问行列表
            for row in data.rows().iter_unkeyed() {
                *row.value().write() *= 2;
            }
            // 记录信号的新值
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 迭代 rows 并显示每一行的值
        <For
            each=move || data.rows()
            key=|row| row.read().key.clone()
            children=|child| {
                let value = child.value();
                view! { <p>{move || value.get()}</p> }
            }
        />
    }
}

表单(Forms)和输入

表单和表单输入是交互式应用程序的重要组成部分。在 Leptos 中,有两种与输入交互的基本模式,如果你熟悉 React、SolidJS 或类似框架,这些模式可能会让你感到熟悉:受控 (controlled)非受控 (uncontrolled) 输入。

受控输入

在“受控输入”中,框架会控制输入元素的状态。每次触发 input 事件时,都会更新一个保存当前状态的本地信号,而这个信号反过来又会更新输入的 value 属性。

有两个重要的点需要记住:

  1. input 事件会在元素的每次(几乎是每次)更改时触发,而 change 事件会在输入失去焦点时(大致是这样)触发。你可能更需要使用 on:input,但框架也给你选择的自由。
  2. value 属性 仅设置输入的初始值,即它只会在你开始输入之前更新输入值。而 value 属性 (property) 则会在你开始输入后持续更新输入值。出于这个原因,你通常需要设置 prop:value。(对于 <input type="checkbox"> 中的 checkedprop:checked 也是如此。)
let (name, set_name) = signal("Controlled".to_string());

view! {
    <input type="text"
        // 添加 :target 可以让我们以类型安全的方式访问
        // 触发事件的目标元素
        on:input:target=move |ev| {
            // .value() 返回 HTML 输入元素的当前值
            set_name.set(ev.target().value());
        }

        // 使用 `prop:` 语法更新 DOM 属性而不是 HTML 属性
        prop:value=name
    />
    <p>"Name is: " {name}</p>
}

为什么需要使用 prop:value

Web 浏览器是现存最普遍、最稳定的图形用户界面渲染平台之一。它们在存在的三十多年中还保持了令人难以置信的向后兼容性。这不可避免地导致了一些奇怪的行为。

一个奇怪的地方是,HTML 属性 (attribute) 和 DOM 元素属性 (property) 之间存在区别,即所谓的“属性(attribute)”是从 HTML 中解析出来的,可以通过 .setAttribute() 在 DOM 元素上设置,而“属性 (property)”是解析后的 HTML 元素在 JavaScript 类表示中的一个字段。

<input value=...> 为例,设置 value 属性 (attribute) 被定义为设置输入的初始值,而设置 value 属性 (property) 则是设置其当前值。你可以通过打开 about:blank 并在浏览器控制台中逐行运行以下 JavaScript 代码来更容易理解这一点:

// 创建一个输入框并将其添加到 DOM
const el = document.createElement("input");
document.body.appendChild(el);

el.setAttribute("value", "test"); // 更新输入值
el.setAttribute("value", "another test"); // 再次更新输入值

// 现在尝试在输入框中输入内容,比如删除一些字符等

el.setAttribute("value", "one more time?");
// 此时应该什么都没改变,设置“初始值”现在不起作用

// 然而……
el.value = "But this works";

许多其他前端框架混淆了属性 (attribute) 和属性 (property) 的概念,或者为输入框创建了一个特殊的处理方式,使其值可以正确设置。也许 Leptos 也应该这样做;但目前,我更倾向于给用户最大程度的控制,允许他们选择是设置属性 (attribute) 还是属性 (property),同时尽力向用户解释底层浏览器的实际行为,而不是隐藏它。

使用 bind: 简化受控输入

遵循 Web 标准,并清晰地区分“从信号读取”和“写入信号”是很好的做法,但以这种方式创建受控输入有时可能看起来比实际需要的更多样板代码。

Leptos 还包括了一种特殊的 bind: 语法,用于输入控件,可以让你自动将信号绑定到输入控件。它们与上面提到的“受控输入”模式完全相同:创建一个事件监听器来更新信号,并通过动态属性从信号读取数据。你可以使用 bind:value 绑定文本输入,使用 bind:checked 绑定复选框。

let (name, set_name) = signal("Controlled".to_string());
let email = RwSignal::new("".to_string());
let spam_me = RwSignal::new(true);

view! {
    <input type="text"
        bind:value=(name, set_name)
    />
    <input type="email"
        bind:value=email
    />
    <label>
        "Please send me lots of spam email."
        <input type="checkbox"
            bind:checked=spam_me
        />
    </label>
    <p>"Name is: " {name}</p>
    <p>"Email is: " {email}</p>
    <Show when=move || spam_me.get()>
        <p>"You’ll receive cool bonus content!"</p>
    </Show>
}

非受控输入(Uncontrolled Inputs)

在“非受控输入”中,浏览器控制输入元素的状态。而不是不断更新一个信号来存储其值,我们使用 NodeRef 来在需要获取值时访问输入元素。

在下面的示例中,我们只在 <form> 触发 submit 事件时通知框架。请注意 leptos::html 模块的使用,它提供了每个 HTML 元素的多种类型。

let (name, set_name) = signal("Uncontrolled".to_string());

let input_element: NodeRef<html::Input> = NodeRef::new();

view! {
    <form on:submit=on_submit> // on_submit 在下方定义
        <input type="text"
            value=name
            node_ref=input_element
        />
        <input type="submit" value="Submit"/>
    </form>
    <p>"Name is: " {name}</p>
}

到现在为止,这个视图应该是相当直观的。请注意以下两点:

  1. 与受控输入示例不同,我们使用 value(而不是 prop:value)。这是因为我们只是设置输入框的初始值,并让浏览器控制其状态。(当然,我们也可以使用 prop:value。)
  2. 我们使用 node_ref=... 来填充 NodeRef。(早期的示例有时使用 _ref,它们的作用是相同的,但 node_refrust-analyzer 的支持更好。)

NodeRef 是一种 响应式智能指针,它允许我们访问底层的 DOM 节点。当元素被渲染时,其值将被设置。

let on_submit = move |ev: SubmitEvent| {
    // 阻止页面刷新
    ev.prevent_default();

    // 这里,我们从输入框中提取值
    let value = input_element
        .get()
        // 事件处理程序只能在视图挂载到 DOM 后触发,
        // 因此 `NodeRef` 一定是 `Some`
        .expect("<input> 应该已经挂载")
        // `leptos::HtmlElement<html::Input>` 实现了 `Deref`
        // 到 `web_sys::HtmlInputElement`,
        // 这意味着我们可以调用 `HtmlInputElement::value()`
        // 来获取输入框的当前值
        .value();
    set_name.set(value);
};

我们的 on_submit 处理程序会访问输入框的值,并用它来调用 set_name。要访问 NodeRef 存储的 DOM 节点,我们可以直接调用它(或使用 .get())。它会返回 Option<leptos::HtmlElement<html::Input>>,但我们知道该元素已经被挂载(否则事件无法触发!),因此在这里安全地 unwrap 是可以接受的。

然后,我们可以调用 .value() 来获取输入框的值,因为 NodeRef 为我们提供了一个正确类型的 HTML 元素。

要了解更多关于 leptos::HtmlElement 的用法,可以查看 web_sysHtmlElement。此外,请查看页面底部的完整 CodeSandbox 示例。

特殊情况:<textarea><select>

有两种表单元素在使用时容易引发一些混淆,分别是 <textarea><select>

<textarea>

<input> 不同,<textarea> 元素不支持 value 属性。相反,它通过其 HTML 子节点中的纯文本节点来接收其值。

在当前版本的 Leptos(0.1 到 0.6)中,创建动态子节点会插入一个注释标记节点。如果你尝试使用动态内容,这可能会导致 <textarea> 渲染错误(以及在 hydration 期间的问题)。

相反,你可以将一个非响应式的初始值作为子节点传递,并使用 prop:value 来设置其当前值。(<textarea> 不支持 value 属性(attribute),但 确实 支持 value 属性值(property)。)

view! {
    <textarea
        prop:value=move || some_value.get()
        on:input:target=move |ev| some_value.set(ev.target().value())
    >
        /* 纯文本初始值,即使信号发生变化也不会改变 */
        {some_value.get_untracked()}
    </textarea>
}

<select>

<select> 元素同样可以通过其自身的 value 属性来控制,value 属性会选择与该值匹配的 <option> 元素。

let (value, set_value) = signal(0i32);
view! {
  <select
    on:change:target=move |ev| {
      set_value.set(ev.target().value().parse().unwrap());
    }
    prop:value=move || value.get().to_string()
  >
    <option value="0">"0"</option>
    <option value="1">"1"</option>
    <option value="2">"2"</option>
  </select>
  // 一个可以循环切换选项的按钮
  <button on:click=move |_| set_value.update(|n| {
    if *n == 2 {
      *n = 0;
    } else {
      *n += 1;
    }
  })>
    "Next Option"
  </button>
}

Controlled vs uncontrolled forms CodeSandbox

Click to open CodeSandbox.

CodeSandbox Source
use leptos::{ev::SubmitEvent};
use leptos::prelude::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <h2>"Controlled Component"</h2>
        <ControlledComponent/>
        <h2>"Uncontrolled Component"</h2>
        <UncontrolledComponent/>
    }
}

#[component]
fn ControlledComponent() -> impl IntoView {
    // create a signal to hold the value
    let (name, set_name) = signal("Controlled".to_string());

    view! {
        <input type="text"
            // fire an event whenever the input changes
            // adding :target after the event gives us access to
            // a correctly-typed element at ev.target()
            on:input:target=move |ev| {
                set_name.set(ev.target().value());
            }

            // the `prop:` syntax lets you update a DOM property,
            // rather than an attribute.
            //
            // IMPORTANT: the `value` *attribute* only sets the
            // initial value, until you have made a change.
            // The `value` *property* sets the current value.
            // This is a quirk of the DOM; I didn't invent it.
            // Other frameworks gloss this over; I think it's
            // more important to give you access to the browser
            // as it really works.
            //
            // tl;dr: use prop:value for form inputs
            prop:value=name
        />
        <p>"Name is: " {name}</p>
    }
}

#[component]
fn UncontrolledComponent() -> impl IntoView {
    // import the type for <input>
    use leptos::html::Input;

    let (name, set_name) = signal("Uncontrolled".to_string());

    // we'll use a NodeRef to store a reference to the input element
    // this will be filled when the element is created
    let input_element: NodeRef<Input> = NodeRef::new();

    // fires when the form `submit` event happens
    // this will store the value of the <input> in our signal
    let on_submit = move |ev: SubmitEvent| {
        // stop the page from reloading!
        ev.prevent_default();

        // here, we'll extract the value from the input
        let value = input_element.get()
            // event handlers can only fire after the view
            // is mounted to the DOM, so the `NodeRef` will be `Some`
            .expect("<input> to exist")
            // `NodeRef` implements `Deref` for the DOM element type
            // this means we can call`HtmlInputElement::value()`
            // to get the current value of the input
            .value();
        set_name.set(value);
    };

    view! {
        <form on:submit=on_submit>
            <input type="text"
                // here, we use the `value` *attribute* to set only
                // the initial value, letting the browser maintain
                // the state after that
                value=name

                // store a reference to this input in `input_element`
                node_ref=input_element
            />
            <input type="submit" value="Submit"/>
        </form>
        <p>"Name is: " {name}</p>
    }
}

// This `main` function is the entry point into the app
// It just mounts our component to the <body>
// Because we defined it as `fn App`, we can now use it in a
// template as <App/>
fn main() {
    leptos::mount::mount_to_body(App)
}

控制流(Control Flow)

在大多数应用程序中,你有时需要做出决策:是否应该渲染视图的这一部分?应该渲染 <ButtonA/> 还是 <WidgetB/>?这就是 控制流(Control Flow)

一些提示

在考虑如何用 Leptos 实现控制流时,记住以下几点是很重要的:

  1. Rust 是一种面向表达式的语言:像 if x() { y } else { z }match x() { ... } 这样的控制流表达式会返回值。这使得它们在声明式用户界面中非常有用。
  2. 任何实现了 IntoView 的类型都可以渲染——换句话说,任何 Leptos 知道如何渲染的类型,Option<T>Result<T, impl Error> 实现了 IntoView。而且,就像 Fn() -> T 会渲染一个响应式 T 一样,Fn() -> Option<T>Fn() -> Result<T, impl Error> 也是响应式的。
  3. Rust 提供了许多方便的工具函数,比如 Option::mapOption::and_thenOption::ok_orResult::mapResult::okbool::then。这些工具函数允许你以声明式的方式在不同的标准类型之间转换,而这些类型都可以被渲染。特别是,花时间学习 OptionResult 的文档是提升你 Rust 技能的最佳方式之一。
  4. 始终记住:为了保持响应性,值必须是函数。你会发现我在下面的示例中经常用 move || 闭包包裹内容。这是为了确保它们在依赖的信号发生变化时重新运行,从而保持 UI 的响应性。

那么,这意味着什么?

简单来说,这意味着你实际上可以使用 原生 Rust 代码 来实现大部分控制流,而无需依赖专门的控制流组件或特殊的知识。

例如,我们可以从一个简单的信号和一个派生信号开始:

let (value, set_value) = signal(0);
let is_odd = move || value.get() % 2 != 0;

我们可以利用这些信号以及普通的 Rust 语法来构建大部分的控制流。

if 语句

假设我们想要在数字为奇数时渲染一些文本,而在数字为偶数时渲染另一些文本。那么,可以这样做:

view! {
    <p>
        {move || if is_odd() {
            "Odd"
        } else {
            "Even"
        }}
    </p>
}

if 表达式会返回一个值,而 &str 实现了 IntoView,所以 Fn() -> &str 也实现了 IntoView,因此,这样的写法……就能直接工作!

Option<T>

假设我们想在数字为奇数时渲染一些文本,而在偶数时不渲染任何内容。

let message = move || {
    if is_odd() {
        Some("Ding ding ding!")
    } else {
        None
    }
};

view! {
    <p>{message}</p>
}

这可以正常工作。我们还可以使用 bool::then() 让代码更简洁:

let message = move || is_odd().then(|| "Ding ding ding!");
view! {
    <p>{message}</p>
}

你甚至可以将其内联到 view! 中,不过从 view! 之外提取逻辑有时会带来更好的 cargo fmtrust-analyzer 支持,所以根据需要选择最合适的方式。

match 语句

我们仍然只是在编写普通的 Rust 代码,对吧?所以你可以充分利用 Rust 的 模式匹配 机制。

let message = move || {
    match value.get() {
        0 => "Zero",
        1 => "One",
        n if is_odd() => "Odd",
        _ => "Even"
    }
};
view! {
    <p>{message}</p>
}

为什么不这样做呢?反正 YOLO(你只活一次),对吧?

防止过度渲染

并不是那么 YOLO(随心所欲)。

我们刚才做的一切基本上都是可以的,但有一件事你需要记住并注意。到目前为止,我们创建的每个控制流函数本质上都是一个 派生信号(derived signal),它会在 value 发生变化时重新运行。在上面的示例中,由于 value 每次都会在 奇数偶数 之间切换,这没有问题。

但请考虑以下示例:

let (value, set_value) = signal(0);

let message = move || if value.get() > 5 {
    "Big"
} else {
    "Small"
};

view! {
    <p>{message}</p>
}

确实 能正常工作。但如果你添加了一条日志,你可能会感到惊讶:

let message = move || if value.get() > 5 {
    logging::log!("{}: rendering Big", value());
    "Big"
} else {
    logging::log!("{}: rendering Small", value());
    "Small"
};

当用户点击按钮时,你可能会看到类似这样的日志输出:

1: rendering Small
2: rendering Small
3: rendering Small
4: rendering Small
5: rendering Small
6: rendering Big
7: rendering Big
8: rendering Big
... ad infinitum

每次 value 发生变化时,if 语句都会重新运行。这在响应式编程的工作方式下是合理的。但它也有一个 缺点。对于一个简单的文本节点来说,重新运行 if 语句并重新渲染 问题不大。但如果代码是这样的呢?

let message = move || if value.get() > 5 {
    <Big/>
} else {
    <Small/>
};

value0 增加到 5 的过程中,它会 重复渲染 <Small/> 五次,然后在 value > 5 之后 无限次渲染 <Big/>。如果这些组件涉及 加载资源、创建信号,甚至只是创建 DOM 节点,那么每次都重新渲染就是 不必要的性能开销

<Show/>

<Show/> 组件是解决方案。你可以传递一个 when 条件函数,一个当 when 函数返回 false 时显示的 fallback,以及当 when 返回 true 时渲染的子节点。

let (value, set_value) = signal(0);

view! {
  <Show
    when=move || { value.get() > 5 }
    fallback=|| view! { <Small/> }
  >
    <Big/>
  </Show>
}

<Show/> 会对 when 条件进行 缓存(memoize),因此它只会渲染一次 <Small/>,并持续显示该组件,直到 value 大于 5;然后只渲染一次 <Big/>,并持续显示它,除非 value 再次小于 5,此时会重新渲染 <Small/>

这是一种有效的工具,可以在使用动态 if 表达式时避免不必要的重新渲染。但需要注意的是,这也有一定的开销:对于非常简单的节点(比如更新单个文本节点或更新类名、属性),使用 move || if ... 会更高效。但如果渲染任何一个分支的开销较大,优先选择 <Show/>

注意:类型转换

在这一部分,还有最后一个重要的事情需要说明。

Leptos 使用 静态类型的视图树view! 宏对于不同类型的视图会返回不同的类型。

下面的代码 不会成功编译,因为不同的 HTML 元素属于不同的类型:

view! {
    <main>
        {move || match is_odd() {
            true if value.get() == 1 => {
                view! { <pre>"One"</pre> }
            },
            false if value.get() == 2 => {
                view! { <p>"Two"</p> }
            }
            // 返回 HtmlElement<Textarea>
            _ => view! { <textarea>{value.get()}</textarea> }
        }}
    </main>
}

这种 强类型 机制非常强大,因为它允许进行各种 编译时优化。但在像这样的 条件逻辑 中,它可能会有些麻烦,因为 Rust 不允许不同分支返回不同的类型。要解决这个问题,你可以使用以下两种方法:

  1. 使用 Either(以及 EitherOf3EitherOf4 等)将不同类型转换为相同类型。
  2. 使用 .into_any() 将多个类型转换为 类型擦除(type-erased)AnyView

下面是修正后的示例,添加了类型转换,这样,所有分支的返回类型都被转换为 AnyView,从而使代码可以正常编译:

view! {
    <main>
        {move || match is_odd() {
            true if value() == 1 => {
                // 返回 HtmlElement<Pre>
                view! { <pre>"One"</pre> }.into_any()
            },
            false if value() == 2 => {
                // 返回 HtmlElement<P>
                view! { <p>"Two"</p> }.into_any()
            }
            // 返回 HtmlElement<Textarea>
            _ => view! { <textarea>{value()}</textarea> }.into_any()
        }}
    </main>
}

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

#[component]
fn App() -> impl IntoView {
    let (value, set_value) = signal(0);
    let is_odd = move || value.get() & 1 == 1;
    let odd_text = move || if is_odd() {
        Some("How odd!")
    } else {
        None
    };

    view! {
        <h1>"Control Flow"</h1>

        // Simple UI to update and show a value
        <button on:click=move |_| *set_value.write() += 1>
            "+1"
        </button>
        <p>"Value is: " {value}</p>

        <hr/>

        <h2><code>"Option<T>"</code></h2>
        // For any `T` that implements `IntoView`,
        // so does `Option<T>`

        <p>{odd_text}</p>
        // This means you can use `Option` methods on it
        <p>{move || odd_text().map(|text| text.len())}</p>

        <h2>"Conditional Logic"</h2>
        // You can do dynamic conditional if-then-else
        // logic in several ways
        //
        // a. An "if" expression in a function
        //    This will simply re-render every time the value
        //    changes, which makes it good for lightweight UI
        <p>
            {move || if is_odd() {
                "Odd"
            } else {
                "Even"
            }}
        </p>

        // b. Toggling some kind of class
        //    This is smart for an element that's going to
        //    toggled often, because it doesn't destroy
        //    it in between states
        //    (you can find the `hidden` class in `index.html`)
        <p class:hidden=is_odd>"Appears if even."</p>

        // c. The <Show/> component
        //    This only renders the fallback and the child
        //    once, lazily, and toggles between them when
        //    needed. This makes it more efficient in many cases
        //    than a {move || if ...} block
        <Show when=is_odd
            fallback=|| view! { <p>"Even steven"</p> }
        >
            <p>"Oddment"</p>
        </Show>

        // d. Because `bool::then()` converts a `bool` to
        //    `Option`, you can use it to create a show/hide toggled
        {move || is_odd().then(|| view! { <p>"Oddity!"</p> })}

        <h2>"Converting between Types"</h2>
        // e. Note: if branches return different types,
        //    you can convert between them with
        //    `.into_any()` (for different HTML element types)
        //    or `.into_view()` (for all view types)
        {move || match is_odd() {
            true if value.get() == 1 => {
                // <pre> returns HtmlElement<Pre>
                view! { <pre>"One"</pre> }.into_any()
            },
            false if value.get() == 2 => {
                // <p> returns HtmlElement<P>
                // so we convert into a more generic type
                view! { <p>"Two"</p> }.into_any()
            }
            _ => view! { <textarea>{value.get()}</textarea> }.into_any()
        }}
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

错误处理

在上一章,我们看到可以渲染 Option<T>:对于 None 情况,它会渲染空内容;对于 Some(T) 情况,它会渲染 T(前提是 T 实现了 IntoView)。实际上,你可以对 Result<T, E> 做类似的事情。在 Err(_) 情况下,它会渲染空内容;在 Ok(T) 情况下,它会渲染 T

让我们从一个简单的组件开始,捕获一个数字输入:

#[component]
fn NumericInput() -> impl IntoView {
    let (value, set_value) = signal(Ok(0));

    view! {
        <label>
            "输入一个整数(或其他内容!)"
            <input type="number" on:input:target=move |ev| {
              // 当输入更改时,尝试从输入中解析一个数字
              set_value.set(ev.target().value().parse::<i32>())
            }/>
            <p>
                "你输入的是 "
                <strong>{value}</strong>
            </p>
        </label>
    }
}

每次你更改输入时,on_input 都会尝试将其值解析为一个 32 位整数(i32),并将结果存储在我们的 value 信号中,该信号是一个 Result<i32, _>。如果你输入数字 42,UI 会显示:

你输入的是 42

但如果你输入字符串 foo,它会显示:

你输入的是

这不太理想。虽然避免了使用 .unwrap_or_default() 或类似的操作,但如果我们可以捕获错误并对其进行处理,效果会更好。

为此,你可以使用 <ErrorBoundary/> 组件来实现。

Note

人们经常指出,<input type="number"> 可以防止用户输入 foo 这样的字符串,或者其他非数字的内容。这在某些浏览器中确实如此,但并非所有浏览器都这样!此外,在普通的数字输入框中,可以输入许多不属于 i32 的内容,例如浮点数、大于 32 位的数字、字母 e 等等。虽然可以通过设置浏览器来强制执行某些限制,但不同浏览器的行为仍然有所不同。因此,自己解析输入数据是很重要的!

<ErrorBoundary/>

<ErrorBoundary/> 有点类似于上一章我们看到的 <Show/> 组件。如果一切正常(也就是说,如果所有内容都是 Ok(_)),它会渲染其子组件。但如果其中有 Err(_) 被渲染,它将触发 <ErrorBoundary/>fallback(备用内容)。

让我们在这个示例中添加一个 <ErrorBoundary/>

#[component]
fn NumericInput() -> impl IntoView {
        let (value, set_value) = signal(Ok(0));

    view! {
        <h1>"错误处理"</h1>
        <label>
            "输入一个数字(或者输入非数字的内容!)"
            <input type="number" on:input:target=move |ev| {
                // 当输入发生变化时,尝试从输入中解析一个数字
                set_value.set(ev.target().value().parse::<i32>())
            }/>
            // 如果 `<ErrorBoundary/>` 内部渲染了 `Err(_)`,
            // 那么 `fallback` 会被显示。否则,会显示 `<ErrorBoundary/>` 的子元素。
            <ErrorBoundary
                // `fallback` 接收一个包含当前错误的信号
                fallback=|errors| view! {
                    <div class="error">
                        <p>"不是一个数字!错误信息:" </p>
                        // 我们可以将错误列表渲染为字符串
                        <ul>
                            {move || errors.get()
                                .into_iter()
                                .map(|(_, e)| view! { <li>{e.to_string()}</li>})
                                .collect::<Vec<_>>()
                            }
                        </ul>
                    </div>
                }
            >
                <p>
                    "你输入了 "
                    // 因为 `value` 是 `Result<i32, _>`,
                    // 如果它是 `Ok`,它会渲染 `i32`;
                    // 如果它是 `Err`,它不会渲染任何内容,而是触发错误边界。
                    // 这是一个信号(signal),所以当 `value` 变化时,它会动态更新。
                    <strong>{value}</strong>
                </p>
            </ErrorBoundary>
        </label>
    }
}

现在,如果你输入 42value 变为 Ok(42),你会看到:

你输入了 42

如果你输入 foovalue 变为 Err(_)fallback 将会被渲染。
我们选择将错误列表渲染为 String,所以你会看到类似以下的内容:

不是一个数字!错误信息:
- cannot parse integer from empty string

如果你修正输入,错误消息会消失,而 <ErrorBoundary/> 包裹的内容将再次出现。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

#[component]
fn App() -> impl IntoView {
    let (value, set_value) = signal(Ok(0));

    view! {
        <h1>"Error Handling"</h1>
        <label>
            "Type a number (or something that's not a number!)"
            <input type="number" on:input:target=move |ev| {
                // when input changes, try to parse a number from the input
                set_value.set(ev.target().value().parse::<i32>())
            }/>
            // If an `Err(_) had been rendered inside the <ErrorBoundary/>,
            // the fallback will be displayed. Otherwise, the children of the
            // <ErrorBoundary/> will be displayed.
            <ErrorBoundary
                // the fallback receives a signal containing current errors
                fallback=|errors| view! {
                    <div class="error">
                        <p>"Not a number! Errors: "</p>
                        // we can render a list of errors
                        // as strings, if we'd like
                        <ul>
                            {move || errors.get()
                                .into_iter()
                                .map(|(_, e)| view! { <li>{e.to_string()}</li>})
                                .collect::<Vec<_>>()
                            }
                        </ul>
                    </div>
                }
            >
                <p>
                    "You entered "
                    // because `value` is `Result<i32, _>`,
                    // it will render the `i32` if it is `Ok`,
                    // and render nothing and trigger the error boundary
                    // if it is `Err`. It's a signal, so this will dynamically
                    // update when `value` changes
                    <strong>{value}</strong>
                </p>
            </ErrorBoundary>
        </label>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

父子组件通信

你可以将应用程序看作一个嵌套的组件树。每个组件都处理自己的局部状态并管理用户界面的一部分,因此组件通常是相对独立的。

不过,有时你可能需要在父组件和子组件之间进行通信。例如,假设你定义了一个 <FancyButton/> 组件,为 <button/> 添加了一些样式、日志记录或其他功能。你希望在 <App/> 组件中使用 <FancyButton/>。但是,如何在两者之间进行通信呢?

从父组件向子组件传递状态是很简单的。在组件和属性的内容中,我们已经介绍了一些相关内容。基本上,如果你想让父组件与子组件通信,可以将 ReadSignalSignal 作为属性(Prop)传递给子组件。

但是反过来呢?如何让子组件将事件或状态变化的通知发送回父组件?

在 Leptos 中,父子组件通信有四种基本模式。

1. 传递 WriteSignal

一种方法是直接将 WriteSignal 从父组件传递给子组件,并在子组件中更新它。这样可以让子组件操作父组件的状态。

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"是否切换? " {toggled}</p>
        <ButtonA setter=set_toggled/>
    }
}

#[component]
pub fn ButtonA(setter: WriteSignal<bool>) -> impl IntoView {
    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "切换"
        </button>
    }
}

这种模式很简单,但需要谨慎使用:随意传递 WriteSignal 可能会让代码变得难以理解。在这个示例中,当你阅读 <App/> 组件时,很明显它将 toggled 状态的修改权限交给了 ButtonA,但具体在何时或如何发生变化并不直观。在这个小型示例中,这种方式很好理解,但如果你在整个代码库中随意传递 WriteSignal,就可能导致代码混乱,难以维护。如果你发现自己经常使用这种模式,应该认真考虑它是否会让代码变得过于复杂和难以管理。

2. 使用回调函数(Callback)

另一种方法是将一个回调函数传递给子组件,例如 on_click

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"是否切换? " {toggled}</p>
        <ButtonB on_click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}

#[component]
pub fn ButtonB(on_click: impl FnMut(MouseEvent) + 'static) -> impl IntoView {
    view! {
        <button on:click=on_click>
            "切换"
        </button>
    }
}

你会注意到,与 <ButtonA/> 接收一个 WriteSignal 并决定如何修改它不同,<ButtonB/> 仅触发了一个事件:状态的修改发生在 <App/> 中。这种方法的优点是可以保持状态的局部性,避免了杂乱的状态修改问题。但这也意味着修改信号的逻辑需要存在于 <App/> 中,而不是 <ButtonB/> 中。这两种方法各有优劣,并不是简单的对错问题。

3. 使用事件监听器

实际上,你可以稍微调整方法 2 的写法。如果回调函数可以直接映射到原生 DOM 事件,你可以在 <App/>view! 宏中直接为组件添加 on: 监听器。

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"是否切换? " {toggled}</p>
        // 注意这里使用的是 on:click,而不是 on_click
        // 这与 HTML 元素的事件监听器语法相同
        <ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}

#[component]
pub fn ButtonC() -> impl IntoView {
    view! {
        <button>"切换"</button>
    }
}

这样,你在 <ButtonC/> 组件中编写的代码比 <ButtonB/> 组件要少得多,但仍然能够正确地将事件传递给监听器。其原理是:on: 事件监听器会被添加到 <ButtonC/> 返回的每个元素上,在本例中就是 <button>

当然,这种方法仅适用于那些可以直接映射到 DOM 事件的情况,也就是你直接将事件传递给组件内部的元素。如果你的逻辑较为复杂,无法直接映射到某个具体的元素(比如创建 <ValidatedForm/> 组件,并希望使用 on_valid_form_submit 回调),那么你应该使用方法 2。

4. 提供上下文(Context)

这种方法实际上是方法 1 的一种变体。假设你有一个深层嵌套的组件树:

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"是否切换? " {toggled}</p>
        <Layout/>
    }
}

#[component]
pub fn Layout() -> impl IntoView {
    view! {
        <header>
            <h1>"我的页面"</h1>
        </header>
        <main>
            <Content/>
        </main>
    }
}

#[component]
pub fn Content() -> impl IntoView {
    view! {
        <div class="content">
            <ButtonD/>
        </div>
    }
}

#[component]
pub fn ButtonD() -> impl IntoView {
    todo!()
}

现在 <ButtonD/> 不再是 <App/> 的直接子组件,因此你无法直接通过属性(prop)将 WriteSignal 传递给它。你可以尝试通过每一层组件传递属性(通常被称为“属性钻取(grilling)”):

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"是否切换? " {toggled}</p>
        <Layout set_toggled/>
    }
}

#[component]
pub fn Layout(set_toggled: WriteSignal<bool>) -> impl IntoView {
    view! {
        <header>
            <h1>"我的页面"</h1>
        </header>
        <main>
            <Content set_toggled/>
        </main>
    }
}

#[component]
pub fn Content(set_toggled: WriteSignal<bool>) -> impl IntoView {
    view! {
        <div class="content">
            <ButtonD set_toggled/>
        </div>
    }
}

#[component]
pub fn ButtonD(set_toggled: WriteSignal<bool>) -> impl IntoView {
    todo!()
}

这非常混乱!<Layout/><Content/> 并不需要 set_toggled,它们只是将它传递给 <ButtonD/>。但是我们必须在每一层都声明这个属性。这不仅令人烦恼,而且难以维护:想象一下,我们添加了一个“半切换”选项,并且 set_toggled 的类型需要更改为一个 enum。我们必须在三个地方进行更改!

难道没有办法跳过中间的层级吗?

答案是:有!

4.1 Context API(上下文 API)

你可以通过使用 provide_contextuse_context 提供数据,从而跳过层级传递(prop drilling)。上下文通过提供的数据类型(在本例中为 WriteSignal<bool>)进行识别,并存在于一个从上到下的树结构中,树的结构与 UI 树的层次相对应。在这个例子中,我们可以使用上下文来避免不必要的属性传递。

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);

    // 将 `set_toggled` 共享给该组件的所有子组件
    provide_context(set_toggled);

    view! {
        <p>"是否切换? " {toggled}</p>
        <Layout/>
    }
}

// 省略 <Layout/> 和 <Content/>
// 在这个版本中,可以去掉每一层中的 `set_toggled` 参数

#[component]
pub fn ButtonD() -> impl IntoView {
    // use_context 会向上搜索上下文树,尝试找到
    // 一个 `WriteSignal<bool>`。
    // 在这里使用 .expect(),因为我知道之前已经提供了它
    let setter = use_context::<WriteSignal<bool>>().expect("找不到提供的 setter");

    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "切换"
        </button>
    }
}

<ButtonA/> 中的警告相同:传递 WriteSignal 时要谨慎,因为它允许你从代码中的任意部分修改状态。但是,如果小心使用,这可能是 Leptos 中最有效的全局状态管理技术之一:只需在需要状态的最高层次提供它,并在较低层次的任意位置使用它。

这种方法没有性能上的缺点。因为你传递的是一个细粒度的响应式信号,所以在更新时,中间的组件(如 <Layout/><Content/>不会发生任何变化。你实际上是在 <ButtonD/><App/> 之间直接通信。事实上——这就是细粒度响应式的强大之处——你是在 <ButtonD/> 中的按钮点击事件和 <App/> 中的单个文本节点之间直接通信。这种通信方式使得组件本身看起来几乎不存在。而实际上……在运行时,它们确实不存在。它本质上只是信号与响应效果的组合,从头到尾都如此。

请注意,这种方法做出了一个重要的权衡:在 provide_contextuse_context 之间,你不再拥有类型安全性。在子组件中接收正确的上下文变成了一个运行时检查(参见 use_context.expect(...))。在重构时,编译器不会像早期的方法那样为你提供指导。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::{ev::MouseEvent, prelude::*};

// This highlights four different ways that child components can communicate
// with their parent:
// 1) <ButtonA/>: passing a WriteSignal as one of the child component props,
//    for the child component to write into and the parent to read
// 2) <ButtonB/>: passing a closure as one of the child component props, for
//    the child component to call
// 3) <ButtonC/>: adding an `on:` event listener to a component
// 4) <ButtonD/>: providing a context that is used in the component (rather than prop drilling)

#[derive(Copy, Clone)]
struct SmallcapsContext(WriteSignal<bool>);

#[component]
pub fn App() -> impl IntoView {
    // just some signals to toggle four classes on our <p>
    let (red, set_red) = signal(false);
    let (right, set_right) = signal(false);
    let (italics, set_italics) = signal(false);
    let (smallcaps, set_smallcaps) = signal(false);

    // the newtype pattern isn't *necessary* here but is a good practice
    // it avoids confusion with other possible future `WriteSignal<bool>` contexts
    // and makes it easier to refer to it in ButtonD
    provide_context(SmallcapsContext(set_smallcaps));

    view! {
        <main>
            <p
                // class: attributes take F: Fn() => bool, and these signals all implement Fn()
                class:red=red
                class:right=right
                class:italics=italics
                class:smallcaps=smallcaps
            >
                "Lorem ipsum sit dolor amet."
            </p>

            // Button A: pass the signal setter
            <ButtonA setter=set_red/>

            // Button B: pass a closure
            <ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>

            // Button C: use a regular event listener
            // setting an event listener on a component like this applies it
            // to each of the top-level elements the component returns
            <ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>

            // Button D gets its setter from context rather than props
            <ButtonD/>
        </main>
    }
}

/// Button A receives a signal setter and updates the signal itself
#[component]
pub fn ButtonA(
    /// Signal that will be toggled when the button is clicked.
    setter: WriteSignal<bool>,
) -> impl IntoView {
    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle Red"
        </button>
    }
}

/// Button B receives a closure
#[component]
pub fn ButtonB(
    /// Callback that will be invoked when the button is clicked.
    on_click: impl FnMut(MouseEvent) + 'static,
) -> impl IntoView
{
    view! {
        <button
            on:click=on_click
        >
            "Toggle Right"
        </button>
    }
}

/// Button C is a dummy: it renders a button but doesn't handle
/// its click. Instead, the parent component adds an event listener.
#[component]
pub fn ButtonC() -> impl IntoView {
    view! {
        <button>
            "Toggle Italics"
        </button>
    }
}

/// Button D is very similar to Button A, but instead of passing the setter as a prop
/// we get it from the context
#[component]
pub fn ButtonD() -> impl IntoView {
    let setter = use_context::<SmallcapsContext>().unwrap().0;

    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle Small Caps"
        </button>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

组件子节点(Component Children)

在组件中传递子节点(children)是一个非常常见的需求,就像你可以将子节点传递给 HTML 元素一样。例如,假设有一个 <FancyForm/> 组件,用来增强 HTML 的 <form>。你需要一种方法将其所有的输入传递进去。

view! {
    <FancyForm>
        <fieldset>
            <label>
                "Some Input"
                <input type="text" name="something"/>
            </label>
        </fieldset>
        <button>"Submit"</button>
    </FancyForm>
}

在 Leptos 中如何实现这一点?基本上有两种方式将子组件传递给其他组件:

  1. 渲染属性(render props):属性是返回视图的函数。
  2. children 属性:一个特殊的组件属性,用于包含传递给组件的任何子节点。

事实上,你已经在 <Show/> 组件中看到过这两种方式:

view! {
  <Show
    // `when` 是一个普通属性
    when=move || value.get() > 5
    // `fallback` 是一个“渲染属性”:一个返回视图的函数
    fallback=|| view! { <Small/> }
  >
    // `<Big/>`(以及这里的其他任何内容)
    // 将被传递给 `children` 属性
    <Big/>
  </Show>
}

现在我们定义一个组件,该组件可以接收一些子节点和一个渲染属性。

/// 在标记中显示一个 `render_prop` 和一些子节点。
#[component]
pub fn TakesChildren<F, IV>(
    /// 接收一个函数(类型 F),返回任何可以
    /// 转换为视图(类型 IV)的内容
    render_prop: F,
    /// `children` 可以接收多种不同类型,每种类型
    /// 都是返回某种视图类型的函数
    children: Children,
) -> impl IntoView
where
    F: Fn() -> IV,
    IV: IntoView,
{
    view! {
        <h1><code>"<TakesChildren/>"</code></h1>
        <h2>"渲染属性"</h2>
        {render_prop()}
        <hr/>
        <h2>"子节点"</h2>
        {children()}
    }
}

render_propchildren 都是函数,因此我们可以调用它们以生成相应的视图。特别是 childrenBox<dyn FnOnce() -> AnyView> 的别名。(很高兴我们将它命名为 Children,对吧?)这里返回的 AnyView 是一个不透明的、类型擦除的视图:你不能对其进行任何检查。还有多种其他类型的子节点,例如 ChildrenFragment 将返回一个 Fragment,它是一个可以迭代其子节点的集合。

如果需要多次调用 children,因此需要一个 FnFnMut,我们还提供了 ChildrenFnChildrenMut 的别名。

我们可以像下面这样使用这个组件:

view! {
    <TakesChildren render_prop=|| view! { <p>"Hi, there!"</p> }>
        // 这些内容将被传递给 `children`
        "Some text"
        <span>"A span"</span>
    </TakesChildren>
}

操作子节点(Manipulating Children)

Fragment 类型本质上是 Vec<AnyView> 的一个封装。你可以在视图中的任何位置插入它。

但你也可以直接访问这些内部视图来操作它们。例如,下面是一个组件,它接收子节点并将它们转换为一个无序列表(<ul>)。

/// 将每个子节点包装在 `<li>` 中,并嵌套在 `<ul>` 内。
#[component]
pub fn WrapsChildren(children: ChildrenFragment) -> impl IntoView {
    // children() 返回一个 `Fragment`,它包含一个 `nodes` 字段,
    // 其中存储了一个 `Vec<View>`。
    // 这意味着我们可以遍历子节点来创建新的内容!
    let children = children()
        .nodes
        .into_iter()
        .map(|child| view! { <li>{child}</li> })
        .collect::<Vec<_>>();

    view! {
        <h1><code>"<WrapsChildren/>"</code></h1>
        // 将包装后的子节点放入一个 UL 中
        <ul>{children}</ul>
    }
}

这样调用该组件会创建一个列表:

view! {
    <WrapsChildren>
        "A"
        "B"
        "C"
    </WrapsChildren>
}

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;

// Often, you want to pass some kind of child view to another
// component. There are two basic patterns for doing this:
// - "render props": creating a component prop that takes a function
//   that creates a view
// - the `children` prop: a special property that contains content
//   passed as the children of a component in your view, not as a
//   property

#[component]
pub fn App() -> impl IntoView {
    let (items, set_items) = signal(vec![0, 1, 2]);
    let render_prop = move || {
        let len = move || items.read().len();
        view! {
            <p>"Length: " {len}</p>
        }
    };

    view! {
        // This component just displays the two kinds of children,
        // embedding them in some other markup
        <TakesChildren
            // for component props, you can shorthand
            // `render_prop=render_prop` => `render_prop`
            // (this doesn't work for HTML element attributes)
            render_prop
        >
            // these look just like the children of an HTML element
            <p>"Here's a child."</p>
            <p>"Here's another child."</p>
        </TakesChildren>
        <hr/>
        // This component actually iterates over and wraps the children
        <WrapsChildren>
            <p>"Here's a child."</p>
            <p>"Here's another child."</p>
        </WrapsChildren>
    }
}

/// Displays a `render_prop` and some children within markup.
#[component]
pub fn TakesChildren<F, IV>(
    /// Takes a function (type F) that returns anything that can be
    /// converted into a View (type IV)
    render_prop: F,
    /// `children` takes the `Children` type
    /// this is an alias for `Box<dyn FnOnce() -> Fragment>`
    /// ... aren't you glad we named it `Children` instead?
    children: Children,
) -> impl IntoView
where
    F: Fn() -> IV,
    IV: IntoView,
{
    view! {
        <h1><code>"<TakesChildren/>"</code></h1>
        <h2>"Render Prop"</h2>
        {render_prop()}
        <hr/>
        <h2>"Children"</h2>
        {children()}
    }
}

/// Wraps each child in an `<li>` and embeds them in a `<ul>`.
#[component]
pub fn WrapsChildren(children: ChildrenFragment) -> impl IntoView {
    // children() returns a `Fragment`, which has a
    // `nodes` field that contains a Vec<View>
    // this means we can iterate over the children
    // to create something new!
    let children = children()
        .nodes
        .into_iter()
        .map(|child| view! { <li>{child}</li> })
        .collect::<Vec<_>>();

    view! {
        <h1><code>"<WrapsChildren/>"</code></h1>
        // wrap our wrapped children in a UL
        <ul>{children}</ul>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

无宏:视图构建器语法(View Builder Syntax)

如果你对目前为止介绍的 view! 宏语法感到满意,可以跳过本章。本节描述的构建器语法始终可用,但并非必须使用。

出于各种原因,许多开发者希望避免使用宏。也许你不喜欢 rustfmt 对宏的有限支持(不过,你可以试试 leptosfmt,这是一个很棒的工具!)。也许你担心宏对编译时间的影响。也许你更喜欢纯 Rust 语法的美感,或者在 HTML 样式的语法和 Rust 代码之间切换时感到困难。或者你希望比 view 宏提供的功能更灵活地创建和操作 HTML 元素。

如果你属于上述任何一种情况,那么构建器语法可能适合你。

view 宏会将 HTML 样式的语法展开为一系列 Rust 函数和方法调用。如果你不想使用 view 宏,也可以直接使用这种展开后的语法。实际上,这种方式也相当简洁!

首先,如果你愿意,甚至可以不使用 #[component] 宏:一个组件只是一个用于创建视图的设置函数,因此你可以将组件定义为一个简单的函数调用:

pub fn counter(initial_value: i32, step: u32) -> impl IntoView { }

元素是通过调用与 HTML 元素同名的函数创建的:

p()

可以通过 .child() 为元素添加子节点,该方法可以接收一个子节点、一个元组或一个实现 IntoView 的数组。

p().child((em().child("Big, "), strong().child("bold "), "text"))

属性通过 .attr() 添加。该方法可以接收与 view 宏中属性支持的相同类型(实现了 Attribute 的类型)。

p().attr("id", "foo")
    .attr("data-count", move || count.get().to_string())

属性也可以通过特定的属性方法添加,这些方法适用于所有内置的 HTML 属性名称:

p().id("foo")
    .attr("data-count", move || count.get().to_string())

类似地,class:prop:style: 语法分别对应于 .class().prop().style() 方法。

事件监听器可以通过 .on() 添加。在 leptos::ev 中定义的类型化事件可以防止事件名称中的拼写错误,并允许在回调函数中正确推断类型。

button()
    .on(ev::click, move |_| set_count.set(0))
    .child("Clear")

所有这些功能加起来,如果你喜欢这种风格,它可以让你以一种非常“Rust 风格”的语法构建功能齐全的视图。

/// 一个简单的计数器视图。
// 组件本质上只是一个函数调用:它运行一次,用于创建 DOM 和响应式系统
pub fn counter(initial_value: i32, step: i32) -> impl IntoView {
    let (count, set_count) = signal(initial_value);
    div().child((
        button()
            // leptos::ev 中的类型化事件
            // 1) 防止事件名称中的拼写错误
            // 2) 允许在回调函数中正确推断类型
            .on(ev::click, move |_| set_count.set(0))
            .child("清零"),
        button()
            .on(ev::click, move |_| *set_count.write() -= step)
            .child("-1"),
        span().child(("值: ", move || count.get(), "!")),
        button()
            .on(ev::click, move |_| *set_count.write() += step)
            .child("+1"),
    ))
}

响应式

Leptos 基于一个精细的响应式系统构建而成,旨在针对变化的响应式值,尽可能少地运行昂贵的副作用(例如在浏览器中进行渲染或发起网络请求)。

到目前为止,我们已经看到信号(signal)的运作方式。接下来的章节将更深入地探讨,并了解效应(effect),它们是故事的另一半。

信号的使用

到目前为止,我们已经通过一些简单的例子了解了如何使用 signal,它返回一个 ReadSignal 读取器和一个 WriteSignal 写入器。

获取与设置

以下是一些基本的信号操作:

获取值

  1. .read() 返回一个只读保护对象(read guard),可以通过解引用来获取信号的值,并且会对信号值的未来变化进行响应式跟踪。注意,在此保护对象被释放之前,不能更新信号的值,否则会导致运行时错误。
  2. .with() 接收一个函数,该函数会获得信号当前值的引用(&T),并对信号进行跟踪。
  3. .get() 克隆信号的当前值,并对信号值的后续更改进行跟踪。

.get() 是访问信号最常用的方法。.read() 适用于需要通过不可变引用(而不是克隆值)调用的方法(如 my_vec_signal.read().len())。.with() 则在需要对该引用执行更多操作时非常有用,并确保不会长时间持有锁。

设置值

  1. .write() 返回一个写保护对象(write guard),它是信号值的一个可变引用,并会通知所有订阅者需要更新。注意,在该保护对象被释放之前,无法读取信号的值,否则会导致运行时错误。
  2. .update() 接收一个函数,该函数会获得信号当前值的可变引用(&mut T),并通知订阅者更新。(.update() 不会返回闭包的返回值,但如果需要返回值,可以使用 .try_update(),例如当从 Vec<_> 中移除一个元素并希望获取被移除的元素时。)
  3. .set() 替换信号的当前值,并通知订阅者更新。

.set() 是设置新值最常用的方法;.write() 在原地更新值时非常有用。与 .read().with() 类似,.update() 在需要避免长时间持有写锁时也非常实用。

Note

这些特性(traits)基于特性组合,并通过通用实现(blanket implementations)提供。例如,Read 为任何实现了 TrackReadUntracked 的类型提供实现;With 为任何实现了 Read 的类型提供实现;Get 为实现了 WithClone 的类型提供实现,等等。

类似的关系也适用于 WriteUpdateSet

在阅读文档时值得注意:如果只看到 ReadUntrackedTrack 作为已实现的特性,仍然可以使用 .with().get()(如果 T: Clone),等等。

信号的使用

你可能会注意到,.get().set() 可以通过 .read().write(),或者 .with().update() 实现。换句话说,count.get() 等同于 count.with(|n| n.clone())count.read().clone(),而 count.set(1) 的实现可以通过 count.update(|n| *n = 1)*count.write() = 1 实现。

当然,.get().set() 的语法更加简洁。

不过,其他方法也有一些非常好的使用场景。

例如,考虑一个保存了 Vec<String> 的信号。

let (names, set_names) = signal(Vec::new());
if names.get().is_empty() {
	set_names(vec!["Alice".to_string()]);
}

从逻辑上看,这段代码很简单,但它隐藏了一些显著的效率问题。请记住,names.get().is_empty() 会克隆整个值。这意味着我们克隆了整个 Vec<String>,运行了 is_empty() 方法,然后立即丢弃了克隆的副本。

同样,set_names 用一个全新的 Vec<_> 替换了原有值。这种做法是可以的,但其实我们完全可以直接就地修改原有的 Vec<_>

let (names, set_names) = signal(Vec::new());
if names.read().is_empty() {
	set_names.write().push("Alice".to_string());
}

现在,我们的函数通过引用访问 names 来运行 is_empty(),避免了克隆操作,并且直接对原有的 Vec<_> 进行修改。

线程安全和线程局部值

你可能已经注意到,无论是通过阅读文档还是通过自己实验应用程序,存储在信号中的值必须是 Send + Sync 的。这是因为反应式系统实际上支持多线程:信号可以跨线程传递,整个反应式图可以在多个线程上工作。(这在使用像 Axum 这样的服务器框架进行 服务器端渲染 时特别有用,这些框架使用 Tokio 的多线程执行器。)在大多数情况下,这对你所做的没有影响:普通的 Rust 数据类型默认是 Send + Sync 的。

然而,浏览器环境是单线程的,除非你使用 Web Worker,并且 wasm-bindgenweb-sys 提供的 JavaScript 类型都明确是 !Send。这意味着它们不能存储在普通的信号中。

因此,我们为每种信号原语提供了“局部”替代方案,可以用于存储 !Send 数据。你应该仅在需要存储 !Send 浏览器类型到信号中时,才使用这些替代方案。

Nightly 语法

在使用 nightly 特性和 nightly 语法时,将 ReadSignal 作为函数调用是 .get() 的语法糖。将 WriteSignal 作为函数调用是 .set() 的语法糖。因此:

let (count, set_count) = signal(0);
set_count(1);
logging::log!(count());

等同于:

let (count, set_count) = signal(0);
set_count.set(1);
logging::log!(count.get());

这不仅仅是语法糖,而是通过将信号语义上与函数统一,使 API 更加一致:详情请参阅 插曲:函数

让信号相互依赖

经常有人会问,如果一个信号需要根据另一个信号的值进行变化,该如何实现?对此,有三种不错的方法,以及一种虽然不太理想但在特定情况下也可以接受的方法。

推荐的选项

1) B 是 A 的函数。 为 A 创建一个信号,为 B 创建一个派生信号或 Memo。

// A
let (count, set_count) = signal(1);
// B 是 A 的函数
let derived_signal_double_count = move || count.get() * 2;
// B 是 A 的函数
let memoized_double_count = Memo::new(move |_| count.get() * 2);

关于是选择派生信号还是 Memo 的建议,请参阅 Memo 的文档。

2) C 是 A 和其他事物 B 的函数。 为 A 和 B 创建信号,为 C 创建一个派生信号或 Memo。

// A
let (first_name, set_first_name) = signal("Bridget".to_string());
// B
let (last_name, set_last_name) = signal("Jones".to_string());
// C 是 A 和 B 的函数
let full_name = move || format!("{} {}", &*first_name.read(), &*last_name.read());

3) A 和 B 是独立的信号,但有时会同时更新。 在调用更新 A 时,单独调用更新 B。

// A
let (age, set_age) = signal(32);
// B
let (favorite_number, set_favorite_number) = signal(42);
// 用于处理“清空”按钮的点击事件
let clear_handler = move |_| {
  // 同时更新 A 和 B
  set_age.set(0);
  set_favorite_number.set(0);
};

如果你真的必须这样做……

4) 创建一个 Effect,当 A 更改时写入 B。 这种方法不被推荐,原因如下: a) 它的效率总是较低,因为每次 A 更新时都会进行两次完整的响应式流程(更新 A 会触发 effect 的运行,以及任何依赖 A 的其他 effect 的运行;然后更新 B,会触发任何依赖 B 的 effect 的运行)。
b) 它增加了意外创建无限循环或过度重新运行 effect 的风险。这种“乒乓式”响应式意大利面条代码在 2010 年代初很常见,但我们通过读取-写入分离等机制试图避免这些问题,并不推荐从 effect 中写入信号。

在大多数情况下,最好通过派生信号或 Memo 的方式,按照清晰的自上而下的数据流重新设计。但即使不这样,也不是不可接受的。

我这里特意没有提供示例。阅读 Effect 文档,了解具体如何实现这种方式。

用 Effects 响应变化

我们已经走到了这一步,却还没有提到反应式系统的一半内容:Effects(效果)

反应性由两部分组成:更新单个反应性值(“信号”)会通知依赖于它们的代码片段(“效果”)需要重新运行。这两部分是相互依赖的。没有效果,信号可以在反应式系统中发生变化,但无法被外界观察到,也无法与外界交互。没有信号,效果只会运行一次,之后再也不会运行,因为没有可观察的值可以订阅。效果实际上是反应式系统的“副作用”:它们的存在是为了将反应式系统与外部的非反应式世界同步。

渲染器使用效果来根据信号的变化更新 DOM 的某些部分。你也可以创建自己的效果,以其他方式将反应式系统与外界同步。

Effect::new 接受一个函数作为参数。它会在反应式系统的下一个“tick”中运行该函数。(例如,如果在组件中使用它,它会在组件渲染后立即运行。)如果你在这个函数中访问了任何反应性信号,效果会记录下该效果依赖于这些信号的事实。只要效果依赖的信号之一发生变化,效果就会再次运行。

let (a, set_a) = signal(0);
let (b, set_b) = signal(0);

Effect::new(move |_| {
  // 立即打印 "Value: 0",并订阅 `a`
  logging::log!("Value: {}", a.get());
});

效果函数会接收一个参数,参数包含效果上次运行时返回的值。在初次运行时,这个值为 None

默认情况下,效果不会在服务器端运行。这意味着你可以在效果函数中调用浏览器特定的 API 而不会引发问题。如果需要效果在服务器端运行,可以使用 Effect::new_isomorphic

自动追踪和动态依赖

如果你熟悉像 React 这样的框架,你可能会注意到一个关键的区别。React 和类似框架通常需要你提供一个“依赖数组”,明确指定哪些变量决定效果何时重新运行。

由于 Leptos 源自同步反应式编程的传统,我们不需要这个显式的依赖列表。相反,我们会根据效果中访问的信号自动追踪依赖。

这种方式有两个效果(不是双关):

  1. 自动化:你无需维护一个依赖列表,也不用担心哪些应该或不应该被包括。框架会自动追踪哪些信号可能导致效果重新运行,并处理这些依赖。
  2. 动态化:依赖列表会在每次效果运行时清除并更新。如果效果包含条件语句(例如),只有当前分支中使用的信号会被追踪。这意味着效果只会以绝对最小的次数重新运行。

如果这听起来像是魔法,并且你想深入了解自动依赖追踪的原理,可以观看这个视频。(音量较低,请见谅!)

Effects 作为接近零成本的抽象

从技术上讲,效果并非完全“零成本抽象”——它们需要一些额外的内存,并在运行时存在等。然而,从更高层次的视角来看,对于你在其中进行的任何昂贵的 API 调用或其他操作,效果可以视为零成本抽象。它们只会以必要的最小次数重新运行。

假设我正在创建某种聊天软件,我希望用户可以显示全名或仅显示名字,并在名字更改时通知服务器:

let (first, set_first) = signal(String::new());
let (last, set_last) = signal(String::new());
let (use_last, set_use_last) = signal(true);

// 每当一个源信号发生变化,这段代码会将名字记录到日志中
Effect::new(move |_| {
    logging::log!(
        "{}", if use_last.get() {
            format!("{} {}", first.get(), last.get())
        } else {
            first.get()
        },
    )
});

如果 use_lasttrue,效果会在 firstlastuse_last 发生变化时重新运行。但如果我将 use_last 切换为 falselast 的变化将不会触发全名的变化。实际上,last 会从依赖列表中移除,直到 use_last 再次切换为 true。这避免了在 use_lastfalse 时多次更改 last 导致的多余 API 请求。

是否创建效果?

效果的目的是将反应式系统与外部的非反应式世界同步,而不是在不同的反应式值之间同步。换句话说:使用效果从一个信号读取值并将其设置到另一个信号中总是次优的。

如果需要定义一个依赖于其他信号值的信号,可以使用派生信号或 Memo。在效果中写入信号不会引发灾难(例如,电脑不会着火),但派生信号或 memo 总是更好的选择——不仅因为数据流清晰,而且性能更好。

let (a, set_a) = signal(0);

// ⚠️ 不太好
let (b, set_b) = signal(0);
Effect::new(move |_| {
    set_b.set(a.get() * 2);
});

// ✅ 更优选择!
let b = move || a.get() * 2;

如果需要将某个反应式值与外部的非反应式世界(例如 Web API、控制台、文件系统或 DOM)同步,在效果中写入信号是可以接受的。然而在很多情况下,你实际上是在事件监听器或其他地方写入信号,而不是在效果中。在这些情况下,可以查看 leptos-use 是否已经提供了一个反应式封装原语来完成这一任务!

如果你想进一步了解何时应该或不应该使用 create_effect可以观看这个视频,以获得更深入的理解!

Effects 与渲染

我们已经讨论了这么多,却几乎没有提到效果,因为它们被内置到了 Leptos 的 DOM 渲染器中。我们已经看到,你可以创建一个信号并将其传递到 view! 宏中,信号变化时相关的 DOM 节点会更新:

let (count, set_count) = signal(0);

view! {
    <p>{count}</p>
}

这之所以有效,是因为框架实际上为这个更新创建了一个效果。你可以想象 Leptos 将这个视图翻译成如下代码:

let (count, set_count) = signal(0);

// 创建一个 DOM 元素
let document = leptos::document();
let p = document.create_element("p").unwrap();

// 创建一个效果以反应式更新文本
Effect::new(move |prev_value| {
    // 首先,访问信号的值并将其转换为字符串
    let text = count.get().to_string();

    // 如果与之前的值不同,则更新节点
    if prev_value != Some(text) {
        p.set_text_content(&text);
    }

    // 返回此值,以便对下一次更新进行记忆
    text
});

每次 count 更新时,这个效果都会重新运行。这是实现对 DOM 进行细粒度更新的关键。

使用 Effect::watch() 进行显式追踪

除了 Effect::new(),Leptos 还提供了 Effect::watch() 方法,可以通过显式传递值集来分离追踪和响应变化。

watch 有三个参数。其中deps参数是反应式追踪的,然而callbackimmediate不是。每当 deps 参数中的反应式值发生变化时,callback 就会运行。如果immediate的值是false,callback只有在检测到任何在deps中访问的信号发生第一次变化后才会运行。watch 返回一个 Effect,可以通过 .stop() 停止追踪依赖。

let (num, set_num) = signal(0);

let effect = Effect::watch(
    move || num.get(),
    move |num, prev_num, _| {
        leptos::logging::log!("Number: {}; Prev: {:?}", num, prev_num);
    },
    false,
);

set_num.set(1); // 输出: "Number: 1; Prev: Some(0)"

effect.stop(); // 停止追踪

set_num.set(2); // (没有输出)

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::html::Input;
use leptos::prelude::*;

#[derive(Copy, Clone)]
struct LogContext(RwSignal<Vec<String>>);

#[component]
fn App() -> impl IntoView {
    // Just making a visible log here
    // You can ignore this...
    let log = RwSignal::<Vec<String>>::new(vec![]);
    let logged = move || log.get().join("\n");

    // the newtype pattern isn't *necessary* here but is a good practice
    // it avoids confusion with other possible future `RwSignal<Vec<String>>` contexts
    // and makes it easier to refer to it
    provide_context(LogContext(log));

    view! {
        <CreateAnEffect/>
        <pre>{logged}</pre>
    }
}

#[component]
fn CreateAnEffect() -> impl IntoView {
    let (first, set_first) = signal(String::new());
    let (last, set_last) = signal(String::new());
    let (use_last, set_use_last) = signal(true);

    // this will add the name to the log
    // any time one of the source signals changes
    Effect::new(move |_| {
        log(if use_last.get() {
            let first = first.read();
            let last = last.read();
            format!("{first} {last}")
        } else {
            first.get()
        })
    });

    view! {
        <h1>
            <code>"create_effect"</code>
            " Version"
        </h1>
        <form>
            <label>
                "First Name"
                <input
                    type="text"
                    name="first"
                    prop:value=first
                    on:change:target=move |ev| set_first.set(ev.target().value())
                />
            </label>
            <label>
                "Last Name"
                <input
                    type="text"
                    name="last"
                    prop:value=last
                    on:change:target=move |ev| set_last.set(ev.target().value())
                />
            </label>
            <label>
                "Show Last Name"
                <input
                    type="checkbox"
                    name="use_last"
                    prop:checked=use_last
                    on:change:target=move |ev| set_use_last.set(ev.target().checked())
                />
            </label>
        </form>
    }
}

#[component]
fn ManualVersion() -> impl IntoView {
    let first = NodeRef::<Input>::new();
    let last = NodeRef::<Input>::new();
    let use_last = NodeRef::<Input>::new();

    let mut prev_name = String::new();
    let on_change = move |_| {
        log("      listener");
        let first = first.get().unwrap();
        let last = last.get().unwrap();
        let use_last = use_last.get().unwrap();
        let this_one = if use_last.checked() {
            format!("{} {}", first.value(), last.value())
        } else {
            first.value()
        };

        if this_one != prev_name {
            log(&this_one);
            prev_name = this_one;
        }
    };

    view! {
        <h1>"Manual Version"</h1>
        <form on:change=on_change>
            <label>"First Name" <input type="text" name="first" node_ref=first/></label>
            <label>"Last Name" <input type="text" name="last" node_ref=last/></label>
            <label>
                "Show Last Name" <input type="checkbox" name="use_last" checked node_ref=use_last/>
            </label>
        </form>
    }
}

fn log(msg: impl std::fmt::Display) {
    let log = use_context::<LogContext>().unwrap().0;
    log.update(|log| log.push(msg.to_string()));
}

fn main() {
    leptos::mount::mount_to_body(App)
}

插曲:反应性与函数

我们的核心贡献者之一最近对我说:“在用 Leptos 之前,我从未如此频繁地使用闭包。”这确实是事实。闭包是任何 Leptos 应用程序的核心。有时看起来有点滑稽:

// 一个信号持有一个值,可以被更新
let (count, set_count) = signal(0);

// 一个派生信号是一个访问其他信号的函数
let double_count = move || count.get() * 2;
let count_is_odd = move || count.get() & 1 == 1;
let text = move || if count_is_odd() {
    "odd"
} else {
    "even"
};

// 一个效果会自动追踪它依赖的信号
// 并在信号发生变化时重新运行
Effect::new(move |_| {
    logging::log!("text = {}", text());
});

view! {
    <p>{move || text().to_uppercase()}</p>
}

闭包,到处都是闭包!

但为什么呢?

函数与 UI 框架

函数是每个 UI 框架的核心。这完全合情合理。创建用户界面基本上可以分为两个阶段:

  1. 初始渲染
  2. 更新

在 Web 框架中,框架会进行某种初始渲染。然后将控制权交还给浏览器。当某些事件(如鼠标点击)触发或异步任务(如 HTTP 请求完成)结束时,浏览器会唤醒框架以更新内容。框架运行某些代码来更新用户界面,然后再次休眠,直到浏览器再次唤醒它。

这里的关键短语是“运行某些代码”。在 Rust 或任何其他编程语言中,在任意时间点“运行某些代码”的自然方式是调用一个函数。实际上,每个 UI 框架都基于某种函数的反复运行:

  1. 虚拟 DOM(VDOM)框架(如 React、Yew 或 Dioxus)反复运行组件或渲染函数,以生成一个虚拟 DOM 树,该树可以与之前的结果对比并更新 DOM。
  2. 编译型框架(如 Angular 和 Svelte)将组件模板划分为“创建”和“更新”函数,并在检测到组件状态发生变化时运行更新函数。
  3. 细粒度反应式框架(如 SolidJS、Sycamore 或 Leptos),由开发者自己定义重新运行的函数。

这就是我们所有组件在做的事情。

以下是我们常见的 <SimpleCounter/> 示例的最简形式:

#[component]
pub fn SimpleCounter() -> impl IntoView {
    let (value, set_value) = signal(0);

    let increment = move |_| *set_value.write() += 1;

    view! {
        <button on:click=increment>
            {value}
        </button>
    }
}

SimpleCounter 函数本身只运行一次。value 信号只创建一次。框架将 increment 函数作为事件监听器交给浏览器。当你点击按钮时,浏览器调用 increment,通过 set_value 更新 value,从而更新视图中由 {value} 表示的单个文本节点。

函数是反应性的关键。它们为框架提供了在响应变化时重新运行应用程序最小单位的能力。

所以记住两点:

  1. 你的组件函数是一个初始化函数,而不是渲染函数:它只运行一次。
  2. 对于模板中的值要具有反应性,它们必须是反应性函数:要么是信号,要么是捕获并读取信号的闭包。

Note

这实际上是 Leptos 稳定版和夜间版的主要区别之一。如你所知,使用夜间编译器和 nightly 功能可以直接调用信号作为函数:比如 value() 替代 value.get()

但这不仅仅是语法糖。它允许一个极为一致的语义模型:反应性事物就是函数。信号通过调用函数访问。要表示“传递一个信号作为参数”,你可以接收任何实现 impl Fn() -> T 的东西。而这种基于函数的接口不会区分信号、memo 或派生信号:它们都可以通过函数调用访问。

不幸的是,在自定义结构(如信号)上实现 Fn 特性需要夜间版 Rust,尽管这个功能已经搁置很久且可能短期内不会稳定。由于种种原因,许多人会避免使用夜间版。因此,随着时间推移,我们的文档等默认内容都更倾向于稳定版。然而,这让“信号是函数”这个简单的心智模型稍显复杂。

测试你的组件

测试用户界面可能相对棘手,但非常重要。这篇文章将讨论一些测试 Leptos 应用程序的原则和方法。

1. 使用普通的 Rust 测试业务逻辑

在很多情况下,将逻辑从组件中抽离出来并单独测试是明智的。对于一些简单的组件,没有什么特殊的逻辑需要测试,但对于很多组件,值得使用一个可测试的封装类型,并在普通的 Rust impl 块中实现逻辑。

例如,不要直接在组件中嵌入逻辑,如下所示:

#[component]
pub fn TodoApp() -> impl IntoView {
    let (todos, set_todos) = signal(vec![Todo { /* ... */ }]);
    // ⚠️ 这是难以测试的,因为它嵌入在组件中
    let num_remaining = move || todos.read().iter().filter(|todo| !todo.completed).sum();
}

可以将逻辑抽离到一个单独的数据结构中并测试它:

pub struct Todos(Vec<Todo>);

impl Todos {
    pub fn num_remaining(&self) -> usize {
        self.0.iter().filter(|todo| !todo.completed).sum()
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_remaining() {
        // ...
    }
}

#[component]
pub fn TodoApp() -> impl IntoView {
    let (todos, set_todos) = signal(Todos(vec![Todo { /* ... */ }]));
    // ✅ 这段逻辑可以单独测试
    let num_remaining = move || todos.read().num_remaining();
}

一般来说,逻辑越少嵌入组件本身,你的代码就越符合惯用的 Rust 风格,也越容易测试。

2. 使用端到端测试(e2e)测试组件

我们的 examples 目录包含了多个带有端到端测试的示例,使用了不同的测试工具。

最简单的了解方式是直接查看这些测试示例本身:

使用 wasm-bindgen-test 测试 counter

这是一个相对简单的手动测试设置,使用 wasm-pack test 命令。

示例测试

#[wasm_bindgen_test]
async fn clear() {
    let document = document();
    let test_wrapper = document.create_element("section").unwrap();
    let _ = document.body().unwrap().append_child(&test_wrapper);

    // 渲染计数器并将其挂载到 DOM 上
    let _dispose = mount_to(
        test_wrapper.clone().unchecked_into(),
        || view! { <SimpleCounter initial_value=10 step=1/> },
    );

    // 从 DOM 中提取按钮
    let div = test_wrapper.query_selector("div").unwrap().unwrap();
    let clear = test_wrapper
        .query_selector("button")
        .unwrap()
        .unwrap()
        .unchecked_into::<web_sys::HtmlElement>();

    // 点击 `clear` 按钮
    clear.click();

    // 由于反应式系统基于异步系统,因此更改不会立即反映在 DOM 中
    tick().await;

    // 测试 <div> 的内容是否符合预期
    assert_eq!(div.outer_html(), {
        let (value, _set_value) = signal(0);
        view! {
            <div>
                <button>"Clear"</button>
                <button>"-1"</button>
                <span>"Value: " {value} "!"</span>
                <button>"+1"</button>
            </div>
        }
        .into_view()
        .build()
        .outer_html()
    });

    // 更简单的方法是直接测试初始值为 0 的 <SimpleCounter/>
    assert_eq!(test_wrapper.inner_html(), {
        let comparison_wrapper = document.create_element("section").unwrap();
        let _dispose = mount_to(
            comparison_wrapper.clone().unchecked_into(),
            || view! { <SimpleCounter initial_value=0 step=1/>},
        );
        comparison_wrapper.inner_html()
    });
}

使用 Playwright 测试 counters

这些测试使用了常见的 JavaScript 测试工具 Playwright,对相同示例进行端到端测试,采用了许多前端开发者熟悉的库和测试方法。

示例测试

test.describe("Increment Count", () => {
  test("should increase the total count", async ({ page }) => {
    const ui = new CountersPage(page);
    await ui.goto();
    await ui.addCounter();

    await ui.incrementCount();
    await ui.incrementCount();
    await ui.incrementCount();

    await expect(ui.total).toHaveText("3");
  });
});

使用 Gherkin/Cucumber 测试 todo_app_sqlite

你可以将任何测试工具集成到这个流程中。本示例使用 Cucumber,一种基于自然语言的测试框架。

@add_todo
Feature: Add Todo

    Background:
        Given I see the app

    @add_todo-see
    Scenario: Should see the todo
        Given I set the todo as Buy Bread
        When I click the Add button
        Then I see the todo named Buy Bread

    # @allow.skipped
    @add_todo-style
    Scenario: Should see the pending todo
        When I add a todo as Buy Oranges
        Then I see the pending todo

这些操作的定义在 Rust 代码中实现:

use crate::fixtures::{action, world::AppWorld};
use anyhow::{Ok, Result};
use cucumber::{given, when};

#[given("I see the app")]
#[when("I open the app")]
async fn i_open_the_app(world: &mut AppWorld) -> Result<()> {
    let client = &world.client;
    action::goto_path(client, "").await?;

    Ok(())
}

#[given(regex = "^I add a todo as (.*)$")]
#[when(regex = "^I add a todo as (.*)$")]
async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> {
    let client = &world.client;
    action::add_todo(client, text.as_str()).await?;

    Ok(())
}

// 等等

了解更多

可以查看 Leptos 仓库中的 CI 设置,了解如何在自己的应用中使用这些工具。这些测试方法会定期针对 Leptos 示例应用运行。

使用 async

到目前为止,我们一直在处理同步用户界面:你提供一些输入,应用程序立即处理并更新界面。这很棒,但只是 Web 应用程序功能中的很小一部分。尤其是,大多数 Web 应用程序需要处理某种异步数据加载,通常是从 API 加载数据。

将异步数据与代码中的同步部分集成是出了名的困难,因为它涉及“函数着色”问题。

在接下来的章节中,我们将看到一些用于处理异步数据的反应式原语。但首先需要注意:如果你只是想进行一些异步操作,Leptos 提供了一个跨平台的 spawn_local 函数,它可以轻松运行一个 Future。如果接下来的原语中没有满足需求的,可以考虑将 spawn_local 与信号结合使用。

尽管接下来的原语非常有用,甚至在某些情况下是必需的,但人们有时确实只需要启动一个任务并在完成后再执行其他操作。在这种情况下,请使用 spawn_local

使用 Resources 加载数据

Resources 是用于异步任务的反应式封装,可以将异步的 Future 集成到同步的反应式系统中。它允许你加载异步数据,并以同步或异步的方式访问这些数据。你可以像普通的 Future 一样对资源使用 .await,这会对其进行追踪。你也可以使用 .get() 或其他信号访问方法访问资源,就像资源是一个返回 Some(T)(表示已完成)或 None(表示仍在等待)的信号一样。

Resources 主要有两种类型:ResourceLocalResource。如果你使用服务器端渲染(SSR,本书稍后会讨论),应默认使用 Resource。如果你使用的是仅客户端渲染(CSR)并依赖 !Send API(例如许多浏览器 API),或者尽管使用 SSR 但有些异步任务只能在浏览器上完成(例如访问异步浏览器 API),应使用 LocalResource

本地资源(Local Resources)

LocalResource::new() 接受一个参数:一个返回 Future 的“fetcher”函数。

Future 可以是一个 async 块、async fn 调用的结果,或任何其他 Rust Future。其行为类似于派生信号或其他我们已见过的反应式闭包:你可以在其中读取信号,并且每当信号发生变化时,该函数都会重新运行,创建一个新的 Future 来执行。

// `count` 是我们的同步本地状态
let (count, set_count) = signal(0);

// 追踪 `count`,每当 `count` 变化时调用 `load_data`
let async_data = LocalResource::new(move || load_data(count.get()));

创建资源时会立即调用其 fetcher 并开始轮询 Future。在异步任务完成之前,读取资源会返回 None;任务完成后会通知其订阅者,返回 Some(value)

你还可以对资源使用 .await。这看起来似乎没什么意义——为什么要创建一个 Future 的封装,然后再对它 .await?在接下来的章节中我们会看到原因。

资源(Resources)

如果你使用 SSR,大多数情况下应使用 Resource 而非 LocalResource

此 API 略有不同。Resource::new() 接受两个函数作为参数:

  1. 源函数:包含输入内容。该输入会被 memoized(记忆化),每当其值变化时,会调用 fetcher。
  2. fetcher 函数:从源函数中获取数据并返回一个 Future

LocalResource 不同,Resource 会将其值从服务器序列化到客户端。随后,在客户端首次加载页面时,初始值会被反序列化,而不是重新运行异步任务。这非常重要且有用:这意味着在客户端 WASM 包加载并开始运行应用程序之前,数据加载已经在服务器上开始。(稍后章节会对此进行更多讨论。)

这也是 API 分为两部分的原因:源函数中的信号会被追踪,而 fetcher 中的信号不会被追踪,因为这允许资源在保持反应性的同时,在客户端首次加载时不需要重新运行 fetcher。

以下是使用 Resource 替代 LocalResource 的相同示例:

// `count` 是我们的同步本地状态
let (count, set_count) = signal(0);

// 我们的资源
let async_data = Resource::new(
    move || count.get(),
    // 每次 `count` 变化时运行此函数
    |count| load_data(count) 
);

Resources 还提供了一个 refetch() 方法,允许你手动重新加载数据(例如响应按钮点击)。

如果只需要运行一次的资源,可以使用 OnceResource,它接受一个 Future 并对只加载一次的情况进行了优化。

let once = OnceResource::new(load_data(42));

访问 Resources

LocalResourceResource 都实现了多种信号访问方法(.read().with().get()),但返回的是 Option<T> 而不是 T;在异步数据加载完成之前,它们会返回 None

LocalResource 实际上会返回一个 Option<SendWrapper<T>>,这是出于线程安全的要求;可以使用 .as_deref() 来访问内部类型(见下例)。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;

// Here we define an async function
// This could be anything: a network request, database read, etc.
// Here, we just multiply a number by 10
async fn load_data(value: i32) -> i32 {
    // fake a one-second delay
    TimeoutFuture::new(1_000).await;
    value * 10
}

#[component]
pub fn App() -> impl IntoView {
    // this count is our synchronous, local state
    let (count, set_count) = signal(0);

    // tracks `count`, and reloads by calling `load_data`
    // whenever it changes
    let async_data = LocalResource::new(move || load_data(count.get()));

    // a resource will only load once if it doesn't read any reactive data
    let stable = LocalResource::new(|| load_data(1));

    // we can access the resource values with .get()
    // this will reactively return None before the Future has resolved
    // and update to Some(T) when it has resolved
    let async_result = move || {
        async_data
            .get()
            .as_deref()
            .map(|value| format!("Server returned {value:?}"))
            // This loading state will only show before the first load
            .unwrap_or_else(|| "Loading...".into())
    };

    view! {
        <button
            on:click=move |_| *set_count.write() += 1
        >
            "Click me"
        </button>
        <p>
            <code>"stable"</code>": " {move || stable.get().as_deref().copied()}
        </p>
        <p>
            <code>"count"</code>": " {count}
        </p>
        <p>
            <code>"async_value"</code>": "
            {async_result}
            <br/>
        </p>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

<Suspense/>

在上一章中,我们展示了如何创建一个简单的加载屏幕,在资源加载时显示一个备用内容:

let (count, set_count) = signal(0);
let once = Resource::new(move || count.get(), |count| async move { load_a(count).await });

view! {
    <h1>"My Data"</h1>
    {move || match once.get() {
        None => view! { <p>"Loading..."</p> }.into_view(),
        Some(data) => view! { <ShowData data/> }.into_view()
    }}
}

但是,如果我们有两个资源,想要等它们都加载完毕怎么办?

let (count, set_count) = signal(0);
let (count2, set_count2) = signal(0);
let a = Resource::new(move || count.get(), |count| async move { load_a(count).await });
let b = Resource::new(move || count2.get(), |count| async move { load_b(count).await });

view! {
    <h1>"My Data"</h1>
    {move || match (a.get(), b.get()) {
        (Some(a), Some(b)) => view! {
            <ShowA a/>
            <ShowA b/>
        }.into_view(),
        _ => view! { <p>"Loading..."</p> }.into_view()
    }}
}

这虽然不是特别糟糕,但有些麻烦。如果我们能够反转控制流会怎么样呢?

<Suspense/> 组件正是为此而设计的。你可以给它一个 fallback 属性和子节点,子节点中通常会读取某些资源。在 <Suspense/> 内读取资源会自动将该资源注册到 <Suspense/> 中。如果资源仍在加载中,它会显示 fallback,当所有资源加载完成后,它会显示子节点。

let (count, set_count) = signal(0);
let (count2, set_count2) = signal(0);
let a = Resource::new(count, |count| async move { load_a(count).await });
let b = Resource::new(count2, |count| async move { load_b(count).await });

view! {
    <h1>"My Data"</h1>
    <Suspense
        fallback=move || view! { <p>"Loading..."</p> }
    >
        <h2>"My Data"</h2>
        <h3>"A"</h3>
        {move || {
            a.get()
                .map(|a| view! { <ShowA a/> })
        }}
        <h3>"B"</h3>
        {move || {
            b.get()
                .map(|b| view! { <ShowB b/> })
        }}
    </Suspense>
}

每次其中一个资源重新加载时,"Loading..." 的备用内容会再次显示。

这种反转的控制流使得添加或移除单个资源更加简单,因为你不需要自己手动匹配。同时,它在服务器端渲染中也带来了巨大的性能提升(稍后章节会详细讨论)。

使用 <Suspense/> 还提供了一种直接对资源 .await 的便捷方式,这可以减少嵌套的复杂性。Suspend 类型允许我们创建一个可渲染的 Future,并将其用于视图中:

view! {
    <h1>"My Data"</h1>
    <Suspense
        fallback=move || view! { <p>"Loading..."</p> }
    >
        <h2>"My Data"</h2>
        {move || Suspend::new(async move {
            let a = a.await;
            let b = b.await;
            view! {
                <h3>"A"</h3>
                <ShowA a/>
                <h3>"B"</h3>
                <ShowB b/>
            }
        })}
    </Suspense>
}

Suspend 让我们不必对每个资源进行空值检查,同时简化了代码。

<Await/>

如果你只是想等待某个 Future 解析后再渲染,可以使用 <Await/> 组件来减少样板代码。<Await/> 实际上是一个 OnceResource 和不带备用内容的 <Suspense/> 的组合。

换句话说:

  1. 它只会轮询 Future 一次,不响应任何反应式变化。
  2. Future 解析之前不会渲染任何内容。
  3. Future 解析后,会将数据绑定到你选择的变量名,并在该变量范围内渲染其子节点。
async fn fetch_monkeys(monkey: i32) -> i32 {
    // 可能不需要异步,但作为示例
    monkey * 2
}
view! {
    <Await
        // `future` 提供要解析的 `Future`
        future=fetch_monkeys(3)
        // 数据会绑定到你提供的变量名
        let:data
    >
        // 你可以在这里通过引用使用该数据
        <p>{*data} " little monkeys, jumping on the bed."</p>
    </Await>
}

Live example

Click to open CodeSandbox.

CodeSandbox Source
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;

async fn important_api_call(name: String) -> String {
    TimeoutFuture::new(1_000).await;
    name.to_ascii_uppercase()
}

#[component]
pub fn App() -> impl IntoView {
    let (name, set_name) = signal("Bill".to_string());

    // this will reload every time `name` changes
    let async_data = LocalResource::new(move || important_api_call(name.get()));

    view! {
        <input
            on:change:target=move |ev| {
                set_name.set(ev.target().value());
            }
            prop:value=name
        />
        <p><code>"name:"</code> {name}</p>
        <Suspense
            // the fallback will show whenever a resource
            // read "under" the suspense is loading
            fallback=move || view! { <p>"Loading..."</p> }
        >
            // Suspend allows you use to an async block in the view
            <p>
                "Your shouting name is "
                {move || Suspend::new(async move {
                    async_data.await
                })}
            </p>
        </Suspense>
        <Suspense
            // the fallback will show whenever a resource
            // read "under" the suspense is loading
            fallback=move || view! { <p>"Loading..."</p> }
        >
            // the children will be rendered once initially,
            // and then whenever any resources has been resolved
            <p>
                "Which should be the same as... "
                {move || async_data.get().as_deref().map(ToString::to_string)}
            </p>
        </Suspense>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

<Transition/>

<Suspense/> 的示例中,如果你不断重新加载数据,界面会反复闪回到 "Loading..."。有时这没问题,但在某些情况下,可以使用 <Transition/>

<Transition/> 的行为与 <Suspense/> 完全相同,但不同之处在于,它只在第一次加载时显示备用内容。在后续加载时,它会继续显示旧数据,直到新数据加载完成。这对于避免闪烁效果并允许用户继续与应用程序交互非常有用。

以下示例展示了如何使用 <Transition/> 创建一个简单的选项卡式联系人列表。当你选择一个新选项卡时,界面会继续显示当前联系人,直到新数据加载完成。这比反复显示加载消息提供了更好的用户体验。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;

async fn important_api_call(id: usize) -> String {
    TimeoutFuture::new(1_000).await;
    match id {
        0 => "Alice",
        1 => "Bob",
        2 => "Carol",
        _ => "User not found",
    }
    .to_string()
}

#[component]
fn App() -> impl IntoView {
    let (tab, set_tab) = signal(0);
    let (pending, set_pending) = signal(false);

    // this will reload every time `tab` changes
    let user_data = LocalResource::new(move || important_api_call(tab.get()));

    view! {
        <div class="buttons">
            <button
                on:click=move |_| set_tab.set(0)
                class:selected=move || tab.get() == 0
            >
                "Tab A"
            </button>
            <button
                on:click=move |_| set_tab.set(1)
                class:selected=move || tab.get() == 1
            >
                "Tab B"
            </button>
            <button
                on:click=move |_| set_tab.set(2)
                class:selected=move || tab.get() == 2
            >
                "Tab C"
            </button>
        </div>
        <p>
            {move || if pending.get() {
                "Hang on..."
            } else {
                "Ready."
            }}
        </p>
        <Transition
            // the fallback will show initially
            // on subsequent reloads, the current child will
            // continue showing
            fallback=move || view! { <p>"Loading initial data..."</p> }
            // this will be set to `true` whenever the transition is ongoing
            set_pending
        >
            <p>
                {move || user_data.read().as_deref().map(ToString::to_string)}
            </p>
        </Transition>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

使用 Actions 修改数据

我们已经讨论了如何使用资源(resources)加载 async 数据。资源会立即加载数据,并与 <Suspense/><Transition/> 组件紧密配合,显示应用程序中的数据加载状态。但如果你只想调用一些任意的 async 函数并跟踪它的状态,该怎么办?

当然,你可以使用 spawn_local。它允许你在同步环境中启动一个 async 任务,将 Future 提交给浏览器(或服务器上的 Tokio 或其他运行时)。但是,你怎么知道任务是否仍在进行中呢?你可以设置一个信号来显示加载状态,再设置另一个信号来存储结果……

这些当然可以做到。但你也可以使用最后一个异步原语:Action

Actions 和资源看起来相似,但它们本质上是不同的。如果你试图通过运行 async 函数加载数据(无论是一次性还是随着某个值的变化),你可能需要用资源。如果你试图响应用户点击按钮等事件偶尔运行 async 函数,那么你可能需要用 Action。

假设我们有一个 async 函数需要运行:

async fn add_todo_request(new_title: &str) -> Uuid {
    /* 在服务器上添加一个新的 todo */
}

Action::new() 接受一个 async 函数作为参数,该函数需要一个引用作为输入(“输入类型”)。

输入总是一个单一类型。如果需要传递多个参数,可以使用结构体或元组。

// 单一参数
let action1 = Action::new(|input: &String| {
   let input = input.clone();
   async move { todo!() }
});

// 无参数
let action2 = Action::new(|input: &()| async { todo!() });

// 多个参数
let action3 = Action::new(
  |input: &(usize, String)| async { todo!() }
);

因为 Action 函数接受引用,但 Future 需要 'static 生命周期,所以通常需要克隆值以传递给 Future。虽然这有些麻烦,但它解锁了一些强大的功能,比如乐观 UI。我们将在后续章节中详细介绍。

在这个例子中,我们可以这样创建一个 Action:

let add_todo_action = Action::new(|input: &String| {
    let input = input.to_owned();
    async move { add_todo_request(&input).await }
});

与直接调用 add_todo_action 不同,我们会使用 .dispatch() 调用它,例如:

add_todo_action.dispatch("Some value".to_string());

你可以在事件监听器、定时器或任何地方调用它;因为 .dispatch() 不是一个异步函数,所以可以在同步上下文中调用。

Actions 提供了一些信号,可以在调用异步操作和同步反应式系统之间进行同步:

let submitted = add_todo_action.input(); // RwSignal<Option<String>>
let pending = add_todo_action.pending(); // ReadSignal<bool>
let todo_id = add_todo_action.value(); // RwSignal<Option<Uuid>>

这些信号让你可以轻松跟踪请求的当前状态、显示加载指示器或基于提交成功的假设实现“乐观 UI”。

let input_ref = NodeRef::<Input>::new();

view! {
    <form
        on:submit=move |ev| {
            ev.prevent_default(); // 阻止页面刷新
            let input = input_ref.get().expect("input to exist");
            add_todo_action.dispatch(input.value());
        }
    >
        <label>
            "What do you need to do?"
            <input type="text"
                node_ref=input_ref
            />
        </label>
        <button type="submit">"Add Todo"</button>
    </form>
    // 显示加载状态
    <p>{move || pending.get().then_some("Loading...")}</p>
}

也许你觉得这一切有些复杂,或者过于受限。我在这里介绍 Actions,与资源一起补全了反应式系统的功能拼图。在一个真正的 Leptos 应用中,你会经常将 Actions 与服务器函数 ServerAction 以及 <ActionForm/> 组件结合使用,从而创建功能强大的渐进增强表单。如果你现在觉得这个原语没什么用……不用担心!以后你可能会理解它的价值。(或者现在就查看我们的 todo_app_sqlite 示例。)

Live example

Click to open CodeSandbox.

CodeSandbox Source
use gloo_timers::future::TimeoutFuture;
use leptos::{html::Input, prelude::*};
use uuid::Uuid;

// Here we define an async function
// This could be anything: a network request, database read, etc.
// Think of it as a mutation: some imperative async action you run,
// whereas a resource would be some async data you load
async fn add_todo(text: &str) -> Uuid {
    _ = text;
    // fake a one-second delay
    // SendWrapper allows us to use this !Send browser API; don't worry about it
    send_wrapper::SendWrapper::new(TimeoutFuture::new(1_000)).await;
    // pretend this is a post ID or something
    Uuid::new_v4()
}

#[component]
pub fn App() -> impl IntoView {
    // an action takes an async function with single argument
    // it can be a simple type, a struct, or ()
    let add_todo = Action::new(|input: &String| {
        // the input is a reference, but we need the Future to own it
        // this is important: we need to clone and move into the Future
        // so it has a 'static lifetime
        let input = input.to_owned();
        async move { add_todo(&input).await }
    });

    // actions provide a bunch of synchronous, reactive variables
    // that tell us different things about the state of the action
    let submitted = add_todo.input();
    let pending = add_todo.pending();
    let todo_id = add_todo.value();

    let input_ref = NodeRef::<Input>::new();

    view! {
        <form
            on:submit=move |ev| {
                ev.prevent_default(); // don't reload the page...
                let input = input_ref.get().expect("input to exist");
                add_todo.dispatch(input.value());
            }
        >
            <label>
                "What do you need to do?"
                <input type="text"
                    node_ref=input_ref
                />
            </label>
            <button type="submit">"Add Todo"</button>
        </form>
        <p>{move || pending.get().then_some("Loading...")}</p>
        <p>
            "Submitted: "
            <code>{move || format!("{:#?}", submitted.get())}</code>
        </p>
        <p>
            "Pending: "
            <code>{move || format!("{:#?}", pending.get())}</code>
        </p>
        <p>
            "Todo ID: "
            <code>{move || format!("{:#?}", todo_id.get())}</code>
        </p>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

子节点投影(Projecting Children)

在构建组件时,你可能会遇到需要通过多层组件“投影”子节点的情况。

问题

考虑以下代码:

pub fn NestedShow<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
    F: Fn() -> IV + Send + Sync + 'static,
    IV: IntoView + 'static,
{
    view! {
        <Show
            when=|| todo!()
            fallback=|| ()
        >
            <Show
                when=|| todo!()
                fallback=fallback
            >
                {children()}
            </Show>
        </Show>
    }
}

这段代码很直观:如果内部条件为 true,我们希望显示 children;如果不是,则显示 fallback。如果外部条件为 false,我们渲染 ()(即什么都不显示)。

换句话说,我们希望将 <NestedShow/> 的子节点通过外部的 <Show/> 组件传递,成为内部 <Show/> 的子节点。这就是所谓的“投影”。

但是,这段代码无法编译。

error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnOnce`

每个 <Show/> 需要多次构造其 children。第一次构造外部 <Show/> 的子节点时,它会移动 fallbackchildren 到内部 <Show/> 的调用中,但之后这些值无法用于构造未来的外部 <Show/> 子节点。

细节

如果你只想知道解决方案,可以直接跳到下一节。

为了真正理解这里的问题,可以查看 view 宏的展开形式。以下是简化版:

Show(
    ShowProps::builder()
        .when(|| todo!())
        .fallback(|| ())
        .children({
            // 这里将 `children` 和 `fallback` 移动到闭包中
            ::leptos::children::ToChildren::to_children(move || {
                Show(
                    ShowProps::builder()
                        .when(|| todo!())
                        // 这里消费了 `fallback`
                        .fallback(fallback)
                        .children({
                            // 这里捕获了 `children`
                            ::leptos::children::ToChildren::to_children(
                                move || children(),
                            )
                        })
                        .build(),
                )
            })
        })
        .build(),
)

所有组件都拥有它们的 props;因此在这种情况下 <Show/> 无法调用,因为它仅捕获了 fallbackchildren 的引用。

解决方案

<Suspense/><Show/> 都接受 ChildrenFn,即它们的 children 应该实现 Fn 类型,这样它们可以通过不可变引用多次调用。这意味着我们不需要拥有 childrenfallback 的所有权,只需要能够传递 'static 引用即可。

我们可以通过使用 StoredValue 原语解决这个问题。StoredValue 本质上是将值存储在反应式系统中,将所有权交给框架,并提供类似信号的 Copy'static 引用,供我们通过特定方法访问或修改。

以下是改进后的代码:

pub fn NestedShow<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
    F: Fn() -> IV + Send + Sync + 'static,
    IV: IntoView + 'static,
{
    let fallback = StoredValue::new(fallback);
    let children = StoredValue::new(children);

    view! {
        <Show
            when=|| todo!()
            fallback=|| ()
        >
            <Show
                when=move || todo!()
                fallback=move || fallback.read_value()()
            >
                {children.read_value()()}
            </Show>
        </Show>
    }
}

在顶层,我们将 fallbackchildren 存储在 NestedShow 的反应式作用域中。然后,我们可以将这些引用传递到 <Show/> 组件的其他层中,并在那里调用它们。

最后一点

这种方法之所以有效,是因为 <Show/> 只需要它们子节点的不可变引用(.read_value() 可以提供),而不需要所有权。

在某些情况下,你可能需要通过一个接收 ChildrenFn 的函数投影具有所有权的 props,并且该函数需要被多次调用。这种情况下,你可以使用 view 宏中的 clone: 帮助方法。

以下是一个示例:

#[component]
pub fn App() -> impl IntoView {
    let name = "Alice".to_string();
    view! {
        <Outer>
            <Inner>
                <Inmost name=name.clone()/>
            </Inner>
        </Outer>
    }
}

#[component]
pub fn Outer(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inner(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inmost(name: String) -> impl IntoView {
    view! {
        <p>{name}</p>
    }
}

即使使用了 name=name.clone(),也会报错:

cannot move out of `name`, a captured variable in an `Fn` closure

这是因为它通过多层子节点传递,而子节点需要多次运行,并且没有明显的方法将其克隆到子节点中。

这种情况下,clone: 语法非常有用。调用 clone:name 会在将 name 移动到 <Inner/> 的子节点之前先克隆它,从而解决所有权问题。

view! {
    <Outer>
        <Inner clone:name>
            <Inmost name=name.clone()/>
        </Inner>
    </Outer>
}

由于 view 宏的复杂性,这类问题可能会让人难以理解或调试。但总体来说,总有解决方法。

全局状态管理

到目前为止,我们只处理了组件中的局部状态,并学习了如何在父组件和子组件之间协调状态。在某些情况下,人们会寻找一种更通用的全局状态管理解决方案,可以在整个应用程序中使用。

一般来说,你并不需要本章内容。典型的模式是将应用程序分解为组件,每个组件管理自己的局部状态,而不是将所有状态存储在全局结构中。然而,在某些场景下(比如主题管理、保存用户设置或在 UI 的不同部分之间共享数据),你可能需要某种全局状态管理方法。

管理全局状态的三种最佳方法是:

  1. 使用路由器通过 URL 驱动全局状态
  2. 通过上下文传递信号
  3. 使用存储(stores)创建全局状态结构

选项 1:将 URL 作为全局状态

从某种意义上说,URL 实际上是存储全局状态的最佳方式之一。它可以从树中的任何组件访问。还有原生的 HTML 元素(例如 <form><a>)专门用于更新 URL。此外,URL 状态可以跨页面刷新和设备之间保持一致。你可以将 URL 分享给朋友,或从手机发送到笔记本电脑,URL 中存储的任何状态都将被复制。

本教程的接下来的部分会详细介绍路由器相关的主题,因此我们会深入讨论这些内容。

现在,我们只讨论选项 2 和选项 3。


选项 2:通过上下文传递信号

父子通信 部分中,我们看到可以使用 provide_context 将信号从父组件传递到子组件,并使用 use_context 在子组件中读取该信号。而 provide_context 的作用范围不受距离限制。如果你想创建一个可以在应用程序任意位置访问的全局信号,可以使用上下文提供它并通过上下文读取。

通过上下文提供的信号只会在读取的地方引发响应式更新,而不会影响中间的任何组件,因此即使跨组件传递信号,也能保持精细粒度的响应式更新能力。

我们从在应用程序根组件中创建信号开始,然后通过 provide_context 提供该信号,让其可被所有子组件和后代组件访问。

#[component]
fn App() -> impl IntoView {
    // 在根组件中创建信号,可在应用的任何地方使用
    let (count, set_count) = signal(0);
    // 我们会将 setter 传递给特定的组件,
    // 但通过上下文将 count 提供给整个应用
    provide_context(count);

    view! {
        // SetterButton 组件可以修改 count
        <SetterButton set_count/>
        // 以下组件只读该信号
        // 如果需要,也可以通过传递 `set_count` 给予写入权限
        <FancyMath/>
        <ListItems/>
    }
}

<SetterButton/> 是我们之前多次编写的计数器组件类型(请参阅下面的沙箱示例以了解更多)。

<FancyMath/><ListItems/> 都会通过 use_context 消费我们通过上下文提供的信号,并对其执行某些操作。

/// 一个使用全局计数进行“复杂”数学运算的组件
#[component]
fn FancyMath() -> impl IntoView {
    // 通过 `use_context` 消费全局信号
    let count = use_context::<ReadSignal<u32>>()
        // 我们知道刚刚在父组件中提供了这个信号
        .expect("需要有一个 `count` 信号被提供");
    let is_even = move || count.get() & 1 == 0;

    view! {
        <div class="consumer blue">
            "数字 "
            <strong>{count}</strong>
            {move || if is_even() {
                " 是"
            } else {
                " 不是"
            }}
            " 偶数。"
        </div>
    }
}

选项 3:创建全局状态存储(Store)

部分内容与关于复杂迭代中存储的章节 此处 重叠。由于这两部分都是中级/可选内容,因此一些重复不会有太大影响。

存储是 Leptos 0.7 中提供的新型响应式原语,通过伴随的 reactive_stores crate 提供。(目前该 crate 独立发布,以便可以在不影响整个框架版本的情况下继续开发。)

存储允许你包装整个结构体,并对个别字段进行响应式读取和更新,而不会跟踪其他字段的更改。

你可以通过在结构体上添加 #[derive(Store)] 来使用存储。(导入宏时需要使用 use reactive_stores::Store;。)这会为结构体创建一个扩展 trait,当结构体被包裹在 Store<_> 中时,该 trait 提供每个字段的 getter 方法。

#[derive(Clone, Debug, Default, Store)]
struct GlobalState {
    count: i32,
    name: String,
}

这会创建一个名为 GlobalStateStoreFields 的 trait,为 Store<GlobalState> 添加 countname 方法。每个方法返回一个响应式存储字段。

#[component]
fn App() -> impl IntoView {
    provide_context(Store::new(GlobalState::default()));

    // 等等
}

/// 一个更新全局状态中计数的组件
#[component]
fn GlobalStateCounter() -> impl IntoView {
    let state = expect_context::<Store<GlobalState>>();

    // 这让我们能响应式访问 `count` 字段
    let count = state.count();

    view! {
        <div class="consumer blue">
            <button
                on:click=move |_| {
                    *count.write() += 1;
                }
            >
                "增加全局计数"
            </button>
            <br/>
            <span>"计数为: " {move || count.get()}</span>
        </div>
    }
}

点击按钮时只会更新 state.count。如果我们在其他地方读取了 state.name,点击按钮不会通知它。这让你能够结合自上而下的数据流和精细粒度的响应式更新的优势。

查看代码库中的 stores 示例,了解更详细的示例内容。

路由

基础知识

路由驱动了大多数网站的运行。路由器解决了这样一个问题:“给定这个 URL,页面上应该显示什么内容?”

一个 URL 由许多部分组成。例如,URL https://my-cool-blog.com/blog/search?q=Search#results 包括以下部分:

  • 方案scheme):https
  • 域名domain):my-cool-blog.com
  • 路径path):/blog/search
  • 查询querysearch):?q=Search
  • 哈希hash):#results

Leptos 路由器主要处理路径和查询部分(/blog/search?q=Search)。给定这个 URL 的部分信息,应用程序应该在页面上渲染什么内容?

哲学理念

在大多数情况下,路径应该决定页面上显示的内容。从用户的角度来看,对于大多数应用程序,应用程序状态的主要变化应该反映在 URL 中。如果你复制并粘贴 URL 并在另一个标签页中打开,你应该能够大致回到同一个位置。

从这个意义上说,路由器实际上是你应用程序全局状态管理的核心。与其他任何东西相比,它主要负责页面上显示的内容。

路由器通过将当前位置映射到特定组件,自动处理大部分工作。

定义路由

入门

使用路由器非常简单。

首先,请确保已将 leptos_router 包添加到你的依赖项中。与 leptos 不同,该包没有单独的 csrhydrate 功能;但它确实有一个仅供服务器端使用的 ssr 功能,因此请在服务器端构建时激活该功能。

路由器是一个独立于 leptos 的包,这一点非常重要。这意味着路由器中的所有内容都可以用用户代码定义。如果你想创建自己的路由器,或者根本不使用路由器,完全可以自由地做到这一点!

接着从路由器中导入相关类型,例如:

use leptos_router::components::{Router, Route, Routes};

提供 <Router/>

路由行为是由 <Router/> 组件提供的。它通常位于应用程序的根部附近,包裹住应用的其余部分。

不应该在应用中使用多个 <Router/>。请记住,路由器驱动全局状态:如果有多个路由器,当 URL 发生变化时,哪个路由器来决定行为?

以下是一个使用路由器的简单 <App/> 组件示例:

use leptos::prelude::*;
use leptos_router::components::Router;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          /* ... */
        </main>
      </Router>
    }
}

定义 <Routes/>

<Routes/> 组件是定义用户在应用程序中可以导航到的所有路由的地方。每个可能的路由由 <Route/> 组件定义。

你应该将 <Routes/> 组件放置在应用程序中希望渲染路由的位置。<Routes/> 之外的内容会出现在每个页面上,因此像导航栏或菜单这样的内容可以留在 <Routes/> 之外。

use leptos::prelude::*;
use leptos_router::components::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          // 所有的路由都会出现在 <main> 中
          <Routes fallback=|| "Not found.">
            /* ... */
          </Routes>
        </main>
      </Router>
    }
}

<Routes/> 还应该有一个 fallback,即一个函数,当没有路由匹配时定义应该显示的内容。

单个路由是通过为 <Routes/> 提供子组件 <Route/> 来定义的。<Route/> 接受一个 path 和一个 view。当当前的位置匹配 path 时,将创建并显示 view

path 最简单的定义方式是使用 path! 宏,它可以包括:

  • 静态路径(例如:/users),
  • 以冒号开头的动态命名参数(例如:/:id),
  • 和/或以星号开头的通配符(例如:/user/*any)。

view 是一个返回视图的函数。任何没有属性的组件或返回视图的闭包都可以在这里使用。

<Routes fallback=|| "Not found.">
  <Route path=path!("/") view=Home/>
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
  <Route path=path!("/*any") view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>

view 接受一个 Fn() -> impl IntoView。如果组件没有属性,可以直接传递给 view。例如,view=Home|| view! { <Home/> } 的简写。

现在,如果你导航到 //users,将会看到主页或 <Users/>。如果你访问 /users/3/blahblah,将会显示用户简介或 404 页面 (<NotFound/>)。每次导航时,路由器都会决定匹配哪个 <Route/>,从而在 <Routes/> 组件定义的位置显示相应内容。

够简单吧?

嵌套路由

我们刚刚定义了一组路由:

<Routes fallback=|| "Not found.">
  <Route path=path!("/") view=Home/>
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
  <Route path=path!("/*any") view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>

这里有一点重复:/users/users/:id。对于一个小应用来说,这样写没问题,但你可能已经意识到它的可扩展性并不好。如果我们能把这些路由做一个嵌套,会不会更好呢?

答案是:当然可以!

<Routes fallback=|| "Not found.">
  <Route path=path!("/") view=Home/>
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
  </ParentRoute>
  <Route path=path!("/*any") view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>

我们可以把一个 <Route/> 放在 <ParentRoute/> 里面。看上去很直接。

但是要注意,这样做已经悄悄地改变了我们的应用行为。

接下来的内容是整个路由章节里最重要的部分之一。请仔细阅读,如果有任何不理解的地方,欢迎提问。

将嵌套路由视为布局

嵌套路由是一种布局(layout)方式,而不是定义路由的一种手段。

换句话说:定义嵌套路由的主要目的,并不是为了在路由定义里少写一点重复的路径字符串,而是为了告诉路由器在页面上同时、并列地显示多个 <Route/> 组件。

让我们再看一下之前的例子。

<Routes fallback=|| "Not found.">
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
</Routes>

这意味着:

  • 当我访问 /users 时,会匹配 <Users/> 组件。
  • 当我访问 /users/3 时,会匹配 <UserProfile/> 组件(并将 id 参数设置为 3,后面会详细介绍如何获取参数)。

如果我用嵌套路由来替换它:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
  </ParentRoute>
</Routes>

这意味着:

  • 当我访问 /users/3 时,路径实际上会匹配两个 <Route/><Users/><UserProfile/>
  • 当我访问 /users 时,其实并没有被匹配到任何路由。

所以,我需要添加一个回退路由(fallback):

<Routes>
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
    <Route path=path!("") view=NoUser/>
  </ParentRoute>
</Routes>

这样一来:

  • 当我访问 /users/3 时,会匹配到 <Users/><UserProfile/>
  • 当我访问 /users 时,会匹配到 <Users/><NoUser/>

换句话说,当使用嵌套路由时,每个路径可能会匹配到多个路由:同一个 URL 可以同时渲染多个 <Route/> 组件提供的视图,并且它们会在同一个页面上一起显示。

这可能有些反直觉,但是它在某些场景中非常强大,原因你很快就会见到。

为什么要使用嵌套路由?

这么折腾是为了什么?

大多数 Web 应用都有层级式的导航结构,对应不同的布局区域。比如,在一个电子邮件应用中,可能有一个 URL /contacts/greg,用来在屏幕左侧显示联系人列表,右侧显示名为 Greg 的联系人的详情。联系人列表和联系人的详细信息应该始终同时显示在屏幕上。如果没有选择任何联系人,你或许想在右侧显示一段提示文字。

使用嵌套路由,可以很轻松地实现:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/contacts") view=ContactList>
    <Route path=path!(":id") view=ContactInfo/>
    <Route path=path!("") view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </ParentRoute>
</Routes>

你还可以继续深入嵌套。假设你想在某个联系人详情页里再分标签页,比如地址、邮箱/电话、以及与该联系人的会话记录。可以在 :id 下再添加另一层嵌套路由:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/contacts") view=ContactList>
    <ParentRoute path=path!(":id") view=ContactInfo>
      <Route path=path!("") view=EmailAndPhone/>
      <Route path=path!("address") view=Address/>
      <Route path=path!("messages") view=Messages/>
    </ParentRoute>
    <Route path=path!("") view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </ParentRoute>
</Routes>

如果你访问 Remix 官网(由 React Router 的开发者创建的 React 框架),主页有一个非常直观的例子,你往下滚动会看到三层嵌套路由:Sales > Invoices > 具体的 invoice。

<Outlet/>

父路由(<ParentRoute/> 或者 <Route> 里再嵌套路由)并不会自动渲染它们的嵌套子路由。毕竟,父级只是一个普通组件,不知道确切应该在哪里渲染它的子组件。而“把子组件贴在父组件结尾”也并不是一个好方案。

相反,你需要通过 <Outlet/> 组件告诉父组件应该在哪里渲染嵌套路由。<Outlet/> 的逻辑非常简单:

  • 如果没有匹配到任何子路由,就不渲染任何内容
  • 如果匹配到了子路由,就渲染该子路由对应的视图

就这么简单!但这点非常重要,也是很多 “为什么渲染不出来?” 问题的来源。如果你没有放一个 <Outlet/>,那么对应的嵌套路由自然也不会显示。

#[component]
pub fn ContactList() -> impl IntoView {
  let contacts = todo!();

  view! {
    <div style="display: flex">
      // 左侧联系人列表
      <For each=contacts
        key=|contact| contact.id
        children=|contact| todo!()
      />
      // 如果有匹配到子路由,就在右侧渲染相应内容
      // 别忘了这个 <Outlet/>!
      <Outlet/>
    </div>
  }
}

重构路由定义

如果你愿意,不必把所有的路由都定义在同一个地方。任何 <Route/> 及其子组件都可以被提取到单独的组件中去。

例如,可以把上面这个示例重构成两个单独的组件:

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <Routes fallback=|| "Not found.">
          <Route path=path!("/contacts") view=ContactList>
            <ContactInfoRoutes/>
            <Route path=path!("") view=|| view! {
              <p>"Select a contact to view more info."</p>
            }/>
          </Route>
        </Routes>
      </Router>
    }
}

#[component(transparent)]
fn ContactInfoRoutes() -> impl MatchNestedRoutes + Clone {
    view! {
      <ParentRoute path=path!(":id") view=ContactInfo>
        <Route path=path!("") view=EmailAndPhone/>
        <Route path=path!("address") view=Address/>
        <Route path=path!("messages") view=Messages/>
      </ParentRoute>
    }
    .into_inner()
}

第二个组件使用了 #[component(transparent)],这意味着它仅仅返回数据,而不返回视图;同时,它使用 .into_inner() 来去除由 view 宏添加的调试信息,只返回 <ParentRoute/> 创建的路由定义。

嵌套路由与性能

到此为止,概念上都很不错,但它的真正价值在哪里?

答案是:性能。

在像 Leptos 这样基于细粒度响应式的库中,始终要尽量减少渲染的工作量。因为我们直接使用真实的 DOM,而不是对虚拟 DOM 进行 diff,我们希望尽可能少地 “重新渲染” 组件。嵌套路由会让这件事变得非常简单。

想象一下上面的联系人示例:当我在联系人间切换(Greg → Alice → Bob → Greg),右侧的联系人信息需要改变,但 <ContactList/> 组件本身却不需要重新渲染。这样不仅节省了渲染性能,也能保留 UI 状态。比如在 <ContactList/> 顶部可能有个搜索框,切换联系人时也不会清空搜索内容。

实际上,在这种情况下,导航联系人时也不需要让 <Contact/> 组件重新渲染。路由器会在导航过程中响应式地更新 :id 参数,我们只需进行一些细粒度的更新即可。当在不同联系人间切换时,只会更新文本节点来改变联系人姓名、地址等信息,而不需要进行额外的任何整体重新渲染

这里的示例 sandbox 中包含了本节和上一节讨论的一些特性(比如嵌套路由),以及我们在本章后续部分会提到的一些特性。由于路由器系统是一个整体,所以我们选择使用一个示例来综合展示。如果有任何地方你看不太懂,也不用惊讶。可以先了解大概思路,再继续往后看。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // this <nav> will show on every routes,
            // because it's outside the <Routes/>
            // note: we can just use normal <a> tags
            // and the router will use client-side navigation
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // if no id specified, fall back
                        <ParentRoute path=path!(":id") view=ContactInfo>
                            <Route path=path!("") view=|| view! {
                                <div class="tab">
                                    "(Contact Info)"
                                </div>
                            }/>
                            <Route path=path!("conversations") view=|| view! {
                                <div class="tab">
                                    "(Conversations)"
                                </div>
                            }/>
                        </ParentRoute>
                        // if no id specified, fall back
                        <Route path=path!("") view=|| view! {
                            <div class="select-user">
                                "Select a user to view contact info."
                            </div>
                        }/>
                    </ParentRoute>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn ContactList() -> impl IntoView {
    view! {
        <div class="contact-list">
            // here's our contact list component itself
            <h3>"Contacts"</h3>
            <div class="contact-list-contacts">
                <A href="alice">"Alice"</A>
                <A href="bob">"Bob"</A>
                <A href="steve">"Steve"</A>
            </div>

            // <Outlet/> will show the nested child route
            // we can position this outlet wherever we want
            // within the layout
            <Outlet/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // we can access the :id param reactively with `use_params_map`
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // imagine we're loading data from an API here
    let name = move || match id().as_str() {
        "alice" => "Alice",
        "bob" => "Bob",
        "steve" => "Steve",
        _ => "User not found.",
    };

    view! {
        <h4>{name}</h4>
        <div class="contact-info">
            <div class="tabs">
                <A href="" exact=true>"Contact Info"</A>
                <A href="conversations">"Conversations"</A>
            </div>

            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

参数和查询

静态路径对于区分不同的页面很有用,但几乎每个应用程序都需要通过 URL 传递数据。

有两种方法可以实现:

  1. 使用命名路由参数,例如 /users/:id 中的 id
  2. 使用命名路由查询,例如 /search?q=Foo 中的 q

由于 URL 的构造方式,你可以从任何 <Route/> 视图访问查询(query)。而对于路由参数(params),你可以从定义它们的 <Route/> 或它的任何嵌套子路由中访问。

通过几个钩子函数,可以很简单地访问参数和查询:

这些函数分别提供了类型化选项(use_queryuse_params)和非类型化选项(use_query_mapuse_params_map)。

非类型化版本会返回一个简单的键值映射。而对于类型化版本,可以在结构体上派生 Params 特性。

Params 是一个非常轻量级的特性,用于通过对每个字段应用 FromStr 将字符串的扁平键值映射转换为结构体。由于路由参数和 URL 查询的扁平结构,它比类似 serde 的工具灵活性低得多,但也大大减少了对二进制文件的负担。

use leptos::Params;
use leptos_router::params::Params;

#[derive(Params, PartialEq)]
struct ContactParams {
    id: Option<usize>,
}

#[derive(Params, PartialEq)]
struct ContactSearch {
    q: Option<String>,
}

注意:Params 派生宏位于 leptos_router::params::Params

如果使用稳定版,你只能在参数中使用 Option<T>。如果启用了 nightly 特性,你可以使用 TOption<T>

现在可以在组件中使用它们。假设一个同时包含参数和查询的 URL,例如 /contacts/:id?q=Search

类型化版本返回 Memo<Result<T, _>>。它是一个 Memo,因此会响应 URL 的变化。同时,它是一个 Result,因为参数或查询需要从 URL 中解析,解析结果可能有效也可能无效。

use leptos_router::hooks::{use_params, use_query};

let params = use_params::<ContactParams>();
let query = use_query::<ContactSearch>();

// id: || -> usize
let id = move || {
    params
        .read()
        .as_ref()
        .ok()
        .and_then(|params| params.id)
        .unwrap_or_default()
};

非类型化版本返回 Memo<ParamsMap>。同样,它是 Memo,用于响应 URL 的变化。ParamsMap 的行为类似于其他映射类型,提供 .get() 方法来返回 Option<String>

use leptos_router::hooks::{use_params_map, use_query_map};

let params = use_params_map();
let query = use_query_map();

// id: || -> Option<String>
let id = move || params.read().get("id");

这样做可能会显得有些繁琐:派生一个信号来封装 Option<_>Result<_> 可能需要一些步骤。但这样做是值得的,原因有两点:

  1. 正确性:它迫使你考虑以下情况,“如果用户没有为查询字段传递值怎么办?如果传递了无效值怎么办?”
  2. 性能:具体来说,当你在匹配相同 <Route/> 的路径之间导航时,仅参数或查询发生变化时,可以对应用程序的不同部分进行细粒度更新,而无需重新渲染。例如,在联系人列表示例中,在不同联系人之间导航时,会针对性地更新名称字段(以及联系信息),而无需替换或重新渲染整个 <Contact/> 组件。这正是细粒度响应式的用途所在。

这实际上是上一节的示例。由于路由器系统是一个整体,所以提供了一个单一示例来展示多个特性,即使我们还没有全部解释清楚,也不必惊讶。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // this <nav> will show on every routes,
            // because it's outside the <Routes/>
            // note: we can just use normal <a> tags
            // and the router will use client-side navigation
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // if no id specified, fall back
                        <ParentRoute path=path!(":id") view=ContactInfo>
                            <Route path=path!("") view=|| view! {
                                <div class="tab">
                                    "(Contact Info)"
                                </div>
                            }/>
                            <Route path=path!("conversations") view=|| view! {
                                <div class="tab">
                                    "(Conversations)"
                                </div>
                            }/>
                        </ParentRoute>
                        // if no id specified, fall back
                        <Route path=path!("") view=|| view! {
                            <div class="select-user">
                                "Select a user to view contact info."
                            </div>
                        }/>
                    </ParentRoute>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn ContactList() -> impl IntoView {
    view! {
        <div class="contact-list">
            // here's our contact list component itself
            <h3>"Contacts"</h3>
            <div class="contact-list-contacts">
                <A href="alice">"Alice"</A>
                <A href="bob">"Bob"</A>
                <A href="steve">"Steve"</A>
            </div>

            // <Outlet/> will show the nested child route
            // we can position this outlet wherever we want
            // within the layout
            <Outlet/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // we can access the :id param reactively with `use_params_map`
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // imagine we're loading data from an API here
    let name = move || match id().as_str() {
        "alice" => "Alice",
        "bob" => "Bob",
        "steve" => "Steve",
        _ => "User not found.",
    };

    view! {
        <h4>{name}</h4>
        <div class="contact-info">
            <div class="tabs">
                <A href="" exact=true>"Contact Info"</A>
                <A href="conversations">"Conversations"</A>
            </div>

            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

<A/> 组件

普通的 HTML <a> 元素可以很好地支持客户端导航。路由器会为每个 <a> 元素添加一个监听器,并尝试在客户端处理点击事件,即无需再次向服务器请求 HTML,从而实现你在大多数现代 Web 应用中熟悉的快速“单页应用”(SPA)导航体验。

在以下情况中,路由器将不会处理 <a> 的点击事件:

  • 点击事件调用了 prevent_default()
  • 点击时按住了 MetaAltCtrlShift 键。
  • <a> 元素带有 targetdownload 属性,或者 rel="external"
  • 链接与当前页面不属于同一个源(origin)。

换句话说,只有当路由器确信可以处理时,才会尝试进行客户端导航,并会为每个 <a> 元素升级以获得这种特殊行为。

这也意味着,如果你需要退出客户端路由,可以很容易实现。例如,如果有一个链接指向同一域名上的其他页面,但不属于你的 Leptos 应用,你可以简单地使用 <a rel="external"> 来告诉路由器它不是它能够处理的内容。

路由器还提供了一个 <A> 组件,它额外实现了以下功能:

  1. 正确解析嵌套路由的相对路径。使用普通的 <a> 标签实现相对路由可能会比较棘手。例如,对于路径 /post/:id<A href="1"> 会生成正确的相对路径,但 <a href="1"> 可能不会(取决于它在视图中的位置)。<A/> 会根据它所在嵌套路由的路径解析相对路由。
  2. 如果链接是当前活动链接,则自动设置 aria-current 属性为 page。这对无障碍访问和样式设置非常有用。例如,如果希望将当前页面的链接设置为不同颜色,可以通过 CSS 选择器匹配该属性实现。

编程式导航

最常用的页面导航方式应该是通过 <a><form> 元素,或增强的 <A/><Form/> 组件。使用链接和表单进行导航是实现无障碍访问和优雅降级的最佳解决方案。

不过,有时你可能需要通过编程方式导航,即调用一个函数跳转到新页面。在这种情况下,可以使用 use_navigate 函数。

let navigate = leptos_router::hooks::use_navigate();
navigate("/somewhere", Default::default());

几乎不要<button> 上使用 on:click=move |_| navigate(/* ... */)。任何涉及导航的 on:click 都应该是 <a>,以保证无障碍性。

上述代码的第二个参数是一个 NavigateOptions 选项集合。它包括如下功能:像 <A/> 组件一样相对于当前路由解析导航路径,在导航栈中替换当前记录,包含导航状态,以及在导航时保留当前滚动状态。

再次强调,这是同一示例的一部分。查看其中的相对 <A/> 组件,并在 index.html 中查看基于 ARIA 的样式实现。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // this <nav> will show on every routes,
            // because it's outside the <Routes/>
            // note: we can just use normal <a> tags
            // and the router will use client-side navigation
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // if no id specified, fall back
                        <ParentRoute path=path!(":id") view=ContactInfo>
                            <Route path=path!("") view=|| view! {
                                <div class="tab">
                                    "(Contact Info)"
                                </div>
                            }/>
                            <Route path=path!("conversations") view=|| view! {
                                <div class="tab">
                                    "(Conversations)"
                                </div>
                            }/>
                        </ParentRoute>
                        // if no id specified, fall back
                        <Route path=path!("") view=|| view! {
                            <div class="select-user">
                                "Select a user to view contact info."
                            </div>
                        }/>
                    </ParentRoute>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
fn ContactList() -> impl IntoView {
    view! {
        <div class="contact-list">
            // here's our contact list component itself
            <h3>"Contacts"</h3>
            <div class="contact-list-contacts">
                <A href="alice">"Alice"</A>
                <A href="bob">"Bob"</A>
                <A href="steve">"Steve"</A>
            </div>

            // <Outlet/> will show the nested child route
            // we can position this outlet wherever we want
            // within the layout
            <Outlet/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // we can access the :id param reactively with `use_params_map`
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // imagine we're loading data from an API here
    let name = move || match id().as_str() {
        "alice" => "Alice",
        "bob" => "Bob",
        "steve" => "Steve",
        _ => "User not found.",
    };

    view! {
        <h4>{name}</h4>
        <div class="contact-info">
            <div class="tabs">
                <A href="" exact=true>"Contact Info"</A>
                <A href="conversations">"Conversations"</A>
            </div>

            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

<Form/> 组件

链接(<a>)和表单(<form>)有时看起来完全无关,但实际上它们的工作方式非常相似。

在纯 HTML 中,有三种方式可以导航到另一个页面:

  1. 使用 <a> 元素链接到另一个页面:通过其 href 属性的 URL 使用 GET HTTP 方法导航。
  2. 使用 <form method="GET">:通过其 action 属性的 URL 使用 GET HTTP 方法导航,同时将表单输入数据编码到 URL 查询字符串中。
  3. 使用 <form method="POST">:通过其 action 属性的 URL 使用 POST HTTP 方法导航,同时将表单输入数据编码到请求体中。

由于我们有客户端路由器,因此可以实现客户端的链接导航,无需重新加载页面,即无需往返服务器。这也意味着,我们可以以类似的方式在客户端实现表单导航。

路由器提供了一个 <Form> 组件,它的功能类似于 HTML 的 <form> 元素,但使用客户端导航而不是完全重新加载页面。<Form/> 支持 GETPOST 请求。当设置 method="GET" 时,它会导航到包含表单数据编码的 URL;而设置 method="POST" 时,它会发起一个 POST 请求并处理服务器的响应。

<Form/> 是一些组件(如 <ActionForm/><MultiActionForm/>,将在后续章节中看到)的基础。但它本身也支持一些非常强大的模式。

例如,假设你想创建一个搜索框,用户在搜索时可以实时更新搜索结果而无需重新加载页面,同时将搜索结果存储在 URL 中,这样用户可以复制并将其分享给其他人。

实际上,我们已经学到的模式可以轻松实现这一需求:

async fn fetch_results() {
    // 一个异步函数,用于获取搜索结果
}

#[component]
pub fn FormExample() -> impl IntoView {
    // 对 URL 查询字符串的响应式访问
    let query = use_query_map();
    // 将搜索存储为 ?q=
    let search = move || query.read().get("q").unwrap_or_default();
    // 基于搜索字符串的资源
    let search_results = Resource::new(search, |_| fetch_results());

    view! {
        <Form method="GET" action="">
            <input type="search" name="q" value=search/>
            <input type="submit"/>
        </Form>
        <Transition fallback=move || ()>
            /* 渲染搜索结果 */
            {todo!()}
        </Transition>
    }
}

每次点击 Submit 时,<Form/> 会“导航”到 ?q={search}。但由于这种导航在客户端完成,因此没有页面闪烁或重新加载。URL 查询字符串发生变化后会触发 search 更新。因为 searchsearch_results 资源的源信号,这会进一步触发 search_results 重新加载其资源。<Transition/> 会在新结果加载完成前继续显示当前搜索结果,加载完成后会切换显示新结果。

这是一个很好的模式,数据流非常清晰:所有数据从 URL 流向资源再流向 UI。应用程序的当前状态存储在 URL 中,这意味着你可以刷新页面或将链接发给朋友,它们会看到你期望的结果。一旦引入服务器端渲染,这种模式会变得非常健壮:因为它在底层使用 <form> 元素和 URL,即使不加载客户端的 WASM,也能很好地工作。

我们还可以更进一步,做一些有趣的优化:

view! {
    <Form method="GET" action="">
        <input type="search" name="q" value=search
            oninput="this.form.requestSubmit()"
        />
    </Form>
}

你会注意到,这个版本去掉了 Submit 按钮。相反,我们在输入框中添加了一个 oninput 属性。需要注意的是,这里是 oninput 而不是 on:input,后者会监听 input 事件并运行一些 Rust 代码。而没有冒号的 oninput 是普通的 HTML 属性,因此其值是一个 JavaScript 字符串。this.form 获取到输入框所属的表单,requestSubmit() 会触发 <form>submit 事件,就像点击了一个 Submit 按钮一样。现在,这个表单会在每次输入时“导航”,让 URL(因此也包括搜索)始终与用户的输入保持同步。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Form, Route, Router, Routes};
use leptos_router::hooks::use_query_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1><code>"<Form/>"</code></h1>
            <main>
                <Routes fallback=|| "Not found.">
                    <Route path=path!("") view=FormExample/>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
pub fn FormExample() -> impl IntoView {
    // reactive access to URL query
    let query = use_query_map();
    let name = move || query.read().get("name").unwrap_or_default();
    let number = move || query.read().get("number").unwrap_or_default();
    let select = move || query.read().get("select").unwrap_or_default();

    view! {
        // read out the URL query strings
        <table>
            <tr>
                <td><code>"name"</code></td>
                <td>{name}</td>
            </tr>
            <tr>
                <td><code>"number"</code></td>
                <td>{number}</td>
            </tr>
            <tr>
                <td><code>"select"</code></td>
                <td>{select}</td>
            </tr>
        </table>
        // <Form/> will navigate whenever submitted
        <h2>"Manual Submission"</h2>
        <Form method="GET" action="">
            // input names determine query string key
            <input type="text" name="name" value=name/>
            <input type="number" name="number" value=number/>
            <select name="select">
                // `selected` will set which starts as selected
                <option selected=move || select() == "A">
                    "A"
                </option>
                <option selected=move || select() == "B">
                    "B"
                </option>
                <option selected=move || select() == "C">
                    "C"
                </option>
            </select>
            // submitting should cause a client-side
            // navigation, not a full reload
            <input type="submit"/>
        </Form>
        // This <Form/> uses some JavaScript to submit
        // on every input
        <h2>"Automatic Submission"</h2>
        <Form method="GET" action="">
            <input
                type="text"
                name="name"
                value=name
                // this oninput attribute will cause the
                // form to submit on every input to the field
                oninput="this.form.requestSubmit()"
            />
            <input
                type="number"
                name="number"
                value=number
                oninput="this.form.requestSubmit()"
            />
            <select name="select"
                onchange="this.form.requestSubmit()"
            >
                <option selected=move || select() == "A">
                    "A"
                </option>
                <option selected=move || select() == "B">
                    "B"
                </option>
                <option selected=move || select() == "C">
                    "C"
                </option>
            </select>
            // submitting should cause a client-side
            // navigation, not a full reload
            <input type="submit"/>
        </Form>
    }
}

fn main() {
    leptos::mount::mount_to_body(App)
}

插曲:样式

几乎所有在做网站或应用开发的人都会碰到一个问题:怎么给界面加样式?对于一个小应用来说,用一份 CSS 文件来管理全部样式就足够了。但随着应用规模的增长,许多开发者会发现纯 CSS 越来越难以维护。

一些前端框架(如 Angular、Vue、Svelte)内置了将 CSS 作用域限定到特定组件的方法,能更轻松地管理大型应用的样式,避免只想改一个小组件的样式却影响到全局。另一些框架(如 React、Solid)并没有内置的 CSS 作用域功能,而是依赖社区生态中的库来实现。Leptos 也属于后者:它本身对 CSS 并没有任何看法,但是提供了一些工具和基础功能,方便其他人建立自己的样式库。

下面介绍在 Leptos 应用中,除了直接使用纯 CSS 以外的几种常见做法。

TailwindCSS:基于工具类的 CSS

TailwindCSS 是一个流行的“工具类优先(utility-first)”CSS 库。它通过在模板中使用各类工具类(utility class)进行样式控制,并配合一个自定义 CLI 工具扫描代码中所有 Tailwind 类名,然后打包所需 CSS。

这样,你可以写出类似如下的组件:

#[component]
fn Home() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <main class="my-0 mx-auto max-w-3xl text-center">
            <h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
            <p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
            <button
                class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
                on:click=move |_| *set_count.write() += 1
            >
                {move || if count.get() == 0 {
                    "Click me!".to_string()
                } else {
                    count.get().to_string()
                }}
            </button>
        </main>
    }
}

初次配置 Tailwind 需要一些步骤。你可以查看以下示例,了解如何在 client-side-rendered 的 trunk 应用server-rendered 的 cargo-leptos 应用 中使用 Tailwind。cargo-leptos 还提供了内置的 Tailwind 支持,可用于替代 Tailwind CLI。

Stylers:编译期提取 CSS

Stylers 是一个编译期作用域 CSS 库,允许你在组件里内联定义局部(作用域)CSS。Stylers 会在编译期提取这些 CSS 到独立的文件中,然后在你的应用中引入。因此它不会额外增加应用的 WASM 二进制体积。

下面是一个使用 Stylers 编写组件的示例:

use stylers::style;

#[component]
pub fn App() -> impl IntoView {
    let styler_class = style! { "App",
        #two{
            color: blue;
        }
        div.one{
            color: red;
            content: raw_str(r#"\hello"#);
            font: "1.3em/1.2" Arial, Helvetica, sans-serif;
        }
        div {
            border: 1px solid black;
            margin: 25px 50px 75px 100px;
            background-color: lightblue;
        }
        h2 {
            color: purple;
        }
        @media only screen and (max-width: 1000px) {
            h3 {
                background-color: lightblue;
                color: blue
            }
        }
    };

    view! { class = styler_class,
        <div class="one">
            <h1 id="two">"Hello"</h1>
            <h2>"World"</h2>
            <h2>"and"</h2>
            <h3>"friends!"</h3>
        </div>
    }
}

Stylance:将作用域 CSS 写在独立文件中

Stylers 让你可以在 Rust 代码中内联写 CSS,并在编译期提取和作用域化。Stylance 则允许你在单独的 CSS 文件中编写样式,然后在组件中引入,并把 CSS 类作用域限定到对应的组件。

这种方式与 trunkcargo-leptos 的热重载(live reloading)特性配合得很好,因为你可以直接编辑 CSS 文件并在浏览器中立刻看到更新。

import_style!(style, "app.module.scss");

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <div class=style::jumbotron/>
    }
}

你可以直接编辑 CSS 文件,而无需引发 Rust 重新编译:

.jumbotron {
  background: blue;
}

欢迎贡献

Leptos 本身对样式没有特定主张,但我们很乐意为任何旨在简化样式管理的工具提供支持。如果你正在开发某种 CSS 或样式方案,并且想要把它加入这份清单,请告诉我们!

元数据

到目前为止,我们渲染的所有内容都在 HTML 文档的 <body> 内。这是有道理的,毕竟网页上所有可见的内容都位于 <body> 中。

然而,在某些情况下,你可能需要使用与 UI 相同的响应式原语和组件模式来更新文档 <head> 中的内容。

这就是 leptos_meta 包的作用。

元数据组件

leptos_meta 提供了一些特殊组件,可以让你从应用程序的任何组件中向 <head> 注入数据:

<Title/> 允许你从任意组件设置文档的标题。它还支持一个 formatter 函数,用于对其他页面设置的标题应用相同的格式。例如,如果你在 <App/> 组件中放置 <Title formatter=|text| format!("{text} — My Awesome Site")/>,然后在路由中分别放置 <Title text="Page 1"/><Title text="Page 2"/>,你会得到 Page 1 — My Awesome SitePage 2 — My Awesome Site

<Link/><head> 注入一个 <link> 元素。

<Stylesheet/> 创建一个带有指定 href<link rel="stylesheet">

<Style/> 创建一个 <style> 元素,并将你传递的子内容(通常是字符串)放入其中。可以在编译时引入其他文件的自定义 CSS,例如 <Style>{include_str!("my_route.css")}</Style>

<Meta/> 允许你设置 <meta> 标签,如描述或其他元数据。

<Script/><script>

leptos_meta 还提供了一个 <Script/> 组件。这里值得稍作停顿说明。上面提到的所有组件都将 <head> 元素注入 <head> 中,而 <script> 既可以放在 <head> 中,也可以放在 <body> 中。

有一个简单的规则可以帮助你决定是使用 <Script/> 组件还是 <script> 元素:<Script/> 会被渲染到 <head> 中,而 <script> 会根据你在用户界面中放置的位置,直接渲染到 <body> 中。两者加载和运行 JavaScript 的时间不同,因此根据需要选择适合你的方式。

<Body/><Html/>

还有两个元素专为语义化 HTML 和样式设计提供便利:<Body/><Html/>。它们允许你向页面上的 <html><body> 标签添加任意属性。可以通过常规 Leptos 语法结合扩展操作符({..})添加任意数量的属性,这些属性会直接添加到相应的元素中。

<Html
    {..}
    lang="he"
    dir="rtl"
    data-theme="dark"
/>

元数据与服务器端渲染

某些场景下,上述功能是非常有用的,而在搜索引擎优化(SEO)中则尤为重要。确保拥有适当的 <title><meta> 标签是关键。现代搜索引擎爬虫确实能够处理客户端渲染的应用(即通过空的 index.html 加载,然后用 JS/WASM 渲染整个页面)。但搜索引擎更喜欢直接接收到已经渲染成实际 HTML 的页面,并带有 <head> 中的元数据。

这正是 leptos_meta 的作用。事实上,在服务器端渲染时,它会收集整个应用程序中使用这些组件声明的所有 <head> 内容,然后将它们注入实际的 <head> 中。

不过我们稍微超前了一点。我们尚未正式讨论服务器端渲染。下一章将介绍如何与 JavaScript 库集成,然后完成客户端部分的讨论,接着进入服务器端渲染的主题。

与 JavaScript 集成:wasm-bindgenweb_sysHtmlElement

Leptos 提供了多种工具,让你能够构建声明式 Web 应用,而无需离开框架的世界。像反应式系统、componentview 宏以及路由器等工具,可以让你直接用 Rust 构建用户界面,这非常棒——假设你喜欢 Rust。(如果你已经读到了本书的这个部分,我们假设你确实喜欢 Rust。)

leptos-use 提供的一整套出色的工具,可以更进一步,为许多 Web API 提供针对 Leptos 的反应式封装。

然而,在许多情况下,你仍然需要直接访问 JavaScript 库或 Web API。本章将为你提供帮助。

使用 wasm-bindgen 调用 JavaScript 库

你的 Rust 代码可以被编译为 WebAssembly (WASM) 模块并在浏览器中运行。然而,WASM 无法直接访问浏览器的 API。Rust/WASM 生态系统依赖于从 Rust 代码生成绑定,以与 JavaScript 浏览器环境交互。

wasm-bindgen 是该生态系统的核心。它提供了用于标记 Rust 代码部分的接口,这些标记可以告诉编译器如何调用 JavaScript,以及一个 CLI 工具,用于生成必要的 JavaScript glue 代码。事实上,你一直在不知不觉中使用它:trunkcargo-leptos 都在底层依赖 wasm-bindgen

如果你想从 Rust 中调用某个 JavaScript 库,可以参考 wasm-bindgen 的文档 导入 JavaScript 函数。它可以让你轻松地从 JavaScript 导入函数、类或值,并在 Rust 应用中使用。

但将 JavaScript 库直接集成到应用中并不总是简单的。特别是那些依赖于特定 JavaScript 框架(如 React)的库可能难以集成。那些以某种方式操作 DOM 状态的库(如富文本编辑器)也需要小心使用,因为 Leptos 和 JavaScript 库可能都认为自己是应用状态的最终真相来源,因此需要注意分离它们的职责。

使用 web-sys 访问 Web API

如果你只是需要访问一些浏览器 API,而不需要引入单独的 JavaScript 库,可以使用 web_sys crate。它为浏览器提供的所有 Web API 提供了绑定,将浏览器的类型和函数 1:1 映射到 Rust 的结构体和方法。

通常,如果你在问“如何用 Leptos 做某事”,其中“做某事”是指访问某些 Web API,那么查找一个纯 JavaScript 解决方案并通过 web-sys 文档 将其翻译为 Rust 是一个不错的选择。

你可能会发现 wasm-bindgen 指南关于 web-sys 的章节 对进一步阅读很有帮助。

启用特性(features)

web_sys 使用了大量功能标志来降低编译时间。如果你想使用它的某些 API,可能需要启用相应的功能。

文档中会列出使用某个项目所需的功能。例如,要使用 Element::get_bounding_rect_client,你需要启用 DomRectElement 功能。

Leptos 已经启用了 很多功能,如果所需功能已经在这里启用,你就不需要在自己的应用中启用它了。否则,只需在你的 Cargo.toml 中添加它即可:

[dependencies.web-sys]
version = "0.3"
features = ["DomRect"]

然而,随着 JavaScript 标准的演变,可能会出现尚未完全稳定的浏览器功能(如 WebGPU)。web_sys 将跟随这一标准,这意味着不提供稳定性保证。

要使用这些功能,你需要添加 RUSTFLAGS=--cfg=web_sys_unstable_apis 作为环境变量。这可以通过每次运行命令时添加,或者将其添加到你的项目的 .cargo/config.toml 中。

作为命令的一部分:

RUSTFLAGS=--cfg=web_sys_unstable_apis cargo # ...

.cargo/config.toml 中:

[env]
RUSTFLAGS = "--cfg=web_sys_unstable_apis"

view 中访问原始 HtmlElement

框架的声明式风格意味着你不需要直接操作 DOM 节点来构建用户界面。然而,在某些情况下,你可能希望直接访问表示视图部分的底层 DOM 元素。书中关于 “非受控输入” 的部分介绍了如何使用 NodeRef 类型实现这一点。

NodeRef::get 返回一个经过正确类型化的 web-sys 元素,可以直接操作。

例如,以下代码:

#[component]
pub fn App() -> impl IntoView {
    let node_ref = NodeRef::<Input>::new();

    Effect::new(move |_| {
        if let Some(node) = node_ref.get() {
            leptos::logging::log!("value = {}", node.value());
        }
    });

    view! {
        <input node_ref=node_ref/>
    }
}

在这里的 Effect 中,node 是一个 web_sys::HtmlInputElement。这使得我们可以调用任何适当的方法。

(注意,.get() 在这里返回一个 Option,因为在 DOM 元素实际创建之前,NodeRef 是空的。Effects 在组件运行后延迟一个周期运行,因此在大多数情况下,当 Effect 运行时,<input> 已经被创建。)

第一部分总结:客户端渲染

到目前为止,我们编写的所有内容几乎完全是在浏览器中渲染的。当我们使用 Trunk 创建应用时,它是通过一个本地开发服务器提供服务的。如果将其构建为生产版本并部署,它会通过你的服务器或 CDN 提供服务。在这两种情况下,提供的内容是一个 HTML 页面,其中包含:

  1. 你的 Leptos 应用程序的 URL,该应用程序已被编译为 WebAssembly (WASM);
  2. 用于初始化这个 WASM 模块的 JavaScript 的 URL;
  3. 一个空的 <body> 元素。

当 JavaScript 和 WASM 加载完成后,Leptos 会将你的应用渲染到 <body> 中。这意味着在 JavaScript 和 WASM 加载并运行之前,屏幕上不会显示任何内容。这种方式存在一些缺点:

  1. 增加加载时间:用户的屏幕在额外资源下载完成之前是空白的;
  2. 对 SEO 不友好:加载时间更长,并且提供的 HTML 没有任何有意义的内容;
  3. 对部分用户不可用:如果某些原因导致 JavaScript/WASM 没有加载(例如用户在乘火车,刚好进入隧道,WASM 还未加载完成;用户使用不支持 WASM 的旧设备;或者用户因某些原因禁用了 JavaScript/WASM),应用将无法运行。

这些缺点存在于整个 Web 生态系统中,但对 WASM 应用尤其明显。

然而,具体项目是否受这些限制的影响取决于其需求。

如果你只想部署你的客户端渲染 (CSR) 网站,可以直接跳到 “部署” 一章,在那里你会找到如何最好地部署 Leptos CSR 网站的指导。

但是,如果你希望在 index.html 页面中返回的不仅仅是一个空的 <body> 标签,该怎么办呢?可以使用“服务端渲染” (Server-Side Rendering, SSR)!

关于这个主题,可以写一本(可能已经有)完整的书,但其核心非常简单:通过 SSR,你可以返回一个初始的 HTML 页面,反映应用或站点的实际初始状态。这样在 JavaScript/WASM 加载期间,用户可以访问纯 HTML 版本,而不是只看到一个空白页面。

本书第二部分将详细介绍 Leptos 的服务端渲染 (SSR)!

第二部分:服务端渲染

在上一章中,你了解到客户端渲染的 Web 应用存在一些限制。本书的第二部分将讨论如何使用服务端渲染 (Server-Side Rendering, SSR) 来克服这些限制,从而为你的 Leptos 应用提供最佳性能和 SEO 表现。

Info

当在服务端使用 Leptos 时,你可以选择官方支持的 Actix 或 Axum 集成,也可以选择由社区支持的其他方案。官方选择提供 Leptos 的全部功能,而社区方案可能支持较少。请参考它们的文档了解详细信息。

我们有多种社区支持的选择,包括兼容 WinterCG 的运行时(如 Deno 或 Cloudflare)以及服务端 WASM 运行时(如 Spin)。此外,还有更传统的服务端选择,如 Viz 和 Pavex 的社区集成。不建议初学者自己编写集成,但中高级 Rust 用户可能会有兴趣这样做。如果有相关问题,欢迎在我们的 Discord 或 Github 上联系我们。

对于初学者,推荐使用 Axum 或 Actix。两者功能齐全,选择哪个完全取决于个人喜好。没有错误的选择,但如果你需要建议,Leptos 团队目前默认在新项目中使用 Axum。

介绍 cargo-leptos

到目前为止,我们只是运行代码在浏览器中,并使用 Trunk 来协调构建过程和运行本地开发流程。如果我们要添加服务端渲染 (SSR),还需要在服务器上运行应用代码。这意味着需要构建两个单独的二进制文件:一个编译为本地代码并运行服务器,另一个编译为 WebAssembly (WASM) 并在用户的浏览器中运行。此外,服务器需要知道如何将这个 WASM 版本(以及初始化它所需的 JavaScript)提供给浏览器。

虽然这不是一个不可逾越的任务,但确实增加了一些复杂性。为了方便开发并简化开发体验,我们创建了 cargo-leptos 构建工具。cargo-leptos 主要用于协调应用的构建过程,处理在代码更改时重新编译服务器和客户端部分,并内置支持如 Tailwind、SASS 和测试等功能。

开始使用非常简单。只需运行以下命令安装:

cargo install --locked cargo-leptos

然后可以通过以下命令创建新项目:

# Actix 模板
cargo leptos new --git https://github.com/leptos-rs/start-actix

或者

# Axum 模板
cargo leptos new --git https://github.com/leptos-rs/start-axum

确保你已经添加了 wasm32-unknown-unknown 目标,以便 Rust 可以将代码编译为在浏览器中运行的 WebAssembly:

rustup target add wasm32-unknown-unknown

现在进入你创建的项目目录并运行:

cargo leptos watch

当你的应用程序编译完成后,你可以打开浏览器访问 http://localhost:3000 查看。

cargo-leptos 还有许多额外功能和内置工具。你可以在它的 README 文档 中了解更多。

但是,当你打开浏览器访问 localhost:3000 时,究竟发生了什么呢?继续阅读以了解更多!

页面加载的生命周期

在深入讨论之前,了解一下更高层次的概览可能会有所帮助。从你在浏览器中输入一个服务器渲染的 Leptos 应用的 URL 到点击按钮使计数器增加之间,究竟发生了什么?

这里假设你对互联网的工作原理有一定的了解,因此不会详细讨论 HTTP 或其他相关内容。我们将着重展示 Leptos API 的不同部分如何对应于整个过程的各个阶段。

本文基于以下前提:你的应用是为两个独立的目标进行编译的:

  1. 服务器版本:通常运行在 Actix 或 Axum 上,使用 Leptos 的 ssr 功能进行编译;
  2. 浏览器版本:编译为 WebAssembly (WASM),使用 Leptos 的 hydrate 功能进行编译。

cargo-leptos 构建工具的作用就是协调为这两个不同目标编译你的应用程序的过程。

在服务器端

  • 浏览器向服务器发出一个针对该 URL 的 GET 请求。这时,浏览器对将要渲染的页面几乎一无所知。(至于“浏览器如何知道要向哪里请求页面?”是一个有趣的问题,但超出了本教程的范围!)
  • 服务器接收到请求,并检查是否有处理该路径的 GET 请求的方法。这正是 leptos_axumleptos_actix.leptos_routes() 方法的用途。当服务器启动时,这些方法会根据 <Routes/> 提供的路由结构生成应用程序可以处理的所有路由列表,并告诉服务器的路由器:“对于每一个这些路由,如果接收到请求……将其交给 Leptos 处理。”
  • 服务器发现该路由可以由 Leptos 处理。因此,它渲染你的根组件(通常称为 <App/>),并向其提供正在请求的 URL 以及其他一些数据(例如 HTTP 头信息和请求元数据)。
  • 应用程序在服务器端运行一次,生成该路由下组件树的 HTML 版本。(有关资源和 <Suspense/> 的更多内容将在下一章讨论。)
  • 服务器返回这个 HTML 页面,同时注入信息,用于加载已编译为 WASM 的客户端版本以及初始化它所需的 JavaScript。

返回的 HTML 页面本质上是你的应用程序的“脱水版”或“冻干版”:它是没有任何反应式功能或事件监听器的 HTML。浏览器会通过为服务器渲染的 HTML 添加反应式系统并附加事件监听器来“重水化”该页面。因此,这个过程的两个阶段分别使用了两种功能标志:服务器端渲染的 ssr 和浏览器端重水化的 hydrate

在浏览器端

  • 浏览器从服务器接收到这个 HTML 页面。然后,它会立即向服务器请求加载运行交互式客户端版本所需的 JS 和 WASM。
  • 在此期间,浏览器渲染 HTML 版本。
  • 当 WASM 版本加载完成后,它会执行与服务器相同的路由匹配过程。因为 <Routes/> 组件在服务器端和客户端是完全一致的,浏览器版本会读取 URL 并渲染与服务器返回的页面相同的内容。
  • 在初始“重水化”阶段,WASM 版本不会重新创建组成应用的 DOM 节点。相反,它会遍历现有的 HTML 树,“拾取”已有的元素,并添加必要的交互功能。

需要注意的是,这里有一些权衡。在完成重水化之前,页面看起来是交互式的,但实际上不会响应交互。例如,如果你有一个计数按钮,并在 WASM 加载完成之前点击它,计数不会增加,因为必要的事件监听器和反应式功能尚未添加。我们将在后续章节中讨论如何实现“优雅降级”。

客户端导航

接下来的步骤非常重要。假设用户现在点击了一个链接,导航到应用程序中的另一个页面。

此时,浏览器不会再次向服务器发送请求,也不会像普通 HTML 页面或仅使用服务端渲染(如 PHP)应用那样重新加载整个页面。

相反,WASM 版本的应用程序会在浏览器中直接加载新页面,而无需从服务器请求其他页面。基本上,应用程序会从一个服务器加载的“多页面应用程序”升级为一个浏览器渲染的“单页面应用程序”。这结合了两种模式的最佳优点:服务器渲染的 HTML 提供了快速的初始加载时间,而客户端路由提供了快速的二次导航。

在后续章节中描述的一些内容(例如服务器函数、资源与 <Suspense/> 的交互)可能看起来过于复杂。你可能会问:“如果我的页面已经在服务器上渲染为 HTML,为什么我不能直接在服务器上 .await?如果我可以在服务器函数中调用库 X,为什么不能在组件中调用它?”原因很简单:为了实现从服务器渲染到客户端渲染的平滑过渡,应用中的所有内容都必须能够在服务器或浏览器中运行。

当然,这并不是创建网站或 Web 框架的唯一方式。但我们认为,这是为用户创造尽可能平滑体验的一种非常好的方法。

异步渲染和 SSR “模式”

如果一个页面使用的所有数据都是同步可用的,那么服务端渲染就相对简单:只需要沿着组件树往下遍历,将每个元素都渲染成 HTML 字符串即可。但这忽略了一个重要问题:如果页面包含异步数据——也就是在客户端会放在 <Suspense/> 里渲染的那种——该怎么处理呢?

当页面需要加载异步数据时,我们应该怎样做?等所有异步数据都加载完,再一次性渲染所有内容吗?(我们把这个叫做“异步(async)渲染”)或者说,完全走向另一个极端,只是立刻把现有的 HTML 发送给客户端,然后让客户端自行加载资源并填充数据?(我们把这个叫做“同步(synchronous)渲染”)又或者我们可以找到某个折中的方法?(提示:确实有!)

如果你曾听过在线音乐或看过在线视频,就知道 HTTP 支持流式传输 (streaming)。这意味着,一个连接可以分批发送数据,而不必等到所有内容都准备好再一次性发送。你可能不知道的是,浏览器在渲染部分 HTML 页面时也做得很不错。把这两点结合起来,你就能通过流式传输 HTML来提升用户体验。Leptos 甚至默认就支持了这一点,而且无需任何额外配置。而且,流式传输 HTML还有不止一种方式:你既可以按顺序把生成页面所需的 HTML 片段像视频帧一样按顺序发送,也可以不按顺序发送……确实可以有各种方式。

下面我们进一步看看具体是怎么回事。

Leptos 支持各种主要的、包含异步数据的服务端渲染方式:

  1. 同步渲染(Synchronous Rendering)
  2. 异步渲染(Async Rendering)
  3. 按顺序流式传输(In-Order Streaming)
  4. 不按顺序流式传输(Out-of-Order Streaming)(以及部分阻塞变体)

同步渲染(Synchronous Rendering)

  1. Synchronous:返回一个带有 <Suspense/> fallback 的 HTML shell(外壳)。在客户端通过 create_local_resource 加载数据,数据加载完成后再替换掉 fallback
  • 优点 (Pros)
    • 应用外壳(App shell)能非常快地出现,即极佳的 TTFB(到首字节的时间)。
  • 缺点 (Cons)
    • 资源加载相对较慢;你需要等到 JS 和 WASM 都加载完才能开始请求任何数据。
    • 无法在 <title> 或其他 <meta> 标签等位置使用异步资源的数据,从而影响 SEO,以及社交媒体的链接预览等。

如果你正在使用服务端渲染,那么从性能角度看,“同步模式”几乎不是你真正想要的方式。原因在于它错过了一个重要的优化点:如果你在服务端渲染期间加载异步资源,你可以在服务器就开始加载数据,而不是等到客户端收到 HTML、再加载 JS + WASM,然后才知道需要哪些资源并开始加载。服务端渲染可以在客户端第一次发出请求时就开始加载资源。从这个角度看,对于服务端渲染来说,异步资源就像一个在服务端启动加载、在客户端完成的 Future。只要这些资源可以序列化,整体加载时间就会更快。

这就是为什么 Resource 需要它的数据可序列化(serializable),以及为什么对于不能序列化、只能在浏览器端加载的异步数据,你应该使用 LocalResource。当你可以创建可序列化(serializable)的资源却选择用 LocalResource 时,就意味着你放弃了一个可能的优化机会。

异步渲染(Async Rendering)

  1. async:在服务器端加载所有资源。等所有数据都加载完成,再一次性输出整页 HTML。
  • 优点 (Pros)
    • 能够在真正渲染 <head> 之前就已经知道所有异步数据,从而更好地处理 <meta> 等标签。
    • 相比 “同步(synchronous)” 渲染,整体加载速度更快,因为异步资源会在服务器上提前开始加载。
  • 缺点 (Cons)
    • 更长的加载时间 / TTFB:你必须等到所有异步资源都加载完成后,才能向客户端展示任何东西。在这之前,页面都是空白的。

按顺序流式传输(In-Order Streaming)

  1. In-order streaming:遍历组件树,渲染 HTML,直到遇到 <Suspense/>。将到目前为止生成的所有 HTML 作为一个数据块发送给客户端,然后等待这个 <Suspense/> 下需要的所有资源加载完成,接着再将它们渲染成 HTML,继续往下遍历并发送,直到遇到下一个 <Suspense/> 或者页面结束。
  • 优点 (Pros)
    • 页面不会一直空白;在数据尚未准备好之前,至少可以显示一些内容。
  • 缺点 (Cons)
    • 由于在每个 <Suspense/> 处都要暂停,外壳会比同步渲染(或者不按顺序流式传输)更慢出现。
    • 无法展示 <Suspense/> 的 fallback 状态。
    • 需要等整个页面全部加载完成后才能开始“重水化”(hydrate),因此在等待那些被“挂起”的片段加载完之前,已经发送给客户端的页面部分也无法互动。

不按顺序流式传输(Out-of-Order Streaming)

  1. Out-of-order streaming:类似同步渲染,会立即返回一个带有 <Suspense/> fallback 的 HTML 壳。但实际上还是在服务器端加载数据,并在数据加载完后通过流式传输把真正的内容发给客户端,用来替换原本的 fallback。
  • 优点 (Pros)
    • 同步async 的优点结合:
      • 由于立即发送了整个同步壳,初始响应 / TTFB 非常快;
      • 同时,资源在服务器端提前加载,使整体时间也很快;
      • 可以显示 fallback 加载状态,并在数据就绪后动态替换,而不是给未加载的数据留空白。
  • 缺点 (Cons)
    • 要让被挂起的片段以正确顺序出现,需要启用 JavaScript。对于不支持或禁用 JavaScript 的用户,需要一小段在 <template> 标签旁的 <script> 来完成片段替换,但不用额外加载其他 JS 文件。
  1. 部分阻塞流式传输(Partially-blocked streaming)
    当页面上有多个 <Suspense/> 时,“部分阻塞”流式传输会很有用。可通过在路由上设置 ssr=SsrMode::PartiallyBlocked 并在视图中使用“阻塞资源”(blocking resources)触发。若某个 <Suspense/> 会读取一个或多个“阻塞资源” (参见下方说明),则不会发送 fallback;服务器会等它准备好后,在服务器端直接替换 fallback,并将完整片段包含在初始 HTML 响应里,所以即使 JavaScript 被禁用也能看到内容。其他 <Suspense/> 将会以不按顺序的方式流式传输,即和 SsrMode::OutOfOrder 的默认行为类似。

    这在你有多个 <Suspense/> 且其中一个比其他更重要的场景非常有用:例如博客文章(比较重要)和评论(不太重要),或者产品信息(重要)和评论(次要)。如果页面只有一个 <Suspense/>,或者所有 <Suspense/> 都包含阻塞资源,那么此时它就等同于慢一些的 async 渲染,没有太大意义。

    • 优点 (Pros)
      • 即使用户禁用了 JavaScript,也能看到已加载的内容。
    • 缺点 (Cons)
      • 初始响应时间比不按顺序流式传输更慢。
      • 服务器需做更多工作,可能会稍微拖慢整体响应时间。
      • 没有 fallback 状态展示。

如何使用这些 SSR 模式

因为它在性能方面有一个很好的平衡,Leptos 默认采用“不按顺序流式传输”(out-of-order streaming)。不过,想要使用其他模式也很简单:你只需在某个(或多个)<Route/> 组件上加一个 ssr 属性即可,具体可参考 “ssr_modes” 示例

<Routes fallback=|| "Not found.">
    // 用不按顺序流式传输和 <Suspense/> 加载首页
    <Route path=path!("") view=HomePage/>

    // 加载帖子时使用异步渲染,以便在加载完数据后才能设定
    // 标题和元数据
    <Route
        path=path!("/post/:id")
        view=Post
        ssr=SsrMode::Async
    />
</Routes>

如果一个路径包含多个嵌套路由,则会使用其中限制性最强的 SSR 模式:也就是说,如果有一个嵌套路由要求 async 渲染,那么整个初始请求就会采用 async 来渲染。从严格程度上看,async 是最严格的,其次是 in-order,然后才是 out-of-order。(思考一下就能明白为什么是这样。)

阻塞资源(Blocking Resources)

可以通过 Resource::new_blocking 创建一个阻塞资源(blocking resource)。阻塞资源依然是异步加载,就像任何 Rust 中的异步操作或 .await 一样,并不会真的阻塞服务器线程等。但如果某个 <Suspense/> 内部读取了一个阻塞资源,那么在该 <Suspense/> 完成之前,整个 HTML (包括初始同步外壳)都不会发回客户端。

从性能角度看,这通常不是理想的,因为页面的同步外壳在资源准备好之前无法显示。然而,这样做可以让你在真正渲染 <head> 时使用这些资源的数据,比如设置 <title><meta> 等标签。这听起来跟 async 渲染很相似,但有一个重大区别:如果你有多个 <Suspense/> 区域,你可以只对其中一个 <Suspense/> 阻塞,但对其他 <Suspense/> 仍然可以使用 fallback 并采用流式传输。

举个例子:想象一个博客页面。为了 SEO 和社交分享,我非常想在初始 HTML 的 <head> 中呈现博客文章的标题和摘要;但评论加载早或晚并不重要,我可以希望尽可能延迟加载它们。

用阻塞资源,你可以这么写:

#[component]
pub fn BlogPost() -> impl IntoView {
    let post_data = Resource::new_blocking(/* 加载博文内容 */);
    let comments_data = Resource::new(/* 加载评论 */);
    view! {
        <Suspense fallback=|| ()>
            {move || Suspend::new(async move {
                let data = post_data.await;
                view! {
                    <Title text=data.title/>
                    <Meta name="description" content=data.excerpt/>
                    <article>
                        /* 渲染文章内容 */
                    </article>
                }
            })}
        </Suspense>
        <Suspense fallback=|| "Loading comments...">
            {move || Suspend::new(async move {
                let comments = comments_data.await;
                todo!()
            })}
        </Suspense>
    }
}

第一个 <Suspense/>(也就是博客文章的正文)使用的是阻塞资源,因此会阻塞 HTML 流的返回,直到数据准备好。这样依赖该资源的 <meta><title> 等也会在服务器端就生成好。

如果再结合下面的路由定义(其中使用了 SsrMode::PartiallyBlocked),被阻塞的资源会在服务器端完整渲染,这让禁用 JavaScript 或不支持 JavaScript 的用户也能查看到文章内容:

<Routes fallback=|| "Not found.">
    // 首页使用不按顺序流式传输和 <Suspense/>
    <Route path=path!("") view=HomePage/>

    // 加载帖子时使用异步渲染,以便在加载完数据后才能设定
    // 标题和元数据
    <Route
        path=path!("/post/:id")
        view=Post
        ssr=SsrMode::PartiallyBlocked
    />
</Routes>

而第二个 <Suspense/>(评论部分)并不会阻塞流式传输。这样一来,“阻塞资源”能让你在 SEO 和用户体验之间做一个很好的平衡和细粒度控制:在满足搜索引擎和社交分享需要的同时,其他不太重要的数据依然可以采用更快的流式加载方式。

Hydration Bugs (以及如何避免它们)

一个思维实验

让我们做个实验来测试你的直觉。打开一个使用 cargo-leptos 进行服务器渲染的应用。(如果你之前只是使用 trunk 来运行示例,现在可以克隆一个 cargo-leptos 模板来完成这个练习。)

在你的根组件中放一个日志。(我通常将它命名为 <App/>,但任何名称都可以。)

#[component]
pub fn App() -> impl IntoView {
	logging::log!("where do I run?");
	// ... 其他代码
}

然后启动它:

cargo leptos watch

你认为 where do I run? 会在哪里打印日志?

  • 在运行服务器的命令行中?
  • 在加载页面时的浏览器控制台中?
  • 都不会?
  • 两个地方都打印?

试试看。

...

...

...

好的,以下是答案提示。

你会注意到它当然会在两个地方打印日志(假设一切正常)。实际上,在服务器上会打印两次——第一次是在服务器启动时,Leptos 渲染你的应用以提取路由树;第二次是在你发送请求时。每次你重新加载页面时,where do I run? 会在服务器和客户端各打印一次日志。

如果你思考一下前几节的描述,希望这会有意义。你的应用程序会在服务器上运行一次,构建一棵 HTML 树,并将其发送到客户端。在这次初始渲染期间,where do I run? 会在服务器上打印日志。

一旦 WASM 二进制文件加载到浏览器中,你的应用会第二次运行,遍历同一用户界面树并添加交互性。

这听起来像是浪费吗?从某种意义上来说确实是。但减少这种浪费是一个真正困难的问题。这正是一些 JS 框架(例如 Qwik)试图解决的问题,尽管目前判断它是否比其他方法在性能上有净增益可能还为时过早。

潜在的 Bug

好了,希望以上内容都让你理解了。但这些内容和本章标题“Hydration Bugs(及其避免方法)”有什么关系呢?

记住,应用程序需要同时运行在服务器和客户端上。这会产生一些潜在的问题,你需要知道如何避免。

服务器和客户端代码的不匹配

一个常见的 Bug 来源是服务器发送的 HTML 和客户端渲染的内容之间的不匹配。这种问题其实几乎不小心就发生(至少从我收到的 Bug 报告来看是这样的)。但想象我做了这样的事情:

#[component]
pub fn App() -> impl IntoView {
    let data = if cfg!(target_arch = "wasm32") {
        vec![0, 1, 2]
    } else {
        vec![]
    };
    data.into_iter()
        .map(|value| view! { <span>{value}</span> })
        .collect_view()
}

换句话说,如果被编译成 WASM,就会有三个元素;否则它是空的。

当我在浏览器中加载页面时,我什么都看不到。如果我打开控制台,会看到一个 panic:

ssr_modes.js:423 panicked at /.../tachys/src/html/element/mod.rs:352:14:
called `Option::unwrap()` on a `None` value

运行在浏览器中的 WASM 版本应用程序期望找到一些元素(事实上它期望找到三个元素!),但服务器发送的 HTML 中却没有这些内容。

解决方案

虽然你很少会有意这样做,但通过某种方式在服务器和浏览器中运行不同的逻辑,这种问题还是有可能发生。如果你看到类似的警告,并且认为不是你的问题,很可能是 <Suspense/> 或其他功能的 Bug。欢迎前往 GitHub 提交 issue参与讨论 寻求帮助。

无效/边缘情况的 HTML,以及 HTML 与 DOM 的不匹配

服务器通过 HTML 响应请求,浏览器然后将该 HTML 解析为称为文档对象模型(DOM)的树。在 Hydration 过程中,Leptos 会遍历应用程序的视图树:先 Hydrate 一个元素,然后进入它的子元素,Hydrate 第一个子元素,再移动到它的兄弟节点,以此类推。这假设应用程序在服务器上生成的 HTML 树与浏览器解析这些 HTML 后生成的 DOM 树完全对应。

以下是一些需要注意的情况,这些情况下由你的 view 创建的 HTML 树和 DOM 树可能并不完全对应,从而导致 Hydration 错误。

无效的 HTML

以下是一个会引发 Hydration 错误的简单应用:

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <p>
            <div class:blue=move || count.get() == 2>
                 "First"
            </div>
        </p>
    }
}

这会显示如下错误信息:

A hydration error occurred while trying to hydrate an element defined at src/app.rs:6:14.

The framework expected a text node, but found this instead:  <p></p>

The hydration mismatch may have occurred slightly earlier, but this is the first time the framework found a node of an unexpected type.

(在大多数浏览器的开发者工具中,你可以右键单击 <p></p>,查看它在 DOM 中的位置,非常方便。)

如果你查看 DOM 检查器,会发现 <p> 中没有 <div>,而是显示为:

<p></p>
<div>First</div>
<p></p>

这是因为这是无效的 HTML!<div> 不能放在 <p> 中。当浏览器解析到 <div> 时,它会自动关闭前面的 <p>,然后打开 <div>;随后,当它看到未匹配的 </p> 时,会将其视为一个新的、空的 <p>

因此,我们的 DOM 树不再匹配预期的视图树,进而导致 Hydration 错误。

目前,使用现有模型在编译时确保 HTML 的有效性是困难的,因为这会对整体编译时间造成影响。对于这种问题,可以考虑将 HTML 输出通过验证器进行检查。(如上例,W3C HTML Validator 的确会报告错误!)

Info

你可能会注意到,从 Leptos 0.6 迁移到 0.7 时,会出现一些相关的 Bug。这是因为 Hydration 的工作方式发生了变化。

Leptos 0.1-0.6 使用了一种通过为每个 HTML 元素分配唯一 ID 的 Hydration 方法,然后通过该 ID 在 DOM 中找到元素。而 Leptos 0.7 改为直接遍历 DOM,按顺序 Hydrate 每个元素。这种方法具有更好的性能特点(HTML 输出更简洁,Hydration 时间更短),但对上述无效或边缘情况的 HTML 例子更敏感。更重要的是,这种方法还修复了许多 其他 Hydration 中的边缘情况和 Bug,使框架在整体上更为健壮。

没有 <tbody><table>

还有一个边缘情况,即 有效 的 HTML 会生成与视图树不同的 DOM 树,这种情况出现在 <table> 中。当(大多数)浏览器解析 HTML 的 <table> 时,无论你是否包含 <tbody>,它们都会在 DOM 中插入一个 <tbody>

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <table>
            <tr>
                <td class:blue=move || count.get() == 0>"First"</td>
            </tr>
        </table>
    }
}

再次运行时,这会生成一个 Hydration 错误,因为浏览器在 DOM 树中插入了一个额外的 <tbody>,而这个 <tbody> 并没有出现在视图树中。

解决方法非常简单:添加 <tbody>

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <table>
            <tbody>
                <tr>
                    <td class:blue=move || count.get() == 0>"First"</td>
                </tr>
            </tbody>
        </table>
    }
}

(未来可以探索是否可以更轻松地为这个特殊的情况添加 lint,而不是为所有有效 HTML 添加 lint。)

一般建议

这类不匹配问题可能会很棘手。一般来说,我的调试建议如下:

  1. 右键单击错误信息中的元素,查看框架首次 发现 问题的位置。
  2. 对比该位置及其上方的 DOM,检查是否与视图树存在不匹配的情况。是否有多余的元素?是否缺少某些元素?

并非所有客户端代码都能在服务器上运行

假设你愉快地导入了一个像 gloo-net 这样的依赖库,之前你习惯用它来在浏览器中发起请求,并在一个服务器渲染的应用中将其用于 create_resource

你可能会立刻看到令人头痛的消息:

panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets'

糟糕。

不过,这其实是可以理解的。我们刚刚提到,应用需要同时运行在客户端和服务器端。

解决方案

以下是一些避免这种问题的方法:

  1. 只使用可以同时运行在服务器和客户端上的库。例如,reqwest 可以在这两种环境下发起 HTTP 请求。
  2. 在服务器和客户端使用不同的库,并使用 #[cfg] 宏进行区分。(点击此处查看示例。)
  3. 将仅客户端代码包装在 Effect::new。因为 Effects 只会在客户端运行,这是一种访问浏览器 API(且这些 API 在初始渲染时不需要)的有效方式。

例如,如果我想在信号变化时将某些内容存储到浏览器的 localStorage 中:

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
	let storage = gloo_storage::LocalStorage::raw();
	logging::log!("{storage:?}");
}

这段代码会 panic,因为在服务器渲染期间无法访问 LocalStorage

但如果我将其包装在一个 Effect 中:

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
    Effect::new(move |_| {
        let storage = gloo_storage::LocalStorage::raw();
		log!("{storage:?}");
    });
}

这样就没问题了!这段代码会在服务器上正确渲染,忽略仅客户端的代码,然后在浏览器中访问存储并打印日志消息。


并非所有服务器代码都能在客户端运行

运行在浏览器中的 WebAssembly 是一个非常受限的环境。你无法访问文件系统或许多标准库可能会用到的功能。并非所有的 crate 都能被编译为 WASM,更不用说在 WASM 环境中运行了。

尤其是,有时你会看到关于 mio crate 或缺少 core 中某些内容的错误。这通常表明你尝试将某些无法编译为 WASM 的代码编译成 WASM。如果你正在添加仅服务器使用的依赖,请在 Cargo.toml 中将它们标记为 optional = true,然后在 ssr 功能定义中启用它们。(可以查看模板的 Cargo.toml 文件获取更多详情。)

你可以使用 create_effect 来指定某些内容只在客户端运行,而不在服务器上运行。那么,有没有办法指定某些内容只在服务器上运行,而不在客户端运行呢?

事实上,有办法。下一章将详细介绍服务器函数的主题。(同时,你也可以查看它们的文档。)

与服务器协作

前一节描述了服务器端渲染的过程,即使用服务器生成页面的 HTML 版本,该页面随后将在浏览器中变得交互式。到目前为止,所有内容都是“同构的”;换句话说,你的应用在客户端和服务器上具有相同的“形状”(“iso”表示相同,“morphe”表示形状)。

但是,服务器不仅仅能渲染 HTML!实际上,服务器还能做很多浏览器 无法 完成的事情,比如读取和写入 SQL 数据库。

如果你习惯于构建 JavaScript 前端应用程序,你可能已经习惯通过某种 REST API 调用来完成这类服务器工作。如果你习惯用 PHP、Python 或 Ruby(或者 Java、C# 等)构建网站,这些服务器端的工作可能就是你的拿手好戏,而客户端的交互性往往是事后才考虑的事情。

使用 Leptos,你可以同时完成两者:不仅使用相同的语言、共享相同的类型,甚至可以在同一个文件中实现!

本节将讨论如何构建应用程序中那些仅限服务器端的部分。

服务器函数

如果你在创建任何超越简单应用的项目,那么你将需要始终在服务器上运行代码,比如:读取或写入只能在服务器上运行的数据库,使用你不想发送到客户端的库进行复杂计算,访问需要从服务器调用而非客户端调用的 API(例如为了避免 CORS 问题或保护存储在服务器上的秘密 API 密钥,这些密钥绝不能被传递到用户的浏览器中)。

传统上,这是通过分离服务器和客户端代码来完成的,并通过设置类似 REST API 或 GraphQL API 的方式让客户端能够在服务器上获取和修改数据。这种方式是可以的,但它需要你在多个不同的地方编写和维护代码(客户端代码用于获取数据,服务器端代码用于处理请求),还需要管理一个额外的东西——客户端和服务器之间的 API 契约。

Leptos 是一系列现代框架之一,它引入了**服务器函数(server functions)**的概念。服务器函数有两个关键特点:

  1. 服务器函数与组件代码共同定位,这使得你可以按功能组织工作,而不是按技术划分。例如,你可能需要一个“暗模式”功能,应该在多个会话中保留用户的深色/浅色模式偏好,并在服务器端渲染期间应用,以避免闪烁。这需要一个在客户端交互的组件,同时在服务器上完成一些工作(设置 cookie,甚至将用户存储到数据库中)。传统上,这一功能可能会被分散在代码中的两个不同位置,一个在“前端”,一个在“后端”。而使用服务器函数,你可能只需将它们一起写入一个 dark_mode.rs 文件即可。
  2. 服务器函数是同构的(isomorphic),即它们可以从服务器或浏览器调用。这是通过为两个平台生成不同的代码实现的。在服务器上,服务器函数直接运行。而在浏览器中,服务器函数的主体会被替换为一个存根(stub),实际上是向服务器发起一个 fetch 请求,将参数序列化到请求中,并从响应中反序列化返回值。在这两端,你都可以简单地调用该函数:比如你可以创建一个 add_todo 函数,它将数据写入数据库,并直接从浏览器中某个按钮的点击处理程序调用它!

使用服务器函数

举个例子,代码会是什么样子?其实非常简单。

// todo.rs

#[server]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
    let mut conn = db().await?;

    match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
        .bind(title)
        .execute(&mut conn)
        .await
    {
        Ok(_row) => Ok(()),
        Err(e) => Err(ServerFnError::ServerError(e.to_string())),
    }
}

#[component]
pub fn BusyButton() -> impl IntoView {
	view! {
        <button on:click=move |_| {
            spawn_local(async {
                add_todo("So much to do!".to_string()).await;
            });
        }>
            "Add Todo"
        </button>
	}
}

你会立即注意到以下几点:

  • 服务器函数可以使用仅限服务器的依赖项,比如 sqlx,并访问仅限服务器的资源,比如数据库。
  • 服务器函数是 async 的。即使它们在服务器上只执行同步工作,函数签名仍然需要是 async,因为从浏览器调用它们必须是异步的。
  • 服务器函数返回 Result<T, ServerFnError>。同样,即使它们只在服务器上执行不可能失败的工作,这也是必要的,因为 ServerFnError 的变体包括可能在网络请求过程中出现的各种错误。
  • 服务器函数可以从客户端调用。看看点击处理程序中的代码。这是仅在客户端运行的代码。但它可以调用 add_todo 函数(使用 spawn_local 来运行 Future),就像它是一个普通的异步函数一样:
move |_| {
	spawn_local(async {
		add_todo("So much to do!".to_string()).await;
	});
}
  • 服务器函数是顶级函数,用 fn 定义。与事件监听器、派生信号和 Leptos 中的其他大多数内容不同,它们不是闭包!作为 fn 调用,它们无法访问应用的反应式状态或未作为参数传入的其他内容。这很合理:当你向服务器发出请求时,服务器无法访问客户端状态,除非你明确地发送它。(否则我们将不得不序列化整个反应式系统,并在每次请求时将其发送到服务器。这并不是一个好主意。)
  • 服务器函数的参数和返回值都需要是可序列化的。同样,这应该很合理:虽然函数参数通常不需要序列化,但从浏览器调用服务器函数意味着需要将参数序列化并通过 HTTP 发送。

关于定义服务器函数的方式,还有以下几点需要注意:

  • 服务器函数通过使用 #[server] 宏注解顶级函数来创建,可以定义在任何位置。

服务器函数通过使用条件编译实现。在服务器上,服务器函数创建一个 HTTP 端点,该端点接收参数作为 HTTP 请求,并将结果返回为 HTTP 响应。而对于客户端/浏览器构建,服务器函数的主体将被替换为一个 HTTP 请求的存根。

Warning

关于安全性的重要提示

服务器函数是一种很酷的技术,但请记住:**服务器函数并不是魔法,它们只是定义公共 API 的语法糖。**服务器函数的“主体”从未公开;它只是服务器二进制文件的一部分。但服务器函数是一个公开可访问的 API 端点,其返回值只是一个 JSON 或类似的 blob。不要返回任何信息,除非它是公开的,或者你已经实施了适当的安全措施。这些措施可能包括验证传入请求、确保加密、限制访问速率等。

定制服务器函数

默认情况下,服务器函数将其参数编码为 HTTP POST 请求(使用 serde_qs),并将其返回值编码为 JSON(使用 serde_json)。这一默认行为旨在促进对 <form> 元素的兼容性,因为 <form> 元素本身支持发出 POST 请求,即使在 WASM 被禁用、不受支持或尚未加载的情况下。这些端点会挂载到一个哈希化的 URL,以防止名称冲突。

不过,服务器函数提供了许多自定义选项,包括支持的输入和输出编码、设置特定端点的能力等。

有关更多信息和示例,请查看 #[server] 宏、server_fn crate 的文档,以及 GitHub 中的 server_fns_axum 示例。

将服务器函数与 Leptos 集成

到目前为止,我们讨论的内容实际上是框架无关的。(实际上,Leptos 的服务器函数 crate 已经集成到 Dioxus 中!)服务器函数本质上是一种定义类似 RPC 调用的方式,利用了 Web 标准,如 HTTP 请求和 URL 编码。

但从某种意义上说,它们也为我们的故事补充了最后一个缺失的原语。由于服务器函数只是一个普通的 Rust 异步函数,它可以完美地与我们之前讨论的异步 Leptos 原语集成。所以你可以轻松地将你的服务器函数与应用的其他部分集成:

  • 创建资源,调用服务器函数从服务器加载数据。
  • <Suspense/><Transition/> 中读取这些资源,以在数据加载时启用流式 SSR 和回退状态。
  • 创建操作,调用服务器函数以在服务器上修改数据。

本书的最后部分将通过引入使用渐进增强的 HTML 表单运行这些服务器操作的模式,使其更加具体。

但在接下来的章节中,我们将实际看看如何处理服务器函数的细节,包括如何最好地与 Actix 和 Axum 服务器框架提供的强大提取器集成。

提取器(Extractors)

上一章的服务器函数展示了如何在服务器上运行代码,并将其与浏览器中渲染的用户界面集成。但它们并没有深入展示如何充分利用服务器的潜力。

服务器框架

我们称 Leptos 为“全栈”框架,但“全栈”总是有些夸大(毕竟,它并不意味着覆盖从浏览器到你的电力公司的所有层面)。对我们来说,“全栈”意味着你的 Leptos 应用可以在浏览器中运行,也可以在服务器上运行,并且可以将两者整合在一起,充分利用每一方的独特特性。正如我们在本书中看到的那样,浏览器中的一个按钮点击可以驱动服务器上的数据库读取,而这一切都可以写在同一个 Rust 模块中。但 Leptos 本身并不提供服务器(也不提供数据库、操作系统、固件或电缆...)。

相反,Leptos 提供了与 Rust 最流行的两个 Web 服务器框架的集成:Actix Web(leptos_actix)和 Axum(leptos_axum)。我们为每个服务器的路由器构建了集成,使你可以通过 .leptos_routes() 简单地将 Leptos 应用接入现有服务器,并轻松处理服务器函数调用。

如果你还没有查看我们的 Actix 模板Axum 模板,现在是个好时机去看看。

使用提取器

Actix 和 Axum 的处理程序基于相同的强大理念:提取器(extractors)。提取器从 HTTP 请求中“提取”类型化数据,使你可以轻松访问与服务器相关的数据。

Leptos 提供了 extract 辅助函数,允许你在服务器函数中直接使用这些提取器,并提供了类似于每个框架处理程序的便捷语法。

Actix 提取器

leptos_actix 中的 extract 函数 将处理程序函数作为参数。处理程序遵循与 Actix 处理程序类似的规则:它是一个异步函数,接收从请求中提取的参数,并返回一些值。处理程序函数将提取到的数据作为其参数,并可以在 async move 块中对其进行进一步异步操作。函数返回的值会被返回到服务器函数中。

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct MyQuery {
    foo: String,
}

#[server]
pub async fn actix_extract() -> Result<String, ServerFnError> {
    use actix_web::dev::ConnectionInfo;
    use actix_web::web::Query;
    use leptos_actix::extract;

    let (Query(search), connection): (Query<MyQuery>, ConnectionInfo) = extract().await?;
    Ok(format!("search = {search:?}\nconnection = {connection:?}",))
}

Axum 提取器

leptos_axum::extract 函数的语法非常类似。

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct MyQuery {
    foo: String,
}

#[server]
pub async fn axum_extract() -> Result<String, ServerFnError> {
    use axum::{extract::Query, http::Method};
    use leptos_axum::extract;

    let (method, query): (Method, Query<MyQuery>) = extract().await?;

    Ok(format!("{method:?} and {query:?}"))
}

这些是访问服务器基本数据的简单示例。但你可以使用提取器访问诸如请求头、cookies、数据库连接池等内容,使用的方式与上述 extract() 模式完全相同。

Axum 的 extract 函数仅支持状态为 () 的提取器。如果你需要一个使用 State 的提取器,可以使用 extract_with_state。这需要你提供状态,可以通过使用 Axum 的 FromRef 模式扩展现有的 LeptosOptions 状态,并在渲染和服务器函数期间通过上下文提供状态。

use axum::extract::FromRef;

/// 使用 Axum 的子状态模式派生 FromRef,以允许状态包含多个条目。
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
    pub leptos_options: LeptosOptions,
    pub pool: SqlitePool
}

点击这里查看在自定义处理程序中提供上下文的示例

Axum 状态

Axum 的典型依赖注入模式是提供一个 State,然后在路由处理程序中提取它。Leptos 提供了一种通过上下文进行依赖注入的方法。上下文通常可以代替 State 来提供共享的服务器数据(例如,一个数据库连接池)。

let connection_pool = /* 一些共享状态 */;

let app = Router::new()
    .leptos_routes_with_context(
        &app_state,
        routes,
        move || provide_context(connection_pool.clone()),
        App,
    )
    // 其他配置

然后可以在服务器函数中使用简单的 use_context::<T>() 来访问此上下文。

如果在服务器函数中必须使用 State——例如,你有一个现有的 Axum 提取器需要 State——也可以通过 Axum 的 FromRef 模式和 extract_with_state 实现。这需要你同时通过上下文和 Axum 路由状态提供状态:

#[derive(FromRef, Debug, Clone)]
pub struct MyData {
    pub value: usize,
    pub leptos_options: LeptosOptions,
}

let app_state = MyData {
    value: 42,
    leptos_options,
};

// 构建我们的应用
let app = Router::new()
    .leptos_routes_with_context(
        &app_state,
        routes,
        {
            let app_state = app_state.clone();
            move || provide_context(app_state.clone())
        },
        App,
    )
    .fallback(file_and_error_handler)
    .with_state(app_state);

// ...
#[server]
pub async fn uses_state() -> Result<(), ServerFnError> {
    let state = expect_context::<AppState>();
    let SomeStateExtractor(data) = extract_with_state(&state).await?;
    // todo
}

关于数据加载模式的说明

因为 Actix 和(尤其是)Axum 是基于单次 HTTP 请求和响应的模型构建的,因此你通常会在应用的“顶部”运行提取器(即在渲染之前),并使用提取到的数据来决定如何渲染。在渲染 <button> 之前,你会加载应用可能需要的所有数据。而任何给定的路由处理程序都需要知道该路由需要提取的所有数据。

但 Leptos 同时集成了客户端和服务器,因此能够刷新 UI 的小片段而无需完全重新加载所有数据非常重要。Leptos 更喜欢将数据加载“向下推”,尽量靠近用户界面的叶子节点。当你点击一个 <button> 时,它可以只刷新它需要的数据。这正是服务器函数的用途:它们让你能够以细粒度访问需要加载和重新加载的数据。

extract() 函数允许你通过在服务器函数中使用提取器,结合这两种模型。你可以获得路由提取器的全部功能,同时将提取需求分散到单个组件,从而更容易重构和重新组织路由,无需提前指定路由所需的所有数据。

响应和重定向

提取器(Extractors)为服务器函数提供了一种轻松访问请求数据的方式。而 Leptos 还提供了一种修改 HTTP 响应的方式,通过使用 ResponseOptions 类型(查看 Actix 文档Axum 文档),以及 redirect 辅助函数(查看 Actix 文档Axum 文档)。

ResponseOptions

ResponseOptions 在服务器初次渲染响应期间,以及在任何后续的服务器函数调用中,都会通过上下文(context)提供。它允许你轻松设置 HTTP 响应的状态码,或向 HTTP 响应中添加头部(例如设置 cookie)。

#[server]
pub async fn tea_and_cookies() -> Result<(), ServerFnError> {
    use actix_web::{
        cookie::Cookie,
        http::header::HeaderValue,
        http::{header, StatusCode},
    };
    use leptos_actix::ResponseOptions;

    // 从上下文中获取 ResponseOptions
    let response = expect_context::<ResponseOptions>();

    // 设置 HTTP 状态码
    response.set_status(StatusCode::IM_A_TEAPOT);

    // 在 HTTP 响应中设置一个 cookie
    let cookie = Cookie::build("biscuits", "yes").finish();
    if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
        response.insert_header(header::SET_COOKIE, cookie);
    }
    Ok(())
}

redirect

HTTP 响应中一个常见的修改是重定向到另一个页面。Actix 和 Axum 的集成提供了一个 redirect 函数,使这个过程变得非常简单。

#[server]
pub async fn login(
    username: String,
    password: String,
    remember: Option<String>,
) -> Result<(), ServerFnError> {
    // 从上下文中获取数据库连接池和认证提供者
    let pool = pool()?;
    let auth = auth()?;

    // 检查用户是否存在
    let user: User = User::get_from_username(username, &pool)
        .await
        .ok_or_else(|| {
            ServerFnError::ServerError("User does not exist.".into())
        })?;

    // 检查用户提供的密码是否正确
    match verify(password, &user.password)? {
        // 如果密码正确...
        true => {
            // 登录用户
            auth.login_user(user.id);
            auth.remember_user(remember.is_some());

            // 并重定向到主页
            leptos_axum::redirect("/");
            Ok(())
        }
        // 如果密码不正确,则返回错误
        false => Err(ServerFnError::ServerError(
            "Password does not match.".to_string(),
        )),
    }
}

这个服务器函数可以在你的应用中直接使用。redirect 辅助函数与 <ActionForm/> 组件的渐进增强功能兼容:在没有 JS/WASM 的情况下,服务器响应会通过状态码和头部完成重定向;而在启用 JS/WASM 的情况下,<ActionForm/> 会检测到服务器函数响应中的重定向,并通过客户端导航跳转到新的页面。

渐进增强(Progressive Enhancement)与优雅降级(Graceful Degradation)

我在波士顿开车已经差不多十五年了。如果你不了解波士顿,那让我告诉你:马萨诸塞州拥有全世界最具侵略性的司机(和行人!)。我学会了练习一种有时被称为“防御性驾驶”的方式:假设某人在你有路权的十字路口会突然转向你,假设行人会随时穿越街道,并相应地调整驾驶方式。

“渐进增强”就像是 Web 设计中的“防御性驾驶”。更确切地说,这也可以称为“优雅降级”,它们是同一过程的两面,从不同方向看待。

渐进增强在这个语境中指的是,从一个简单的 HTML 网站或应用开始,使其能够适配任何访问你页面的用户,然后逐步为其添加更多层次的功能:通过 CSS 实现样式,通过 JavaScript 实现交互,通过 WebAssembly 提供 Rust 驱动的交互;如果可用且必要,利用特定的 Web API 提供更丰富的体验。

优雅降级指的是,当增强栈中的某些部分不可用时,应用能够优雅地处理这种失败。以下是用户在你的应用中可能遇到的一些失败情况:

  • 他们的浏览器不支持 WebAssembly,因为需要更新。
  • 他们的浏览器无法支持 WebAssembly,因为浏览器更新受限于较新的操作系统版本,而设备无法安装这些系统。(苹果用户了解这一点!)
  • 他们出于安全或隐私原因关闭了 WASM。
  • 他们出于安全或隐私原因关闭了 JavaScript。
  • 他们的设备不支持 JavaScript(例如,一些仅支持 HTML 浏览的辅助设备)。
  • JavaScript(或 WASM)从未到达他们的设备,因为他们走出家门时失去了 WiFi。
  • 他们在加载初始页面后进入地铁,后续导航无法加载数据。
  • ……等等。

如果上述情况之一、两个甚至三个同时发生,你的应用还能正常运行多少?

如果答案是“95%……好吧,那么 90%……好吧,那么 75%”,那就是优雅降级。如果答案是“我的应用除非一切都正常,否则会显示空白屏幕”,那么这就是“快速且意外的解体”。

对于 WASM 应用来说,优雅降级尤为重要,因为 WASM 是运行在浏览器中的四种语言(HTML、CSS、JS、WASM)中最新且最可能不被支持的一种。

幸运的是,我们有一些工具可以帮到你。

防御性设计

以下实践可以帮助你的应用实现更优雅的降级:

  1. 服务器端渲染(SSR)。 如果没有 SSR,你的应用在 JS 和 WASM 都无法加载的情况下将无法运行。在某些情况下,这可能是可以接受的(例如登录后使用的内部应用),但在其他情况下,这就意味着“彻底坏掉”。
  2. 使用原生 HTML 元素。 使用无需额外代码即可实现功能的 HTML 元素:用于导航的 <a>(包括页面内的哈希导航)、用于手风琴效果的 <details>、用于在 URL 中持久化信息的 <form> 等。
  3. 基于 URL 的状态。 全局状态越多存储在 URL 中(作为路由参数或查询字符串的一部分),就越多页面可以在服务器渲染时生成,并通过 <a><form> 进行更新。这意味着不仅导航,状态变化也可以在没有 JS/WASM 的情况下工作。
  4. SsrMode::PartiallyBlockedSsrMode::InOrder 无序流式传输需要少量内联 JS,但在以下两种情况下可能失败:1)响应中途连接中断,2)客户端设备不支持 JS。异步流式传输会在所有资源加载后提供完整的 HTML 页面。有序流式传输会更快地按自上而下的顺序展示页面片段。“部分阻塞”模式在无序流式传输的基础上,通过替换服务器上阻塞资源的 <Suspense/> 片段,提供更完整的初始 HTML 响应。这会略微增加初始响应时间(因为需要执行 O(n) 的字符串替换),换来更完整的初始响应。这对一些场景特别有用,例如文章内容与评论、产品信息与评价之间有明显优先级的情况下。如果选择阻塞所有内容,你实际上就重新实现了异步渲染。
  5. 依赖 <form> 最近 <form> 再次成为关注的焦点,这并不令人惊讶。<form> 能以易于增强的方式管理复杂的 POSTGET 请求,使其成为实现优雅降级的强大工具。例如,<Form/> 章节中的示例,即使没有 JS/WASM 也能正常工作:因为它使用 <form method="GET"> 将状态持久化到 URL 中,能够通过纯 HTML 实现正常的 HTTP 请求,并在此基础上逐步增强为客户端导航。

框架中还有一个特性尚未展示,它基于 <form> 的这些特性,帮助构建功能强大的应用程序:<ActionForm/>

<ActionForm/>

<ActionForm/> 是一个特殊的 <Form/>,可以接收服务器操作,并在表单提交时自动调用它。这使得你可以直接从 <form> 调用服务器函数,即使没有启用 JS/WASM 也能运行。

实现过程非常简单:

  1. 使用 #[server] 定义一个服务器函数(详见 服务器函数)。
  2. 使用 ServerAction::new() 创建一个操作,指定你定义的服务器函数的类型。
  3. 创建一个 <ActionForm/>,并在 action 属性中提供服务器操作。
  4. 将服务器函数的命名参数作为表单字段,并确保名称一致。

注意: <ActionForm/> 仅支持服务器函数默认的 URL 编码 POST 格式,以确保作为 HTML 表单的正常行为和优雅降级。

#[server]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
    todo!()
}

#[component]
fn AddTodo() -> impl IntoView {
    let add_todo = ServerAction::<AddTodo>::new();
    // 保存服务器返回的最新值
    let value = add_todo.value();
    // 检查服务器是否返回错误
    let has_error = move || value.with(|val| matches!(val, Some(Err(_))));

    view! {
        <ActionForm action=add_todo>
            <label>
                "Add a Todo"
                // `title` 参数名称应与 `add_todo` 的参数一致
                <input type="text" name="title"/>
            </label>
            <input type="submit" value="Add"/>
        </ActionForm>
    }
}

就是这么简单!如果启用了 JS/WASM,表单会在不重新加载页面的情况下提交,将最近一次的提交值存储在操作的 .input() 信号中,并通过 .pending() 获取提交状态等。(如果需要,可以查看 Action 文档以了解更多内容。)如果没有 JS/WASM,表单将通过页面刷新提交。如果调用了 redirect 函数(来自 leptos_axumleptos_actix),它会正确跳转到指定页面。默认情况下,表单会重定向回当前页面。HTML、HTTP 和同构渲染的强大功能使得 <ActionForm/> 即使没有 JS/WASM 也能正常工作。

客户端验证

因为 <ActionForm/> 只是一个 <form>,它会触发 submit 事件。你可以使用 HTML 验证,或者通过 on:submit 添加自定义客户端验证逻辑。调用 ev.prevent_default() 可以阻止提交。

FromFormData trait 可以帮助解析提交表单中的服务器函数参数类型。

let on_submit = move |ev| {
	let data = AddTodo::from_event(&ev);
	// 简单的验证示例:如果任务为 "nope!",则阻止提交
	if data.is_err() || data.unwrap().title == "nope!" {
		// ev.prevent_default() 会阻止表单提交
		ev.prevent_default();
	}
}

Warning

此模式在 0.7 版本中由于事件委托的更改暂时不可用。如果你需要使用此功能,可以在 Cargo.toml 中为 leptos crate 启用 delegation 功能。阅读此问题以获取更多背景信息。

复杂输入

服务器函数的参数如果是具有嵌套可序列化字段的结构体,应使用 serde_qs 的索引表示法。

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct HeftyData {
    first_name: String,
    last_name: String,
}

#[component]
fn ComplexInput() -> impl IntoView {
    let submit = ServerAction::<VeryImportantFn>::new();

    view! {
      <ActionForm action=submit>
        <input type="text" name="hefty_arg[first_name]" value="leptos"/>
        <input
          type="text"
          name="hefty_arg[last_name]"
          value="closures-everywhere"
        />
        <input type="submit"/>
      </ActionForm>
    }
}

#[server]
async fn very_important_fn(hefty_arg: HeftyData) -> Result<(), ServerFnError> {
    assert_eq!(hefty_arg.first_name.as_str(), "leptos");
    assert_eq!(hefty_arg.last_name.as_str(), "closures-everywhere");
    Ok(())
}

部署

部署一个 Web 应用的方法多种多样,开发者有多少种方式,应用就有多少种。但在部署应用时,有一些通用的建议值得牢记。

通用建议

  1. 请务必:始终以 --release 模式构建 Rust 应用,而非调试模式(debug mode)。 这对性能和二进制文件的大小有巨大的影响。
  2. 在本地以 release 模式进行测试。 框架在 release 模式下会应用某些优化,而在调试模式下不会。因此,在这一阶段可能会出现一些问题。(如果你的应用行为不同或出现问题,很可能是框架级别的 bug,你应该在 GitHub 上提交 issue 并附上复现步骤。)
  3. 查看“优化 WASM 二进制文件大小”章节,了解更多技巧以进一步提高 WASM 应用首次加载的交互速度。

我们曾向用户征集他们的部署设置,用于帮助编写这一章节。以下会引用其中的内容,但你可以在此处阅读完整的讨论。

部署客户端渲染(CSR)应用

如果你开发的应用只使用客户端渲染(CSR),并使用 Trunk 作为开发服务器和构建工具,部署过程非常简单。

trunk build --release

trunk build 将会在 dist/ 目录中创建多个构建产物。只需将 dist 发布到某个在线服务器,即可完成应用的部署。这与部署任何 JavaScript 应用的过程非常相似。

我们创建了多个示例仓库,展示了如何将 Leptos 客户端渲染应用部署到不同的托管服务上。

注意:Leptos 不推荐使用任何特定的托管服务,你可以自由选择任何支持静态站点部署的服务。

示例:

Github Pages

将 Leptos 客户端渲染(CSR)应用部署到 Github Pages 非常简单。首先,进入你的 Github 仓库的设置页面,点击左侧菜单中的“Pages”。在页面的“Build and deployment”部分,将“Source”更改为“Github Actions”。然后,将以下内容复制到 .github/workflows/gh-pages-deploy.yml 文件中:

Example

name: Release to Github Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: write # for committing to gh-pages branch.
  pages: write
  id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  Github-Pages-Release:

    timeout-minutes: 10

    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4 # repo checkout

      # Install Rust Nightly Toolchain, with Clippy & Rustfmt
      - name: Install nightly Rust
        uses: dtolnay/rust-toolchain@nightly
        with:
          components: clippy, rustfmt

      - name: Add WASM target
        run: rustup target add wasm32-unknown-unknown

      - name: lint
        run: cargo clippy & cargo fmt


      # If using tailwind...
      # - name: Download and install tailwindcss binary
      #   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


      - name: Download and install Trunk binary
        run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.4/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-

      - name: Build with Trunk
        # "${GITHUB_REPOSITORY#*/}" evaluates into the name of the repository
        # using --public-url something will allow trunk to modify all the href paths like from favicon.ico to repo_name/favicon.ico .
        # this is necessary for github pages where the site is deployed to username.github.io/repo_name and all files must be requested
        # relatively as favicon.ico. if we skip public-url option, the href paths will instead request username.github.io/favicon.ico which
        # will obviously return error 404 not found.
        run: ./trunk build --release --public-url "${GITHUB_REPOSITORY#*/}"


      # Deploy to gh-pages branch
      # - name: Deploy 🚀
      #   uses: JamesIves/github-pages-deploy-action@v4
      #   with:
      #     folder: dist


      # Deploy with Github Static Pages

      - name: Setup Pages
        uses: actions/configure-pages@v4
        with:
          enablement: true
          # token:

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v2
        with:
          # Upload dist dir
          path: './dist'

      - name: Deploy to GitHub Pages 🚀
        id: deployment
        uses: actions/deploy-pages@v3

更多关于部署到 Github Pages 的信息,请参考此示例仓库

Vercel

第 1 步:设置 Vercel

在 Vercel 的 Web 界面中:

  1. 创建一个新项目。
  2. 确保以下设置正确:
    • 将“Build Command”留空并启用 Override。
    • 将“Output Directory”更改为 dist(这是 Trunk 构建的默认输出目录)并启用 Override。

第 2 步:为 GitHub Actions 添加 Vercel 凭据

注意:预览和部署操作都需要在 GitHub secrets 中设置 Vercel 凭据。

  1. 获取你的 Vercel Access Token,进入“Account Settings” > “Tokens”并创建一个新令牌——保存该令牌以便在后续步骤 5 中使用。

  2. 使用命令 npm i -g vercel 安装 Vercel CLI,然后运行 vercel login 登录到你的账户。

  3. 在项目文件夹中运行 vercel link 创建一个新的 Vercel 项目;在 CLI 中,当被问到“Link to an existing project?”时,回答“是”,然后输入你在步骤 1 中创建的项目名称。此操作将为你生成一个 .vercel 文件夹。

  4. 打开生成的 .vercel 文件夹中的 project.json 文件,保存其中的 projectIdorgId,以便在下一步使用。

  5. 在 GitHub 中,进入仓库的“Settings” > “Secrets and Variables” > “Actions”,将以下内容添加为仓库密钥

    • 将你的 Vercel Access Token(步骤 1 中创建的)保存为 VERCEL_TOKEN
    • .vercel/project.json 中的 projectId 保存为 VERCEL_PROJECT_ID
    • .vercel/project.json 中的 orgId 保存为 VERCEL_ORG_ID

完整说明请参阅:“如何将 Github Actions 与 Vercel 一起使用”

第 3 步:添加 GitHub Action 脚本

最后,只需将以下两个文件——一个用于部署,另一个用于 PR 预览——复制粘贴到你的 .github/workflows/ 文件夹中,或者从示例仓库的 .github/workflows/ 文件夹中复制它们。完成后,你的下一次提交或 PR 将会自动触发部署。

生产部署脚本:vercel_deploy.yml

Example

name: Release to Vercel

on:
push:
	branches:
	- main
env:
CARGO_TERM_COLOR: always
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
Vercel-Production-Deployment:
	runs-on: ubuntu-latest
	environment: production
	steps:
	- name: git-checkout
		uses: actions/checkout@v3

	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt
	- uses: Swatinem/rust-cache@v2
	- name: Setup Rust
		run: |
		rustup target add wasm32-unknown-unknown
		cargo clippy
		cargo fmt --check

	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release

	- name: Install Vercel CLI
		run: npm install --global vercel@latest

	- name: Pull Vercel Environment Information
		run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

	- name: Deploy to Vercel & Display URL
		id: deployment
		working-directory: ./dist
		run: |
		vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }} >> $GITHUB_STEP_SUMMARY
		echo $GITHUB_STEP_SUMMARY

预览部署脚本:vercel_preview.yml

Example

# For more info re: vercel action see:
# https://github.com/amondnet/vercel-action

name: Leptos CSR Vercel Preview

on:
pull_request:
	branches: [ "main" ]

workflow_dispatch:

env:
CARGO_TERM_COLOR: always
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
fmt:
	name: Rustfmt
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: rustfmt
	- name: Enforce formatting
		run: cargo fmt --check

clippy:
	name: Clippy
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy
	- uses: Swatinem/rust-cache@v2
	- name: Linting
		run: cargo clippy -- -D warnings

test:
	name: Test
	runs-on: ubuntu-latest
	needs: [fmt, clippy]
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
	- uses: Swatinem/rust-cache@v2
	- name: Run tests
		run: cargo test

build-and-preview-deploy:
	runs-on: ubuntu-latest
	name: Build and Preview

	needs: [test, clippy, fmt]

	permissions:
	pull-requests: write

	environment:
	name: preview
	url: ${{ steps.preview.outputs.preview-url }}

	steps:
	- name: git-checkout
		uses: actions/checkout@v4

	- uses: dtolnay/rust-toolchain@nightly
	- uses: Swatinem/rust-cache@v2
	- name: Build
		run: rustup target add wasm32-unknown-unknown

	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release

	- name: Preview Deploy
		id: preview
		uses: amondnet/vercel-action@v25.1.1
		with:
		vercel-token: ${{ secrets.VERCEL_TOKEN }}
		github-token: ${{ secrets.GITHUB_TOKEN }}
		vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
		vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
		github-comment: true
		working-directory: ./dist

	- name: Display Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.preview.outputs.preview-url }}" >> $GITHUB_STEP_SUMMARY

查看示例仓库了解更多信息。

Spin - 无服务器 WebAssembly

另一种选择是使用无服务器平台,例如 Spin。尽管 Spin 是开源的,可以在你自己的基础设施上运行(例如 Kubernetes 中),但在生产环境中使用 Spin 最简单的方法是使用 Fermyon Cloud。

首先按照此处的说明安装 Spin CLI,并为你的 Leptos CSR 项目创建一个 Github 仓库(如果尚未创建)。

  1. 打开“Fermyon Cloud” > “User Settings”。如果未登录,选择“Login With GitHub”按钮。

  2. 在“Personal Access Tokens”部分,选择“Add a Token”。输入名称“gh_actions”,然后点击“Create Token”。

  3. Fermyon Cloud 会显示生成的令牌;点击复制按钮将其复制到剪贴板。

  4. 打开你的 Github 仓库,进入“Settings” > “Secrets and Variables” > “Actions”,将 Fermyon Cloud 的令牌添加为“Repository secrets”,变量名为 FERMYON_CLOUD_TOKEN

  5. 将以下 Github Actions 脚本复制粘贴到 .github/workflows/<SCRIPT_NAME>.yml 文件中。

  6. 启用“预览”和“部署”脚本后,Github Actions 将在每次拉取请求(PR)中生成预览,并在更新主分支时自动部署。

生产部署脚本:spin_deploy.yml

Example

# For setup instructions needed for Fermyon Cloud, see:
# https://developer.fermyon.com/cloud/github-actions

# For reference, see:
# https://developer.fermyon.com/cloud/changelog/gh-actions-spin-deploy

# For the Fermyon gh actions themselves, see:
# https://github.com/fermyon/actions

name: Release to Spin Cloud

on:
push:
	branches: [main]
workflow_dispatch:

permissions:
contents: read
id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "spin"
cancel-in-progress: false

jobs:
Spin-Release:

	timeout-minutes: 10

	environment:
	name: production
	url: ${{ steps.deployment.outputs.app-url }}

	runs-on: ubuntu-latest

	steps:
	- uses: actions/checkout@v4 # repo checkout

	# Install Rust Nightly Toolchain, with Clippy & Rustfmt
	- name: Install nightly Rust
		uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt

	- name: Add WASM & WASI targets
		run: rustup target add wasm32-unknown-unknown && rustup target add wasm32-wasi

	- name: lint
		run: cargo clippy & cargo fmt


	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release


	# Install Spin CLI & Deploy

	- name: Setup Spin
		uses: fermyon/actions/spin/setup@v1
		# with:
		# plugins:


	- name: Build and deploy
		id: deployment
		uses: fermyon/actions/spin/deploy@v1
		with:
		fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
		# key_values: |-
			# abc=xyz
			# foo=bar
		# variables: |-
			# password=${{ secrets.SECURE_PASSWORD }}
			# apikey=${{ secrets.API_KEY }}

	# Create an explicit message to display the URL of the deployed app, as well as in the job graph
	- name: Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.deployment.outputs.app-url }}" >> $GITHUB_STEP_SUMMARY

预览部署脚本:spin_preview.yml

Example

# For setup instructions needed for Fermyon Cloud, see:
# https://developer.fermyon.com/cloud/github-actions


# For the Fermyon gh actions themselves, see:
# https://github.com/fermyon/actions

# Specifically:
# https://github.com/fermyon/actions?tab=readme-ov-file#deploy-preview-of-spin-app-to-fermyon-cloud---fermyonactionsspinpreviewv1

name: Preview on Spin Cloud

on:
pull_request:
	branches: ["main", "v*"]
	types: ['opened', 'synchronize', 'reopened', 'closed']
workflow_dispatch:

permissions:
contents: read
pull-requests: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "spin"
cancel-in-progress: false

jobs:
Spin-Preview:

	timeout-minutes: 10

	environment:
	name: preview
	url: ${{ steps.preview.outputs.app-url }}

	runs-on: ubuntu-latest

	steps:
	- uses: actions/checkout@v4 # repo checkout

	# Install Rust Nightly Toolchain, with Clippy & Rustfmt
	- name: Install nightly Rust
		uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt

	- name: Add WASM & WASI targets
		run: rustup target add wasm32-unknown-unknown && rustup target add wasm32-wasi

	- name: lint
		run: cargo clippy & cargo fmt


	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release


	# Install Spin CLI & Deploy

	- name: Setup Spin
		uses: fermyon/actions/spin/setup@v1
		# with:
		# plugins:


	- name: Build and preview
		id: preview
		uses: fermyon/actions/spin/preview@v1
		with:
		fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
		github_token: ${{ secrets.GITHUB_TOKEN }}
		undeploy: ${{ github.event.pull_request && github.event.action == 'closed' }}
		# key_values: |-
			# abc=xyz
			# foo=bar
		# variables: |-
			# password=${{ secrets.SECURE_PASSWORD }}
			# apikey=${{ secrets.API_KEY }}


	- name: Display Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.preview.outputs.app-url }}" >> $GITHUB_STEP_SUMMARY

查看示例仓库了解更多信息。

部署全栈 SSR 应用

可以将 Leptos 全栈 SSR 应用部署到多种服务器或容器托管服务中。将 Leptos SSR 应用投入生产的最简单方法可能是使用 VPS 服务,并在虚拟机中本地运行 Leptos(更多详细信息请参见此处)。或者,你可以将 Leptos 应用容器化,并在 PodmanDocker 中运行,无论是本地托管还是云服务器都可以。

部署设置和托管服务种类繁多,总体来说,Leptos 本身对你使用的部署方式持中立态度。考虑到这些不同的部署目标,我们将探讨以下内容:

注意:Leptos 并不推荐使用任何特定的部署方式或托管服务。

创建一个 Containerfile

目前,人们部署使用 cargo-leptos 构建的全栈应用最常见的方法是使用支持 Podman 或 Docker 构建的云托管服务。以下是一个示例 Containerfile / Dockerfile,基于我们用于部署 Leptos 网站的文件。

Debian

# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-bullseye as builder

# If you’re using stable, use this instead
# FROM rust:1.74-bullseye as builder

# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin

# Install required tools
RUN apt-get update -y \
  && apt-get install -y --no-install-recommends clang

# Install cargo-leptos
RUN cargo binstall cargo-leptos -y

# Add the WASM target
RUN rustup target add wasm32-unknown-unknown

# Make an /app dir, which everything will eventually live in
RUN mkdir -p /app
WORKDIR /app
COPY . .

# Build the app
RUN cargo leptos build --release -vv

FROM debian:bookworm-slim as runtime
WORKDIR /app
RUN apt-get update -y \
  && apt-get install -y --no-install-recommends openssl ca-certificates \
  && apt-get autoremove -y \
  && apt-get clean -y \
  && rm -rf /var/lib/apt/lists/*

# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# Copy the server binary to the /app directory
COPY --from=builder /app/target/release/leptos_start /app/

# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site

# Copy Cargo.toml if it’s needed at runtime
COPY --from=builder /app/Cargo.toml /app/

# Set any required env variables and
ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8080

# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# Run the server
CMD ["/app/leptos_start"]

Alpine

# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-alpine as builder

RUN apk update && \
    apk add --no-cache bash curl npm libc-dev binaryen

RUN npm install -g sass

RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/leptos-rs/cargo-leptos/releases/latest/download/cargo-leptos-installer.sh | sh

# Add the WASM target
RUN rustup target add wasm32-unknown-unknown

WORKDIR /work
COPY . .

RUN cargo leptos build --release -vv

FROM rustlang/rust:nightly-alpine as runner

WORKDIR /app

COPY --from=builder /work/target/release/leptos_start /app/
COPY --from=builder /work/target/site /app/site
COPY --from=builder /work/Cargo.toml /app/

ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT=./site
EXPOSE 8080

CMD ["/app/leptos_start"]

更多信息:用于 Leptos 应用程序的 gnumusl 构建文件。.

云部署

部署到 Fly.io

将 Leptos SSR 应用部署到 Fly.io 是一种选择。Fly.io 使用你的 Leptos 应用的 Dockerfile 定义,并将其运行在快速启动的微型虚拟机中。此外,Fly.io 提供了多种存储选项和托管数据库,可以与你的项目配合使用。以下示例展示了如何部署一个简单的 Leptos 入门应用,帮助你快速上手;如需使用 Fly.io 的存储选项,可以参阅此处获取更多信息。

首先,在应用程序的根目录中创建一个 Dockerfile,并填入推荐的内容(如上文所示);确保将 Dockerfile 示例中的二进制文件名称更新为你自己应用程序的名称,并根据需要进行其他调整。

确保已安装 flyctl CLI 工具,并在 Fly.io 上注册了一个账户。在 MacOS、Linux 或 Windows WSL 上安装 flyctl 的命令如下:

curl -L https://fly.io/install.sh | sh

如果遇到问题,或需要在其他平台上安装,请参阅完整安装说明

接下来登录 Fly.io:

fly auth login

然后手动启动你的应用程序:

fly launch

flyctl CLI 工具会引导你完成将应用部署到 Fly.io 的过程。

Note

默认情况下,Fly.io 会在一段时间内没有流量时自动停止运行的机器。虽然 Fly.io 的轻量级虚拟机启动速度很快,但如果你希望最小化 Leptos 应用的延迟,并确保其始终快速响应,可以进入生成的 fly.toml 文件,将 min_machines_running 的值从默认的 0 修改为 1。

更多详情请参阅 Fly.io 文档

如果你更倾向于使用 Github Actions 管理部署,需要通过 Fly.io 的 Web UI 创建一个新的访问令牌。

前往“Account” > “Access Tokens”,创建一个名为 "github_actions" 的令牌,然后进入 Github 项目的“Settings” > “Secrets and Variables” > “Actions”,创建一个名为 "FLY_API_TOKEN" 的新仓库密钥,将令牌添加进去。

要生成用于部署到 Fly.io 的 fly.toml 配置文件,需要首先在项目源目录中运行以下命令:

fly launch --no-deploy

这将创建一个新的 Fly 应用并注册到服务中。然后将生成的 fly.toml 文件提交到 Git 仓库。

最后,将以下内容复制到 .github/workflows/fly_deploy.yml 文件中,以设置 Github Actions 的部署工作流:

Example

# For more details, see: https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/

name: Deploy to Fly.io
on:
push:
	branches:
	- main
jobs:
deploy:
	name: Deploy app
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: superfly/flyctl-actions/setup-flyctl@master
	- name: Deploy to fly
		id: deployment
		run: |
		  flyctl deploy --remote-only | tail -n 1 >> $GITHUB_STEP_SUMMARY
		env:
		  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

在你下一次成功提交到 Github main 分支时,项目将自动部署到 Fly.io。

请参见此处的示例仓库

Railway

另一个云部署服务提供商是 Railway
Railway 与 GitHub 集成,可以自动部署你的代码。

有一个社区模板可以帮助你快速入门:

Deploy on Railway

该模板已经配置了 Renovate 来保持依赖项的最新,并支持在部署前使用 GitHub Actions 测试代码。

Railway 提供免费的套餐,无需信用卡注册,而 Leptos 的资源需求很小,这个免费套餐应该能支持很长时间。

请参阅示例仓库

部署到无服务器运行环境

Leptos 支持部署到 FaaS(Function as a Service)或“无服务器”运行环境,例如 AWS Lambda,以及与 WinterCG 兼容的 JavaScript 运行时(如 Deno 和 Cloudflare)。需要注意的是,与 VM 或容器类型的部署相比,无服务器环境对 SSR 应用的功能会有一些限制(请参阅以下说明)。

AWS Lambda

Cargo Lambda 工具的帮助下,Leptos SSR 应用可以部署到 AWS Lambda。一个使用 Axum 作为服务器的入门模板仓库可在 leptos-rs/start-aws 找到;该说明也可以适配用于 Leptos + Actix-web 的服务器。该入门模板仓库包括用于 CI/CD 的 GitHub Actions 脚本,以及设置 Lambda 函数和获取云部署所需凭据的说明。

但请注意,一些本地服务器功能无法在像 Lambda 这样的 FaaS 服务中正常工作,因为环境在不同请求之间不一定一致。特别是 'start-aws' 文档 提到:“由于 AWS Lambda 是一个无服务器平台,您需要更加小心如何管理长时间运行的状态。写入磁盘或使用状态提取器在请求之间无法可靠地工作。相反,您需要使用数据库或其他微服务来从 Lambda 函数中查询数据。”

另一个需要注意的因素是 FaaS 服务的“冷启动”时间——根据您的用例和所使用的 FaaS 平台,这可能会或可能不会满足您的延迟要求;如果需要优化请求速度,可能需要保持一个函数始终运行。

Deno 和 Cloudflare Workers

目前,Leptos-Axum 支持在 JavaScript 托管的 WebAssembly 运行时(例如 Deno、Cloudflare Workers 等)中运行。这种选择需要对源代码的设置进行一些更改(例如,在 Cargo.toml 中必须使用 crate-type = ["cdylib"] 定义应用,并为 leptos_axum 启用 "wasm" 功能)。Leptos HackerNews JS-fetch 示例 演示了所需的修改,并展示了如何在 Deno 运行时运行应用。此外,leptos_axum crate 文档 也是设置适用于 JS 托管 WASM 运行时的 Cargo.toml 文件时的有用参考。

虽然为 JS 托管 WASM 运行时的初始设置并不复杂,但需要注意一个重要限制:由于您的应用将在服务器端和客户端都被编译为 WebAssembly(wasm32-unknown-unknown),因此必须确保应用中使用的所有 crates 都支持 WASM。这可能是一个限制条件,因为 Rust 生态系统中的所有 crates 并非都支持 WASM。

如果您能接受 WASM 服务器端的限制,那么目前最好的起点是查看 Leptos 官方 GitHub 仓库中 使用 Deno 运行 Leptos 的示例

支持 Leptos 的平台

部署到 Spin 无服务器 WASI(支持 Leptos SSR)

服务器端的 WebAssembly 近年来发展迅速,开源的无服务器 WebAssembly 框架 Spin 的开发者正在努力实现对 Leptos 的原生支持。尽管 Leptos-Spin 的 SSR 集成还处于早期阶段,但已经有一个可用的示例可以尝试。

关于让 Leptos SSR 和 Spin 一起工作的完整说明,请参考 Fermyon 博客的这篇文章。如果您想跳过文章直接尝试一个可用的入门仓库,请点击这里

部署到 Shuttle.rs

许多 Leptos 用户询问是否可以使用对 Rust 友好的 Shuttle.rs 服务来部署 Leptos 应用。不幸的是,目前 Shuttle.rs 服务尚未正式支持 Leptos。

不过,Shuttle.rs 的开发团队承诺未来将实现对 Leptos 的支持。如果您想了解该工作的最新进展,请关注此 Github 问题

此外,已经有一些尝试让 Shuttle 与 Leptos 协作,但目前为止,部署到 Shuttle 云仍然未达到预期效果。如果您感兴趣,可以自行研究或贡献修复:Shuttle.rs 的 Leptos Axum 入门模板

优化 WASM 二进制文件大小

部署 Rust/WebAssembly 前端应用的主要缺点之一是将一个 WASM 文件拆分成可动态加载的小块比拆分 JavaScript 包更困难。虽然 Emscripten 生态中有类似 wasm-split 的实验,但目前还没有办法拆分和动态加载 Rust/wasm-bindgen 二进制文件。这意味着整个 WASM 二进制文件需要在应用变为交互式之前加载完毕。由于 WASM 格式设计为流式编译,WASM 文件的每千字节编译速度比 JavaScript 文件快得多。(更多详细内容,请参考 Mozilla 团队的一篇文章,讲述了 WASM 流式编译的原理。)

尽管如此,尽可能向用户提供最小的 WASM 二进制文件仍然很重要,这可以减少网络使用并让应用尽快变为可交互状态。

那么,有哪些实用的优化步骤呢?

优化措施

  1. 确保使用 release 构建。(Debug 构建会大得多。)
  2. 为 WASM 添加一个优化大小而非速度的 release 配置。

对于一个 cargo-leptos 项目,可以在 Cargo.toml 中添加以下内容:

[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1

# ....

[package.metadata.leptos]
# ....
lib-profile-release = "wasm-release"

这会让 WASM 的 release 构建针对大小进行高度优化,同时保持服务器端构建针对速度优化。(对于纯客户端渲染的应用,可以直接将 [profile.wasm-release] 配置用作 [profile.release]。)

  1. 在生产环境中始终使用压缩的 WASM 文件。
    WASM 通常压缩效果很好,未压缩大小的 50% 以下,使用 Actix 或 Axum 提供静态文件时可以轻松启用压缩。

  2. 如果使用 nightly Rust,可以用相同的配置重建标准库,而不是使用 wasm32-unknown-unknown 目标提供的预构建标准库。

在项目中创建 .cargo/config.toml 文件:

[unstable]
build-std = ["std", "panic_abort", "core", "alloc"]
build-std-features = ["panic_immediate_abort"]

注意:如果同时用于 SSR,这相同的 Cargo 配置也会被应用到服务器。需要明确指定目标:

[build]
target = "x86_64-unknown-linux-gnu" # 或其他目标

如果出现由于未设置 has_std 导致的构建错误,可以通过以下方式修复:

[build]
rustflags = ["--cfg=has_std"]

此外,需要在 Cargo.toml[profile.release] 中添加 panic = "abort"

  1. 序列化/反序列化代码会增加二进制大小。
    Leptos 默认使用 serde 处理资源的序列化和反序列化。可以尝试使用 miniserdeserde-lite,这些库实现了 serde 的子集功能,通常更注重优化大小而非速度。

避免的情况

某些 crates 会显著增加二进制文件大小。例如,regex crate 默认功能会增加大约 500kb(主要因为需要引入 Unicode 表数据)。在对大小敏感的场景下,可以考虑避免使用正则表达式,或者直接调用浏览器 API 使用内置正则引擎。(例如,leptos_router 在需要正则时就是这样做的。)

Rust 对运行时性能的承诺有时会与优化二进制大小相冲突。例如,Rust 会对泛型函数进行单态化,为每种调用类型创建单独的函数版本。这比动态调度更快,但会增加二进制文件大小。Leptos 在运行时性能与二进制大小之间进行了平衡;但如果你在代码中大量使用泛型,可能会增加二进制大小。例如,一个泛型组件体内包含大量代码,且被四种不同类型调用,编译器可能会包含这段代码的四个副本。可以通过重构为具体的内部函数或辅助函数,既保持性能又减少二进制大小。

最后一点思考

请记住,在服务器渲染的应用中,JS 包大小或 WASM 二进制文件大小只会影响一个方面:首次加载的交互时间。这对用户体验非常重要:没有人希望点击按钮三次却没反应,因为交互代码仍在加载——但这不是唯一重要的指标。

值得注意的是,流式加载一个完整的 WASM 二进制文件意味着后续导航几乎是即时的,仅取决于额外数据的加载。正因为 WASM 二进制文件不是按包拆分的,导航到新路由时不需要像 JavaScript 框架那样加载额外的 JS/WASM。这是权衡两种方法的真实体现。

始终优化应用中的“低垂果实”,同时在真实用户的网络速度和设备条件下测试应用效果,在采取复杂优化之前确保实际效果良好。

指南:Islands(岛屿架构)

Leptos 0.5 引入了新的 islands 功能。本指南将带你了解岛屿架构的核心概念,并通过一个示例应用来实现这一架构。

岛屿架构(Islands Architecture)

主流的 JavaScript 前端框架(如 React、Vue、Svelte、Solid、Angular)最初都是为构建客户端渲染的单页应用(SPA)而设计的。初始页面加载时渲染为 HTML,随后进行“hydration”(状态复原),之后的导航全部由客户端处理。(因此称为“单页”:所有内容都基于从服务器加载的一页,即使后来有客户端路由。)这些框架后来都增加了服务器端渲染(SSR),以改善初始加载时间、SEO 和用户体验。

这意味着默认情况下,整个应用都是交互式的。同时也意味着整个应用必须作为 JavaScript 文件发送到客户端以进行 hydration。Leptos 也遵循了这一模式。

你可以在服务器端渲染章节中了解更多相关内容。

但我们也可以反过来工作。与其构建一个完全交互式的应用、在服务器上渲染为 HTML 并在浏览器中进行 hydration,不如从一个纯 HTML 页面开始,在页面中添加小块交互区域。这是 2010 年之前任何网站或应用的传统形式:浏览器向服务器发出一系列请求,并为每个新页面返回 HTML。在“单页应用”(SPA)兴起后,这种方法有时被称为“多页应用”(MPA)。

近年来,“岛屿架构”(Islands Architecture)这一术语被用来描述从“服务器渲染的 HTML 页面海洋”开始,并在页面中添加“交互岛屿”的方法。

推荐阅读

本指南接下来的部分将介绍如何在 Leptos 中使用岛屿功能。如果你想了解这一方法的更多背景信息,可以参考以下文章:

激活 Islands 模式

让我们从一个新的 cargo-leptos 应用开始:

cargo leptos new --git leptos-rs/start-axum

在这个示例中,Actix 和 Axum 应该没有本质区别。

接下来我会运行:

cargo leptos build

然后打开编辑器继续修改代码。

首先,我会在 Cargo.toml 文件中为 leptos crate 添加 islands 功能:

leptos = { version = "0.7", features = ["islands"] }

接下来,我会修改 src/lib.rs 中导出的 hydrate 函数。我会移除调用 leptos::mount::mount_to_body(App) 的代码,并将其替换为:

leptos::mount::hydrate_islands();

这样,系统不会运行整个应用并对其视图进行 hydration,而是按顺序对每个独立的 island 进行 hydration。

app.rs 文件中,我们还需要在 shell 函数中的 HydrationScripts 组件添加 islands=true

<HydrationScripts options islands=true/>

现在,启动 cargo leptos watch 并访问 http://localhost:3000(或其他地址)。

点击按钮,结果是……

什么也没发生!

完美。

Note

初始模板在 hydrate() 函数定义中包含了 use app::*;。切换到 Islands 模式后,你将不再使用导入的主 App 函数,因此你可能认为可以删除这一行代码。(事实上,如果你不删除,Rust 的 lint 工具可能会发出警告!)

然而,如果你在使用 workspace 设置,这可能会引发问题。我们使用 wasm-bindgen 为每个函数单独导出一个入口点。根据我的经验,如果你的 frontend crate 中没有实际使用 app crate 的任何内容,那么这些绑定可能不会正确生成。更多讨论请见这里

使用 Islands

目前什么都没发生,因为我们完全颠覆了应用的思维模型。现在,应用默认是静态的 HTML,而不是默认交互式并对所有内容进行 hydration。我们需要主动选择哪些部分实现交互。

这种模式对 WASM 二进制文件大小有很大影响:如果以 release 模式编译,这个应用的 WASM 文件只有 24kb(未压缩),而非 Islands 模式下是 274kb。(274kb 对于“Hello, world!”来说相当大,主要是因为包含了与客户端路由相关的代码,而这些代码在这个示例中并未使用。)

当点击按钮时,什么都没发生,因为整个页面是静态的。

那么,我们如何让它变得交互式呢?

让我们将 HomePage 组件转换为一个 Island!

以下是非交互式版本:

#[component]
fn HomePage() -> impl IntoView {
    // 创建一个反应式值以更新按钮
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

以下是交互式版本:

#[island]
fn HomePage() -> impl IntoView {
    // 创建一个反应式值以更新按钮
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在,当我点击按钮时,它可以正常工作了!

#[island] 宏与 #[component] 宏的工作方式完全相同,但在 Islands 模式下,它将这个组件标记为交互式 Island。如果我们再次检查二进制大小,结果是 166kb(release 模式下未压缩),比完全静态版本的 24kb 大得多,但比完全 hydration 的 355kb 小得多。

如果你现在查看页面的源代码,会发现你的 HomePage Island 被渲染为一个特殊的 <leptos-island> HTML 元素,并指定了用于 hydration 的组件:

<leptos-island data-component="HomePage_7432294943247405892">
  <h1>Welcome to Leptos!</h1>
  <button>
    Click Me:
    <!>0
  </button>
</leptos-island>

只有这个 <leptos-island> 内的代码会被编译为 WASM,并且仅在 hydration 时运行这些代码。

高效使用 Islands

请记住,只有标记为 #[island] 的代码需要被编译为 WASM 并发送到浏览器。这意味着 Islands 应尽可能小而具体。例如,我的 HomePage 会更好地被拆分为一个普通组件和一个 Island:

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <Counter/>
    }
}

#[island]
fn Counter() -> impl IntoView {
    // 创建一个反应式值以更新按钮
    let (count, set_count) = signal(0);
    let on_click = move |_| *set_count.write() += 1;

    view! {
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在,<h1> 不需要包含在客户端包中,也不需要被 hydration。这看起来可能是一个微不足道的优化,但请注意,你现在可以向 HomePage 添加任意多的静态 HTML 内容,而 WASM 二进制文件的大小完全不会增加。

在常规的 hydration 模式下,WASM 二进制大小随着应用的规模和复杂性增长而增长。而在 Islands 模式下,WASM 二进制大小则与应用中交互部分的数量成正比。你可以在 Islands 之外添加任意多的非交互内容,它不会增加二进制文件大小。

解锁超级能力

将 WASM 二进制文件大小减少 50% 当然很好。但真正的意义是什么呢?

意义在于结合以下两点:

  1. #[component] 函数内部的代码现在在服务器上运行,除非你在 Island 中使用它。*
  2. 子节点和 props 可以从服务器传递到 Islands,而无需包含在 WASM 二进制文件中。

这意味着你可以直接在组件的主体中运行仅限服务器的代码,并将结果直接传递给子组件。在完全 hydration 的应用中需要复杂的服务器函数和 Suspense 的任务,现在可以直接在 Islands 中完成。

* 这里的“除非你在 Island 中使用它”很重要。这并不意味着 #[component] 组件只在服务器上运行。实际上,它们是“共享组件”,只有在 Island 的主体中使用时才会被编译进 WASM 二进制文件。但如果你不在 Island 中使用它们,它们不会在浏览器中运行。

在本示例的剩余部分,我们还将依赖以下事实:

  1. 上下文可以在原本独立的 Islands 之间传递。

因此,抛开计数器示例,我们来实现一个更有趣的东西:一个从服务器文件读取数据的标签式界面。

将服务器子节点传递给 Islands

Islands 的一个强大之处在于,你可以将服务器渲染的子节点传递给一个 Island,而无需该 Island 了解这些子节点的任何信息。Islands 会对自己的内容进行 hydration,但不会对传递给它的子节点进行 hydration。

正如 React 的 Dan Abramov 在类似的 React Server Components (RSCs) 场景中所说,Islands 其实并不是孤立的“岛屿”,它们更像是“甜甜圈”:你可以将仅限服务器的内容直接传递到“甜甜圈的中心洞”,从而创建交互的“小环礁”,周围被一片“静态服务器 HTML 的海洋”包围。

在下面的示例代码中,我添加了一些样式,将所有服务器内容显示为浅蓝色“海洋”,而 Islands 显示为浅绿色“陆地”。希望这能帮助你更直观地理解。

继续我们的示例:我将创建一个 Tabs 组件。切换选项卡需要一些交互性,因此这当然会是一个 Island。让我们从一个简单的版本开始:

#[island]
fn Tabs(labels: Vec<String>) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-between;">
            {buttons}
        </div>
    }
}

哎呀,这会报错:

error[E0463]: can't find crate for `serde`
  --> src/app.rs:43:1
   |
43 | #[island]
   | ^^^^^^^^^ can't find crate

这很好修复:运行 cargo add serde --features=derive#[island] 宏需要引入 serde,因为它需要对 labels prop 进行序列化和反序列化。

现在更新 HomePage 以使用 Tabs

#[component]
fn HomePage() -> impl IntoView {
	// 我们将读取的文件
    let files = ["a.txt", "b.txt", "c.txt"];
	// 标签的名字就是文件名
	let labels = files.iter().copied().map(Into::into).collect();
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels/>
    }
}

如果你查看 DOM 检查器,你会发现 Island 现在是这样的:

<leptos-island
  data-component="Tabs_1030591929019274801"
  data-props='{"labels":["a.txt","b.txt","c.txt"]}'
>
  <div style="display: flex; width: 100%; justify-content: space-between;;">
    <button>a.txt</button>
    <button>b.txt</button>
    <button>c.txt</button>
    <!---->
  </div>
</leptos-island>

我们的 labels prop 被序列化为 JSON 并存储在 HTML 属性中,以便用来对 Island 进行 hydration。

现在让我们添加一些选项卡。此时,一个 Tab Island 非常简单:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    view! {
        <div>{children()}</div>
    }
}

目前,每个选项卡只是一个 <div> 包裹着它的子节点。

Tabs 组件现在也会接收一些子节点:目前我们只是简单地显示它们。

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-around;">
            {buttons}
        </div>
        {children()}
    }
}

现在回到 HomePage,我们将创建选项卡列表并放入 Tabs 中。

#[component]
fn HomePage() -> impl IntoView {
    let files = ["a.txt", "b.txt", "c.txt"];
    let labels = files.iter().copied().map(Into::into).collect();
	let tabs = move || {
        files
            .into_iter()
            .enumerate()
            .map(|(index, filename)| {
                let content = std::fs::read_to_string(filename).unwrap();
                view! {
                    <Tab index>
                        <h2>{filename.to_string()}</h2>
                        <p>{content}</p>
                    </Tab>
                }
            })
            .collect_view()
    };

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels>
            <div>{tabs()}</div>
        </Tabs>
    }
}

嗯……等等?

如果你熟悉 Leptos,你知道这是行不通的。组件主体中的代码必须在服务器上运行(渲染为 HTML)并在浏览器中运行(进行 hydration),因此你不能直接调用 std::fs;它会 panic,因为浏览器无法访问本地文件系统(更不用说服务器的文件系统了)。这会成为安全噩梦!

但是……等等。我们现在使用的是 Islands 模式。这个 HomePage 组件确实只在服务器上运行。因此,我们实际上可以像这样使用普通的服务器端代码。

**这是不是一个愚蠢的示例?**是的!在 .map() 中同步读取三个本地文件在现实场景中并不是一个好选择。这里的重点只是演示这确实是仅限服务器的内容。

接下来,在项目根目录中创建三个文件:a.txtb.txtc.txt,并填入一些内容。

刷新页面,你应该会在浏览器中看到这些内容。编辑这些文件并再次刷新,内容会更新。

你可以将仅限服务器的内容从 #[component] 传递到 #[island] 的子节点中,而无需 Island 知道如何访问或渲染这些数据。

这非常重要。 将服务器子节点传递给 Islands 意味着你可以保持 Islands 足够小。理想情况下,你不会将整个页面的大块内容都标记为 #[island]。你应该将交互部分拆分出来作为 #[island],并将大量额外的服务器内容作为子节点传递给这个 Island,以便将交互区域中的非交互部分排除在 WASM 二进制文件之外。

在 Islands 之间传递上下文

目前,这些“选项卡”还不是真正的选项卡:它们总是显示所有内容。因此,让我们为 TabsTab 组件添加一些简单的逻辑。

我们将修改 Tabs,创建一个简单的 selected 信号。通过上下文提供 ReadSignal 的部分,并在用户点击按钮时设置该信号的值。

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let (selected, set_selected) = signal(0);
    provide_context(selected);

    let buttons = labels
        .into_iter()
        .enumerate()
        .map(|(index, label)| view! {
            <button on:click=move |_| set_selected.set(index)>
                {label}
            </button>
        })
        .collect_view();
    // ...
    view! {
        <div style="display: flex; width: 100%; justify-content: space-around;">
            {buttons}
        </div>
        {children()}
    }
}

然后修改 Tab Island,使用上下文来决定是否显示自身:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    let selected = expect_context::<ReadSignal<usize>>();
    view! {
        <div
            style:background-color="lightgreen"
            style:padding="10px"
            style:display=move || if selected.get() == index {
                "block"
            } else {
                "none"
            }
        >
            {children()}
        </div>
    }
}

现在,选项卡的行为完全符合预期。Tabs 通过上下文将信号传递给每个 TabTab 根据该信号决定是否显示自身。

这就是为什么在 HomePage 中,我将 let tabs = move || 定义为一个函数,并以 {tabs()} 的形式调用它:以这种方式延迟创建选项卡,确保在每个 Tab 查找上下文时,Tabs Island 已经提供了 selected 上下文。

我们的完整选项卡示例大约是 200kb(未压缩):虽然不是最小的示例,但仍然显著小于我们最初使用客户端路由的“Hello, world”示例!为了测试,我用非 Islands 模式构建了相同的示例,使用 #[server] 函数和 Suspense,二进制大小超过了 400kb。因此,再次证明 Islands 模式实现了大约 50% 的二进制大小节省。而且这个应用包含的服务器端内容非常少:请记住,当我们添加额外的服务器端组件和页面时,这 200kb 的大小不会增加。

概述

这个示例看起来可能很基础,确实如此。但它带来了几个显而易见的收获:

  • 50% 的 WASM 二进制大小减少,这意味着客户端的交互时间和初始加载时间得到了显著改善。
  • 降低数据序列化成本。在客户端创建一个资源并读取它意味着需要序列化数据以用于 hydration。如果你还读取了该数据以在 Suspense 中生成 HTML,那么会产生“双重数据”,即相同的数据既被渲染为 HTML,又被序列化为 JSON。这会增加响应大小,从而减慢加载速度。
  • 轻松使用服务器专属 API。在 #[component] 中使用服务器端 API,就像运行在服务器上的普通 Rust 函数一样——因为在 Islands 模式中,它确实如此!
  • 减少 #[server]/create_resource/Suspense 样板代码,用于加载服务器数据。

未来探索

islands 功能反映了当前前端 Web 框架正在探索的最前沿工作。就目前来看,我们的 Islands 方法与 Astro(在最近支持 View Transitions 之前)非常相似:它允许你构建一个传统的服务器渲染多页应用,并几乎无缝地集成交互的 Islands。

以下是一些可以轻松添加的小改进,例如,我们可以引入类似 Astro 的 View Transitions 方法:

  • 为 Islands 应用添加客户端路由,通过从服务器获取后续导航并用新 HTML 文档替换旧文档。
  • 使用 View Transitions API 在新旧文档之间添加动画过渡。
  • 支持显式的持久 Islands,例如,你可以为 Islands 标记唯一 ID(类似于在视图中的组件上使用 persist:searchbar),以便将它们从旧文档复制到新文档,而不会丢失当前状态。

同时,还有一些较大的架构更改,但我目前还未完全认可

附加信息

更多讨论请参考 islands 示例路线图、以及 Hackernews 示例

示例代码

use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <main style="background-color: lightblue; padding: 10px">
            <HomePage/>
        </main>
    }
}

/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
    let files = ["a.txt", "b.txt", "c.txt"];
    let labels = files.iter().copied().map(Into::into).collect();
    let tabs = move || {
        files
            .into_iter()
            .enumerate()
            .map(|(index, filename)| {
                let content = std::fs::read_to_string(filename).unwrap();
                view! {
                    <Tab index>
                        <div style="background-color: lightblue; padding: 10px">
                            <h2>{filename.to_string()}</h2>
                            <p>{content}</p>
                        </div>
                    </Tab>
                }
            })
            .collect_view()
    };

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels>
            <div>{tabs()}</div>
        </Tabs>
    }
}

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let (selected, set_selected) = signal(0);
    provide_context(selected);

    let buttons = labels
        .into_iter()
        .enumerate()
        .map(|(index, label)| {
            view! {
                <button on:click=move |_| set_selected.set(index)>
                    {label}
                </button>
            }
        })
        .collect_view();
    view! {
        <div
            style="display: flex; width: 100%; justify-content: space-around;\
            background-color: lightgreen; padding: 10px;"
        >
            {buttons}
        </div>
        {children()}
    }
}

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    let selected = expect_context::<ReadSignal<usize>>();
    view! {
        <div
            style:background-color="lightgreen"
            style:padding="10px"
            style:display=move || if selected.get() == index {
                "block"
            } else {
                "none"
            }
        >
            {children()}
        </div>
    }
}

附录:反应式系统如何工作?

要成功使用 Leptos,你无需深入了解反应式系统的具体工作原理。但当你开始以更高级的方式使用框架时,了解其幕后运行机制会非常有帮助。

反应式系统的原语分为三类:

  • 信号(Signals)ReadSignal/WriteSignalRwSignalResourceTrigger):可以主动更改以触发反应式更新的值。
  • 计算(Computations)Memo):依赖于信号(或其他计算),通过某些纯计算派生出新的反应式值。
  • 效果(Effects):观察者,监听某些信号或计算的变化并运行一个函数,从而产生某种副作用。

派生信号(Derived Signals)是一种非原语计算:它们是简单的闭包,只是允许你将一些重复的基于信号的计算重构为可重用的函数,可以在多个地方调用,但它们并未在反应式系统中作为节点表示。

其他所有原语实际上都存在于反应式系统中,作为反应式图中的节点。

反应式系统的大部分工作是将信号的更改传播到效果中,可能会经过一些中间的 Memo

反应式系统假定,效果(例如渲染到 DOM 或发出网络请求)的成本远远高于在应用中更新一个 Rust 数据结构。

因此,反应式系统的主要目标尽可能少地运行效果

Leptos 通过构建反应式图来实现这一点。

Leptos 当前的反应式系统在很大程度上基于 JavaScript 的 Reactively 库。你可以阅读 Milo 的文章《Super-Charging Fine-Grained Reactivity》,这是一篇关于其算法以及细粒度反应式系统的优秀说明,其中包括一些非常漂亮的图表!

反应式图

信号(signals)、计算(memos)和效果(effects)都具有以下三个特性:

  • 值(Value):它们有一个当前值:要么是信号的值,要么是(对于 memos 和 effects)上一次运行返回的值(如果有)。
  • 来源(Sources):它们依赖的其他反应式原语。(对于信号,这始终是一个空集。)
  • 订阅者(Subscribers):依赖它们的其他反应式原语。(对于效果,这始终是一个空集。)

实际上,信号、memos 和效果只是“反应式图”中“节点”这一通用概念的常规名称。信号始终是“根节点”,没有来源/父节点。效果始终是“叶节点”,没有订阅者。memos 通常既有来源又有订阅者。

在以下示例中,我将使用 nightly 语法,这样可以减少文档的冗长,便于阅读,而非复制粘贴。

简单依赖关系

想象以下代码:

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
Effect::new(move |_| {
	log!("{}", name_upper());
});

set_name("Bob");

这里的反应式图非常清晰:name 是唯一的信号/源节点,Effect::new 是唯一的效果/终端节点,中间有一个 memo。

A   (name)
|
B   (name_upper)
|
C   (the effect)

分裂分支

让我们增加一些复杂性:

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("len = {}", name_len());
});

// E
Effect::new(move |_| {
	log!("name = {}", name_upper());
});

这也很直观:一个信号源 name(A)分裂为两条平行的分支:name_upper(B)和 name_len(C),每条分支都有一个依赖它的效果。

 __A__
|     |
B     C
|     |
E     D

现在更新信号:

set_name("Bob");

日志会立即输出:

len = 3
name = BOB

再试一次:

set_name("Tim");

日志显示:

name = TIM

len = 3 不会再次记录。

记住:反应式系统的目标是尽可能减少运行效果的次数。将 name"Bob" 改为 "Tim" 会导致每个 memo 重新运行。但只有当它们的值实际上发生变化时,它们才会通知其订阅者。"BOB""TIM" 不同,因此相关效果会再次运行。但两个名字的长度都是 3,因此相关效果不会再次运行。

合并分支

我们来看一个被称为菱形问题(diamond problem)的例子:

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("{} is {} characters long", name_upper(), name_len());
});

这个例子的反应式图看起来像这样:

 __A__
|     |
B     C
|     |
|__D__|

你可以看到为什么它被称为“菱形问题”。如果用直线而不是 ASCII 艺术来连接这些节点,它会形成一个菱形:两个 memos 分别依赖于一个信号,并最终合并到一个效果中。

一个简单的基于推送的反应式实现会导致这个效果运行两次,这会很糟糕。(记住,我们的目标是尽可能少地运行效果。)例如,可以实现一种反应式系统,让信号和 memos 将它们的更改立即推送到整个图的下游,基本上是以深度优先的方式遍历图。换句话说,更新 A 会通知 B,然后通知 D;接着 A 再通知 C,然后再次通知 D。这既低效(D 运行了两次),又容易出错(D 在第一次运行时实际上使用了第二个 memo 的不正确值)。

解决菱形问题

任何值得信赖的反应式实现都会致力于解决这个问题。解决方法有多种(再次推荐阅读 Milo 的文章 获取全面的概览)。

以下是 Leptos 的实现方式的简要说明。

一个反应式节点始终处于以下三种状态之一:

  • Clean:已知没有更改。
  • Check:可能已更改。
  • Dirty:确定已更改。

更新信号会将信号标记为 Dirty,并递归地将其所有后代标记为 Check。任何后代中是效果的节点会被加入到队列中以便重新运行。

    ____A (DIRTY)___
   |               |
B (CHECK)    C (CHECK)
   |               |
   |____D (CHECK)__|

接下来,效果会被运行。(此时,所有效果都会被标记为 Check。)在重新运行其计算之前,效果会检查其父节点是否为 Dirty。因此:

  1. D 检查 B 是否为 Dirty
  2. B 被标记为 Check,因此 B 会检查它的父节点 A
    • B 发现 ADirty
    • 这意味着 B 需要重新运行,因为它的来源之一发生了更改。
    • B 重新运行,生成一个新值,并标记自己为 Clean
    • 由于 B 是一个 memo,它会将之前的值与新值进行比较。
    • 如果它们相同,B 返回“没有更改”;否则,返回“有更改”。
  3. 如果 B 返回“有更改”,D 知道需要运行并立即重新运行,而无需检查其他来源。
  4. 如果 B 返回“没有更改”,D 会继续检查 CC 的处理方式与 B 相同)。
  5. 如果 BC 都没有更改,效果不需要重新运行。
  6. 如果 BC 中的任意一个更改,效果将会重新运行。

因为效果只会被标记为 Check 一次并且只会被加入队列一次,所以它只会运行一次。

如果简单版本是一个“基于推送”的反应式系统,通过图的下游推送反应式更改并导致效果运行两次,那么这种实现可以称为“推-拉”系统。它将 Check 状态推送到图的下游,然后“拉”回来。在大规模图中,系统可能需要在图中上下左右来回移动,以准确确定哪些节点需要重新运行。

重要的权衡:基于推送的反应式系统传播信号更改的速度更快,但代价是过度运行 memos 和效果。记住:反应式系统的设计目标是最小化效果的运行次数,基于一个正确的假设:副作用的成本远远高于这种完全在库的 Rust 代码中进行的、缓存友好的图遍历。衡量一个反应式系统的好坏,不是看它传播更改的速度有多快,而是看它传播更改时能否避免过度通知

Memos 与 Signals 的区别

需要注意的是,信号(signals)始终会通知它们的子节点。也就是说,当信号更新时,它总是会被标记为 Dirty,即使它的新值与旧值相同。否则,我们就必须要求信号实现 PartialEq,但对某些类型来说,这可能是非常昂贵的检查。(例如,像 some_vec_signal.update(|n| n.pop()) 这样的操作很明显会发生变化,因此增加不必要的相等性检查没有意义。)

Memos 则会在通知它们的子节点之前检查它们是否发生了变化。Memos 的计算只会运行一次,无论你调用 .get() 多少次,但它会在其来源信号发生变化时运行。这意味着,如果 memo 的计算非常昂贵,你可能需要对其输入进行缓存(memoize),以确保只有当输入发生变化时才重新计算。

Memos 与派生信号(Derived Signals)的区别

以上机制说明了 Memos 的强大之处,但大多数实际应用的反应式图往往较浅且较宽:你可能有 100 个来源信号和 500 个效果,但没有 Memo,或者在极少数情况下,信号与效果之间可能有三四个 Memo。Memos 非常擅长限制通知其订阅者的频率,但如前文所述,它们也带来了一些开销:

  1. PartialEq 检查:这可能会非常昂贵,具体取决于类型。
  2. 增加内存开销:在反应式系统中存储另一个节点需要额外的内存。
  3. 增加计算开销:图遍历会消耗更多计算资源。

在计算本身比上述反应式处理更便宜的情况下,应该避免过度使用 Memo,而是直接使用派生信号。以下是一个永远不应该使用 Memo 的示例:

let (a, set_a) = signal(1);
// 以下这些计算不需要使用 Memo
let b = move || a() + 2;
let c = move || b() % 2 == 0;
let d = move || if c() { "even" } else { "odd" };

set_a(2);
set_a(3);
set_a(5);

尽管从技术上讲,Memo 可以在将 a3 设置为 5 之间节省一次额外的 d 计算,但这些计算本身比反应式算法更便宜。

在某些情况下,你可能只考虑在运行某些昂贵的副作用之前缓存最后一个节点:

let text = Memo::new(move |_| {
    d()
});
Effect::new(move |_| {
    engrave_text_into_bar_of_gold(&text());
});

这使得 Memo 的使用仅在真正需要时才会进行,避免了不必要的开销。

附录:信号的生命周期

在使用 Leptos 时,中级开发者经常会遇到以下三个问题:

  1. 如何连接到组件的生命周期,在组件挂载(mount)或卸载(unmount)时运行某些代码?
  2. 我如何知道信号(signal)何时被销毁?为什么有时在尝试访问被销毁的信号时会出现 panic?
  3. 信号是如何实现 Copy 的,并且可以在闭包和其他结构中移动而无需显式克隆?

这三个问题的答案密切相关,而且每个问题的答案都比较复杂。本附录将为你提供理解这些答案的上下文,帮助你正确地推理应用的代码及其运行方式。

组件树 vs 决策树

考虑以下简单的 Leptos 应用:

use leptos::logging::log;
use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    let (count, set_count) = signal(0);

    view! {
        <button on:click=move |_| *set_count.write() += 1>"+1"</button>
        {move || if count.get() % 2 == 0 {
            view! { <p>"Even numbers are fine."</p> }.into_any()
        } else {
            view! { <InnerComponent count/> }.into_any()
        }}
    }
}

#[component]
pub fn InnerComponent(count: ReadSignal<usize>) -> impl IntoView {
    Effect::new(move |_| {
        log!("count is odd and is {}", count.get());
    });

    view! {
        <OddDuck/>
        <p>{count}</p>
    }
}

#[component]
pub fn OddDuck() -> impl IntoView {
    view! {
        <p>"You're an odd duck."</p>
    }
}

这个应用只显示一个计数按钮,然后根据计数是否为偶数显示不同的消息。如果是奇数,还会在控制台中记录值。

一种映射这个简单应用的方法是绘制嵌套组件的树:

App 
|_ InnerComponent
   |_ OddDuck

另一种方法是绘制决策点的树:

root
|_ is count even?
   |_ yes
   |_ no

如果将两者结合起来,会发现它们并不完全对应。决策树将我们在 InnerComponent 中创建的视图分为三个部分,并将 InnerComponent 的一部分与 OddDuck 组件合并:

DECISION            COMPONENT           DATA    SIDE EFFECTS
root                <App/>              (count) render <button>
|_ is count even?   <InnerComponent/>
   |_ yes                                       render even <p>
   |_ no                                        start logging the count 
                    <OddDuck/>                  render odd <p> 
                                                render odd <p> (in <InnerComponent/>!)

通过观察这张表,我注意到以下几点:

  1. 组件树和决策树并不匹配:“计数是否为偶数?”的决策将 <InnerComponent/> 分为三个部分(一个永远不变,一个在计数为偶时渲染,一个在计数为奇时渲染),并将其中一个部分与 <OddDuck/> 组件合并。
  2. 决策树和副作用列表完全对应:每个副作用都在特定的决策点创建。
  3. 决策树和数据树也一致。虽然表中只有一个信号,但可以看出,与组件可以包含多个决策或不包含决策不同,信号始终在决策树中的特定位置创建。

关键是:数据的结构和副作用的结构决定了应用程序的实际功能。组件的结构只是为了方便书写。你不需要,也不应该关心哪个组件渲染了哪个 <p> 标签,或哪个组件创建了记录值的副作用。重要的是,这些操作发生在正确的时间点。

在 Leptos 中,组件是不存在的。也就是说:你可以将应用程序写成一个组件树,因为这很方便;我们也提供了一些围绕组件的调试工具和日志记录,因为这也很方便。但你的组件在运行时并不存在:组件不是变更检测或渲染的单位。它们只是函数调用。你可以将整个应用写在一个大组件里,也可以拆分成一百个组件,这不会影响运行时的行为,因为组件本质上并不存在。

另一方面,决策树确实存在,而且非常重要!

决策树、渲染与所有权

每个决策点都是某种类型的反应式语句:一个信号或一个随时间变化的函数。当你将一个信号或函数传递给渲染器时,渲染器会自动将它包裹在一个订阅信号的 effect 中,并随时间更新视图。

这意味着当你的应用被渲染时,它会创建一个嵌套 effect 的树,与决策树完美对应。伪代码如下:

// root
let button = /* 渲染一次 <button> */;

// 渲染器为 `move || if count() ...` 包裹了一个 effect
Effect::new(|_| {
    if count.get() % 2 == 0 {
        let p = /* 渲染偶数的 <p> */;
    } else {
        // 用户创建了一个记录计数的 effect
        Effect::new(|_| {
            log!("count is odd and is {}", count.get());
        });

        let p1 = /* 渲染 OddDuck 的 <p> */;
        let p2 = /* 渲染第二个 <p> */;

        // 渲染器创建了一个更新第二个 <p> 的 effect
        Effect::new(|_| {
            // 使用信号更新 <p> 的内容
            p2.set_text_content(count.get());
        });
    }
})

每个反应式值都包裹在自己的 effect 中,用于更新 DOM 或处理信号变化的其他副作用。但这些 effects 不需要永远运行。例如,当 count 从奇数切换回偶数时,第二个 <p> 不再存在,因此用于更新它的 effect 不再有用。与其让这些 effects 永远运行,不如在创建它们的决策发生变化时取消它们。更具体地说:effects 在创建它们的 effect 重新运行时会被取消。如果它们是在一个条件分支中创建的,而重新运行时经过了相同的分支,则会重新创建 effect;否则不会。

从反应式系统的角度来看,你的应用的“决策树”实际上是一个反应式的“所有权树”。简单来说,一个反应式“所有者”是当前正在运行的 effect 或 memo。它拥有在其中创建的 effects,这些 effects 拥有它们自己的子级,依此类推。当一个 effect 即将重新运行时,它会首先“清理”它的子级,然后再运行。

到目前为止,这种模型与 JavaScript 框架(如 S.js 或 Solid)中的反应式系统共享,它们中的所有权概念用于自动取消 effects。

Leptos 的独特之处在于,所有权在这里具有双重含义:反应式所有者不仅拥有其子级 effects,以便能够取消它们;它还拥有其信号(memos 等),以便能够销毁它们。

所有权与 Copy Arena

这是使 Leptos 能够作为 Rust UI 框架使用的创新点。传统上,在 Rust 中管理 UI 状态是困难的,因为 UI 本质上涉及共享的可变性。(即使是一个简单的计数按钮也足以暴露问题:你需要不可变的访问来设置显示计数值的文本节点,同时在点击事件处理程序中需要可变的访问。而 Rust 恰恰是为了防止这种情况而设计的!)传统上,在 Rust 中使用事件处理程序依赖于以下两种方式之一:通过共享内存和内部可变性(如 Rc<RefCell<_>>Arc<Mutex<_>>)进行通信,或通过通道共享内存进行通信。这两种方式通常都需要显式 .clone() 才能将状态移动到事件监听器中。这种方法虽然可行,但非常不便。

Leptos 始终使用一种基于 arena 分配的信号机制。信号本质上是一个指向数据结构的索引,而该数据结构存储在其他地方。信号是一种轻量级的整数类型,本身不会进行引用计数,因此可以自由地被复制、移动到事件监听器中等,而无需显式克隆。

这些信号的生命周期不是由 Rust 的生命周期或引用计数决定的,而是由所有权树决定的。

就像所有的 effect 都属于一个拥有的父级 effect,并且当父级重新运行时会取消子级一样,所有的信号也属于一个所有者,并且当所有者重新运行时会被销毁。

在大多数情况下,这完全没问题。比如在上面的例子中,<OddDuck/> 可能创建了其他信号,用于更新其 UI 的一部分。在大多数情况下,该信号只会用于组件的本地状态,或者作为 prop 传递给另一个组件。通常,它不会被提升到决策树之外的地方并用于应用的其他部分。当 count 切换回偶数时,该信号不再需要,可以被销毁。

然而,这种机制可能会导致两个潜在问题。

信号在被销毁后仍然可以被使用

你持有的 ReadSignalWriteSignal 仅仅是一个整数:例如,如果它是应用中的第 3 个信号,值就是 3。(当然实际情况稍微复杂一些,但差别不大。)你可以随意复制这个数字并使用它,比如说“嘿,给我第 3 个信号。”当其所有者进行清理时,信号 3 的会被失效;但你到处复制的数字 3 是无法被失效的。(除非用垃圾回收机制!)这意味着,如果你将信号“推”回到决策树的更高层,并将其存储在比创建它时更“高”的位置,信号可能在被销毁后仍然可以被访问。

如果你试图在信号被销毁后更新它,实际上并不会发生什么坏事。框架只会警告你试图更新一个已经不存在的信号。但如果你试图访问它,就只能导致 panic:因为无法返回一个合理的值。(框架提供了 try_ 版本的 .get().with() 方法,如果信号被销毁,它们会返回 None。)

如果在较高作用域创建信号且从未销毁,信号可能会泄漏

反过来也会发生问题,尤其是在处理信号集合时,比如 RwSignal<Vec<RwSignal<_>>>。如果你在更高层级创建一个信号,并将其传递到较低层级的组件中,它不会在上层所有者被清理之前自动销毁。

例如,如果你有一个待办事项应用,每个待办事项创建一个新的 RwSignal<Todo>,将其存储在 RwSignal<Vec<RwSignal<Todo>>> 中,然后将其传递到 <Todo/> 组件中。当你从列表中移除一个待办事项时,该信号不会自动销毁,必须手动销毁,否则会“泄漏”,直到上层的所有者仍然存活为止。(参见 TodoMVC 示例 的讨论。)

这种问题只会在以下情况下发生:你创建信号,将其存储在集合中,并从集合中移除它时没有手动销毁它。

使用引用计数信号解决这些问题

在 0.7 版本中,为每个基于 arena 分配的原语引入了一个引用计数的等价物:每个 RwSignal 都有一个 ArcRwSignal(包括 ArcReadSignalArcWriteSignalArcMemo 等)。

这些信号的内存和销毁由引用计数而不是所有权树管理。

这意味着它们可以安全地用于 arena 分配的信号可能会泄漏或被销毁后使用的情况。

这在创建信号集合时尤其有用:例如,你可以创建一个 ArcRwSignal<_>,然后在表格的每一行中将其转换为 RwSignal<_>

具体示例请参见 counters 示例 中对 ArcRwSignal<i32> 的使用。

总结

现在我们可以回到最初的问题,相信它们的答案应该变得更加清晰了。

组件生命周期

组件没有生命周期,因为组件实际上并不存在。但存在所有权的生命周期,你可以利用它实现相同的功能:

  • 挂载前(before mount):直接在组件主体中运行代码会在“组件挂载前”运行它。
  • 挂载时(on mount)create_effect 会在组件其余部分运行之后的一个 tick 执行,因此它适合那些需要等待视图挂载到 DOM 后再运行的 effect。
  • 卸载时(on unmount):你可以使用 on_cleanup 为反应式系统提供需要在当前所有者清理时运行的代码。这会在所有者重新运行之前执行。由于所有者是围绕某个“决策”存在的,这意味着 on_cleanup 会在组件卸载时运行:如果某个东西可以被卸载,那么渲染器一定创建了一个 effect 来处理它的卸载。

被销毁信号的问题

通常来说,只有当你在所有权树较低的位置创建信号并将其存储在较高的位置时,才会出现问题。如果你遇到了这些问题,应该将信号的创建“提升”到父级,然后将创建的信号传递给子级——如果需要的话,确保在移除时销毁它们!

Copy 信号

整个 Copy 包装类型系统(包括信号、StoredValue 等)利用所有权树来近似表示 UI 不同部分的生命周期。实际上,它类似于 Rust 基于代码块的生命周期系统,但基于 UI 的不同部分。虽然这不能始终在编译时完美检查,但总体来看,这种设计是一个显著的优势。