Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

简介

Introduction

本书旨在介绍 Leptos Web 框架。它将逐步讲解构建应用程序所需的基础概念,从一个简单的在浏览器中渲染的应用程序开始,逐步向具有服务器端渲染和注水的全栈应用程序迈进。

This book is intended as an introduction to the Leptos Web framework. It will walk through the fundamental concepts you need to build applications, beginning with a simple application rendered in the browser, and building toward a full-stack application with server-side rendering and hydration.

本指南并不假设你了解任何关于细粒度响应式或现代 Web 框架的细节。它假设你熟悉 Rust 编程语言、HTML、CSS、DOM 以及基本的 Web API。

The guide doesn’t assume you know anything about fine-grained reactivity or the details of modern Web frameworks. It does assume you are familiar with the Rust programming language, HTML, CSS, the DOM, and basic Web APIs.

Leptos 与 Solid (JavaScript) 和 Sycamore (Rust) 等框架最为相似。它与 React (JavaScript)、Svelte (JavaScript)、Yew (Rust) 和 Dioxus (Rust) 等其他框架也有一些相似之处,因此了解其中一个框架也可能使你更容易理解 Leptos。

Leptos is most similar to frameworks like Solid (JavaScript) and Sycamore (Rust). There are some similarities to other frameworks like React (JavaScript), Svelte (JavaScript), Yew (Rust), and Dioxus (Rust), so knowledge of one of those frameworks may also make it easier to understand Leptos.

你可以在 Docs.rs 找到关于 API 各部分的更详细文档。

You can find more detailed docs for each part of the API at Docs.rs.

本书的源代码可以在这里找到。欢迎提交修正错别字或优化表述的 PR。

The source code for the book is available here. PRs for typos or clarification are always welcome.

Getting Started

There are two basic paths to getting started with Leptos:

  1. Client-side rendering (CSR) with Trunk - a great option if you just want to make a snappy website with Leptos, or work with a pre-existing server or API. In CSR mode, Trunk compiles your Leptos app to WebAssembly (WASM) and runs it in the browser like a typical Javascript single-page app (SPA). The advantages of Leptos CSR include faster build times and a quicker iterative development cycle, as well as a simpler mental model and more options for deploying your app. CSR apps do come with some disadvantages: initial load times for your end users are slower compared to a server-side rendering approach, and the usual SEO challenges that come along with using a JS single-page app model apply to Leptos CSR apps as well. Also note that, under the hood, an auto-generated snippet of JS is used to load the Leptos WASM bundle, so JS must be enabled on the client device for your CSR app to display properly. As with all software engineering, there are trade-offs here you'll need to consider.

  2. Full-stack, server-side rendering (SSR) with cargo-leptos - SSR is a great option for building CRUD-style websites and custom web apps if you want Rust powering both your frontend and backend. With the Leptos SSR option, your app is rendered to HTML on the server and sent down to the browser; then, WebAssembly is used to instrument the HTML so your app becomes interactive - this process is called 'hydration'. On the server side, Leptos SSR apps integrate closely with your choice of either Actix-web or Axum server libraries, so you can leverage those communities' crates to help build out your Leptos server. The advantages of taking the SSR route with Leptos include helping you get the best initial load times and optimal SEO scores for your web app. SSR apps can also dramatically simplify working across the server/client boundary via a Leptos feature called "server functions", which lets you transparently call functions on the server from your client code (more on this feature later). Full-stack SSR isn't all rainbows and butterflies, though - disadvantages include a slower developer iteration loop (because you need to recompile both the server and client when making Rust code changes), as well as some added complexity that comes along with hydration.

By the end of the book, you should have a good idea of which trade-offs to make and which route to take - CSR or SSR - depending on your project's requirements.

In Part 1 of this book, we'll start with client-side rendering Leptos sites and building reactive UIs using Trunk to serve our JS and WASM bundle to the browser.

We’ll introduce cargo-leptos in Part 2 of this book, which is all about working with the full power of Leptos in its full-stack, SSR mode.

Note

If you're coming from the Javascript world and terms like client-side rendering (CSR) and server-side rendering (SSR) are unfamiliar to you, the easiest way to understand the difference is by analogy:

Leptos' CSR mode is similar to working with React (or a 'signals'-based framework like SolidJS), and focuses on producing a client-side UI which you can use with any tech stack on the server.

Using Leptos' SSR mode is similar to working with a full-stack framework like Next.js in the React world (or Solid's "SolidStart" framework) - SSR helps you build sites and apps that are rendered on the server then sent down to the client. SSR can help to improve your site's loading performance and accessibility as well as make it easier for one person to work on both client- and server-side without needing to context-switch between different languages for frontend and backend.

The Leptos framework can be used either in CSR mode to just make a UI (like React), or you can use Leptos in full-stack SSR mode (like Next.js) so that you can build both your UI and your server with one language: Rust.

Hello World! Getting Set up for Leptos CSR Development

First up, make sure Rust is installed and up-to-date (see here if you need instructions).

If you don’t have it installed already, you can install the "Trunk" tool for running Leptos CSR sites by running the following on the command-line:

cargo install trunk

And then create a basic Rust project

cargo init leptos-tutorial

cd into your new leptos-tutorial project and add leptos as a dependency

cargo add leptos --features=csr

Make sure you've added the wasm32-unknown-unknown target so that Rust can compile your code to WebAssembly to run in the browser.

rustup target add wasm32-unknown-unknown

Create a simple index.html in the root of the leptos-tutorial directory

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

And add a simple “Hello, world!” to your main.rs

use leptos::prelude::*;

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

Your directory structure should now look something like this

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

Now run trunk serve --open from the root of the leptos-tutorial directory. Trunk should automatically compile your app and open it in your default browser. If you make edits to main.rs, Trunk will recompile your source code and live-reload the page.

Welcome to the world of UI development with Rust and WebAssembly (WASM), powered by Leptos and Trunk!

Note

If you are using Windows, note that trunk serve --open may not work. If you have issues with --open, simply use trunk serve and open a browser tab manually.


Now before we get started building your first real applications with Leptos, there are a couple of things you might want to know to help make your experience with Leptos just a little bit easier.

Leptos 开发体验优化

Leptos Developer Experience Improvements

有几件事可以改善你使用 Leptos 开发网站和应用程序的体验。你可能需要花几分钟时间设置你的环境来优化开发体验,特别是如果你想跟随本书中的示例进行编码。

There are a couple of things you can do to improve your experience of developing websites and apps with Leptos. You may want to take a few minutes and set up your environment to optimize your development experience, especially if you want to code along with the examples in this book.

1) 设置 console_error_panic_hook

1) Set up console_error_panic_hook

默认情况下,在浏览器中运行 WASM 代码时发生的 panic 只会在浏览器中抛出一个错误,并带有一条毫无帮助的消息,如 Unreachable executed,以及一个指向 WASM 二进制文件的堆栈跟踪。

By default, panics that happen while running your WASM code in the browser just throw an error in the browser with an unhelpful message like Unreachable executed and a stack trace that points into your WASM binary.

使用 console_error_panic_hook,你可以获得一个真实的 Rust 堆栈跟踪,其中包含 Rust 源代码中的行号。

With console_error_panic_hook, you get an actual Rust stack trace that includes a line in your Rust source code.

设置非常简单:

It's very easy to set up:

  1. 在你的项目中运行 cargo add console_error_panic_hook

  2. Run cargo add console_error_panic_hook in your project

  3. 在你的 main 函数中,添加 console_error_panic_hook::set_once();

  4. In your main function, add console_error_panic_hook::set_once();

如果不清楚,点击这里查看示例

If this is unclear, click here for an example.

现在,你应该在浏览器控制台中获得更好的 panic 消息了!

Now you should have much better panic messages in the browser console!

2) 在 #[component]#[server] 内部进行编辑器自动补全

2) Editor Autocompletion inside #[component] and #[server]

由于宏的特性(它们可以将任何内容展开为任何内容,但前提是输入在那一刻必须完全正确),rust-analyzer 可能很难提供适当的自动补全和其他支持。

Because of the nature of macros (they can expand from anything to anything, but only if the input is exactly correct at that instant) it can be hard for rust-analyzer to do proper autocompletion and other support.

如果你在编辑器中使用这些宏时遇到问题,可以明确告诉 rust-analyzer 忽略某些过程宏(proc macros)。特别是对于 #[server] 宏,它注解了函数体,但实际上并不会转换函数体内的任何内容,这会非常有帮助。

If you run into issues using these macros in your editor, you can explicitly tell rust-analyzer to ignore certain proc macros. For the #[server] macro especially, which annotates function bodies but doesn't actually transform anything inside the body of your function, this can be really helpful.

Note

从 Leptos 0.5.3 版本开始,增加了对 #[component] 宏的 rust-analyzer 支持,但如果你遇到问题,也可以考虑将 #[component] 添加到宏忽略列表中(见下文)。 请注意,这意味着 rust-analyzer 将无法识别你的组件属性(props),这可能会在 IDE 中生成它自己的一套错误或警告。

Starting in Leptos version 0.5.3, rust-analyzer support was added for the #[component] macro, but if you run into issues, you may want to add #[component] to the macro ignore list as well (see below). Note that this means that rust-analyzer doesn't know about your component props, which may generate its own set of errors or warnings in the IDE.

VSCode settings.json

VSCode settings.json:

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

带有 cargo-leptos 的 VSCode settings.json

VSCode with cargo-leptos settings.json:

"rust-analyzer.procMacro.ignored": {
	"leptos_macro": [
        // 可选:
        // optional:
		// "component",
		"server"
	],
},
// 如果由 `ssr` 特性控制的代码显示为非活动状态,
// 你可能需要告诉 rust-analyzer 默认启用 `ssr` 特性
//
// 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
//
// 你也可以使用 `rust-analyzer.cargo.allFeatures` 来启用所有特性
// you can also use `rust-analyzer.cargo.allFeatures` to enable all features
"rust-analyzer.cargo.features": ["ssr"]

Neovim:

Neovim:

vim.lsp.config('rust_analyzer', {
  -- 其他配置 ...
  -- Other Configs ...
  settings = {
    ["rust-analyzer"] = {
      -- 其他设置 ...
      -- Other Settings ...
      procMacro = {
        ignored = {
          leptos_macro = {
            -- 可选: --
            -- optional: --
            -- "component",
            "server",
          },
        },
      },
    },
  }
})

Helix,在 .helix/languages.toml 中:

Helix, in .helix/languages.toml:

[[language]]
name = "rust"

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

Zed,在 settings.json 中:

Zed, in settings.json:

{
  -- 其他设置 ...
  -- Other Settings ...
  "lsp": {
    "rust-analyzer": {
      "procMacro": {
        "ignored": [
          // 可选:
          // optional:
          // "component",
          "server"
        ]
      }
    }
  }
}

SublimeText 3,在 Goto Anything... 菜单下的 LSP-rust-analyzer.sublime-settings 中:

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

// 此处的设置将覆盖 "LSP-rust-analyzer/LSP-rust-analyzer.sublime-settings" 中的设置
// Settings in here override those in "LSP-rust-analyzer/LSP-rust-analyzer.sublime-settings"
{
  "rust-analyzer.procMacro.ignored": {
    "leptos_macro": [
      // 可选:
      // optional:
      // "component",
      "server"
    ],
  },
}

3) 在编辑器的 Rust-Analyzer 中启用特性(可选)

3) Enable features in Rust-Analyzer for your Editor (optional)

默认情况下,rust-analyzer 只会针对 Rust 项目中的默认特性(default features)运行。Leptos 使用不同的特性来控制编译。对于客户端渲染(CSR)项目,我们在不同地方使用 csr;对于服务器端渲染(SSR)应用,它们可能包含用于服务器代码的 ssr 和用于仅在浏览器中运行的代码的 hydrate

By default, rust-analyzer will only run against the default features in your Rust project. Leptos uses different features to control compilation. For client side rendered projects, we use csr in different places, for server side rendered apps they can include ssr for server code and hydrate for code that we'll only run in the browser.

如何启用这些特性因 IDE 而异,我们在下面列出了一些常用的设置。如果你的 IDE 未列出,通常可以通过搜索 rust-analyzer.cargo.featuresrust-analyzer.cargo.allFeatures 找到该设置。

How to enable these features varies by your IDE, we've listed some common ones below. If your IDE is not listed, you can usually find the setting by searching for rust-analyzer.cargo.features or rust-analyzer.cargo.allFeatures.

VSCode,在 settings.json 中:

VSCode, in settings.json:

{
  "rust-analyzer.cargo.features": "all",  // 启用所有特性
  // "rust-analyzer.cargo.features": "all",  // Enable all features
}

Neovim,在 init.lua 中:

Neovim, in init.lua:

vim.lsp.config('rust_analyzer', {
  settings = {
    ["rust-analyzer"] = {
      cargo = {
        features = "all", -- 启用所有特性
        -- features = "all", -- Enable all features
      },
    },
  }
})

helix,在 .helix/languages.toml 或每个项目的 .helix/config.toml 中:

helix, in .helix/languages.toml or per project in .helix/config.toml:

[[language]]
name = "rust"

[language-server.rust-analyzer.config.cargo]
allFeatures = true

Zed,在 settings.json 中:

Zed, in settings.json:

{
  -- 其他设置 ...
  -- Other Settings ...
  "lsp": {
    "rust-analyzer": {
      "initialization_options": {
        "cargo": {
          "allFeatures": true // 启用所有特性
          // "allFeatures": true // Enable all features
        }
      }
	}
  }
}

SublimeText 3,在 LSP-rust-analyzer-settings.json 的用户设置中:

SublimeText 3,in the user settings for LSP-rust-analyzer-settings.json

 {
        "settings": {
            "LSP": {
                "rust-analyzer": {
                    "settings": {
                        "cargo": {
                            "features": "all"
                        }
                    }
                }
            }
        }
    }

4) 设置 leptosfmt(可选)

4) Set up leptosfmt (optional)

leptosfmt 是一个用于 Leptos view! 宏(通常在其中编写 UI 代码)的格式化工具。由于 view! 宏启用了一种 “RSX”(类似于 JSX)风格的 UI 编写方式,cargo-fmt 很难自动格式化 view! 宏内部的代码。leptosfmt 是一个解决格式化问题并保持 RSX 风格 UI 代码整洁的 crate!

leptosfmt is a formatter for the Leptos view! macro (inside of which you'll typically write your UI code). Because the view! macro enables an 'RSX' (like JSX) style of writing your UI's, cargo-fmt has a harder time auto-formatting your code that's inside the view! macro. leptosfmt is a crate that solves your formatting issues and keeps your RSX-style UI code looking nice and tidy!

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

leptosfmt can be installed and used via the command line or from within your code editor:

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

First, install the tool with cargo install leptosfmt.

如果你只想在命令行中使用默认选项,只需在项目根目录下运行 leptosfmt ./**/*.rs 即可使用 leptosfmt 格式化所有 Rust 文件。

If you just want to use the default options from the command line, just run leptosfmt ./**/*.rs from the root of your project to format all the rust files using leptosfmt.

在支持 Rust Analyzer 的 IDE 中自动运行

Run automatically in Rust Analyzer IDEs

如果你希望设置编辑器以配合 leptosfmt 使用,或者希望自定义 leptosfmt 体验,请参阅 leptosfmt GitHub 仓库 README.md 页面上的说明。

If you wish to set up your editor to work with leptosfmt, or if you wish to customize your leptosfmt experience, please see the instructions available on the leptosfmt github repo's README.md page.

请注意,为了获得最佳效果,建议在每个工作区的基础上为编辑器设置 leptosfmt

Just note that it's recommended to set up your editor with leptosfmt on a per-workspace basis for best results.

在 RustRover 中自动运行

Run automatically in RustRover

遗憾的是,RustRover 不支持 Rust Analyzer,因此需要采用不同的方法来自动运行 leptosfmt。一种方法是使用 FileWatchers 插件并进行以下配置:

Unfortunately, RustRover does not support Rust Analyzer, so a different approach is required in order to automatically run leptosfmt. One way is to use the FileWatchers plugin with the below configuration:

  • 名称 (Name): Leptosfmt
  • 文件类型 (File type): Rust files
  • 程序 (Program): /path/to/leptosfmt(如果它在你的 $PATH 环境变量中,可以简写为 leptosfmt
  • 参数 (Arguments): $FilePath$
  • 刷新输出路径 (Output paths to refresh): $FilePath$

5) 开发期间使用 --cfg=erase_components

5) Use --cfg=erase_components during development

Leptos 0.7 对渲染器进行了许多更改,使其更加依赖类型系统。对于大型项目,这可能会导致编译速度变慢。通过在开发期间使用自定义配置标志 --cfg=erase_components,可以缓解大部分编译变慢的情况。(这会擦除部分类型信息,以减少编译器的工作量和发出的调试信息,代价是增加二进制文件大小和运行时开销,因此最好不要在发布(release)模式下使用它。)

Leptos 0.7 made a number of changes to the renderer that relied more heavily on the type system. For larger projects, this can lead to slower compile times. Most of the slowdown in compile times can be alleviated by using the custom configuration flag --cfg=erase_components during development. (This erases some of that type information to reduce the amount of work done and debug info emitted by the compiler, at the expense of additional binary size and runtime cost, so it’s best not to use it in release mode.)

从 cargo-leptos v0.2.40 开始,这在开发模式下会自动为你启用。如果你使用的是 trunk,不使用 cargo-leptos,或者想为非开发用途启用它,可以在命令行中轻松设置(RUSTFLAGS="--cfg erase_components" trunk serveRUSTFLAGS="--cfg erase_components" cargo leptos watch),或者在你的 .cargo/config.toml 中设置:

As of cargo-leptos v0.2.40, this is automatically enabled for you in development mode. If you are using trunk, not using cargo-leptos, or want to enable it for non-dev uses, you can set this easily in the command line (RUSTFLAGS="--cfg erase_components" trunk serve or RUSTFLAGS="--cfg erase_components" cargo leptos watch), or in your .cargo/config.toml:

# 使用你自己的原生目标 (target)
# use your own native target
[target.aarch64-apple-darwin]
rustflags = [
  "--cfg",
  "erase_components",
]

[target.wasm32-unknown-unknown]
rustflags = [
   "--cfg",
   "erase_components",
]

Leptos 社区和 leptos-* Crates

The Leptos Community and leptos-* Crates

社区

The Community

在我们开始使用 Leptos 构建项目之前,最后提醒一点:如果你还没有加入,欢迎加入正在不断壮大的 Leptos DiscordGitHub 社区。特别是我们的 Discord 频道非常活跃且友好——我们非常期待你的加入!

One final note before we get to building with Leptos: if you haven't already, feel free to join the growing community on the Leptos Discord and on Github. Our Discord channel in particular is very active and friendly - we'd love to have you there!

Note

如果你在阅读 Leptos 手册的过程中发现某些章节或解释不够清楚,请直接在 "docs-and-education" 频道中提及,或在 "help" 频道中提问,以便我们澄清问题并为他人更新手册。

If you find a chapter or an explanation that isn't clear while you're working your way through the Leptos book, just mention it in the "docs-and-education" channel or ask a question in "help" so we can clear things up and update the book for others.

随着你在 Leptos 之旅中不断深入,如果你对 “如何使用 Leptos 实现某项功能” 有疑问,可以搜索 Discord 的 "help" 频道,看看之前是否有人问过类似的问题,或者随时发布你自己的问题——社区非常乐于助人且响应迅速。

As you get further along in your Leptos journey and find that you have questions about "how to do 'x' with Leptos", then search the Discord "help" channel to see if a similar question has been asked before, or feel free to post your own question - the community is quite helpful and very responsive.

GitHub 上的 “Discussions” 也是提问和关注 Leptos 公告的好地方。

The "Discussions" on Github are also a great place for asking questions and keeping up with Leptos announcements.

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

And of course, if you run into any bugs while developing with Leptos or would like to make a feature request (or contribute a bug fix / new feature), open up an issue on the Github issue tracker.

Leptos-* Crates

Leptos-* Crates

社区已经构建了越来越多与 Leptos 相关的 crate,这将帮助你更快地提高 Leptos 项目的开发效率——请查看 GitHub 上的 Awesome Leptos 仓库,了解由社区贡献并基于 Leptos 构建的 crate 列表。

The community has built a growing number of Leptos-related crates that will help you get productive with Leptos projects more quickly - check out the list of crates built on top of Leptos and contributed by the community on the Awesome Leptos repo on Github.

如果你想寻找最新的、极具潜力的 Leptos 相关 crate,请查看 Leptos Discord 的 “Tools and Libraries” 板块。在该板块中,有用于 Leptos view! 宏格式化器的频道(在 “leptosfmt” 频道);有用于工具库 “leptos-use” 的频道;有用于 UI 组件库 “thaw-ui” 的频道;还有一个 “libraries” 频道,在新的 leptos-* crate 进入 Awesome Leptos 上不断增长的 crate 和资源列表之前,会在那里进行讨论。

If you want to find the newest, up-and-coming Leptos-related crates, check out the "Tools and Libraries" section of the Leptos Discord. In that section, there are channels for the Leptos view! macro formatter (in the "leptosfmt" channel); there's a channel for the utility library "leptos-use"; another channel for the UI component library "thaw-ui"; and a "libraries" channel where new leptos-* crates are discussed before making their way into the growing list of crates and resources available on Awesome Leptos.

第一部分:构建用户界面

Part 1: Building User Interfaces

在本书的第一部分,我们将研究如何使用 Leptos 在客户端构建用户界面。在底层,Leptos 和 Trunk 将一小段 JavaScript 代码打包在一起,用于加载已编译为 WebAssembly 的 Leptos UI,从而驱动你的 CSR(客户端渲染)网站中的交互。

In the first part of the book, we're going to look at building user interfaces on the client-side using Leptos. Under the hood, Leptos and Trunk are bundling up a snippet of Javascript which will load up the Leptos UI, which has been compiled to WebAssembly to drive the interactivity in your CSR (client-side rendered) website.

第一部分将向你介绍构建由 Leptos 和 Rust 驱动的响应式用户界面所需的基础工具。到第一部分结束时,你应该能够构建一个在浏览器中渲染的、响应迅速的同步网站,并可以将其部署在任何静态网站托管服务上,如 GitHub Pages 或 Vercel。

Part 1 will introduce you to the basic tools you need to build a reactive user interface powered by Leptos and Rust. By the end of Part 1, you should be able to build a snappy synchronous website that's rendered in the browser and which you can deploy on any static-site hosting service, like Github Pages or Vercel.

信息

为了从本书中获得最大收益,我们鼓励你按照提供的示例编写代码。在快速入门Leptos DX 章节中,我们向你展示了如何使用 Leptos 和 Trunk 设置一个基础项目,包括在浏览器中进行 WASM 错误处理。该基础设置足以让你开始使用 Leptos 进行开发。

To get the most out of this book, we encourage you to code along with the examples provided. In the Getting Started and Leptos DX chapters, we showed you how to set up a basic project with Leptos and Trunk, including WASM error handling in the browser. That basic setup is enough to get you started developing with Leptos.

如果你更愿意从一个功能更齐全的模板开始,该模板演示了如何设置一些你在实际 Leptos 项目中会见到的基础功能,例如路由(本书稍后会介绍)、向页面头部注入 <Title><Meta> 标签,以及其他一些便捷功能,那么请随意利用 leptos-rs 的 start-trunk 模板仓库来启动并运行项目。

If you'd prefer to get started using a more full-featured template which demonstrates how to set up a few of the basics you'd see in a real Leptos project, such as routing, (covered later in the book), injecting <Title> and <Meta> tags into the page head, and a few other niceties, then feel free to utilize the leptos-rs start-trunk template repo to get up and running.

start-trunk 模板要求你安装了 Trunkcargo-generate,你可以通过运行 cargo install trunkcargo install cargo-generate 来获取它们。

The start-trunk template requires that you have Trunk and cargo-generate installed, which you can get by running cargo install trunk and cargo install cargo-generate.

要使用该模板设置你的项目,只需运行

To use the template to set up your project, just run

cargo generate --git https://github.com/leptos-rs/start-trunk

然后运行

then run

trunk serve --port 3000 --open

在新建应用的目录中运行上述命令即可开始开发你的应用。Trunk 服务器会在文件更改时重新加载你的应用,使开发过程相对无缝。

in the newly created app's directory to start developing your app. The Trunk server will reload your app on file changes, making development relatively seamless.

一个基础组件

A Basic Component

“Hello, world!” 那是一个非常简单的例子。让我们来做一些更像普通应用的事情。

That “Hello, world!” was a very simple example. Let’s move on to something a little more like an ordinary app.

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

First, let’s edit the main function so that, instead of rendering the whole app, it just renders an <App/> component. Components are the basic unit of composition and design in most web frameworks, and Leptos is no exception. Conceptually, they are similar to HTML elements: they represent a section of the DOM, with self-contained, defined behavior. Unlike HTML elements, they are in PascalCase, so most Leptos applications will start with something like an <App/> component.

use leptos::mount::mount_to_body;

fn main() {
    mount_to_body(App);
}

现在让我们定义 App 组件本身。因为它相对简单,我会先给出完整的代码,然后逐行讲解。

Now let’s define our App component itself. Because it’s relatively simple, I’ll give you the whole thing up front, then walk through it line by line.

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

Importing the Prelude

Leptos 提供了一个 prelude(预导入模块),其中包含常用的 trait 和函数。如果你更喜欢使用单独的导入,请随意;编译器会为每个导入提供有用的建议。

Leptos provides a prelude which includes commonly-used traits and functions. If you'd prefer to use individual imports, feel free to do that; the compiler will provide helpful recommendations for each import.

组件签名

The Component Signature

与所有组件定义一样,它以 #[component] 宏开始。#[component] 标注了一个函数,使其可以在你的 Leptos 应用程序中作为组件使用。我们将在接下来的几章中看到这个宏的其他一些特性。

Like all component definitions, this begins with the #[component] macro. #[component] annotates a function so it can be used as a component in your Leptos application. We’ll see some of the other features of this macro in a couple chapters.

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

Every component is a function with the following characteristics

  1. 它接受零个或多个任何类型的参数。

  2. It takes zero or more arguments of any type.

  3. 它返回 impl IntoView,这是一种不透明类型,包含你可以从 Leptos view 返回的任何内容。

  4. It returns impl IntoView, which is an opaque type that includes anything you could return from a Leptos view.

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

Component function arguments are gathered together into a single props struct which is built by the view macro as needed.

组件体

The Component Body

组件函数体是一个只运行一次的设置(set-up)函数,而不是多次重复运行的渲染函数。你通常会用它来创建一些响应式变量,定义响应这些值变化而运行的副作用,并描述用户界面。

The body of the component function is a set-up function that runs once, not a render function that reruns multiple times. You’ll typically use it to create a few reactive variables, define any side effects that run in response to those values changing, and describe the user interface.

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

signal creates a signal, the basic unit of reactive change and state management in Leptos. This returns a (getter, setter) tuple. To access the current value, you’ll use count.get() (or, on nightly Rust, the shorthand count()). To set the current value, you’ll call set_count.set(...) (or, on nightly, set_count(...)).

.get() 会克隆值,而 .set() 会覆盖它。在许多情况下,使用 .with().update() 会更高效;如果你想在此时了解更多关于这些权衡的信息,请查看 ReadSignalWriteSignal 的文档。

.get() clones the value and .set() overwrites it. In many cases, it’s more efficient to use .with() or .update(); check out the docs for ReadSignal and WriteSignal if you’d like to learn more about those trade-offs at this point.

视图

The View

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

Leptos defines user interfaces using a JSX-like format via the view macro.

view! {
    <button
        // 使用 on: 定义事件监听器
        // define an event listener with on:
        on:click=move |_| set_count.set(3)
    >
        // 文本节点被包裹在引号中
        // text nodes are wrapped in quotation marks
        "Click me: "

        // 代码块包含 Rust 代码
        // 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 字符串的文本节点。支持所有 HTML 元素,包括内置元素(如 <p>)和自定义元素/Web 组件(如 <my-custom-element>)。

This should mostly be easy to understand: it mostly looks like HTML, with a special on:click syntax to define a click event listener and a few text nodes that look like Rust strings. All HTML elements are supported, including both built-in elements (like <p>) and custom elements/web components (like <my-custom-element>).

未加引号的文本

view 宏确实对未加引号的文本节点提供了一些支持,这在 HTML 或 JSX 中是常态(即 <p>Hello!</p> 而不是 <p>"Hello!"</p>)。由于 Rust 过程宏的限制,使用未加引号的文本偶尔会导致标点符号周围的间距问题,并且不支持所有的 Unicode 字符串。如果你愿意,可以使用未加引号的文本;请注意,如果你遇到任何问题,始终可以通过将文本节点作为普通 Rust 字符串加引号来解决。

然后是大括号中的两个值:一个 {count} 似乎很容易理解(它只是我们信号的值),然后是……

Then there are two values in braces: one, {count}, seems pretty easy to understand (it's just the value of our signal), and then...

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

不管那是什么。

whatever that is.

人们有时会开玩笑说,他们在第一个 Leptos 应用程序中使用的闭包比他们一生中使用的还要多。这很有道理。

People sometimes joke that they use more closures in their first Leptos application than they’ve ever used in their lives. And fair enough.

将函数传递到视图中告诉框架:“嘿,这是可能会改变的东西。”

Passing a function into the view tells the framework: “Hey, this is something that might change.”

当我们点击按钮并调用 set_count 时,count 信号就会更新。这个 move || count.get() * 2 闭包(其值取决于 count 的值)会重新运行,框架会对该特定文本节点进行有针对性的更新,而不会触及应用程序中的其他任何内容。这就是实现 DOM 极高效更新的原因。

When we click the button and call set_count, the count signal is updated. This move || count.get() * 2 closure, whose value depends on the value of count, reruns, and the framework makes a targeted update to that specific text node, touching nothing else in your application. This is what allows for extremely efficient updates to the DOM.

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

Remember—and this is very important—only signals and functions are treated as reactive values in the view.

这意味着 {count}{count.get()} 在你的视图中执行完全不同的操作。{count} 传入一个信号,告诉框架每次 count 改变时都要更新视图。{count.get()} 则只访问一次 count 的值,并将一个 i32 传入视图,非响应式地渲染一次。

This means that {count} and {count.get()} do very different things in your view. {count} passes in a signal, telling the framework to update the view every time count changes. {count.get()} accesses the value of count once, and passes an i32 into the view, rendering it once, unreactively.

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

In the same way, {move || count.get() * 2} and {count.get() * 2} behave differently. The first one is a function, so it's rendered reactively. The second is a value, so it's just rendered once, and won't update when count changes.

你可以在下面的 CodeSandbox 中看到区别!

You can see the difference in the CodeSandbox below!

让我们做最后一个修改。set_count.set(3) 对于点击处理程序来说是一件相当没用的事情。让我们把 “将此值设置为 3” 替换为 “将此值增加 1”:

Let’s make one final change. set_count.set(3) is a pretty useless thing for a click handler to do. Let’s replace “set this value to 3” with “increment this value by 1”:

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

你可以在这里看到,虽然 set_count 只是设置值,但 set_count.write() 为我们提供了一个可变引用并原地修改值。任何一种方式都会触发 UI 中的响应式更新。

You can see here that while set_count just sets the value, set_count.write() gives us a mutable reference and mutates the value in place. Either one will trigger a reactive update in our UI.

在整个教程中,我们将使用 CodeSandbox 来展示交互式示例。将鼠标悬停在任何变量上以显示 Rust-Analyzer 详情和正在发生的事情的文档。欢迎 fork 这些示例来自己动手尝试!

Throughout this tutorial, we’ll use CodeSandbox to show interactive examples. Hover over any of the variables to show Rust-Analyzer details and docs for what’s going on. Feel free to fork the examples to play with them yourself!

在线示例

点击打开 CodeSandbox。

要在沙盒中显示浏览器,你可能需要点击 Add DevTools > Other Previews > 8080. To show the browser in the sandbox, you may need to click Add DevTools > Other Previews > 8080.

CodeSandbox Source
use leptos::prelude::*;

// #[component] 宏将一个函数标记为可复用组件
// 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
    // 并得到一个 (getter, setter) 对
    // 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);

    // view! 宏是我们定义用户界面的方式
    // the `view` macro is how we define the user interface
    // 它使用一种类似于 HTML 的格式,可以接受特定的 Rust 值
    // it uses an HTML-like format that can accept certain Rust values
    view! {
        <button
            // 每当 click 事件触发时,on:click 就会运行
            // on:click will run whenever the `click` event fires
            // 每个事件处理程序都定义为 on:{eventname}
            // every event handler is defined as `on:{eventname}`

            // 我们能够将 set_count 移动到闭包中
            // we're able to move `set_count` into the closure
            // 因为信号是 Copy 且具有 'static 生命周期的
            // because signals are Copy and 'static

            on:click=move |_| *set_count.write() += 1
        >
            // RSX 中的文本节点应该用引号包裹,
            // text nodes in RSX should be wrapped in quotes,
            // 就像普通的 Rust 字符串一样
            // like a normal Rust string
            "Click me: "
            {count}
        </button>
        <p>
            <strong>"Reactive: "</strong>
            // 你可以将 Rust 表达式作为值插入 DOM
            // 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
            // 仅包装 getter 的函数的简写
            // for a function that just wraps the getter
            {count}
        </p>
        <p>
            <strong>"Not reactive: "</strong>
            // 注意:如果你只是写 {count.get()},这 *不会* 是响应式的
            // NOTE: if you just write {count.get()}, this will *not* be reactive
            // 它只是获取一次 count 的值
            // it simply gets the value of count once
            {count.get()}
        </p>
    }
}

// 这个 main 函数是应用程序的入口点
// This `main` function is the entry point into the app
// 它只是将我们的组件挂载到 <body>
// It just mounts our component to the <body>
// 因为我们将它定义为 fn App,所以我们现在可以在
// Because we defined it as `fn App`, we can now use it in a
// 模板中以 <App/> 的形式使用它
// template as <App/>
fn main() {
    leptos::mount::mount_to_body(App)
}

view:动态类、样式和属性

view: Dynamic Classes, Styles and Attributes

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

So far we’ve seen how to use the view macro to create event listeners and to create dynamic text by passing a function (such as a signal) into the view.

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

But of course there are other things you might want to update in your user interface. In this section, we’ll look at how to update classes, styles and attributes dynamically, and we’ll introduce the concept of a derived signal.

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

Let’s start with a simple component that should be familiar: click a button to increment a counter.

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

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

到目前为止,我们在上一章中已经涵盖了所有这些内容。

So far, we’ve covered all of this in the previous chapter.

动态类

Dynamic Classes

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

Now let’s say I’d like to update the list of CSS classes on this element dynamically. For example, let’s say I want to add the class red when the count is odd. I can do this using the class: syntax.

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

class: 属性接受:

class: attributes take

  1. 冒号后的类名(red

  2. the class name, following the colon (red)

  3. 一个值,可以是 bool 或返回 bool 的函数

  4. a value, which can be a bool or a function that returns a bool

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

When the value is true, the class is added. When the value is false, the class is removed. And if the value is a function that accesses a signal, the class will reactively update when the signal changes.

现在,每当我点击按钮时,随着数字在奇偶之间切换,文本应该在红色和黑色之间切换。

Now every time I click the button, the text should toggle between red and black as the number switches between even and odd.

<button
    on:click=move |_| {
        *set_count.write() += 1;
    }
    // class: 语法会响应式地更新单个类
    // the class: syntax reactively updates a single class
    // 在这里,当 `count` 为奇数时,我们将设置 `red` 类
    // here, we'll set the `red` class when `count` is odd
    class:red=move || count.get() % 2 == 1
>
    "Click me"
</button>

如果你在跟着做,请确保进入 index.html 并添加如下内容:

If you’re following along, make sure you go into your index.html and add something like this:

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

某些 CSS 类名无法被 view 宏直接解析,特别是如果它们混合了横杠和数字或其他字符。在这种情况下,你可以使用元组语法:class=("name", value) 仍然可以直接更新单个类。

Some CSS class names can’t be directly parsed by the view macro, especially if they include a mix of dashes and numbers or other characters. In that case, you can use a tuple syntax: class=("name", value) still directly updates a single class.

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

元组语法还允许使用数组作为元组的第一个元素,在单个条件下指定多个类。

The tuple syntax also allows specifying multiple classes under a single condition using an array as the first tuple element.

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

动态样式

Dynamic Styles

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

Individual CSS properties can be directly updated with a similar style: syntax.

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

view! {
    <button
        on:click=move |_| {
            *set_count.write() += 10;
        }
        // 设置 `style` 属性
        // set the `style` attribute
        style="position: absolute"
        // 并通过 `style:` 切换单个 CSS 属性
        // 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"
        // 设置一个 CSS 变量供样式表使用
        // Set a CSS variable for stylesheet use
        style=("--columns", move || count.get().to_string())
    >
        "Click to Move"
    </button>
}

动态属性

Dynamic Attributes

这同样适用于普通属性。向属性传递普通字符串或原始值会赋予它一个静态值。向属性传递一个函数(包括信号)会导致它响应式地更新其值。让我们在视图中添加另一个元素:

The same applies to plain attributes. Passing a plain string or primitive value to an attribute gives it a static value. Passing a function (including a signal) to an attribute causes it to update its value reactively. Let’s add another element to our view:

<progress
    max="50"
    // 信号是函数,因此 `value=count` 和 `value=move || count.get()`
    // signals are functions, so `value=count` and `value=move || count.get()`
    // 是可以互换的。
    // are interchangeable.
    value=count
/>

现在,每当我们设置计数时,不仅 <button>class 会切换,<progress> 条的 value 也会增加,这意味着我们的进度条将向前推进。

Now every time we set the count, not only will the class of the <button> be toggled, but the value of the <progress> bar will increase, which means that our progress bar will move forward.

派生信号

Derived Signals

为了好玩,让我们再深入一层。

Let’s go one layer deeper, just for fun.

你已经知道,我们只需将函数传入 view 即可创建响应式界面。这意味着我们可以轻松地更改进度条。例如,假设我们希望它的移动速度快一倍:

You already know that we create reactive interfaces just by passing functions into the view. This means that we can easily change our progress bar. For example, suppose we want it to move twice as fast:

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

但想象一下,如果我们想在不止一个地方重用该计算。你可以使用派生信号(derived signal)来实现:一个访问信号的闭包。

But imagine we want to reuse that calculation in more than one place. You can do this using a derived signal: a closure that accesses a signal.

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

/* 插入视图的其余部分 */
/* insert the rest of the view */
<progress
    max="50"
    // 我们在这里使用一次
    // we use it once here
    value=double_count
/>
<p>
    "Double Count: "
    // 在这里再次使用
    // and again here
    {double_count}
</p>

派生信号允许你创建响应式计算值,这些值可以以最小的开销在应用程序的多个位置使用。

Derived signals let you create reactive computed values that can be used in multiple places in your application with minimal overhead.

注意:像这样使用派生信号意味着每当信号改变(当 count() 改变时)以及我们每次访问 double_count 时,计算都会运行一次;换句话说,运行两次。这是一个非常廉价的计算,所以没关系。我们将在后面的章节中讨论 memo(备忘录),它们旨在为昂贵的计算解决这个问题。

Note: Using a derived signal like this means that the calculation runs once per signal change (when count() changes) and once per place we access double_count; in other words, twice. This is a very cheap calculation, so that’s fine. We’ll look at memos in a later chapter, which were designed to solve this problem for expensive calculations.

高级主题:注入原始 HTML

Advanced Topic: Injecting Raw HTML

view 宏提供了一个额外的属性 inner_html,它可以用于直接设置任何元素的 HTML 内容,从而清除你给它的任何其他子元素。请注意,这不会对你提供的 HTML 进行转义。你应该确保它只包含受信任的输入,或者任何 HTML 实体都已转义,以防止跨站脚本(XSS)攻击。

The view macro provides support for an additional attribute, inner_html, which can be used to directly set the HTML contents of any element, wiping out any other children you’ve given it. Note that this does not escape the HTML you provide. You should make sure that it only contains trusted input or that any HTML entities are escaped, to prevent cross-site scripting (XSS) attacks.

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

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

Click here for the full view macros docs.

在线示例

点击打开 CodeSandbox。 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;
            }
            // class: 语法响应式地更新单个类
            // the class: syntax reactively updates a single class
            // 在这里,当 `count` 为奇数时,我们将设置 `red` 类
            // 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>
        // 注意:像 <br> 这样的自闭合标签需要显式的 /
        // NOTE: self-closing tags like <br> need an explicit /
        <br/>

        // 我们将在每次 `count` 改变时更新这个进度条
        // We'll update this progress bar every time `count` changes
        <progress
            // 静态属性的工作方式与 HTML 中相同
            // static attributes work as in HTML
            max="50"

            // 向属性传递函数会响应式地设置该属性
            // passing a function to an attribute
            // reactively sets that attribute
            // 信号是函数,因此 `value=count` 和 `value=move || count.get()` 是可以互换的
            // signals are functions, so `value=count` and `value=move || count.get()`
            // are interchangeable.
            value=count
        >
        </progress>
        <br/>

        // 这个进度条将使用 `double_count`
        // This progress bar will use `double_count`
        // 所以它的移动速度应该快一倍!
        // so it should move twice as fast!
        <progress
            max="50"
            // 派生信号是函数,因此它们也可以响应式地更新 DOM
            // 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

Components and Props

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

So far, we’ve been building our whole application in a single component. This is fine for really tiny examples, but in any real application you’ll need to break the user interface out into multiple components, so you can break your interface down into smaller, reusable, composable chunks.

以我们的进度条为例。假设你想要两个进度条而不是一个:一个点击一次前进一格,另一个点击一次前进两格。

Let’s take our progress bar example. Imagine that you want two progress bars instead of one: one that advances one tick per click, one that advances two ticks per click.

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

You could do this by just creating two <progress> elements:

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
    />
}

但当然,这种方式的扩展性并不好。如果你想添加第三个进度条,你需要再次添加这段代码。如果你想修改它的任何内容,你需要修改三处。

But of course, this doesn’t scale very well. If you want to add a third progress bar, you need to add this code another time. And if you want to edit anything about it, you need to edit it in triplicate.

相反,让我们创建一个 <ProgressBar/> 组件。

Instead, let’s create a <ProgressBar/> component.

#[component]
fn ProgressBar() -> impl IntoView {
    view! {
        <progress
            max="50"
            // 嗯……我们要从哪里获取这个值呢?
            // hmm... where will we get this from?
            value=progress
        />
    }
}

现在只有一个问题:progress 未定义。它应该从哪里来?当我们手动定义一切时,我们直接使用局部变量名。现在我们需要某种方式将参数传递到组件中。

There’s just one problem: progress is not defined. Where should it come from? When we were defining everything manually, we just used the local variable names. Now we need some way to pass an argument into the component.

组件 Props

Component Props

我们通过组件属性(properties)或 “props” 来实现这一点。如果你使用过其他前端框架,这可能是一个熟悉的概念。基本上,属性之于组件就像特性(attributes)之于 HTML 元素:它们允许你将额外的信息传递到组件中。

We do this using component properties, or “props.” If you’ve used another frontend framework, this is probably a familiar idea. Basically, properties are to components as attributes are to HTML elements: they let you pass additional information into the component.

在 Leptos 中,你通过为组件函数提供额外的参数来定义 props。

In Leptos, you define props by giving additional arguments to the component function.

#[component]
fn ProgressBar(
    progress: ReadSignal<i32>
) -> impl IntoView {
    view! {
        <progress
            max="50"
            // 现在这可以工作了
            // now this works
            value=progress
        />
    }
}

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

Now we can use our component in the main <App/> component’s view.

#[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 prop。很简单。

Using a component in the view looks a lot like using an HTML element. You’ll notice that you can easily tell the difference between an element and a component because components always have PascalCase names. You pass the progress prop in as if it were an HTML element attribute. Simple.

响应式和静态 Props

Reactive and Static Props

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

You’ll notice that throughout this example, progress takes a reactive ReadSignal<i32>, and not a plain i32. This is very important.

组件 props 本身没有附加任何特殊含义。组件只是一个运行一次以设置用户界面的函数。告诉界面响应变化的唯一方法是向其传递信号(signal)类型。因此,如果你有一个会随时间变化的组件属性(如我们的 progress),它应该是一个信号。

Component props have no special meaning attached to them. A component is simply a function that runs once to set up the user interface. The only way to tell the interface to respond to changes is to pass it a signal type. So if you have a component property that will change over time, like our progress, it should be a signal.

optional Props

optional Props

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

Right now the max setting is hard-coded. Let’s take that as a prop too. But let’s make this prop optional. We can do that by annotating it with #[prop(optional)].

#[component]
fn ProgressBar(
    // 将此 prop 标记为可选
    // mark this prop optional
    // 使用 <ProgressBar/> 时你可以指定它,也可以不指定
    // you can specify it or not when you use <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。对于进度条来说,最大值为 0 并不是很有用。

Now, we can use <ProgressBar max=50 progress=count/>, or we can omit max to use the default value (i.e., <ProgressBar progress=count/>). The default value on an optional is its Default::default() value, which for a u16 is going to be 0. In the case of a progress bar, a max value of 0 is not very useful.

因此,让我们给它一个特定的默认值。

So let’s give it a particular default value instead.

default props

default props

你可以通过 #[prop(default = ...) 相当简单地指定 Default::default() 以外的默认值。

You can specify a default value other than Default::default() pretty simply with #[prop(default = ...).

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

泛型 Props

Generic Props

这很好。但我们开始时有两个计数器,一个由 count 驱动,另一个由派生信号 double_count 驱动。让我们通过在另一个 <ProgressBar/> 上使用 double_count 作为 progress prop 来重现这一点。

This is great. But we began with two counters, one driven by count, and one by the derived signal double_count. Let’s recreate that by using double_count as the progress prop on another <ProgressBar/>.

#[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/>
        // 添加第二个进度条
        // add a second progress bar
        <ProgressBar progress=double_count/>
    }
}

嗯……这无法编译。理解原因应该很容易:我们已经声明 progress prop 接收 ReadSignal<i32>,而 double_count 不是 ReadSignal<i32>。正如 rust-analyzer 会告诉你的,它的类型是 || -> i32,即它是一个返回 i32 的闭包。

Hm... this won’t compile. It should be pretty easy to understand why: we’ve declared that the progress prop takes ReadSignal<i32>, and double_count is not ReadSignal<i32>. As rust-analyzer will tell you, its type is || -> i32, i.e., it’s a closure that returns an i32.

处理这个问题有几种方法。一种是说:“好吧,我知道为了让视图具有响应性,它需要接收一个函数或信号。我总是可以通过将信号包装在闭包中来将其转为函数……也许我可以接收任何函数?”

There are a couple ways to handle this. One would be to say: “Well, I know that for the view to be reactive, it needs to take a function or a signal. I can always turn a signal into a function by wrapping it in a closure... Maybe I could just take any function?”

如果你正在使用带有 nightly 特性的 nightly Rust,信号本身就是函数,因此你可以使用泛型组件并接收任何 Fn() -> i32

If you’re using nightly Rust with the nightly feature, signals are functions, so you could use a generic component and take any Fn() -> i32:

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

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

Generic props can also be specified using a where clause, or using inline generics like ProgressBar<F: Fn() -> i32 + 'static>.

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

Generics need to be used somewhere in the component props. This is because props are built into a struct, so all generic types must be used somewhere in the struct. This is often easily accomplished using an optional PhantomData prop. You can then specify a generic in the view using the syntax for expressing types: <Component<T>/> (not with the turbofish-style <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>>/>

Note that there are some limitations. For example, our view macro parser can’t handle nested generics like <SizeOf<Vec<T>>/>.

into Props

into Props

如果你在 stable Rust 上,信号并不直接实现 Fn()。我们可以将信号包装在闭包中(move || progress.get()),但这有点繁琐。

If you’re on stable Rust, signals don’t directly implement Fn(). We could wrap the signal in a closure (move || progress.get()) but that’s a bit messy.

我们还有另一种实现方式,那就是使用 #[prop(into)]。该属性会自动对你作为 prop 传入的值调用 .into(),这使你能够轻松传递具有不同类型值的 props。

There’s another way we could implement this, and it would be to use #[prop(into)]. This attribute automatically calls .into() on the values you pass as props, which allows you to easily pass props with different values.

在这种情况下,了解一下 Signal 类型很有帮助。Signal 是一个枚举类型,代表任何类型的可读响应式信号或普通值。当你为想要复用的组件定义 API,同时又想传入不同种类的信号时,它非常有用。

In this case, it’s helpful to know about the Signal type. Signal is an enumerated type that represents any kind of readable reactive signal, or a plain value. It can be useful when defining APIs for components you’ll want to reuse while passing different sorts of signals.

#[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`
        // .into() converts `ReadSignal` to `Signal`
        <ProgressBar progress=count/>
        // 使用 `Signal::derive()` 将派生信号包装为 `Signal` 类型
        // use `Signal::derive()` to wrap a derived signal with the `Signal` type
        <ProgressBar progress=Signal::derive(double_count)/>
    }
}

可选泛型 Props

Optional Generic Props

请注意,你不能为组件指定可选的泛型 props。让我们看看如果你尝试这样做会发生什么:

Note that you can’t specify optional generic props for a component. Let’s see what would happen if you try:

#[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 会贴心地给出错误:

Rust helpfully gives the error

xx |         <ProgressBar/>
   |          ^^^^^^^^^^^ 无法推断函数 `ProgressBar` 上声明的类型参数 `F` 的类型
   |          ^^^^^^^^^^^ 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)在组件上指定泛型。在这里指定正确的类型是不可能的;闭包和一般函数是不可名状的类型。编译器可以用简写显示它们,但你无法指定它们。

You can specify generics on components with a <ProgressBar<F>/> syntax (no turbofish in the view macro). Specifying the correct type here is not possible; closures and functions in general are unnameable types. The compiler can display them with a shorthand, but you can’t specify them.

但是,你可以通过使用 Box<dyn _>&dyn _ 提供具体类型来绕过这个问题:

However, you can get around this by providing a concrete type using Box<dyn _> or &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 编译器现在知道 prop 的具体类型,因此即使在 None 的情况下也知道它在内存中的大小,所以这段代码可以正常编译。

Because the Rust compiler now knows the concrete type of the prop, and therefore its size in memory even in the None case, this compiles fine.

在这种特定情况下,&dyn Fn() -> i32 会导致生命周期问题,但在其他情况下,它可能是一种选择。

In this particular case, &dyn Fn() -> i32 will cause lifetime issues, but in other cases, it may be a possibility.

组件文档

Documenting Components

这是本书中最不重要但又最重要的部分之一。为你的组件及其 props 编写文档并非严格必须。但这可能非常重要,取决于你的团队规模和应用规模。它非常简单,并且能立即见效。

This is one of the least essential but most important sections of this book. It’s not strictly necessary to document your components and their props. It may be very important, depending on the size of your team and your app. But it’s very easy, and bears immediate fruit.

要为组件及其 props 编写文档,你只需在组件函数和每一个 props 上添加文档注释即可:

To document a component and its props, you can simply add doc comments on the component function, and each one of the props:

/// 显示目标的进度。
/// Shows progress toward a goal.
#[component]
fn ProgressBar(
    /// 进度条的最大值。
    /// The maximum value of the progress bar.
    #[prop(default = 100)]
    max: u16,
    /// 应该显示的进度量。
    /// How much progress should be displayed.
    #[prop(into)]
    progress: Signal<i32>,
) -> impl IntoView {
    /* ... */
}

这就是你需要做的全部。这些注释的行为就像普通的 Rust 文档注释,不同之处在于你可以为单个组件 props 编写文档,而普通的 Rust 函数参数无法做到这一点。

That’s all you need to do. These behave like ordinary Rust doc comments, except that you can document individual component props, which can’t be done with Rust function arguments.

这将自动为你的组件、其 Props 类型以及用于添加 props 的每个字段生成文档。当你将鼠标悬停在组件名称或 props 上,并在此处看到 #[component] 宏与 rust-analyzer 结合的强大功能时,你可能才会意识到这有多么强大。

This will automatically generate documentation for your component, its Props type, and each of the fields used to add props. It can be a little hard to understand how powerful this is until you hover over the component name or props and see the power of the #[component] macro combined with rust-analyzer here.

将属性扩展到组件上

Spreading Attributes onto Components

有时你希望用户能够为组件添加额外的属性。例如,你可能希望用户能够添加自己的 classid 属性,用于样式设置或其他目的。

Sometimes you want users to be able to add additional attributes to a component. For example, you might want users to be able to add their own class or id attributes for styling or other purposes.

可以通过创建 classid props 并在随后将其应用于适当的元素来实现这一点。但 Leptos 还支持将额外的属性 “扩展(spreading)” 到组件上。添加到组件的属性将应用于其视图返回的所有顶级 HTML 元素。

You could do this by creating class or id props that you then apply to the appropriate element. But Leptos also supports “spreading” additional attributes onto components. Attributes added to a component will be applied to all top-level HTML elements returned from its view.

// 你可以使用 view 宏并以扩展 {..} 作为标签名来创建属性列表
// you can create attribute lists by using the view macro with a spread {..} as the tag name
let spread_onto_component = view! {
    <{..} aria-label="一个使用属性扩展的组件"/>
};


view! {
    // 扩展到组件上的属性将应用于组件视图返回的 *所有* 元素。
    // attributes that are spread onto a component will be applied to *all* elements returned as part of
    // 要将属性应用于组件的一部分,请通过组件 prop 传递它们
    // the component's view. to apply attributes to a subset of the component, pass them via a component prop
    <ComponentThatTakesSpread
        // 普通标识符用于 props
        // plain identifiers are for props
        some_prop="foo"
        another_prop=42

        // class:, style:, prop:, on: 语法的工作方式与它们在元素上完全相同
        // the class:, style:, prop:, on: syntaxes work just as they do on elements
        class:foo=true
        style:font-weight="bold"
        prop:cool=42
        on:click=move |_| alert("点击了 ComponentThatTakesSpread")

        // 要传递普通 HTML 属性,请使用 attr: 前缀
        // to pass a plain HTML attribute, prefix it with attr:
        attr:id="foo"

        // 或者,如果你想包含多个属性,而不是为每个属性都加上 attr: 前缀,
        // or, if you want to include multiple attributes, rather than prefixing each with
        // 你可以使用扩展 {..} 将它们与组件 props 分开
        // attr:, you can separate them from component props with the spread {..}
        {..} // 此后的所有内容都被视为 HTML 属性
        title="哦,一个标题!"

        // 我们可以添加上面定义的整个属性列表
        // we can add the whole list of attributes defined above
        {..spread_onto_component}
    />
}

Note

如果你想将属性提取到一个函数中,以便在多个组件中使用,你可以通过实现一个返回 impl Attribute 的函数来做到这一点。

这将使上面的示例看起来像这样:

fn spread_onto_component() -> impl Attribute {
    view!{
        <{..} aria-label="一个使用属性扩展的组件"/>
    }
}

view!{
    <SomeComponent {..spread_onto_component()} />
}

</div>
</div>

如果你想将属性扩展到组件上,但又想将属性应用到除所有顶级元素以外的其他元素上,请使用 AttributeInterceptor

If you want to spread attributes onto a component, but want to apply the attributes to something other than all top-level elements, use AttributeInterceptor.

有关更多示例,请参阅 spread 示例

See the spread example for more examples.


[点击打开 CodeSandbox。](https://codesandbox.io/p/devbox/3-components-0-7-rkjn3j?file=%2Fsrc%2Fmain.rs%3A39%2C10)

<noscript>
  请启用 JavaScript 以查看示例。
  Please enable JavaScript to view examples.
</noscript>

<template>
  <iframe src="https://codesandbox.io/p/devbox/3-components-0-7-rkjn3j?file=%2Fsrc%2Fmain.rs%3A39%2C10" width="100%" height="1000px" style="max-height: 100vh"></iframe>
</template>

CodeSandbox Source
use leptos::prelude::*;

// 将不同的组件组合在一起是我们构建用户界面的方式。
// Composing different components together is how we build
// 在这里,我们将定义一个可复用的 <ProgressBar/>。
// 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(
    // 将此标记为可选 prop。它将默认为其类型的默认值,
    // Marks this as an optional prop. It will default to the default
    // 即 0。
    // value of its type, i.e., 0.
    #[prop(default = 100)]
    /// 进度条的最大值。
    /// The maximum value of the progress bar.
    max: u16,
    // 将对传入 prop 的值运行 `.into()`。
    // Will run `.into()` on the value passed into the prop.
    #[prop(into)]
    // `Signal<T>` 是几种响应式类型的包装器。
    // `Signal<T>` is a wrapper for several reactive types.
    // 在这样的组件 API 中它很有帮助,因为我们
    // 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/>
        // 如果你在 CodeSandbox 或具有 rust-analyzer 支持的编辑器中打开它,
        // If you have this open in CodeSandbox or an editor with
        // 请尝试将鼠标悬停在 `ProgressBar`、`max` 或 `progress` 上,
        // rust-analyzer support, try hovering over `ProgressBar`,
        // 以查看我们上面定义的文档
        // `max` or `progress` to see the docs we defined above
        <ProgressBar max=50 progress=count/>
        // 在这一个上使用默认的 max 值
        // Let's use the default max value on this one
        // 默认值是 100,所以它的移动速度应该是原来的一半
        // the default is 100, so it should move half as fast
        <ProgressBar progress=count/>
        // Signal::derive 从我们的派生信号中创建一个 Signal 包装器
        // Signal::derive creates a Signal wrapper from our derived signal
        // 使用 double_count 意味着它的移动速度应该是原来的两倍
        // 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)
}

迭代

Iteration

无论是列出待办事项、显示表格还是展示产品图片,在 Web 应用程序中,迭代项目列表都是一项常见的任务。协调不断变化的项目集之间的差异也是框架处理得好坏最棘手的任务之一。

Whether you’re listing todos, displaying a table, or showing product images, iterating over a list of items is a common task in web applications. Reconciling the differences between changing sets of items can also be one of the trickiest tasks for a framework to handle well.

Leptos 支持两种不同的项目迭代模式:

Leptos supports two different patterns for iterating over items:

  1. 对于静态视图:Vec<_>

  2. For static views: Vec<_>

  3. 对于动态列表:<For/>

  4. For dynamic lists: <For/>

使用 Vec<_> 处理静态视图

Static Views with Vec<_>

有时你需要重复显示一个项目,但你提取的列表并不经常变化。在这种情况下,重要的是要知道你可以将任何 Vec<IV> where IV: IntoView 插入到你的视图中。换句话说,如果你能渲染 T,你就能渲染 Vec<T>

Sometimes you need to show an item repeatedly, but the list you’re drawing from does not often change. In this case, it’s important to know that you can insert any Vec<IV> where IV: IntoView into your view. In other words, if you can render T, you can render Vec<T>.

let values = vec![0, 1, 2];
view! {
    // 这将直接渲染成 "012"
    // this will just render "012"
    <p>{values.clone()}</p>
    // 或者我们可以将它们包裹在 <li> 中
    // or we can wrap them in <li>
    <ul>
        {values.into_iter()
            .map(|n| view! { <li>{n}</li>})
            .collect::<Vec<_>>()}
    </ul>
}

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

Leptos also provides a .collect_view() helper function that allows you to collect any iterator of T: IntoView into Vec<View>.

let values = vec![0, 1, 2];
view! {
    // 这将直接渲染成 "012"
    // this will just render "012"
    <p>{values.clone()}</p>
    // 或者我们可以将它们包裹在 <li> 中
    // or we can wrap them in <li>
    <ul>
        {values.into_iter()
            .map(|n| view! { <li>{n}</li>})
            .collect_view()}
    </ul>
}

列表是静态的,并不意味着界面也必须是静态的。你可以在静态列表中渲染动态项目。

The fact that the list is static doesn’t mean the interface needs to be static. You can render dynamic items as part of a static list.

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

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

Note here that instead of calling signal() to get a tuple with a reader and a writer, here we use RwSignal::new() to get a single, read-write signal. This is just more convenient for a situation where we’d otherwise be passing the tuples around.

// 每个项目管理一个响应式视图
// each item manages a reactive view
// 但列表本身永远不会改变
// but the list itself will never change
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<_>。但请注意,这是一个无键(unkeyed)列表更新:它将重用现有的 DOM 元素,并根据它们在新的 Vec<_> 中的顺序用新值更新它们。如果你只是在列表末尾添加和删除项目,这种方式效果很好,但如果你在移动项目或在列表中间插入项目,这将导致浏览器做多余的工作,并可能对输入状态和 CSS 动画等方面产生令人惊讶的影响。(关于“有键(keyed)”与“无键(unkeyed)”区别的更多信息及一些实际示例,你可以阅读这篇文章。)

You can render a Fn() -> Vec<_> reactively as well. But note that this is an unkeyed list update: it will reuse the existing DOM elements, and update them with the new values, according to their order in the new Vec<_>. If you’re just adding and removing items at the end of the list, this works well, but if you are moving items around or inserting items into the middle of the list, this will cause the browser to do more work than it needs to, and may have surprising effects on things like input state and CSS animations. (For more on the “keyed” vs. “unkeyed” distinction, and some practical examples, you can read this article.)

幸运的是,还有一种高效的方法可以进行有键列表迭代。

Luckily, there’s an efficient way to do keyed list iteration, as well.

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

Dynamic Rendering with the <For/> Component

<For/> 组件是一个有键动态列表。它接受三个 props:

The <For/> component is a keyed dynamic list. It takes three props:

  • each:一个返回要迭代的项目 T 的响应式函数

  • each: a reactive function that returns the items T to be iterated over

  • key:一个接收 &T 并返回稳定且唯一的键或 ID 的键函数

  • key: a key function that takes &T and returns a stable, unique key or ID

  • children:将每个 T 渲染为视图

  • children: renders each T into a view

key 就是那个关键的键。你可以在列表中添加、删除和移动项目。只要每个项目的键随着时间的推移保持稳定,框架就不需要重新渲染任何项目(除非它们是新添加的),并且它可以非常高效地添加、删除和移动发生变化的项目。这允许在列表发生变化时进行极其高效的更新,且只需极少的额外工作。

key is, well, the key. You can add, remove, and move items within the list. As long as each item’s key is stable over time, the framework does not need to rerender any of the items, unless they are new additions, and it can very efficiently add, remove, and move items as they change. This allows for extremely efficient updates to the list as it changes, with minimal additional work.

创建一个好的 key 可能会有点棘手。通常你希望为此目的使用索引,因为它不稳定——如果你删除或移动项目,它们的索引就会改变。

Creating a good key can be a little tricky. You generally do not want to use an index for this purpose, as it is not stable—if you remove or move items, their indices change.

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

But it’s a great idea to do something like generating a unique ID for each row as it is generated, and using that as an ID for the key function.

查看下面的 <DynamicList/> 组件示例。

Check out the <DynamicList/> component below for an example.

在线示例

点击打开 CodeSandbox。

CodeSandbox Source
use leptos::prelude::*;

// 迭代在大多数应用中都是一项非常常见的任务。
// Iteration is a very common task in most applications.
// 那么,如何获取数据列表并将其渲染到 DOM 中呢?
// So how do you take a list of data and render it in the DOM?
// 这个例子将向你展示两种方式:
// This example will show you the two ways:
// 1) 对于大部分是静态的列表,使用 Rust 迭代器
// 1) for mostly-static lists, using Rust iterators
// 2) 对于会增长、收缩或移动项目的列表,使用 <For/>
// 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>"如果列表本身是静态的,请使用这种模式。"</p>
        <StaticList length=5/>
        <h2>"Dynamic List"</h2>
        <p>"如果列表中的行会发生变化,请使用这种模式。"</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));

    // 当你有一个不改变的列表时,你可以
    // 使用普通的 Rust 迭代器来操作它
    // when you have a list that doesn't change, you can
    // manipulate it using ordinary Rust iterators
    // 并将其收集到一个 Vec<_> 中以插入 DOM
    // 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<_>>();

    // 注意,如果 `counter_buttons` 是一个响应式列表
    // 并且其值发生了变化,这将会非常低效:
    // 它在每次列表改变时都会重新渲染每一行。
    // 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 {
    // 这个动态列表将使用 <For/> 组件。
    // This dynamic list will use the <For/> component.
    // <For/> 是一个有键列表。这意味着每一行
    // 都有一个定义的键。如果键不改变,该行
    // 就不会被重新渲染。当列表改变时,只有
    // 最小数量的更改会被应用到 DOM。
    // <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` 将让我们生成唯一的 ID
    // 我们通过在每次创建计数器时简单地将 ID 加一来做到这一点
    // `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;

    // 我们像在 <StaticList/> 中一样生成初始列表
    // 但这次我们将 ID 与信号一起包含在内
    // 请参阅下文 add_counter 中关于 ArcRwSignal 的注释
    // 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 |_| {
        // 为新计数器创建一个信号
        // 我们在这里使用 ArcRwSignal,而不是 RwSignal
        // ArcRwSignal 是一种引用计数类型,而不是我们到目前为止使用的
        // 竞技场分配(arena-allocated)的信号类型。
        // 当我们创建像这样的信号集合时,使用 ArcRwSignal
        // 允许每个信号在其行被删除时被释放。
        // 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| {
            // 既然 `.update()` 给了我们 `&mut T`
            // 我们就可以直接使用正常的 Vec 方法如 `push`
            // since `.update()` gives us `&mut T`
            // we can just use normal Vec methods like `push`
            counters.push((next_counter_id, sig))
        });
        // 递增 ID 以使其始终保持唯一
        // increment the ID so it's always unique
        next_counter_id += 1;
    };

    view! {
        <div>
            <button on:click=add_counter>
                "Add Counter"
            </button>
            <ul>
                // <For/> 组件在这里至关重要
                // 这允许高效的有键列表渲染
                // The <For/> component is central here
                // This allows for efficient, key list rendering
                <For
                    // `each` 接收任何返回迭代器的函数
                    // 这通常应该是一个信号或派生信号
                    // 如果它不是响应式的,直接渲染 Vec<_> 而不是 <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` 接收来自 `each` 迭代器的每个项目
                    // 并返回一个视图
                    // `children` receives each item from your `each` iterator
                    // and returns a view
                    children=move |(id, count)| {
                        // 我们可以将 ArcRwSignal 转换为可复制(Copy)的 RwSignal
                        // 以便在将其移动到视图中时获得更好的开发体验(DX)
                        // 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)
}

在迭代时使用 <ForEnumerate/> 访问索引

Accessing an index while iterating with <ForEnumerate/>

对于需要在迭代时访问实时索引的情况,Leptos 提供了 <ForEnumerate/> 组件。

For the cases where you need to access the real-time index while iterating, Leptos provides a <ForEnumerate/> component.

其 props 与 <For/> 组件完全相同,但在渲染 children 时,它额外提供了一个 ReadSignal<usize> 参数作为索引:

The props are identical to the <For/> component, but when rendering children it additionally provides a ReadSignal<usize> parameter as the index:

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct Counter {
  id: usize,
  count: RwSignal<i32>
}

<ForEnumerate
    each=move || counters.get() // 与 <For/> 相同
    key=|counter| counter.id    // 与 <For/> 相同
    // 提供索引信号和子项目 T
    // Provides the index as a signal and the child T
    children={move |index: ReadSignal<usize>, counter: Counter| {
        view! {
            <button>{move || index.get()} ". Value: " {move || counter.count.get()}</button>
        }
    }}
/>

或者它也可以与更方便的 let 语法一起使用:

or it could also be used with the more convenient let syntax:

<ForEnumerate
    each=move || counters.get() // 与 <For/> 相同
    key=|counter| counter.id    // 与 <For/> 相同
    let(idx, counter)           // let 语法
>
    <button>{move || idx.get()} ". Value: " {move || counter.count.get()}</button>
</ ForEnumerate>

使用 <For/> 遍历更复杂的数据

Iterating over More Complex Data with <For/>

本章将更深入地探讨嵌套数据结构的迭代。它与另一篇关于迭代的章节放在一起,但如果你现在想继续学习更简单的内容,可以随时跳过本章以后再回来。

This chapter goes into iteration over nested data structures in a bit more depth. It belongs here with the other chapter on iteration, but feel free to skip it and come back if you’d like to stick with simpler subjects for now.

问题

The Problem

我刚才说过,除非键(key)发生变化,否则框架不会重新渲染行中的任何项。这起初听起来可能很有道理,但它很容易让你栽跟头。

I just said that the framework does not rerender any of the items in one of the rows, unless the key has changed. This probably makes sense at first, but it can easily trip you up.

让我们来看一个例子,其中每一行中的每一项都是某种数据结构。例如,假设这些项来自某个包含键和值的 JSON 数组:

Let’s consider an example in which each of the items in our row is some data structure. Imagine, for example, that the items come from some JSON array of keys and values:

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

让我们定义一个简单的组件,它将遍历这些行并显示每一行:

Let’s define a simple component that will iterate over the rows and display each one:

#[component]
pub fn App() -> impl IntoView {
    // 以一组三行数据开始
    // start with a set of three rows
    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! {
        // 点击时更新每一行,
        // 将其值翻倍
        // when we click, update each row,
        // doubling its value
        <button on:click=move |_| {
            set_data.update(|data| {
                for row in data {
                    row.value *= 2;
                }
            });
            // 打印信号的新值
            // log the new value of the signal
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 遍历行并显示每个值
        // iterate over the rows and display each value
        <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 })
>

Note the let(child) syntax here. In the previous chapter we introduced <For/> with a children prop. We can actually create this value directly in the children of the <For/> component, without breaking out of the view macro: the let(child) combined with <p>{child.value}</p> above is the equivalent of

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

For convenience, you can also choose to destructure the pattern of your data:

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

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

When you click the Update Values button... nothing happens. Or rather: the signal is updated, the new value is logged, but the {child.value} for each row doesn’t update.

让我们看看:是不是因为我们忘了添加闭包来使其具有响应性?让我们试试 {move || child.value}

Let’s see: is that because we forgot to add a closure to make it reactive? Let’s try {move || child.value}.

……不,还是不行。

...Nope. Still nothing.

问题出在这里:正如我所说,只有当键发生变化时,每一行才会重新渲染。我们更新了每一行的值,但没有更新任何一行的键,所以没有任何内容重新渲染。如果你查看 child.value 的类型,它是一个普通的 i32,而不是响应式的 ReadSignal<i32> 或类似的东西。这意味着即使我们用闭包包裹它,这一行中的值也永远不会更新。

Here’s the problem: as I said, each row is only rerendered when the key changes. We’ve updated the value for each row, but not the key for any of the rows, so nothing has rerendered. And if you look at the type of child.value, it’s a plain i32, not a reactive ReadSignal<i32> or something. This means that even if we wrap a closure around it, the value in this row will never update.

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

We have four possible solutions:

  1. 更改 key,使其在数据结构发生变化时总是更新

  2. 更改 value,使其具有响应性

  3. 采用数据结构的响应式切片(slice),而不是直接使用每一行

  4. 使用 Store

  5. change the key so that it always updates when the data structure changes

  6. change the value so that it’s reactive

  7. take a reactive slice of the data structure instead of using each row directly

  8. use a Store

方案 1:更改键

Option 1: Change the Key

只有当键发生变化时,每一行才会重新渲染。上面的行没有重新渲染,是因为键没有变。那么:为什么不直接强制键发生变化呢?

Each row is only rerendered when the key changes. Our rows above didn’t rerender, because the key didn’t change. So: why not just force the key to change?

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

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

Now we include both the key and the value in the key. This means that whenever the value of a row changes, <For/> will treat it as if it’s an entirely new row, and replace the previous one.

优点

Pros

这非常简单。我们可以通过在 DatabaseEntry 上派生 PartialEqEqHash 来使其变得更加简单,在这种情况下,我们只需 key=|state| state.clone() 即可。

This is very easy. We can make it even easier by deriving PartialEq, Eq, and Hash on DatabaseEntry, in which case we could just key=|state| state.clone().

缺点

Cons

这是四种方案中效率最低的。 每当一行的值发生变化时,它都会丢弃之前的 <p> 元素,并用一个全新的元素替换它。换句话说,它并没有对文本节点进行细粒度的更新,而是确实在每次更改时重新渲染整行,其成本与该行 UI 的复杂度成正比。

This is the least efficient of the four options. Every time the value of a row changes, it throws out the previous <p> element and replaces it with an entirely new one. Rather than making a fine-grained update to the text node, in other words, it really does rerender the entire row on every change, and this is expensive in proportion to how complex the UI of the row is.

你会注意到,我们最终还会克隆整个数据结构,以便 <For/> 可以保留一份键的副本。对于更复杂的结构,这很快就会变成一个坏主意!

You’ll notice we also end up cloning the whole data structure so that <For/> can hold onto a copy of the key. For more complex structures, this can become a bad idea fast!

方案 2:嵌套信号

Option 2: Nested Signals

如果我们确实想要针对该值的细粒度响应性,一种选择是将每一行的 value 包裹在一个信号中。

If we do want that fine-grained reactivity for the value, one option is to wrap the value of each row in a signal.

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

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

RwSignal<_> is a “read-write signal,” which combines the getter and setter in one object. I’m using it here because it’s a little easier to store in a struct than separate getters and setters.

#[component]
pub fn App() -> impl IntoView {
    // 以一组三行数据开始
    // start with a set of three rows
    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! {
        // 点击时更新每一行,
        // 将其值翻倍
        // when we click, update each row,
        // doubling its value
        <button on:click=move |_| {
            for row in &*data.read() {
                row.value.update(|value| *value *= 2);
            }
            // 打印信号的新值
            // log the new value of the signal
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 遍历行并显示每个值
        // iterate over the rows and display each value
        <For
            each=move || data.get()
            key=|state| state.key.clone()
            let(child)
        >
            <p>{child.value}</p>
        </For>
    }
}

这个版本可以工作!如果你在浏览器的 DOM 检查器中查看,你会发现与之前的版本不同,在这个版本中只有单个文本节点被更新。直接将信号传入 {child.value} 是可行的,因为如果你将信号传递给视图,它们会保持响应性。

This version works! And if you look in the DOM inspector in your browser, you’ll see that unlike in the previous version, in this version only the individual text nodes are updated. Passing the signal directly into {child.value} works, as signals do keep their reactivity if you pass them into the view.

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

Note that I changed the set_data.update() to a data.read(). .read() is a non-cloning way of accessing a signal’s value. In this case, we are only updating the inner values, not updating the list of values: because signals maintain their own state, we don’t actually need to update the data signal at all, so the immutable .read() is fine here.

实际上,这个版本并没有更新 data,所以 <For/> 本质上就像上一章中的静态列表一样,这可以只是一个普通的迭代器。但如果我们以后想添加或删除行,<For/> 就很有用了。

In fact, this version doesn’t update data, so the <For/> is essentially a static list as in the last chapter, and this could just be a plain iterator. But the <For/> is useful if we want to add or remove rows in the future.

优点

Pros

这是最高效的选择,并且直接符合框架其余部分的思维模型:随时间变化的值被包裹在信号中,以便界面可以对其做出响应。

This is the most efficient option, and fits directly with the rest of the mental model of the framework: values that change over time are wrapped in signals so the interface can respond to them.

缺点

Cons

如果你正从 API 或其他你无法控制的数据源接收数据,并且你不想创建一个将每个字段都包裹在信号中的不同结构体,那么嵌套响应性可能会很繁琐。

Nested reactivity can be cumbersome if you’re receiving data from an API or another data source you don control, and you don’t want to create a different struct wrapping each field in a signal.

方案 3:记忆化切片 (Memoized Slices)

Option 3: Memoized Slices

Leptos 提供了一个名为 Memo 的原语,它创建一个派生计算,仅在其值发生变化时才触发响应式更新。

Leptos provides a primitive called a Memo, which creates a derived computation that only triggers a reactive update when its value has changed.

这允许你为较大数据结构的子字段创建响应式值,而无需将该结构的字段包裹在信号中。结合 <ForEnumerate/>,这将允许我们仅重新渲染更改后的数据值。

This allows you to create reactive values for subfields of a larger data structure, without needing to wrap the fields of that structure in signals. In combination with <ForEnumerate/>, this will allow us to rerender only changed data values.

应用程序的大部分内容可以与最初(损坏的)版本保持一致,但 <For/> 将更新为:

Most of the application can remain the same as the initial (broken) version, but the <For/> will be updated to this:

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

你会在这里注意到一些不同之处:

You’ll notice a few differences here:

  • 我们使用的是 ForEnumerate 而不是 For,这样我们就可以访问 index 信号

  • 我们明确使用了 children 属性,以便更容易地运行一些非 view 代码

  • 我们定义了一个 value 记忆 (memo) 并在视图中使用它。这个 value 字段实际上并未使用传递到每一行的 child。相反,它使用索引并回溯到原始 data 以获取值。

  • we use ForEnumerate rather than For, so we have access to an index signal

  • we use the children prop explicitly, to make it easier to run some non-view code

  • we define a value memo and use that in the view. This value field doesn’t actually use the child being passed into each row. Instead, it uses the index and reaches back into the original data to get the value.

现在,每当 data 发生变化时,每个记忆都会被重新计算。如果其值发生了变化,它将更新其文本节点,而不会重新渲染整行。

Now every time data changes, each memo will be recalculated. If its value has changed, it will update its text node, without rerendering the whole row.

注意:在此情况下,将 For 与枚举迭代器(enumerated iterator)一起使用是不安全的,正如本示例的早期版本所示:

Note: It is not safe to use For for this with an enumerated iterator, as in an earlier version of this example:

<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 中值的更改会被响应,但顺序的更改则不会,因为 Memo 将始终使用它最初创建时的 index。如果移动了任何项,这将导致渲染输出中出现重复项。

In this case, changes to values in data will be reacted to, but changes to ordering will not, as the Memo will always use the index it was initially created with. This will result in duplicate entries in the rendered output if any items are moved.

优点

Pros

我们获得了与信号包装版本相同的细粒度响应性,而无需将数据包裹在信号中。

We get the same fine-grained reactivity of the signal-wrapped version, without needing to wrap the data in signals.

缺点

Cons

<ForEnumerate/> 循环内部设置这种每一行一个记忆(memo-per-row)比使用嵌套信号稍微复杂一些。例如,你会注意到我们必须通过使用 data.get(index.get()) 来防止 data[index.get()] 因崩溃而导致 panic 的可能性,因为这个记忆可能会在行被移除后立即被触发重新运行一次。(这是因为每一行的记忆和整个 <ForEnumerate/> 都依赖于同一个 data 信号,并且依赖于同一个信号的多个响应式值的执行顺序是不确定的。)

It’s a bit more complex to set up this memo-per-row inside the <ForEnumerate/> loop rather than using nested signals. For example, you’ll notice that we have to guard against the possibility that the data[index.get()] would panic by using data.get(index.get()), because this memo may be triggered to re-run once just after the row is removed. (This is because the memo for each row and the whole <ForEnumerate/> both depend on the same data signal, and the order of execution for multiple reactive values that depend on the same signal isn’t guaranteed.)

还要注意,虽然记忆化会记忆它们的响应式更改,但每次仍然需要重新运行相同的计算来检查值,因此嵌套响应式信号在进行精准更新时仍然会更高效。

Note also that while memos memoize their reactive changes, the same calculation does need to re-run to check the value every time, so nested reactive signals will still be more efficient for pinpoint updates here.

方案 4:Store

Option 4: Stores

其中一些内容在此处关于使用 Store 进行全局状态管理的章节中有所重复。由于这两个章节都是中级/可选内容,所以我认为重复一些内容并无大碍。

Some of this content is duplicated in the section on global state management with stores here. Both sections are intermediate/optional content, so I thought some duplication couldn’t hurt.

Leptos 0.7 引入了一种名为 “Store” 的新型响应式原语。Store 旨在解决本章到目前为止所描述的问题。它们还处于实验阶段,因此需要在你的 Cargo.toml 中添加一个名为 reactive_stores 的额外依赖。

Leptos 0.7 introduces a new reactive primitive called “stores.” Stores are designed to address the issues described in this chapter so far. They’re a bit experimental, so they require an additional dependency called reactive_stores in your Cargo.toml.

Store 让你能够对结构体的各个字段以及 Vec<_> 等集合中的各个项进行细粒度的响应式访问,而无需像上面给出的方案那样手动创建嵌套信号或记忆。

Stores give you fine-grained reactive access to the individual fields of a struct, and to individual items in collections like Vec<_>, without needing to create nested signals or memos manually, as in the options given above.

Store 建立在 Store 派生宏之上,该宏为结构体的每个字段创建一个 getter。调用此 getter 可以对该特定字段进行响应式访问。读取它将仅跟踪该字段及其父级/子级,更新它将仅通知该字段及其父级/子级,而不会通知兄弟级。换句话说,修改 value 不会通知 key,以此类推。

Stores are built on top of the Store derive macro, which creates a getter for each field of a struct. Calling this getter gives reactive access to that particular field. Reading from it will track only that field and its parents/children, and updating it will only notify that field and its parents/children, but not siblings. In other words, mutating value will not notify key, and so on.

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

We can adapt the data types we used in the examples above.

Store 的顶层始终需要是一个结构体,因此我们将创建一个带有单个 rows 字段的 Data包装器。

The top level of a store always needs to be a struct, so we’ll create a Data wrapper with a single rows field.

#[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,
}

#[store(key)] 添加到 rows 字段允许我们对 Store 的字段进行键控访问(keyed access),这在下面的 <For/> 组件中非常有用。我们可以简单地使用 key,这与我们在 <For/> 中使用的键相同。

Adding #[store(key)] to the rows field allows us to have keyed access to the fields of the store, which will be useful in the <For/> component below. We can simply use key, the same key that we’ll use in <For/>.

<For/> 组件非常直观:

The <For/> component is pretty straightforward:

<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 属性。这将对 rows 列表的任何更改做出响应,就像在我们的嵌套信号版本中 move || data.get() 所做的那样。

Because rows is a keyed field, it implements IntoIterator, and we can simply use move || data.rows() as the each prop. This will react to any changes to the rows list, just as move || data.get() did in our nested-signal version.

key 字段调用 .read() 来访问行的当前值,然后克隆并返回 key 字段。

The key field calls .read() to get access to the current value of the row, then clones and returns the key field.

children 属性中,调用 child.value() 为我们提供了对具有此键的行的 value 字段的响应式访问。如果行被重新排序、添加或删除,键控 Store 字段将保持同步,以便此 value 始终与正确的键相关联。

In children prop, calling child.value() gives us reactive access to the value field for the row with this key. If rows are reordered, added, or removed, the keyed store field will keep in sync so that this value is always associated with the correct key.

在更新按钮处理程序中,我们将遍历 rows 中的项,更新每一项:

In the update button handler, we’ll iterate over the entries in rows, updating each one:

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

优点

Pros

我们获得了嵌套信号和记忆版本的细粒度响应性,而无需手动创建嵌套信号或记忆化切片。我们可以处理普通数据(结构体和 Vec<_>),并使用派生宏进行标注,而不是使用特殊的嵌套响应式类型。

We get the fine-grained reactivity of the nested-signal and memo versions, without needing to manually create nested signals or memoized slices. We can work with plain data (a struct and Vec<_>), annotated with a derive macro, rather than special nested reactive types.

就个人而言,我认为 Store 版本是这里最优雅的。这并不奇怪,因为它是最新的 API。我们花了几年的时间思考这些问题,而 Store 包含了我们学到的一些经验教训!

Personally, I think the stores version is the nicest one here. And no surprise, as it’s the newest API. We’ve had a few years to think about these things and stores include some of the lessons we’ve learned!

缺点

Cons

另一方面,它是最新的 API。在撰写本文(2024 年 12 月)时,Store 仅发布了几周时间;我确信仍然有一些错误或边缘情况有待解决。

On the other hand, it’s the newest API. As of writing this sentence (December 2024), stores have only been released for a few weeks; I am sure that there are still some bugs or edge cases to be figured out.

完整示例

Full Example

这是完整的 Store 示例。你可以在此处找到另一个更完整的示例,并在本书的此处查看更多讨论。

Here’s the complete store example. You can find another, more complete example here, and more discussion in the book here.

use reactive_stores::Store;

#[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,
}

#[component]
pub fn App() -> impl IntoView {
    // 我们不是创建一个包含行的信号,而是为 Data 创建一个 store
    // instead of a signal with the rows, we create a store for Data
    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! {
        // 点击时更新每一行,
        // 将其值翻倍
        // when we click, update each row,
        // doubling its value
        <button on:click=move |_| {
            // 允许遍历可迭代 store 字段中的条目
            // allows iterating over the entries in an iterable store field
            use reactive_stores::StoreFieldIterator;

            // 调用 rows() 让我们访问这些行
            // calling rows() gives us access to the rows
            for row in data.rows().iter_unkeyed() {
                *row.value().write() *= 2;
            }
            // 打印信号的新值
            // log the new value of the signal
            leptos::logging::log!("{:?}", data.get());
        }>
            "Update Values"
        </button>
        // 遍历行并显示每个值
        // iterate over the rows and display each value
        <For
            each=move || data.rows()
            key=|row| row.read().key.clone()
            children=|child| {
                let value = child.value();
                view! { <p>{move || value.get()}</p> }
            }
        />
    }
}

表单与输入

Forms and Inputs

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

Forms and form inputs are an important part of interactive apps. There are two basic patterns for interacting with inputs in Leptos, which you may recognize if you’re familiar with React, SolidJS, or a similar framework: using controlled or uncontrolled inputs.

受控输入

Controlled Inputs

在 “受控输入” 中,框架控制着输入元素的状态。在每个 input 事件上,它都会更新一个持有当前状态的本地信号,这反过来又会更新输入的 value 属性。

In a "controlled input," the framework controls the state of the input element. On every input event, it updates a local signal that holds the current state, which in turn updates the value prop of the input.

有两点重要事项需要记住:

There are two important things to remember:

  1. input 事件在元素(几乎)每次更改时触发,而 change 事件则在输入框失去焦点时(或多或少)触发。你可能想要的是 on:input,但我们赋予你选择的自由。

  2. value 特性 (attribute) 仅设置输入的初始值,也就是说,它仅在用户开始输入之前更新输入。在此之后,value 属性 (property) 会继续更新输入。出于这个原因,你通常希望设置 prop:value。(对于 <input type="checkbox"> 上的 checkedprop:checked 也是如此。)

  3. The input event fires on (almost) every change to the element, while the change event fires (more or less) when you unfocus the input. You probably want on:input, but we give you the freedom to choose.

  4. The value attribute only sets the initial value of the input, i.e., it only updates the input up to the point that you begin typing. The value property continues updating the input after that. You usually want to set prop:value for this reason. (The same is true for checked and prop:checked on an <input type="checkbox">.)

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

view! {
    <input type="text"
        // 添加 :target 让我们能够以类型安全的方式访问触发事件的元素
        // adding :target gives us typed access to the element
        // that is the target of the event that fires
        on:input:target=move |ev| {
            // .value() 返回 HTML 输入元素的当前值
            // .value() returns the current value of an HTML input element
            set_name.set(ev.target().value());
        }

        // `prop:` 语法允许你更新 DOM 属性 (property),
        // 而不是特性 (attribute)。
        // the `prop:` syntax lets you update a DOM property,
        // rather than an attribute.
        prop:value=name
    />
    <p>"Name is: " {name}</p>
}

为什么需要 prop:value

Why do you need prop:value?

网络浏览器是现存最普遍、最稳定的图形用户界面渲染平台。在其存在的三十年里,它们还保持了令人难以置信的向后兼容性。不可避免地,这意味着存在一些怪癖。

Web browsers are the most ubiquitous and stable platform for rendering graphical user interfaces in existence. They have also maintained an incredible backwards compatibility over their three decades of existence. Inevitably, this means there are some quirks.

一个奇怪的怪癖是 HTML 特性 (attributes) 和 DOM 元素属性 (properties) 之间存在区别。也就是说,从 HTML 解析并可以使用 .setAttribute() 在 DOM 元素上设置的 “特性 (attribute)”,与作为该解析后的 HTML 元素的 JavaScript 类表示的一个字段的 “属性 (property)” 之间存在区别。

One odd quirk is that there is a distinction between HTML attributes and DOM element properties, i.e., between something called an “attribute” which is parsed from HTML and can be set on a DOM element with .setAttribute(), and something called a “property” which is a field of the JavaScript class representation of that parsed HTML element.

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

In the case of an <input value=...>, setting the value attribute is defined as setting the initial value for the input, and setting value property sets its current value. It may be easier to understand this by opening about:blank and running the following JavaScript in the browser console, line by line:

// 创建一个输入框并将其追加到 DOM

// create an input and append it to the DOM const el = document.createElement("input"); document.body.appendChild(el);

el.setAttribute("value", "test"); // 更新输入框

// updates the input

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

// updates the input again

// 现在去输入框里输入:删除一些字符等。

// now go and type into the input: delete some characters, etc.

el.setAttribute("value", "one more time?");

// 什么都不应该改变。现在设置 “初始值” 不起任何作用

// nothing should have changed. Setting the "initial value" does nothing now

// 然而...

// however... el.value = "But this works";


许多其他前端框架混淆了特性 (attributes) 和属性 (properties),或者为输入框创建了一个能正确设置值的特殊情况。也许 Leptos 也应该这样做,但目前,我更倾向于让用户最大限度地控制他们是在设置特性还是属性,并尽力教育人们了解实际的浏览器底层行为,而不是将其掩盖。

Many other frontend frameworks conflate attributes and properties, or create a special case for inputs that sets the value correctly. Maybe Leptos should do this too, but for now, I prefer giving users the maximum amount of control over whether they’re setting an attribute or a property, and doing my best to educate people about the actual underlying browser behavior rather than obscuring it.

使用 bind: 简化受控输入

Simplifying Controlled Inputs with bind:

坚持 Web 标准以及明确区分 “从信号读取” 和 “写入信号” 是好事,但以这种方式创建受控输入有时可能看起来像是不必要的样板代码。

Adherence to Web standards and a clear division between “reading from a signal” and “writing to a signal” are good, but creating controlled inputs in this way can sometimes seem like more boilerplate than is really necessary.

Leptos 还包含一个特殊的 bind: 语法,用于自动将信号绑定到输入。它们的作用与上面的 “受控输入” 模式完全相同:创建一个更新信号的事件监听器,以及一个从信号读取的动态属性。你可以将 bind:value 用于文本输入,bind:checked 用于复选框,以及 bind:group 用于单选按钮组。

Leptos also includes a special bind: syntax for inputs that allows you to automatically bind signals to inputs. They do exactly the same thing as the “controlled input” pattern above: create an event listener that updates the signal, and a dynamic property that reads from the signal. You can use bind:value for text inputs, bind:checked for checkboxes, and bind:group for radio button groups.

let (name, set_name) = signal("Controlled".to_string());
let email = RwSignal::new("".to_string());
let favorite_color = RwSignal::new("red".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>
    <fieldset>
        <legend>"Favorite color"</legend>
        <label>
            "Red"
            <input
                type="radio"
                name="color"
                value="red"
                bind:group=favorite_color
            />
        </label>
        <label>
            "Green"
            <input
                type="radio"
                name="color"
                value="green"
                bind:group=favorite_color
            />
        </label>
        <label>
            "Blue"
            <input
                type="radio"
                name="color"
                value="blue"
                bind:group=favorite_color
            />
        </label>
    </fieldset>
    <p>"Your favorite color is " {favorite_color} "."</p>
    <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 在我们想要获取其值时访问输入。

In an "uncontrolled input," the browser controls the state of the input element. Rather than continuously updating a signal to hold its value, we use a NodeRef to access the input when we want to get its value.

在这个例子中,我们仅在 <form> 触发 submit 事件时通知框架。请注意 leptos::html 模块的使用,它为每个 HTML 元素提供了大量类型。

In this example, we only notify the framework when the <form> fires a submit event. Note the use of the leptos::html module, which provides a bunch of types for every HTML element.

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>
}

到目前为止,这个视图应该是显而易见的。注意两点:

The view should be pretty self-explanatory by now. Note two things:

  1. 与受控输入示例不同,我们使用 value(而不是 prop:value)。这是因为我们只是设置输入的初始值,并让浏览器控制其状态。(我们也可以使用 prop:value。)

  2. 我们使用 node_ref=... 来填充 NodeRef。(旧示例有时使用 _ref。它们是同一回事,但 node_ref 有更好的 rust-analyzer 支持。)

  3. Unlike in the controlled input example, we use value (not prop:value). This is because we’re just setting the initial value of the input, and letting the browser control its state. (We could use prop:value instead.)

  4. We use node_ref=... to fill the NodeRef. (Older examples sometimes use _ref. They are the same thing, but node_ref has better rust-analyzer support.)

NodeRef 是一种响应式智能指针:我们可以使用它来访问底层的 DOM 节点。当元素渲染时,它的值将被设置。

NodeRef is a kind of reactive smart pointer: we can use it to access the underlying DOM node. Its value will be set when the element is rendered.

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()
        // 事件处理程序只有在视图挂载到 DOM 之后才能触发,
        // 所以 `NodeRef` 将会是 `Some`
        // event handlers can only fire after the view
        // is mounted to the DOM, so the `NodeRef` will be `Some`
        .expect("<input> should be mounted")
        // `leptos::HtmlElement<html::Input>` 为 `web_sys::HtmlInputElement` 实现了 `Deref`。
        // 这意味着我们可以调用 `HtmlInputElement::value()` 来获取输入的当前值。
        // `leptos::HtmlElement<html::Input>` implements `Deref`
        // to a `web_sys::HtmlInputElement`.
        // this means we can call`HtmlInputElement::value()`
        // to get the current value of the input
        .value();
    set_name.set(value);
};

我们的 on_submit 处理程序将访问输入的值并使用它来调用 set_name.set()。要访问存储在 NodeRef 中的 DOM 节点,我们可以简单地将其作为函数调用(或使用 .get())。这将返回 Option<leptos::HtmlElement<html::Input>>,但我们知道该元素已经挂载(否则你怎么能触发这个事件!),所以在这里解包是安全的。

Our on_submit handler will access the input’s value and use it to call set_name.set(). To access the DOM node stored in the NodeRef, we can simply call it as a function (or using .get()). This will return Option<leptos::HtmlElement<html::Input>>, but we know that the element has already been mounted (how else did you fire this event!), so it's safe to unwrap here.

然后我们可以调用 .value() 来从输入中获取值,因为 NodeRef 让我们能够访问到正确类型的 HTML 元素。

We can then call .value() to get the value out of the input, because NodeRef gives us access to a correctly-typed HTML element.

查看 web_sysHtmlElement 以了解有关使用 leptos::HtmlElement 的更多信息。此外,请参阅本页末尾完整的 CodeSandbox 示例。

Take a look at web_sys and HtmlElement to learn more about using a leptos::HtmlElement. Also, see the full CodeSandbox example at the end of this page.

特殊情况:<textarea><select>

Special Cases: <textarea> and <select>

有两个表单元素往往会以不同的方式引起混淆。

Two form elements tend to cause some confusion in different ways.

<textarea>

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

Unlike <input>, the <textarea> element does not support a value attribute in HTML. Instead, it receives its initial value as a plain text node in its HTML children.

因此,如果你想在服务器端渲染一个初始值,并且让该值在浏览器中也具有响应性,你可以既给它传递一个初始文本节点作为子节点,又使用 prop:value 来设置其当前值。

So if you’d like to server render an initial value, and have the value also react in the browser, you can both pass it an initial text node as a child and use prop:value to set its current value.

view! {
    <textarea
        prop:value=move || some_value.get()
        on:input:target=move |ev| some_value.set(ev.target().value())
    >
        {some_value}
    </textarea>
}

<select>

同样地,<select> 元素可以通过 <select> 本身上的 value 属性来控制,这将选中具有该值的 <option>

The <select> element can likewise be controlled via a value property on the <select> itself, which will select whichever <option> has that value.

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>
  // 一个循环切换选项的按钮
  // a button that will cycle through the options
  <button on:click=move |_| set_value.update(|n| {
    if *n == 2 {
      *n = 0;
    } else {
      *n += 1;
    }
  })>
    "Next Option"
  </button>
}

受控 vs 非受控表单 CodeSandbox

点击打开 CodeSandbox。

Click to open CodeSandbox.

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"
            // 每当输入改变时触发事件
            // 在事件后添加 :target 让我们可以通过 ev.target() 访问到类型正确的元素
            // 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());
            }

            // `prop:` 语法允许你更新 DOM 属性 (property),
            // 而不是特性 (attribute)。
            //
            // 重要提示:`value` *特性* 仅设置初始值,直到你做出更改。
            // `value` *属性* 设置当前值。
            // 这是 DOM 的一个怪癖;不是我发明的。
            // 其他框架掩盖了这一点;我认为让你访问真实的浏览器工作方式更重要。
            //
            // tl;dr: 为表单输入使用 prop: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 {
    // 导入 <input> 的类型
    // import the type for <input>
    use leptos::html::Input;

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

    // 我们将使用 NodeRef 来存储对输入元素的引用
    // 这将在创建元素时被填充
    // 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();

    // 当表单发生 `submit` 事件时触发
    // 这会将 <input> 的值存储在我们的信号中
    // 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()
            // 事件处理程序只有在视图挂载到 DOM 之后才能触发,
            // 所以 `NodeRef` 将会是 `Some`
            // event handlers can only fire after the view
            // is mounted to the DOM, so the `NodeRef` will be `Some`
            .expect("<input> to exist")
            // `NodeRef` 为 DOM 元素类型实现了 `Deref`
            // 这意味着我们可以调用 `HtmlInputElement::value()` 来获取输入的当前值。
            // `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"
                // 在这里,我们使用 `value` *特性* 仅设置初始值,
                // 让浏览器在此之后维护状态
                // here, we use the `value` *attribute* to set only
                // the initial value, letting the browser maintain
                // the state after that
                value=name

                // 将此输入的引用存储 in `input_element`
                // store a reference to this input in `input_element`
                node_ref=input_element
            />
            <input type="submit" value="Submit"/>
        </form>
        <p>"Name is: " {name}</p>
    }
}

// 这个 `main` 函数是应用的入口点
// 它只是将我们的组件挂载到 <body>
// 因为我们将其定义为 `fn App`,我们现在可以在模板中以 <App/> 的形式使用它
// 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/>?这就是控制流

In most applications, you sometimes need to make a decision: Should I render this part of the view, or not? Should I render <ButtonA/> or <WidgetB/>? This is control flow.

几点提示

A Few Tips

当思考如何使用 Leptos 实现这一点时,记住以下几点很重要:

When thinking about how to do this with Leptos, it’s important to remember a few things:

  1. Rust 是一门面向表达式的语言:控制流表达式(如 if x() { y } else { z }match x() { ... })会返回它们的值。这使得它们在声明式用户界面中非常有用。

  2. 对于任何实现了 IntoView 的类型 T——换句话说,对于 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 的响应性。

  5. Rust is an expression-oriented language: control-flow expressions like if x() { y } else { z } and match x() { ... } return their values. This makes them very useful for declarative user interfaces.

  6. For any T that implements IntoView—in other words, for any type that Leptos knows how to render—Option<T> and Result<T, impl Error> also implement IntoView. And just as Fn() -> T renders a reactive T, Fn() -> Option<T> and Fn() -> Result<T, impl Error> are reactive.

  7. Rust has lots of handy helpers like Option::map, Option::and_then, Option::ok_or, Result::map, Result::ok, and bool::then that allow you to convert, in a declarative way, between a few different standard types, all of which can be rendered. Spending time in the Option and Result docs in particular is one of the best ways to level up your Rust game.

  8. And always remember: to be reactive, values must be functions. You’ll see me constantly wrap things in a move || closure, below. This is to ensure that they actually rerun when the signal they depend on changes, keeping the UI reactive.

那又怎样?

So What?

简单串联一下这些知识点:这意味着你实际上可以用原生的 Rust 代码来实现大部分控制流,而不需要任何专门的控制流组件或特殊知识。

To connect the dots a little: this means that you can actually implement most of your control flow with native Rust code, without any control-flow components or special knowledge.

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

For example, let’s start with a simple signal and derived signal:

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

我们可以使用这些信号和普通的 Rust 来构建大部分控制流。

We can use these signals and ordinary Rust to build most control flow.

if 语句

if statements

假设如果数字是奇数,我想渲染一些文本,如果是偶数,则渲染另一些文本。那么,这样写怎么样?

Let’s say I want to render some text if the number is odd, and some other text if it’s even. Well, how about this?

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

一个 if 表达式会返回它的值,而 &str 实现了 IntoView,所以 Fn() -> &str 也实现了 IntoView,所以这段代码……可以正常工作!

An if expression returns its value, and a &str implements IntoView, so a Fn() -> &str implements IntoView, so this... just works!

Option<T>

Option<T>

假设如果数字是奇数,我们想渲染一些文本,如果是偶数,则什么都不渲染。

Let’s say we want to render some text if it’s odd, and nothing if it’s even.

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

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

这运行得很好。如果我们愿意,可以使用 bool::then() 让它变得更短一些。

This works fine. We can make it a little shorter if we’d like, using bool::then().

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

如果你愿意,你甚至可以内联这段代码,尽管个人而言,我有时更喜欢把逻辑抽离到 view 之外,以获得更好的 cargo fmtrust-analyzer 支持。

You could even inline this if you’d like, although personally I sometimes like the better cargo fmt and rust-analyzer support I get by pulling things out of the view.

match 语句

match statements

我们仍然只是在写普通的 Rust 代码,对吧?所以你可以随心所欲地使用 Rust 模式匹配的所有强大功能。

We’re still just writing ordinary Rust code, right? So you have all the power of Rust’s pattern matching at your disposal.

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

何乐而不为呢?尽情发挥吧,对吧?

And why not? YOLO, right?

防止过度渲染

Preventing Over-Rendering

不要太放飞自我。

Not so YOLO.

到目前为止,我们所做的一切基本上都没问题。但有一件事你应该记住并尽量小心。目前为止我们创建的每一个控制流函数基本上都是一个派生信号:它在每次值发生变化时都会重新运行。在上面的例子中,值在每次变化时都会在奇偶之间切换,所以这是可以的。

Everything we’ve just done is basically fine. But there’s one thing you should remember and try to be careful with. Each one of the control-flow functions we’ve created so far is basically a derived signal: it will rerun every time the value changes. In the examples above, where the value switches from even to odd on every change, this is fine.

但考虑以下例子:

But consider the following example:

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

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

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

这段代码当然可以运行。但如果你加上一条日志,你可能会感到惊讶:

This works, for sure. But if you added a log, you might be surprised

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

当用户反复点击按钮增加 value 时,你会看到类似这样的内容:

As a user repeatedly clicks a button incrementing value, you’d see something like this:

1: rendering Small
2: rendering Small
3: rendering Small
4: rendering Small
5: rendering Small
6: rendering Big
7: rendering Big
8: rendering Big
... 以此类推,直到永远

每当 value 发生变化,它都会重新运行 if 语句。考虑到响应式的工作原理,这是合理的。但它也有一个缺点。对于一个简单的文本节点,重新运行 if 语句并重新渲染并不是什么大不了的事。但想象一下如果是这样:

Every time value changes, it reruns the if statement. This makes sense, with how reactivity works. But it has a downside. For a simple text node, rerunning the if statement and rerendering isn’t a big deal. But imagine it were like this:

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

这将重新渲染 <Small/> 五次,然后重新渲染 <Big/> 无数次。如果它们正在加载资源、创建信号,甚至只是创建 DOM 节点,这都是不必要的开销。

This rerenders <Small/> five times, then <Big/> infinitely. If they’re loading resources, creating signals, or even just creating DOM nodes, this is unnecessary work.

<Show/>

<Show/>

<Show/> 组件就是答案。你给它传递一个 when 条件函数,一个在 when 函数返回 false 时显示的回退(fallback),以及在 whentrue 时渲染的子组件。

The <Show/> component is the answer. You pass it a when condition function, a fallback to be shown if the when function returns false, and children to be rendered if when is true.

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

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

<Show/> 会对 when 条件进行记忆化(memoize),所以它只渲染一次 <Small/>,并持续显示同一个组件,直到 value 大于五;然后它渲染一次 <Big/>,并持续显示它直到永远,或者直到 value 低于五,届时它会再次渲染 <Small/>

<Show/> memoizes the when condition, so it only renders its <Small/> once, continuing to show the same component until value is greater than five; then it renders <Big/> once, continuing to show it indefinitely or until value goes below five and then renders <Small/> again.

这是在使用动态 if 表达式时避免过度渲染的一个有用工具。一如既往,这存在一些开销:对于一个非常简单的节点(如更新单个文本节点,或更新类名或属性),使用 move || if ... 会更高效。但如果渲染任何一个分支的开销稍微大一点,请务必使用 <Show/>

This is a helpful tool to avoid rerendering when using dynamic if expressions. As always, there's some overhead: for a very simple node (like updating a single text node, or updating a class or attribute), a move || if ... will be more efficient. But if it’s at all expensive to render either branch, reach for <Show/>.

注意:类型转换

Note: Type Conversions

本节最后有一件重要的事情需要说明。

There’s one final thing it’s important to say in this section.

Leptos 使用静态类型的视图树。view 宏为不同种类的视图返回不同的类型。

Leptos uses a statically-typed view tree. The view macro returns different types for different kinds of view.

以下代码无法编译,因为不同的 HTML 元素属于不同的类型。

This won’t compile, because the different HTML elements are different types.

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>
            // returns HtmlElement<Textarea>
            _ => view! { <textarea>{value.get()}</textarea> }
        }}
    </main>
}

这种强类型非常强大,因为它能够实现各种编译时优化。但在这种条件逻辑中可能会有点烦人,因为在 Rust 中,你不能从一个条件分支的不同分支返回不同的类型。有两种方法可以摆脱这种困境:

This strong typing is very powerful, because it enables all sorts of compile-time optimizations. But it can be a little annoying in conditional logic like this, because you can’t return different types from different branches of a condition in Rust. There are two ways to get yourself out of this situation:

  1. 使用枚举 Either(以及 EitherOf3EitherOf4 等)将不同的类型转换为相同的类型。

  2. 使用 .into_any() 将多种类型转换为一种类型擦除后的 AnyView

  3. Use the enum Either (and EitherOf3, EitherOf4, etc.) to convert the different types to the same type.

  4. Use .into_any() to convert multiple types into one typed-erased AnyView.

这是同一个例子,加上了转换逻辑:

Here’s the same example, with the conversion added:

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

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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>

        // 用于更新和显示值的简单 UI
        // 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>
        // 对于任何实现了 IntoView 的 T,
        // Option<T> 同样实现了 IntoView
        // For any `T` that implements `IntoView`,
        // so does `Option<T>`

        <p>{odd_text}</p>
        // 这意味着你可以在其上使用 Option 的方法
        // This means you can use `Option` methods on it
        <p>{move || odd_text().map(|text| text.len())}</p>

        <h2>"Conditional Logic"</h2>
        // 你可以通过几种方式实现动态的 conditional if-then-else 逻辑
        // You can do dynamic conditional if-then-else
        // logic in several ways
        //
        // a. 函数中的 "if" 表达式
        //    这将在每次值发生变化时简单地重新渲染,
        //    这使得它适用于轻量级 UI
        // 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. 切换某种 class
        //    对于会被频繁切换的元素,这很明智,
        //    因为它不会在状态切换期间销毁元素
        //    (你可以在 index.html 中找到 `hidden` 类)
        // 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. <Show/> 组件
        //    这只会懒加载渲染一次 fallback 和子组件,
        //    并在需要时在它们之间切换。
        //    在许多情况下,这比 {move || if ...} 块更高效
        // 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. 因为 `bool::then()` 将 `bool` 转换为 `Option`,
        //    你可以使用它来创建一个显示/隐藏切换
        // 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. 注意:如果分支返回不同的类型,
        //    你可以使用 `.into_any()` 或使用 `Either` 枚举
        //    (`Either`、`EitherOf3`、`EitherOf4` 等)在它们之间进行转换
        // e. Note: if branches return different types,
        //    you can convert between them with
        //    `.into_any()` or using the `Either` enums
        //    (`Either`, `EitherOf3`, `EitherOf4`, etc.)
        {move || match is_odd() {
            true if value.get() == 1 => {
                // <pre> 返回 HtmlElement<Pre>
                // <pre> returns HtmlElement<Pre>
                view! { <pre>"One"</pre> }.into_any()
            },
            false if value.get() == 2 => {
                // <p> 返回 HtmlElement<P>
                // 所以我们将其转换为更通用的类型
                // <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)
}

错误处理

Error Handling

在上一章中,我们看到你可以渲染 Option<T>:在 None 的情况下,它什么都不会渲染;而在 Some(T) 的情况下,它会渲染 T(前提是 T 实现了 IntoView)。实际上,你可以对 Result<T, E> 执行非常相似的操作。在 Err(_) 的情况下,它什么都不会渲染。在 Ok(T) 的情况下,它将渲染 T

In the last chapter, we saw that you can render Option<T>: in the None case, it will render nothing, and in the Some(T) case, it will render T (that is, if T implements IntoView). You can actually do something very similar with a Result<T, E>. In the Err(_) case, it will render nothing. In the Ok(T) case, it will render the T.

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

Let’s start with a simple component to capture a number input.

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

    view! {
        <label>
            "Type an integer (or not!)"
            <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>())
            }/>
            <p>
                "You entered "
                <strong>{value}</strong>
            </p>
        </label>
    }
}

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

Every time you change the input, on_input will attempt to parse its value into a 32-bit integer (i32), and store it in our value signal, which is a Result<i32, _>. If you type the number 42, the UI will display

You entered 42

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

But if you type the string foo, it will display

You entered

这并不理想。虽然它让我们免于使用 .unwrap_or_default() 之类的方法,但如果我们能捕获错误并对其进行处理,那就好得多了。

This is not great. It saves us using .unwrap_or_default() or something, but it would be much nicer if we could catch the error and do something with it.

你可以使用 <ErrorBoundary/> 组件来实现这一点。

You can do that, with the <ErrorBoundary/> component.

注意

人们经常试图指出,<input type="number"> 会阻止你输入像 foo 这样的字符串,或者任何非数字的内容。这在某些浏览器中是真的,但并非所有浏览器都如此!此外,在一个普通的数字输入框中,可以输入很多不是 i32 的内容:浮点数、大于 32 位的数字、字母 e 等等。虽然可以告诉浏览器维持其中一些不变量,但浏览器的行为仍然各不相同:自行解析非常重要!

<ErrorBoundary/>

<ErrorBoundary/>

<ErrorBoundary/> 有点像我们在上一章看到的 <Show/> 组件。如果一切正常——也就是说,如果一切都是 Ok(_)——它会渲染其子组件。但如果在这些子组件中渲染了 Err(_),它将触发 <ErrorBoundary/>fallback(回退界面)。

An <ErrorBoundary/> is a little like the <Show/> component we saw in the last chapter. If everything’s okay—which is to say, if everything is Ok(_)—it renders its children. But if there’s an Err(_) rendered among those children, it will trigger the <ErrorBoundary/>’s fallback.

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

Let’s add an <ErrorBoundary/> to this example.

#[component]
fn NumericInput() -> 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>())
            }/>
            // 如果在 <ErrorBoundary/> 内部渲染了 `Err(_)`,
            // 则会显示 fallback。否则,将显示 <ErrorBoundary/> 的子组件。
            // If an `Err(_) had been rendered inside the <ErrorBoundary/>,
            // the fallback will be displayed. Otherwise, the children of the
            // <ErrorBoundary/> will be displayed.
            <ErrorBoundary
                // fallback 接收一个包含当前错误的信号
                // 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 "
                    // 因为 `value` 是 `Result<i32, _>`,
                    // 如果它是 `Ok`,它将渲染 `i32`;
                    // 如果它是 `Err`,它将不渲染任何内容并触发错误边界。
                    // 这是一个信号,所以当 `value` 改变时,它会动态更新。
                    // 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>
    }
}

现在,如果你输入 42valueOk(42),你将看到:

Now, if you type 42, value is Ok(42) and you’ll see

You entered 42

如果你输入 foo,值将是 Err(_),并且会渲染 fallback。我们选择了将错误列表渲染为 String,所以你会看到类似下面的内容:

If you type foo, value is Err(_) and the fallback will render. We’ve chosen to render the list of errors as a String, so you’ll see something like

Not a number! Errors:
- cannot parse integer from empty string

如果你修复了错误,错误信息将消失,你包裹在 <ErrorBoundary/> 中的内容将再次出现。

If you fix the error, the error message will disappear and the content you’re wrapping in an <ErrorBoundary/> will appear again.

在线示例

点击打开 CodeSandbox。

CodeSandbox 源代码
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>())
            }/>
            // 如果在 <ErrorBoundary/> 内部渲染了 `Err(_)`,
            // 则会显示 fallback。否则,将显示 <ErrorBoundary/> 的子组件。
            // If an `Err(_) had been rendered inside the <ErrorBoundary/>,
            // the fallback will be displayed. Otherwise, the children of the
            // <ErrorBoundary/> will be displayed.
            <ErrorBoundary
                // fallback 接收一个包含当前错误的信号
                // 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 "
                    // 因为 `value` 是 `Result<i32, _>`,
                    // 如果它是 `Ok`,它将渲染 `i32`;
                    // 如果它是 `Err`,它将不渲染任何内容并触发错误边界。
                    // 这是一个信号,所以当 `value` 改变时,它会动态更新。
                    // 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)
}

父子组件通信

Parent-Child Communication

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

You can think of your application as a nested tree of components. Each component handles its own local state and manages a section of the user interface, so components tend to be relatively self-contained.

但有时,你需要在父组件与其子组件之间进行通信。例如,假设你定义了一个 <FancyButton/> 组件,它在 <button/> 的基础上添加了一些样式、日志或其他功能。你想在 <App/> 组件中使用 <FancyButton/>。但是你该如何在这两者之间进行通信呢?

Sometimes, though, you’ll want to communicate between a parent component and its child. For example, imagine you’ve defined a <FancyButton/> component that adds some styling, logging, or something else to a <button/>. You want to use a <FancyButton/> in your <App/> component. But how can you communicate between the two?

将状态从父组件传递到子组件很容易。我们在关于组件和属性的内容中已经介绍过一部分。基本上,如果你想让父组件与子组件通信,你可以将 ReadSignalSignal 作为 prop 传递。

It’s easy to communicate state from a parent component to a child component. We covered some of this in the material on components and props. Basically if you want the parent to communicate to the child, you can pass either a ReadSignal or Signal as a prop.

但另一个方向呢?子组件如何将有关事件或状态更改的通知发送回父组件?

But what about the other direction? How can a child send notifications about events or state changes back up to the parent?

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

There are four basic patterns of parent-child communication in Leptos.

1. 传递 WriteSignal

1. Pass a WriteSignal

一种方法是简单地将 WriteSignal 从父组件传递到子组件,并在子组件中对其进行更新。这让你可以从子组件操纵父组件的状态。

One approach is simply to pass a WriteSignal from the parent down to the child, and update it in the child. This lets you manipulate the state of the parent from the child.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"Toggled? " {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)
        >
            "Toggle"
        </button>
    }
}

这种模式很简单,但你应该谨慎使用:到处传递 WriteSignal 会使代码难以推敲。在这个例子中,当你阅读 <App/> 时,很明显你正在移交修改 toggled 的能力,但完全不清楚它将在何时或如何改变。在这个局部的迷你示例中它很容易理解,但如果你发现自己在整个代码中都像这样传递 WriteSignal,你真的应该考虑这是否会让编写“意大利面条式代码”变得太容易了。

This pattern is simple, but you should be careful with it: passing around a WriteSignal can make it hard to reason about your code. In this example, it’s pretty clear when you read <App/> that you are handing off the ability to mutate toggled, but it’s not at all clear when or how it will change. In this small, local example it’s easy to understand, but if you find yourself passing around WriteSignals like this throughout your code, you should really consider whether this is making it too easy to write spaghetti code.

2. 使用回调

2. Use a Callback

另一种方法是向子组件传递一个回调函数:比如 on_click

Another approach would be to pass a callback to the child: say, on_click.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"Toggled? " {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>
            "Toggle"
        </button>
    }
}

你会注意到,虽然 <ButtonA/> 被赋予了 WriteSignal 并决定如何修改它,但 <ButtonB/> 只是触发一个事件:修改操作发生在 <App/> 中。这样做的好处是保持局部状态局部化,防止出现乱如麻的状态修改。但也意味着修改该信号的逻辑需要存在于 <App/> 中,而不是 <ButtonB/> 中。这些都是现实中的权衡,而非简单的对错之分。

You’ll notice that whereas <ButtonA/> was given a WriteSignal and decided how to mutate it, <ButtonB/> simply fires an event: the mutation happens back in <App/>. This has the advantage of keeping local state local, preventing the problem of spaghetti mutation. But it also means the logic to mutate that signal needs to exist up in <App/>, not down in <ButtonB/>. These are real trade-offs, not a simple right-or-wrong choice.

3. 使用事件监听器

3. Use an Event Listener

你实际上可以用稍微不同的方式来编写方案 2。如果回调直接映射到原生 DOM 事件上,你可以直接在 <App/>view 宏中使用组件的地方添加一个 on: 监听器。

You can actually write Option 2 in a slightly different way. If the callback maps directly onto a native DOM event, you can add an on: listener directly to the place you use the component in your view macro in <App/>.

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"Toggled? " {toggled}</p>
        // 注意是 on:click 而不是 on_click
        // 这与 HTML 元素的事件监听器语法相同
        // note the on:click instead of on_click
        // this is the same syntax as an HTML element event listener
        <ButtonC on:click=move |_| set_toggled.update(|value| *value = !*value)/>
    }
}

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

这让你在 <ButtonC/> 中写的代码比在 <ButtonB/> 中少得多,并且仍然能为监听器提供类型正确的事件。这是通过在 <ButtonC/> 返回的每个元素上添加一个 on: 事件监听器来实现的:在本例中,只是那一个 <button>

This lets you write way less code in <ButtonC/> than you did for <ButtonB/>, and still gives a correctly-typed event to the listener. This works by adding an on: event listener to each element that <ButtonC/> returns: in this case, just the one <button>.

当然,这只适用于你直接传递给组件中渲染元素的实际 DOM 事件。对于不直接映射到元素的更复杂逻辑(比如你创建了 <ValidatedForm/> 并想要一个 on_valid_form_submit 回调),你应该使用方案 2。

Of course, this only works for actual DOM events that you’re passing directly through to the elements you’re rendering in the component. For more complex logic that doesn’t map directly onto an element (say you create <ValidatedForm/> and want an on_valid_form_submit callback) you should use Option 2.

4. 提供上下文 (Context)

4. Providing a Context

这个版本实际上是方案 1 的变体。假设你有一个深度嵌套的组件树:

This version is actually a variant on Option 1. Say you have a deeply-nested component tree:

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

#[component]
pub fn Layout() -> impl IntoView {
    view! {
        <header>
            <h1>"My Page"</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/> 的直接子组件,所以你不能简单地将 WriteSignal 传递给它的 props。你可以做一些有时被称为“prop 钻取 (prop drilling)”的事情,即在两者之间的每一层都添加一个 prop:

Now <ButtonD/> is no longer a direct child of <App/>, so you can’t simply pass your WriteSignal to its props. You could do what’s sometimes called “prop drilling,” adding a prop to each layer between the two:

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

#[component]
pub fn Layout(set_toggled: WriteSignal<bool>) -> impl IntoView {
    view! {
        <header>
            <h1>"My Page"</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/>。但我需要重复声明三次这个 prop。这不仅令人厌烦,而且难以维护:想象一下我们添加了一个“半切换 (half-toggled)”选项,并且 set_toggled 的类型需要更改为 enum。我们必须在三个地方进行更改!

This is a mess. <Layout/> and <Content/> don’t need set_toggled; they just pass it through to <ButtonD/>. But I need to declare the prop in triplicate. This is not only annoying but hard to maintain: imagine we add a “half-toggled” option and the type of set_toggled needs to change to an enum. We have to change it in three places!

难道没有某种方法可以跨级传递吗?

Isn’t there some way to skip levels?

有的!

There is!

4.1 上下文 API (Context API)

4.1 The Context API

通过使用 provide_contextuse_context,你可以提供跨越层级的数据。上下文由你提供的数据类型(在本例中为 WriteSignal<bool>)标识,它们存在于一个遵循 UI 树轮廓的自上而下的树中。在这个例子中,我们可以使用上下文来跳过不必要的 prop 钻取。

You can provide data that skips levels by using provide_context and use_context. Contexts are identified by the type of the data you provide (in this example, WriteSignal<bool>), and they exist in a top-down tree that follows the contours of your UI tree. In this example, we can use context to skip the unnecessary prop drilling.

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

    // 与此组件的所有子组件共享 `set_toggled`
    // share `set_toggled` with all children of this component
    provide_context(set_toggled);

    view! {
        <p>"Toggled? " {toggled}</p>
        <Layout/>
    }
}

// 省略 <Layout/> 和 <Content/>
// 在此版本中,请删除每个组件上的 `set_toggled` 参数
// <Layout/> and <Content/> omitted
// To work in this version, drop the `set_toggled` parameter on each

#[component]
pub fn ButtonD() -> impl IntoView {
    // use_context 向上搜索上下文树,希望能找到一个 `WriteSignal<bool>`
    // 在本例中,我使用 .expect(),因为我知道我已经提供了它
    // use_context searches up the context tree, hoping to
    // find a `WriteSignal<bool>`
    // in this case, I .expect() because I know I provided it
    let setter = use_context::<WriteSignal<bool>>().expect("to have found the setter provided");

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

同样的注意事项也适用于此,就像应用于 <ButtonA/> 一样:到处传递 WriteSignal 应当谨慎,因为它允许你从代码的任意部分修改状态。但如果操作得当,这可能是 Leptos 中全局状态管理最有效的技术之一:只需在需要它的最高层级提供状态,并在其下任何需要的地方使用它。

The same caveats apply to this as to <ButtonA/>: passing a WriteSignal around should be done with caution, as it allows you to mutate state from arbitrary parts of your code. But when done carefully, this can be one of the most effective techniques for global state management in Leptos: simply provide the state at the highest level you’ll need it, and use it wherever you need it lower down.

请注意,这种方法没有性能上的缺点。因为你传递的是细粒度的响应式信号,所以当你更新它时,中间组件(<Layout/><Content/>什么都不会发生。你是在 <ButtonD/><App/> 之间直接通信。事实上——这就是细粒度响应式的力量——你是在 <ButtonD/> 中的按钮点击与 <App/> 中的单个文本节点之间直接通信。就好像组件本身根本不存在一样。而且,嗯……在运行时,它们确实不存在。全程都只是信号和副作用。

Note that there are no performance downsides to this approach. Because you are passing a fine-grained reactive signal, nothing happens in the intervening components (<Layout/> and <Content/>) when you update it. You are communicating directly between <ButtonD/> and <App/>. In fact—and this is the power of fine-grained reactivity—you are communicating directly between a button click in <ButtonD/> and a single text node in <App/>. It’s as if the components themselves don’t exist at all. And, well... at runtime, they don’t. It’s just signals and effects, all the way down.

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

Note that this approach makes an important tradeoff: You don't have type-safety anymore between provide_context and use_context. Receiving the right context in the child component is a runtime check (see use_context.expect(...)). The compiler won't guide you during a refactoring, as it does with the earlier approaches.

在线示例

点击打开 CodeSandbox。

CodeSandbox 源代码
use leptos::{ev::MouseEvent, prelude::*};

// 这展示了子组件与父组件通信的四种不同方式:
// 1) <ButtonA/>: 将 WriteSignal 作为子组件属性之一传递,
//    由子组件写入,父组件读取
// 2) <ButtonB/>: 将闭包作为子组件属性之一传递,供子组件调用
// 3) <ButtonC/>: 向组件添加 `on:` 事件监听器
// 4) <ButtonD/>: 提供一个在组件中使用的上下文(而不是 prop 钻取)

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

#[component]
pub fn App() -> impl IntoView {
    // 几个信号,用于在我们的 <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);

    // newtype 模式在这里不是 *必须* 的,但是一个良好的实践
    // 它避免了与未来可能存在的其他 `WriteSignal<bool>` 上下文混淆
    // 并使在 ButtonD 中引用它变得更容易
    provide_context(SmallcapsContext(set_smallcaps));

    view! {
        <main>
            <p
                // class: 属性接收 F: Fn() => bool,这些信号都实现了 Fn()
                class:red=red
                class:right=right
                class:italics=italics
                class:smallcaps=smallcaps
            >
                "Lorem ipsum sit dolor amet."
            </p>

            // 按钮 A:传递信号 setter
            <ButtonA setter=set_red/>

            // 按钮 B:传递闭包
            <ButtonB on_click=move |_| set_right.update(|value| *value = !*value)/>

            // 按钮 C:使用常规事件监听器
            // 在像这样的组件上设置事件监听器会将其应用于该组件返回的每个顶级元素
            <ButtonC on:click=move |_| set_italics.update(|value| *value = !*value)/>

            // 按钮 D 从上下文而不是 props 获取其 setter
            <ButtonD/>
        </main>
    }
}

/// 按钮 A 接收一个信号 setter 并自行更新信号
#[component]
pub fn ButtonA(
    /// 点击按钮时将切换的信号。
    setter: WriteSignal<bool>,
) -> impl IntoView {
    view! {
        <button
            on:click=move |_| setter.update(|value| *value = !*value)
        >
            "Toggle Red"
        </button>
    }
}

/// 按钮 B 接收一个闭包
#[component]
pub fn ButtonB(
    /// 点击按钮时将调用的回调。
    on_click: impl FnMut(MouseEvent) + 'static,
) -> impl IntoView
{
    view! {
        <button
            on:click=on_click
        >
            "Toggle Right"
        </button>
    }
}

/// 按钮 C 是一个占位符:它渲染一个按钮但不处理其点击。
/// 相反,父组件添加一个事件监听器。
#[component]
pub fn ButtonC() -> impl IntoView {
    view! {
        <button>
            "Toggle Italics"
        </button>
    }
}

/// 按钮 D 与按钮 A 非常相似,但我们不是将 setter 作为 prop 传递,
/// 而是从上下文中获取它
#[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

就像向 HTML 元素传递子节点一样,向组件传递子节点也是非常常见的需求。例如,假设我有一个增强了 HTML <form><FancyForm/> 组件,我需要某种方式来传递它的所有输入项。

It’s pretty common to want to pass children into a component, just as you can pass children into an HTML element. For example, imagine I have a <FancyForm/> component that enhances an HTML <form>. I need some way to pass all its inputs.

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

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

How can you do this in Leptos? There are basically two ways to pass components to other components:

  1. 渲染属性 (render props):本身是返回视图的函数的属性。

  2. children 属性:一个特殊的组件属性,包含你作为子节点传递给组件的任何内容。

  3. render props: properties that are functions that return a view

  4. the children prop: a special component property that includes anything you pass as a child to the component.

事实上,你已经在 <Show/> 组件中见过这两者的应用了:

In fact, you’ve already seen these both in action in the <Show/> component:

view! {
  <Show
    // `when` 是一个普通的属性
    // `when` is a normal prop
    when=move || value.get() > 5
    // `fallback` 是一个 "渲染属性":一个返回视图的函数
    // `fallback` is a "render prop": a function that returns a view
    fallback=|| view! { <Small/> }
  >
    // `<Big/>`(以及这里的任何其他内容)
    // 将被传递给 `children` 属性
    // `<Big/>` (and anything else here)
    // will be given to the `children` prop
    <Big/>
  </Show>
}

让我们定义一个接收一些子节点和渲染属性的组件。

Let’s define a component that takes some children and a render prop.

/// 在标记中显示一个 `render_prop` 和一些子节点。
/// Displays a `render_prop` and some children within markup.
#[component]
pub fn TakesChildren<F, IV>(
    /// 接收一个函数(类型为 F),该函数返回任何可以
    /// 转换为 View 的内容(类型为 IV)
    /// Takes a function (type F) that returns anything that can be
    /// converted into a View (type IV)
    render_prop: F,
    /// `children` 可以接收几种不同类型之一,每种类型
    /// 都是一个返回某种视图类型的函数
    /// `children` can take one of several different types, each of which
    /// is a function that returns some view type
    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()}
    }
}

render_propchildren 都是函数,所以我们可以通过调用它们来生成相应的视图。特别是 Children,它是 Box<dyn FnOnce() -> AnyView> 的别名。(难道你不庆幸我们把它命名为 Children 吗?)这里返回的 AnyView 是一个不透明的、类型擦除后的视图:你无法对它进行检查。还有各种其他的子节点类型:例如,ChildrenFragment 将返回一个 Fragment(片段),这是一个子节点可以被迭代的集合。

render_prop and children are both functions, so we can call them to generate the appropriate views. Children, in particular, is an alias for Box<dyn FnOnce() -> AnyView>. (Aren't you glad we named it Children instead?) The AnyView returned here is an opaque, type-erased view: you can’t do anything to inspect it. There are a variety of other child types: for example, ChildrenFragment will return a Fragment, which is a collection whose children can be iterated over.

如果你需要多次调用 children,因此这里需要 FnFnMut,我们也提供了 ChildrenFnChildrenMut 别名。

If you need a Fn or FnMut here because you need to call children more than once, we also provide ChildrenFn and ChildrenMut aliases.

我们可以像这样使用该组件:

We can use the component like this:

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

类型化子节点:插槽 (Slots)

Typed Children: Slots

到目前为止,我们讨论的是具有单个 children 属性的组件,但有时创建一个具有多个不同类型子节点的组件会很有帮助。例如:

So far, we have discussed components with a single children prop, but sometimes it is helpful to create a component with multiple children of different types. For example:

view! {
    <If condition=a_is_true>
        <Then>"Show content when a is true"</Then>
        <ElseIf condition=b_is_true>"b is true"</ElseIf>
        <ElseIf condition=c_is_true>"c is true"</ElseIf>
        <Else>"None of the above are true"</Else>
    </If>
}

If 组件始终期望有一个 Then 子节点,可选的多个 ElseIf 子节点以及一个可选的 Else 子节点。为了处理这种情况,Leptos 提供了 slot(插槽)。

The If component always expects a Then child, optionally multiple ElseIf children and an optional Else child. To handle this, Leptos provides slots.

#[slot] 宏将一个普通的 Rust 结构体标注为组件插槽:

The #[slot] macro annotates a plain Rust struct as component slot:

// 一个标注了 `#[slot]` 的简单结构体,
// 它期望有子节点
// A simple struct annotated with `#[slot]`,
// which expects children
#[slot]
struct Then {
    children: ChildrenFn,
}

这个插槽可以作为组件中的属性使用:

This slot can be used as a prop in a component:

#[component]
fn If(
    condition: Signal<bool>,
    // 组件插槽,应通过 <Then slot> 语法传递
    // Component slot, should be passed through the <Then slot> syntax
    then_slot: Then,
) -> impl IntoView {
    move || {
        if condition.get() {
            (then_slot.children)().into_any()
        } else {
            ().into_any()
        }
    }
}

现在,If 组件期望一个 Then 类型的子节点。你需要使用 slot:<prop_name> 来标注所使用的插槽:

Now, the If component expects a child of type Then. You would need to annotate the used slot with slot:<prop_name>:

view! {
    <If condition=a_is_true>
        // `If` 组件始终期望 `then_slot` 属性有一个 `Then` 子节点
        // The `If` component always expects a `Then` child for `then_slot`
        <Then slot:then_slot>"Show content when a is true"</Then>
    </If>
}

指定不带名称的 slot 将默认选择该插槽,名称为结构体名称的蛇形命名(snake case)版本。因此在这种情况下,<Then slot> 等同于 <Then slot:then>

Specifying slot without a name will default the chosen slot as the snake case version of the struct name. So in this case <Then slot> would be equivalent to <Then slot:then>.

完整示例请参见 插槽示例

For the complete example, see slots examples.

插槽上的事件处理程序

Event handlers on slots

不能像这样直接在插槽上指定事件处理程序:

Event handlers cannot be specified directly on slots like this:

<ComponentWithSlot>
    // ⚠️ 不允许直接在插槽上使用事件处理程序 `on:click`
    // ⚠️ Event handler `on:click` directly on slot is not allowed
    <SlotWithChildren slot:slot on:click=move |_| {}> 
        <h1>"Hello, World!"</h1>
    </SlotWithChildren>
</ComponentWithSlot>

相反,应将插槽内容包裹在一个普通元素中,并在那里附加事件处理程序:

Instead, wrap the slot content in a regular element and attach event handlers there:

<ComponentWithSlot>
    <SlotWithChildren slot:slot>
        // ✅ 事件处理程序未直接定义在插槽上
        // ✅ Event handler is not defined directly on slot
        <div on:click=move |_| {}>
            <h1>"Hello, World!"</h1>
        </div>
    </SlotWithChildren>
</ComponentWithSlot>

操作子节点

Manipulating Children

Fragment 类型本质上是包装 Vec<AnyView> 的一种方式。你可以将其插入视图中的任何位置。

The Fragment type is basically a way of wrapping a Vec<AnyView>. You can insert it anywhere into your view.

但你也可以直接访问那些内部视图进行操作。例如,下面是一个接收其子节点并将它们转换成无序列表的组件。

But you can also access those inner views directly to manipulate them. For example, here’s a component that takes its children and turns them into an unordered list.

/// 将每个子节点包裹在 `<li>` 中并嵌入到 `<ul>` 中。
/// Wraps each child in an `<li>` and embeds them in a `<ul>`.
#[component]
pub fn WrapsChildren(children: ChildrenFragment) -> impl IntoView {
    // children() 返回一个 `Fragment`,它有一个
    // `nodes` 字段,其中包含一个 Vec<View>
    // 这意味着我们可以遍历子节点来创建一些新的东西!
    // 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>
        // 将包装好的子节点放入 UL 中
        // wrap our wrapped children in a UL
        <ul>{children}</ul>
    }
}

像这样调用它将创建一个列表:

Calling it like this will create a list:

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

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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

// 通常,你希望将某种子视图传递给另一个组件。
// 有两种基本模式可以实现这一点:
// - "渲染属性":创建一个组件属性,它接收一个创建视图的函数
// - `children` 属性:一个特殊的属性,包含作为视图中组件子节点传递的内容,
//   而不是作为普通属性

#[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! {
        // 此组件仅显示两种类型的子节点,并将它们嵌入到其他标记中
        <TakesChildren
            // 对于组件属性,你可以使用简写形式
            // `render_prop=render_prop` => `render_prop`
            // (这不适用于 HTML 元素特性 (attributes))
            render_prop
        >
            // 这些看起来就像 HTML 元素的子节点
            <p>"Here's a child."</p>
            <p>"Here's another child."</p>
        </TakesChildren>
        <hr/>
        // 此组件实际上会遍历并包裹子节点
        <WrapsChildren>
            <p>"Here's a child."</p>
            <p>"Here's another child."</p>
        </WrapsChildren>
    }
}

/// 在标记中显示一个 `render_prop` 和一些子节点。
#[component]
pub fn TakesChildren<F, IV>(
    /// 接收一个函数(类型为 F),该函数返回任何可以
    /// 转换为 View 的内容(类型为 IV)
    render_prop: F,
    /// `children` 接收 `Children` 类型
    /// 这是 `Box<dyn FnOnce() -> Fragment>` 的别名
    /// ……难道你不庆幸我们把它命名为 `Children` 吗?
    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()}
    }
}

/// 将每个子节点包裹在 `<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>
    }
}

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

无宏模式:视图构建器语法

No Macros: The View Builder Syntax

如果你对到目前为止所描述的 view! 宏语法非常满意,欢迎跳过本章。本节中描述的构建器语法始终可用,但并非必须。

If you’re perfectly happy with the view! macro syntax described so far, you’re welcome to skip this chapter. The builder syntax described in this section is always available, but never required.

由于种种原因,许多开发人员更愿意避免使用宏。也许你不喜欢受限的 rustfmt 支持。(尽管你应该去看看 leptosfmt,这是一个非常出色的工具!)也许你担心宏对编译时间的影响。也许你更喜欢纯 Rust 语法的审美,或者你在类 HTML 语法和 Rust 代码之间进行上下文切换时感到困难。或者,你可能希望在创建和操作 HTML 元素时拥有比 view 宏提供的更多灵活性。

For one reason or another, many developers would prefer to avoid macros. Perhaps you don’t like the limited rustfmt support. (Although, you should check out leptosfmt, which is an excellent tool!) Perhaps you worry about the effect of macros on compile time. Perhaps you prefer the aesthetics of pure Rust syntax, or you have trouble context-switching between an HTML-like syntax and your Rust code. Or perhaps you want more flexibility in how you create and manipulate HTML elements than the view macro provides.

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

If you fall into any of those camps, the builder syntax may be for you.

view 宏将类 HTML 语法扩展为一系列 Rust 函数和方法调用。如果你不想使用 view 宏,只需亲自使用这些扩展语法即可。而且实际上它感觉还挺不错的!

The view macro expands an HTML-like syntax to a series of Rust functions and method calls. If you’d rather not use the view macro, you can simply use that expanded syntax yourself. And it’s actually pretty nice!

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

First off, if you want you can even drop the #[component] macro: a component is just a setup function that creates your view, so you can define a component as a simple function call:

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

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

Elements are created by calling a function with the same name as the HTML element:

p()

自定义元素/Web 组件可以通过使用带有名称的 custom() 函数来创建:

Custom elements/web components can be created by using the custom() function with their name:

custom("my-custom-element")

你可以使用 .child() 向元素添加子节点,该方法接收单个子节点,或者实现了 IntoView 的类型的元组或数组。

You can add children to the element with .child(), which takes a single child or a tuple or array of types that implement IntoView.

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

使用 .attr() 添加特性 (Attribute)。这可以接收任何你可以作为特性传递给视图宏的相同类型(即实现了 Attribute 的类型)。

Attributes are added with .attr(). This can take any of the same types that you could pass as an attribute into the view macro (types that implement Attribute).

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

也可以使用特性方法添加它们,这些方法适用于任何内置的 HTML 特性名称:

They can also be added with attribute methods, which are available for any built-in HTML attribute name:

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

类似地,class:prop:style: 语法直接映射到 .class().prop().style() 方法。

Similarly, the class:, prop:, and style: syntaxes map directly onto .class(), .prop(), and .style() methods.

使用 .on() 添加事件监听器。在 leptos::ev 中找到的类型化事件可以防止事件名称中的拼写错误,并允许在回调函数中进行正确的类型推导。

Event listeners can be added with .on(). Typed events found in leptos::ev prevent typos in event names and allow for correct type inference in the callback function.

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

如果你更喜欢这种风格,所有这些加起来就成了一种非常“Rust 化”的语法,用于构建功能齐全的视图。

All of this adds up to a very Rusty syntax to build full-featured views, if you prefer this style.

/// 一个简单的计数器视图。
/// A simple counter view.
// 组件实际上只是一个函数调用:它运行一次以创建 DOM 和响应式系统
// A component is really just a function call: it runs once to create the DOM and reactive system
pub fn counter(initial_value: i32, step: i32) -> impl IntoView {
    let (count, set_count) = signal(initial_value);
    div().child((
        button()
            // 在 leptos::ev 中找到的类型化事件
            // 1) 防止事件名称拼写错误
            // 2) 允许回调函数中的正确类型推导
            // typed events found in leptos::ev
            // 1) prevent typos in event names
            // 2) allow for correct type inference in callbacks
            .on(ev::click, move |_| set_count.set(0))
            .child("Clear"),
        button()
            .on(ev::click, move |_| *set_count.write() -= step)
            .child("-1"),
        span().child(("Value: ", move || count.get(), "!")),
        button()
            .on(ev::click, move |_| *set_count.write() += step)
            .child("+1"),
    ))
}

在构建器语法中使用组件

Using Components with the Builder Syntax

要使用构建器语法创建你自己的组件,你可以简单地使用普通函数(见上文)。要使用其他组件(例如内置的 ForShow 控制流组件),你可以利用这样一个事实:每个组件都是一个具有单个组件属性参数的函数,而组件属性有其自己的构建器。

To create your own components with the builder syntax, you can simply use plain functions (see above). To use other components (for example, the built-in For or Show control-flow components), you can take advantage of the fact that each component is a function of one component props argument, and component props have their own builder.

你可以使用组件属性构建器:

You can either use the component props builder:

use leptos::html::p;

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

Show(
    ShowProps::builder()
        .when(move || value.get() > 5)
        .fallback(|| p().child("I will appear if `value` is 5 or lower"))
        .children(ToChildren::to_children(|| {
            p().child("I will appear if `value` is above 5")
        }))
        .build(),
)

或者你可以直接构建属性结构体:

or you can directly build the props struct:

use leptos::html::p;

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

Show(ShowProps {
    when: move || value.get() > 5,
    fallback: (|| p().child("I will appear if `value` is 5 or lower")).into(),
    children: ToChildren::to_children(|| p().child("I will appear if `value` is above 5")),
})

使用组件构建器会正确应用各种修饰符,如 #[prop(into)];使用结构体语法,我们需要通过自己调用 .into() 来手动应用这一点。

Using the component builder correctly applies the various modifiers like #[prop(into)]; using the struct syntax, we’ve applied this manually by calling .into() ourselves.

扩展宏

Expanding Macros

这里没有详细描述 view 宏或 component 宏语法的每一项特性。然而,Rust 为你提供了理解宏底层运作所需的工具。具体来说,rust-analyzer 的“递归扩展宏”功能允许你扩展任何宏以显示它生成的代码,而 cargo-expand 则将项目中的所有宏扩展为常规 Rust 代码。本书的其余部分将继续使用 view 宏语法,但如果你不确定如何将其转换为构建器语法,可以使用这些工具来探索生成的代码。

Not every feature of the view macro or component macro syntax has been described here in detail. However, Rust provides you the tools you need to understand what is going on with any macro. Specifically, rust-analyzer’s "expand macro recursively" feature allows you to expand any macro to show the code it generates, and cargo-expand expands all the macros in a project into regular Rust code. The rest of this book will continue using the view macro syntax, but if you’re ever unsure how to translate this into the builder syntax, you can use these tools to explore the code that is generated.

响应式

Reactivity

Leptos 构建在细粒度响应式系统之上,旨在尽可能低频地运行昂贵的副作用(例如在浏览器中渲染内容或发起网络请求)以响应响应式值的变化。

Leptos is built on top of a fine-grained reactive system, designed to run expensive side effects (like rendering something in a browser, or making a network request) as infrequently as possible in response to change, reactive values.

到目前为止,我们已经看到了信号(signal)的实际运行。这些章节将进行更深入的探讨,并研究响应式系统的另一半:副作用(effect)。

So far we’ve seen signals in action. These chapters will go into a bit more depth, and look at effects, which are the other half of the story.

使用信号

Working with Signals

到目前为止,我们已经看了一些使用 signal 的简单示例,它返回一个 ReadSignal 获取器(getter)和一个 WriteSignal 设置器(setter)。

So far we’ve used some simple examples of using signal, which returns a ReadSignal getter and a WriteSignal setter.

获取与设置

Getting and Setting

有一些基本的信号操作:

There are a few basic signal operations:

获取

Getting

  1. .read() 返回一个读取守卫(read guard),它可以解引用为信号的值,并响应式地追踪信号值的任何未来变化。请注意,在该守卫被丢弃之前,你不能更新信号的值,否则会导致运行时错误。

  2. .read() returns a read guard which dereferences to the value of the signal, and tracks any future changes to the value of the signal reactively. Note that you cannot update the value of the signal until this guard is dropped, or it will cause a runtime error.

  3. .with() 接受一个函数,该函数通过引用(&T)接收信号的当前值,并追踪该信号。

  4. .with() takes a function, which receives the current value of the signal by reference (&T), and tracks the signal.

  5. .get() 克隆信号的当前值,并追踪值的后续变化。

  6. .get() clones the current value of the signal and tracks further changes to the value.

.get() 是访问信号最常用的方法。.read() 对于那些接受不可变引用且不需要克隆值的方法非常有用(例如 my_vec_signal.read().len())。如果你需要对该引用进行更多操作,但又想确保不会持有锁超过所需的时间,那么 .with() 会非常有用。

.get() is the most common method of accessing a signal. .read() is useful for methods that take an immutable reference, without cloning the value (my_vec_signal.read().len()). .with() is useful if you need to do more with that reference, but want to make sure you don’t hold onto the lock longer than you need.

设置

Setting

  1. .write() 返回一个写入守卫(write guard),它是对信号值的可变引用,并通知所有订阅者它们需要更新。请注意,在丢弃此守卫之前,你不能读取信号的值,否则会导致运行时错误。

  2. .write() returns a write guard which is a mutable reference to the value of the signal, and notifies any subscribers that they need to update. Note that you cannot read from the value of the signal until this guard is dropped, or it will cause a runtime error.

  3. .update() 接受一个函数,该函数接收信号当前值的可变引用(&mut T),并通知订阅者。(.update() 不返回闭包返回的值,但如果你需要,可以使用 .try_update();例如,如果你正从 Vec<_> 中移除一个项并想要获取被移除的项。)

  4. .update() takes a function, which receives a mutable reference to the current value of the signal (&mut T), and notifies subscribers. (.update() doesn’t return the value returned by the closure, but you can use .try_update() if you need to; for example, if you’re removing an item from a Vec<_> and want the removed item.)

  5. .set() 替换信号的当前值并通知订阅者。

  6. .set() replaces the current value of the signal and notifies subscribers.

.set() 是设置新值最常用的方式;.write() 对于原地更新值非常有用。就像 .read().with() 的情况一样,当你想要避免持有写入锁的时间超过预期时,.update() 会非常有用。

.set() is most common for setting a new value; .write() is very useful for updating a value in place. Just as is the case with .read() and .with(), .update() can be useful when you want to avoid the possibility of holding on the write lock longer than you intended to.

这些 trait 基于 trait 组合,并由泛型实现(blanket implementations)提供。例如,Read 为任何实现了 TrackReadUntracked 的类型实现。With 为任何实现了 Read 的类型实现。Get 为任何实现了 WithClone 的类型实现。依此类推。

These traits are based on trait composition and provided by blanket implementations. For example, Read is implemented for any type that implements Track and ReadUntracked. With is implemented for any type that implements Read. Get is implemented for any type that implements With and Clone. And so on.

WriteUpdateSet 之间也存在类似的关系。

Similar relationships exist for Write, Update, and Set.

阅读文档时值得注意:如果你只看到 ReadUntrackedTrack 被实现,你仍然可以使用 .with().get()(如果 T: Clone)等等。

This is worth noting when reading docs: if you only see ReadUntracked and Track as implemented traits, you will still be able to use .with(), .get() (if T: Clone), and so on.

使用信号

Working with Signals

你可能会注意到,.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 来实现的。

You might notice that .get() and .set() can be implemented in terms of .read() and .write(), or .with() and .update(). In other words, count.get() is identical to count.with(|n| n.clone()) or count.read().clone(), and count.set(1) is implemented by doing count.update(|n| *n = 1) or *count.write() = 1.

当然,.get().set() 的语法更漂亮。

But of course, .get() and .set() are nicer syntax.

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

However, there are some very good use cases for the other methods.

例如,考虑一个持有 Vec<String> 的信号。

For example, consider a signal that holds a 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(),然后立即丢弃该克隆。

In terms of logic, this is simple enough, but it’s hiding some significant inefficiencies. Remember that names.get().is_empty() clones the value. This means we clone the whole Vec<String>, run is_empty(), and then immediately throw away the clone.

同样,set_names 用一个全新的 Vec<_> 替换了旧值。这没问题,但我们不如直接原地修改原始的 Vec<_>

Likewise, set_names replaces the value with a whole new Vec<_>. This is fine, but we might as well just mutate the original Vec<_> in place.

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

现在我们的函数只需通过引用获取 names 来运行 is_empty(),避免了克隆,然后原地修改 Vec<_>

Now our function simply takes names by reference to run is_empty(), avoiding that clone, and then mutates the Vec<_> in place.

线程安全与线程局部值

Thread Safety and Thread-Local Values

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

You may have noticed, either by reading the docs or by experimenting with your own applications, that the values that are stored in signals must be Send + Sync. This is because the reactive system actually supports multi-threading: signals can be sent across threads, and the whole reactive graph can work across multiple threads. (This is especially useful when doing server-side rendering with server frameworks like Axum, which use Tokio’s multi-threaded executor.) In most cases, this has no effect on what you do: ordinary Rust data types are Send + Sync by default.

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

However, the browser environment is only single-threaded unless you use a Web Worker, and the JavaScript types provided by wasm-bindgen and web-sys are all explicitly !Send. This mean they can’t be stored in ordinary signals.

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

As a result, we provide “local” alternatives for each of the signal primitives, which can be used to store !Send data. You should only reach for these when you have a !Send browser type you need to store in a signal.

Nightly 语法

Nightly Syntax

当使用 nightly 特性和 nightly 语法时,像调用函数一样调用 ReadSignal.get() 的语法糖。像调用函数一样调用 WriteSignal.set() 的语法糖。所以

When using the nightly feature and nightly syntax, calling a ReadSignal as a function is syntax sugar for .get(). Calling a WriteSignal as a function is syntax sugar for .set(). So

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

等同于

is the same as

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

这不仅仅是语法糖,它通过使信号在语义上与函数相同,从而实现了一个更一致的 API:参见 插曲

This is not just syntax sugar, but makes for a more consistent API by making signals semantically the same thing as functions: see the Interlude.

让信号互相依赖

Making signals depend on each other

经常有人问,在某些情况下,某个信号需要根据另一个信号的值而改变。有三种很好的方法可以做到这一点,还有一种方法虽然不太理想,但在受控情况下也可以接受。

Often people ask about situations in which some signal needs to change based on some other signal’s value. There are three good ways to do this, and one that’s less than ideal but okay under controlled circumstances.

好的选择

Good Options

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

1) B is a function of A. Create a signal for A and a derived signal or memo for B.

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

有关是使用派生信号还是备忘录的指导,请参阅 Memo 的文档。

For guidance on whether to use a derived signal or a memo, see the docs for Memo

2) C 是 A 和另一项 B 的函数。 为 A 和 B 创建信号,为 C 创建一个派生信号或备忘录。

2) C is a function of A and some other thing B. Create signals for A and B and a derived signal or memo for C.

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

3) A 和 B 是独立的信号,但有时会同时更新。 当你调用更新 A 时,另外发起一次调用来更新 B。

3) A and B are independent signals, but sometimes updated at the same time. When you make the call to update A, make a separate call to update B.

// A
// A
let (age, set_age) = signal(32);
// B
// B
let (favorite_number, set_favorite_number) = signal(42);
// 使用它来处理 “清除” 按钮的点击
// use this to handle a click on a `Clear` button
let clear_handler = move |_| {
  // 同时更新 A 和 B
  // update both A and B
  set_age.set(0);
  set_favorite_number.set(0);
};

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

If you really must...

4) 创建一个副作用(effect),在 A 改变时写入 B。 官方不鼓励这样做,原因如下: a) 它的效率总是较低,因为这意味着每次 A 更新时,你都要完整地走两遍响应式过程。(你设置了 A,这导致副作用运行,以及依赖 A 的任何其他副作用。然后你设置了 B,这又导致依赖 B 的任何副作用运行。) b) 它增加了意外创建无限循环或过度重复运行副作用的机会。这种乒乓式的、响应式面条代码在 2010 年代初期很常见,我们试图通过读写分离以及不鼓励在副作用中写入信号等做法来避免这种情况。

4) Create an effect to write to B whenever A changes. This is officially discouraged, for several reasons: a) It will always be less efficient, as it means every time A updates you do two full trips through the reactive process. (You set A, which causes the effect to run, as well as any other effects that depend on A. Then you set B, which causes any effects that depend on B to run.) b) It increases your chances of accidentally creating things like infinite loops or over-re-running effects. This is the kind of ping-ponging, reactive spaghetti code that was common in the early 2010s and that we try to avoid with things like read-write segregation and discouraging writing to signals from effects.

在大多数情况下,最好重写逻辑,使之基于派生信号或备忘录,形成清晰的自顶向下的数据流。但这也不是什么世界末日。

In most situations, it’s best to rewrite things such that there’s a clear, top-down data flow based on derived signals or memos. But this isn’t the end of the world.

我故意不在这里提供示例。阅读 Effect 文档来弄清楚这是如何工作的。

I’m intentionally not providing an example here. Read the Effect docs to figure out how this would work.

使用 Effect 响应变化

Responding to Changes with Effects

到目前为止,我们还没有提到响应式系统的另一半:effect。

We’ve made it this far without having mentioned half of the reactive system: effects.

响应式系统由两部分组成:更新单个响应式值(“信号”,signals)会通知依赖它们的各段代码(“副作用”,effects)需要再次运行。响应式系统的这两部分是相互依赖的。如果没有 effect,信号可以在响应式系统内部改变,但永远无法以与外部世界交互的方式被观察到。如果没有信号,effect 只会运行一次而不会再次运行,因为没有可订阅的可观察值。Effect 从字面上看就是响应式系统的“副作用”:它们的存在是为了将响应式系统与外部的非响应式世界同步。

Reactivity works in two halves: updating individual reactive values (“signals”) notifies the pieces of code that depend on them (“effects”) that they need to run again. These two halves of the reactive system are inter-dependent. Without effects, signals can change within the reactive system but never be observed in a way that interacts with the outside world. Without signals, effects run once but never again, as there’s no observable value to subscribe to. Effects are quite literally “side effects” of the reactive system: they exist to synchronize the reactive system with the non-reactive world outside it.

渲染器使用 effect 来响应信号的变化并更新 DOM 的部分内容。你可以创建自己的 effect,以其他方式将响应式系统与外部世界同步。

The renderer uses effects to update parts of the DOM in response to changes in signals. You can create your own effects to synchronize the reactive system with the outside world in other ways.

Effect::new 接受一个函数作为参数。它会在响应式系统的下一个“tick”运行该函数。(例如,如果你在组件中使用它,它将在该组件渲染后运行。)如果你在该函数内部访问任何响应式信号,它会记录该 effect 依赖于该信号这一事实。每当 effect 依赖的其中一个信号发生变化时,effect 就会再次运行。

Effect::new takes a function as its argument. It runs this function on the next “tick” of the reactive system. (So for example, if you use it in a component, it will run just after that component has been rendered.) If you access any reactive signal inside that function, it registers the fact that the effect depends on that signal. Whenever one of the signals that the effect depends on changes, the effect runs again.

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

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

Effect 函数调用时带有一个参数,该参数包含它上次运行时返回的任何值。在初始运行时,此值为 None

The effect function is called with an argument containing whatever value it returned the last time it ran. On the initial run, this is None.

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

By default, effects do not run on the server. This means you can call browser-specific APIs within the effect function without causing issues. If you need an effect to run on the server, use Effect::new_isomorphic.

自动追踪和动态依赖

Auto-tracking and Dynamic Dependencies

如果你熟悉像 React 这样的框架,你可能会注意到一个关键的区别。React 及类似框架通常要求你传递一个“依赖数组”,即一组显式变量,用于决定 effect 何时重新运行。

If you’re familiar with a framework like React, you might notice one key difference. React and similar frameworks typically require you to pass a “dependency array,” an explicit set of variables that determine when the effect should rerun.

由于 Leptos 源自同步响应式编程的传统,我们不需要这个显式依赖列表。相反,我们会根据 effect 内部访问了哪些信号来自动追踪依赖关系。

Because Leptos comes from the tradition of synchronous reactive programming, we don’t need this explicit dependency list. Instead, we automatically track dependencies depending on which signals are accessed within the effect.

这有两个效果(双关语,非本意)。依赖关系是:

This has two effects (no pun intended). Dependencies are:

  1. 自动的:你不需要维护依赖列表,也不必担心应该包含或不包含什么。框架会简单地追踪哪些信号可能导致 effect 重新运行,并为你处理好一切。

  2. Automatic: You don’t need to maintain a dependency list, or worry about what should or shouldn’t be included. The framework simply tracks which signals might cause the effect to rerun, and handles it for you.

  3. 动态的:依赖列表在每次 effect 运行时都会被清除并更新。例如,如果你的 effect 包含一个条件语句,那么只有当前分支中使用的信号会被追踪。这意味着 effect 重新运行的次数是最少的。

  4. Dynamic: The dependency list is cleared and updated every time the effect runs. If your effect contains a conditional (for example), only signals that are used in the current branch are tracked. This means that effects rerun the absolute minimum number of times.

如果这听起来像魔法,并且你想深入了解自动依赖追踪的工作原理,请观看此视频。(抱歉音量较低!)

If this sounds like magic, and if you want a deep dive into how automatic dependency tracking works, check out this video. (Apologies for the low volume!)

Effect 作为类零成本抽象

Effects as Zero-Cost-ish Abstraction

虽然在最严格的技术意义上它们不是“零成本抽象”——它们需要一些额外的内存使用,存在于运行时等——但在更高层次上,从你在其中进行的任何昂贵的 API 调用或其他工作的角度来看,effect 是一种零成本抽象。根据你的描述,它们重新运行的次数绝对是必要的最小值。

While they’re not a “zero-cost abstraction” in the most technical sense—they require some additional memory use, exist at runtime, etc.—at a higher level, from the perspective of whatever expensive API calls or other work you’re doing within them, effects are a zero-cost abstraction. They rerun the absolute minimum number of times necessary, given how you’ve described them.

假设我正在开发某种聊天软件,我希望人们能够显示他们的全名或仅显示名,并在名字更改时通知服务器:

Imagine that I’m creating some kind of chat software, and I want people to be able to display their full name, or just their first name, and to notify the server whenever their name changes:

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 |_| {
    logging::log!(
        "{}", if use_last.get() {
            format!("{} {}", first.get(), last.get())
        } else {
            first.get()
        },
    )
});

如果 use_lasttrue,则每当 firstlastuse_last 更改时,effect 都会重新运行。但如果我将 use_last 切换为 false,则 last 的更改将永远不会导致全名更改。事实上,last 将从依赖列表中移除,直到 use_last 再次切换。这避免了在 use_last 仍为 false 时,如果我多次更改 last,而向 API 发送多次不必要的请求。

If use_last is true, effect should rerun whenever first, last, or use_last changes. But if I toggle use_last to false, a change in last will never cause the full name to change. In fact, last will be removed from the dependency list until use_last toggles again. This saves us from sending multiple unnecessary requests to the API if I change last multiple times while use_last is still false.

创建 Effect,还是不创建 Effect?

To create an effect, or not to create an effect?

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

Effects are intended to synchronize the reactive system with the non-reactive world outside, not to synchronize between different reactive values. In other words: using an effect to read a value from one signal and set it in another is always sub-optimal.

如果你需要定义一个依赖于其他信号值的信号,请使用派生信号或 Memo。在 effect 内部写入信号并不是世界末日,也不会让你的电脑着火,但派生信号或 memo 总是更好——不仅因为数据流清晰,而且因为性能更好。

If you need to define a signal that depends on the value of other signals, use a derived signal or a Memo. Writing to a signal inside an effect isn’t the end of the world, and it won’t cause your computer to light on fire, but a derived signal or memo is always better—not only because the dataflow is clear, but because the performance is better.

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

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

// ✅ 很好!
// ✅ woo-hoo!
let b = move || a.get() * 2;

如果你需要将某些响应式值与外部非响应式世界(如 Web API、控制台、文件系统或 DOM)同步,那么在 effect 中写入信号是可行的方法。但在许多情况下,你会发现你实际上是在事件监听器或某些其他地方写入信号,而不是在 effect 内部。在这种情况下,你应该查看 leptos-use,看看它是否已经提供了执行该操作的响应式封装原语!

If you need to synchronize some reactive value with the non-reactive world outside—like a web API, the console, the filesystem, or the DOM—writing to a signal in an effect is a fine way to do that. In many cases, though, you’ll find that you’re really writing to a signal inside an event listener or something else, not inside an effect. In these cases, you should check out leptos-use to see if it already provides a reactive wrapping primitive to do that!

如果你想了解更多关于何时应该以及不应该使用 create_effect 的信息,请观看此视频进行更深入的思考!

If you’re curious for more information about when you should and shouldn’t use create_effect, check out this video for a more in-depth consideration!

Effect 与渲染

Effects and Rendering

到目前为止,我们一直没有提到 effect,是因为它们已经内置在 Leptos DOM 渲染器中。我们已经看到,你可以创建一个信号并将其传递给 view 宏,每当信号发生变化时,它都会更新相关的 DOM 节点:

We’ve managed to get this far without mentioning effects because they’re built into the Leptos DOM renderer. We’ve seen that you can create a signal and pass it into the view macro, and it will update the relevant DOM node whenever the signal changes:

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

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

之所以有效,是因为框架本质上创建了一个包装此更新的 effect。你可以想象 Leptos 将此视图转换为类似以下的内容:

This works because the framework essentially creates an effect wrapping this update. You can imagine Leptos translating this view into something like this:

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

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

// 创建一个 effect 来响应式地更新文本
// create an effect to reactively update the text
Effect::new(move |prev_value| {
    // 首先,访问信号的值并将其转换为字符串
    // first, access the signal’s value and convert it to a string
    let text = count.get().to_string();

    // 如果这与之前的值不同,则更新节点
    // if this is different from the previous value, update the node
    if prev_value != Some(text) {
        p.set_text_content(&text);
    }

    // 返回此值,以便我们可以 memoize 下一次更新
    // return this value so we can memoize the next update
    text
});

每当 count 更新时,此 effect 都会重新运行。这就是允许对 DOM 进行响应式、细粒度更新的原因。

Every time count is updated, this effect will rerun. This is what allows reactive, fine-grained updates to the DOM.

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

Explicit Tracking with Effect::watch()

除了 Effect::new() 之外,Leptos 还提供了一个 Effect::watch() 函数,可以通过显式传入一组要追踪的值来分离追踪和响应更改。

In addition to Effect::new(), Leptos provides an Effect::watch() function, which can be used to separate tracking and responding to changes by explicitly passing in a set of values to track.

watch 接受三个参数。dependency_fn 参数是被响应式追踪的,而 handlerimmediate 则不是。每当 dependency_fn 发生变化时,handler 就会运行。如果 immediate 为 false,则 handler 仅在检测到 dependency_fn 中访问的任何信号发生第一次更改后运行。watch 返回一个 Effect,可以调用 .stop() 来停止追踪依赖关系。

watch takes three arguments. The dependency_fn argument is reactively tracked while handler and immediate are not. Whenever dependency_fn is changed, handler is run. If immediate is false, the handler will run only after the first change is detected of any signal that is accessed in dependency_fn. watch returns an Effect, which can be called with .stop() to stop tracking the dependencies.

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(); // 停止监听
// stop watching

set_num.set(2); // (没有任何反应)
// (nothing happens)

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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");

    // 在这里,newtype 模式不是 *必需的*,但这是一个好的做法
    // the newtype pattern isn't *necessary* here but is a good practice
    // 它可以避免与其他未来可能出现的 RwSignal<Vec<String>> 上下文混淆
    // 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)
}

插曲:响应式与函数

Interlude: Reactivity and Functions

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

One of our core contributors said to me recently: “I never used closures this often until I started using Leptos.” And it’s true. Closures are at the heart of any Leptos application. It sometimes looks a little silly:

// 信号持有一个值,并且可以被更新
// a signal holds a value, and can be updated
let (count, set_count) = signal(0);

// 派生信号是一个访问其他信号的函数
// a derived signal is a function that accesses other signals
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)自动追踪它所依赖的信号
// an effect automatically tracks the signals it depends on
// 并在它们改变时重新运行
// and reruns when they change
Effect::new(move |_| {
    logging::log!("text = {}", text());
});

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

到处都是闭包,到处都是!

Closures, closures everywhere!

但这是为什么呢?

But why?

函数与 UI 框架

Functions and UI Frameworks

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

Functions are at the heart of every UI framework. And this makes perfect sense. Creating a user interface is basically divided into two phases:

  1. 初始渲染

  2. initial rendering

  3. 更新

  4. updates

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

In a web framework, the framework does some kind of initial rendering. Then it hands control back over to the browser. When certain events fire (like a mouse click) or asynchronous tasks finish (like an HTTP request finishing), the browser wakes the framework back up to update something. The framework runs some kind of code to update your user interface, and goes back asleep until the browser wakes it up again.

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

The key phrase here is “runs some kind of code.” The natural way to “run some kind of code” at an arbitrary point in time—in Rust or in any other programming language—is to call a function. And in fact every UI framework is based on rerunning some kind of function over and over:

  1. 虚拟 DOM(VDOM)框架,如 React、Yew 或 Dioxus,会反复运行组件或渲染函数,以生成一个虚拟 DOM 树,该树可以与之前的结果进行比对(reconcile),从而对 DOM 进行补丁式更新

  2. virtual DOM (VDOM) frameworks like React, Yew, or Dioxus rerun a component or render function over and over, to generate a virtual DOM tree that can be reconciled with the previous result to patch the DOM

  3. 编译型框架,如 Angular 和 Svelte,将你的组件模板分为“创建(create)”和“更新(update)”函数,当它们检测到组件状态发生变化时,会重新运行更新函数

  4. compiled frameworks like Angular and Svelte divide your component templates into “create” and “update” functions, rerunning the update function when they detect a change to the component’s state

  5. 在细粒度响应式框架(如 SolidJS、Sycamore 或 Leptos)中,由 来定义重新运行的函数

  6. in fine-grained reactive frameworks like SolidJS, Sycamore, or Leptos, you define the functions that rerun

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

That’s what all our components are doing.

以我们最典型的 <SimpleCounter/> 示例的最简形式为例:

Take our typical <SimpleCounter/> example in its simplest form:

#[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} 表示的单个文本节点。

The SimpleCounter function itself runs once. The value signal is created once. The framework hands off the increment function to the browser as an event listener. When you click the button, the browser calls increment, which updates value via set_value. And that updates the single text node represented in our view by {value}.

函数是响应式的关键。它们使框架能够根据变化重新运行应用程序中尽可能小的单位。

Functions are key to reactivity. They provide the framework with the ability to rerun the smallest possible unit of your application in response to a change.

所以请记住两件事:

So remember two things:

  1. 你的组件函数是一个设置(setup)函数,而不是渲染函数:它只运行一次。

  2. Your component function is a setup function, not a render function: it only runs once.

  3. 为了使视图模板中的值具有响应性,它们必须是响应式函数:要么是信号,要么是捕获并读取信号的闭包。

  4. For values in your view template to be reactive, they must be reactive functions: either signals or closures that capture and read from signals.

这实际上是 Leptos 的 stable 版本和 nightly 版本之间的主要区别。如你所知,使用 nightly 编译器和 nightly 特性允许你直接像调用函数一样调用信号:因此,是 value() 而不是 value.get()

This is actually the primary difference between the stable and nightly versions of Leptos. As you may know, using the nightly compiler and the nightly feature allows you to call a signal directly, as a function: so, value() instead of value.get().

但这不仅仅是语法糖。它带来了一个极其一致的语义模型:响应式事物就是函数。通过调用函数来访问信号。要说“给我一个信号作为参数”,你可以接受任何 impl Fn() -> T 的类型。这种基于函数的接口在信号(signal)、备忘录(memo)和派生信号(derived signal)之间没有区别:它们中的任何一个都可以通过作为函数调用来访问。

But this isn’t just syntax sugar. It allows for an extremely consistent semantic model: Reactive things are functions. Signals are accessed by calling functions. To say “give me a signal as an argument” you can take anything that impl Fn() -> T. And this function-based interface makes no distinction between signals, memos, and derived signals: any of them can be accessed by calling them as functions.

遗憾的是,在像信号这样的任意结构体上实现 Fn trait 需要 nightly Rust,尽管这个特定的特性大多只是停滞不前,而且不太可能在短期内改变(或稳定)。出于各种原因,许多人避免使用 nightly。因此,随着时间的推移,我们已经将文档等内容的默认设置转向了 stable。不幸的是,这使得“信号就是函数”这一简单的思维模型变得不那么直观了。

Unfortunately implementing the Fn traits on arbitrary structs like signals requires nightly Rust, although this particular feature has mostly just languished and is not likely to change (or be stabilized) any time soon. Many people avoid nightly, for one reason or another. So, over time we’ve moved the defaults for things like documentation toward stable. Unfortunately, this makes the simple mental model of “signals are functions” a bit less straightforward.

测试你的组件

Testing Your Components

测试用户界面可能相对棘手,但非常重要。本文将讨论测试 Leptos 应用的几个原则和方法。

Testing user interfaces can be relatively tricky, but really important. This article will discuss a couple principles and approaches for testing a Leptos app.

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

1. Test business logic with ordinary Rust tests

在许多情况下,将逻辑从组件中抽离出来并单独测试是有意义的。对于一些简单的组件,可能没有特别的逻辑需要测试,但对于许多组件来说,使用一个可测试的包装类型并在普通的 Rust impl 块中实现逻辑是值得的。

In many cases, it makes sense to pull the logic out of your components and test it separately. For some simple components, there’s no particular logic to test, but for many it’s worth using a testable wrapping type and implementing the logic in ordinary Rust impl blocks.

例如,与其像这样直接在组件中嵌入逻辑:

For example, instead of embedding logic in a component directly like this:

#[component]
pub fn TodoApp() -> impl IntoView {
    let (todos, set_todos) = signal(vec![Todo { /* ... */ }]);
    // ⚠️ 这样做很难测试,因为它嵌入在组件中
    // ⚠️ this is hard to test because it's embedded in the component
    let num_remaining = move || todos.read().iter().filter(|todo| !todo.completed).sum();
}

你可以将该逻辑抽离到独立的数据结构中并进行测试:

You could pull that logic out into a separate data structure and test it:

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 { /* ... */ }]));
    // ✅ 逻辑现在关联了测试
    // ✅ this has a test associated with it
    let num_remaining = move || todos.read().num_remaining();
}

通常,封装在组件本身的逻辑越少,你的代码就会感觉越地道(idiomatic),测试起来也就越容易。

In general, the less of your logic is wrapped into your components themselves, the more idiomatic your code will feel and the easier it will be to test.

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

2. Test components with end-to-end (e2e) testing

我们的 examples 目录下有几个使用了不同测试工具进行广泛端到端测试的示例。

Our examples directory has several examples with extensive end-to-end testing, using different testing tools.

了解如何使用这些工具的最简单方法是查看测试示例本身:

The easiest way to see how to use these is to take a look at the test examples themselves:

配合 counter 使用 wasm-bindgen-test

wasm-bindgen-test with counter

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

This is a fairly simple manual testing setup that uses the wasm-pack test command.

测试示例

Sample 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
    // 注意我们从初始值 10 开始
    // start by rendering our counter and mounting it to the DOM
    // note that we start at the initial value of 10
    let _dispose = mount_to(
        test_wrapper.clone().unchecked_into(),
        || view! { <SimpleCounter initial_value=10 step=1/> },
    );

    // 现在我们通过遍历 DOM 提取按钮
    // 如果它们有 ID,这一步会更容易
    // now we extract the buttons by iterating over the DOM
    // this would be easier if they had IDs
    let div = test_wrapper.query_selector("div").unwrap().unwrap();
    let clear = test_wrapper
        .query_selector("button")
        .unwrap()
        .unwrap()
        .unchecked_into::<web_sys::HtmlElement>();

    // 现在让我们点击 `clear` 按钮
    // now let's click the `clear` button
    clear.click();

    // 响应式系统建立在异步系统之上,因此更改不会同步反映在 DOM 中。
    // 为了在此检测更改,我们将在每次更改后简短地 yield,允许更新视图的副作用运行。
    // the reactive system is built on top of the async system, so changes are not reflected
    // synchronously in the DOM
    // in order to detect the changes here, we'll just yield for a brief time after each change,
    // allowing the effects that update the view to run
    tick().await;

    // 现在让我们根据预期值测试 <div>
    // 我们可以通过测试其 `outerHTML` 来实现
    // now let's test the <div> against the expected value
    // we can do this by testing its `outerHTML`
    assert_eq!(div.outer_html(), {
        // 就像我们用值 0 创建它一样,对吧?
        // it's as if we're creating it with a value of 0, right?
        let (value, _set_value) = signal(0);

        // 我们可以移除事件监听器,因为它们不会渲染到 HTML
        // we can remove the event listeners because they're not rendered to HTML
        view! {
            <div>
                <button>"Clear"</button>
                <button>"-1"</button>
                <span>"Value: " {value} "!"</span>
                <button>"+1"</button>
            </div>
        }
        // Leptos 支持多种用于 HTML 元素的后端渲染器
        // 这里的 .into_view() 只是指定“使用常规 DOM 渲染器”的一种便捷方式
        // Leptos supports multiple backend renderers for HTML elements
        // .into_view() here is just a convenient way of specifying "use the regular DOM renderer"
        .into_view()
        // 视图是惰性的 —— 它们描述了一个 DOM 树,但还没有创建它
        // 调用 .build() 将实际构建 DOM 元素
        // views are lazy -- they describe a DOM tree but don't create it yet
        // calling .build() will actually build the DOM elements
        .build()
        // .build() 返回一个 ElementState,它是 DOM 元素的智能指针。
        // 所以我们仍然可以调用 .outer_html(),它访问实际 DOM 元素上的 outerHTML。
        // .build() returned an ElementState, which is a smart pointer for
        // a DOM element. So we can still just call .outer_html(), which access the outerHTML on
        // the actual DOM element
        .outer_html()
    });

    // 实际上有一种更简单的方法来做到这一点……
    // 我们可以直接针对初始值为 0 的 <SimpleCounter/> 进行测试
    // There's actually an easier way to do this...
    // We can just test against a <SimpleCounter/> with the initial value 0
    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()
    });
}

配合 counters 使用 Playwright

Playwright with counters

这些测试使用了常见的 JavaScript 测试工具 Playwright,对同一个示例运行端到端测试。这种库和测试方法对于许多做过前端开发的人来说非常熟悉。

These tests use the common JavaScript testing tool Playwright to run end-to-end tests on the same example, using a library and testing approach familiar to many who have done frontend development before.

测试示例

Sample Test

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");
  });
});

配合 todo_app_sqlite 使用 Gherkin/Cucumber 测试

Gherkin/Cucumber Tests with todo_app_sqlite

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

You can integrate any testing tool you’d like into this flow. This example uses Cucumber, a testing framework based on natural language.

@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 代码中。

The definitions for these actions are defined in Rust code.

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(())
}

// 等等。
// etc.

了解更多

Learning More

欢迎查看 Leptos 仓库中的 CI 设置,以进一步了解如何在自己的应用中使用这些工具。所有这些测试方法都会定期针对实际的 Leptos 示例应用运行。

Feel free to check out the CI setup in the Leptos repo to learn more about how to use these tools in your own application. All of these testing methods are run regularly against actual Leptos example apps.

使用 async

Working with async

到目前为止,我们一直只在处理同步用户界面:你提供一些输入,应用立即处理并更新界面。这很棒,但只是 Web 应用程序所做工作的一小部分。特别是,大多数 Web 应用必须处理某种异步数据加载,通常是从 API 加载某些内容。

So far we’ve only been working with synchronous user interfaces: You provide some input, the app immediately processes it and updates the interface. This is great, but is a tiny subset of what web applications do. In particular, most web apps have to deal with some kind of asynchronous data loading, usually loading something from an API.

由于“函数染色(function coloring)”问题,异步数据众所周知难以与代码中的同步部分集成。

Asynchronous data is notoriously hard to integrate with the synchronous parts of your code because of problems of “function coloring.”

在接下来的章节中,我们将看到一些用于处理异步数据的响应式原语。但在最开始有一点很重要:如果你只想做一些异步工作,Leptos 提供了一个跨平台的 spawn_local 函数,可以轻松运行 Future。如果本节其余部分讨论的原语似乎无法满足你的需求,请考虑将 spawn_local 与设置信号(signal)结合使用。

In the following chapters, we’ll see a few reactive primitives for working with async data. But it’s important to note at the very beginning: If you just want to do some asynchronous work, Leptos provides a cross-platform spawn_local function that makes it easy to run a Future. If one of the primitives discussed in the rest of this section doesn’t seem to do what you want, consider combining spawn_local with setting a signal.

虽然即将介绍的原语非常有用,在某些情况下甚至是必要的,但人们有时会遇到这样的情况:他们真的只需要派生(spawn)一个任务并等待它完成后再执行其他操作。在这些情况下,请使用 spawn_local

While the primitives to come are very useful, and even necessary in some cases, people sometimes run into situations in which they really just need to spawn a task and wait for it to finish before doing something else. Use spawn_local in those situations!

使用资源加载数据

Loading Data with Resources

资源(Resource)是异步任务的响应式包装器,它们允许你将异步的 Future 集成到同步响应式系统中。

Resources are reactive wrappers for asynchronous tasks, which allow you to integrate an asynchronous Future into the synchronous reactive system.

它们使你能够有效地加载一些异步数据,然后以同步或异步的方式响应式地访问它。你可以像对待普通 Future 一样 .await 一个资源,这会跟踪它。但你也可以通过 .get() 和其他信号访问方法来访问资源,就好像资源是一个信号一样,如果它已解析(resolved),则返回 Some(T),如果仍在挂起(pending),则返回 None

They effectively allow you to load some async data, and then reactively access it either synchronously or asynchronously. You can .await a resource like an ordinary Future, and this will track it. But you can also access a resource with .get() and other signal access methods, as if a resource were a signal that returns Some(T) if it has resolved, and None if it’s still pending.

资源主要有两种形式:ResourceLocalResource。如果你正在使用服务端渲染(本书稍后会讨论),你应该默认使用 Resource。如果你正在使用带有 !Send API 的客户端渲染(例如许多浏览器 API),或者你正在使用 SSR 但有一些只能在浏览器上完成的异步任务(例如访问异步浏览器 API),那么你应该使用 LocalResource

Resources come in two primary flavors: Resource and LocalResource. If you’re using server-side rendering (which this book will discuss later), you should default to using Resource. If you’re using client-side rendering with a !Send API (like many of the browser APIs), or if you are using SSR but have some async task that can only be done on the browser (for example, accessing an async browser API) then you should use LocalResource.

本地资源

Local Resources

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

LocalResource::new() takes a single argument: a “fetcher” function that returns a Future.

这个 Future 可以是一个 async 块、一个 async fn 调用的结果,或任何其他 Rust Future。该函数的工作方式类似于派生信号或我们目前见过的其他响应式闭包:你可以在其内部读取信号,每当信号发生变化时,该函数将再次运行,创建一个新的 Future 来执行。

The Future can be an async block, the result of an async fn call, or any other Rust Future. The function will work like a derived signal or the other reactive closures that we’ve seen so far: you can read signals inside it, and whenever the signal changes, the function will run again, creating a new Future to run.

// 这个 count 是我们的同步本地状态
// this count is our synchronous, local state
let (count, set_count) = signal(0);

// 跟踪 `count`,并在其变化时通过调用 `load_data` 重新加载
// tracks `count`, and reloads by calling `load_data`
// whenever it changes
let async_data = LocalResource::new(move || load_data(count.get()));

创建资源会立即调用其获取器并开始轮询 Future。读取资源将返回 None,直到异步任务完成,此时它会通知其订阅者,并变为 Some(value)

Creating a resource immediately calls its fetcher and begins polling the Future. Reading from a resource will return None until the async task completes, at which point it will notify its subscribers, and now have Some(value).

你也可以 .await 一个资源。这似乎毫无意义——为什么要为一个 Future 创建包装器,然后又去 .await 它呢?我们将在下一章看到原因。

You can also .await a resource. This might seem pointless—Why would you create a wrapper around a Future, only to then .await it? We’ll see why in the next chapter.

资源

Resources

如果你正在使用 SSR,在大多数情况下你应该使用 Resource 而不是 LocalResource

If you’re using SSR, you should be using Resource instead of LocalResource in most cases.

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

This API is slightly different. Resource::new() takes two functions as its arguments:

  1. 一个源函数(source function),包含“输入”。该输入是经过记忆化(memoize)处理的,每当其值发生变化时,都会调用获取器。
  2. a source function, which contains the “input.” This input is memoized, and whenever its value changes, the fetcher will be called.
  3. 一个获取器函数(fetcher function),它从源函数获取数据并返回一个 Future
  4. a fetcher function, which takes the data from the source function and returns a Future

LocalResource 不同,Resource 会将其值从服务器序列化到客户端。然后,在客户端首次加载页面时,初始值将被反序列化,而不是再次运行异步任务。这非常重要且非常有用:这意味着数据加载从服务器开始,而不是等待客户端 WASM 包加载并开始运行应用程序。(关于这一点,后续章节会有更多说明。)

Unlike a LocalResource, a Resource serializes its value from the server to the client. Then, on the client, when first loading the page, the initial value will be deserialized rather than the async task running again. This is extremely important and very useful: It means that rather than waiting for the client WASM bundle to load and begin running the application, data loading begins on the server. (There will be more to say about this in later chapters.)

这也是为什么 API 被拆分为两部分:函数中的信号是被跟踪的,但获取器中的信号是不被跟踪的,因为这允许资源保持响应式,而无需在客户端初始注水(hydration)期间再次运行获取器。

This is also why the API is split into two parts: signals in the source function are tracked, but signals in the fetcher are untracked, because this allows the resource to maintain reactivity without needing to run the fetcher again during initial hydration on the client.

这是一个相同的示例,使用 Resource 代替 LocalResource

Here’s the same example, using Resource instead of LocalResource

// 这个 count 是我们的同步本地状态
// this count is our synchronous, local state
let (count, set_count) = signal(0);

// 我们的资源
// our resource
let async_data = Resource::new(
    move || count.get(),
    // 每当 `count` 变化时,这里都会运行
    // every time `count` changes, this will run
    |count| load_data(count) 
);

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

Resources also provide a refetch() method that allows you to manually reload the data (for example, in response to a button click).

要创建一个仅运行一次的资源,可以使用 OnceResource,它仅接收一个 Future,并添加了一些由于已知其仅加载一次而产生的优化。

To create a resource that simply runs once, you can use OnceResource, which simply takes a Future, and adds some optimizations that come from knowing it will only load once.

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

访问资源

Accessing Resources

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

Both LocalResource and Resource implement the various signal access methods (.read(), .with(), .get()), but return Option<T> instead of T; they will be None until the async data has loaded.

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

CodeSandbox 源码 (CodeSandbox Source)
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;

// 这里我们定义一个异步函数。
// 这可以是任何东西:网络请求、数据库读取等。
// 在这里,我们只是将一个数字乘以 10。
// 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 {
    // 这个 count 是我们的同步本地状态
    // this count is our synchronous, local state
    let (count, set_count) = signal(0);

    // 跟踪 `count`,并在其变化时通过调用 `load_data` 重新加载
    // 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));

    // 我们可以使用 .get() 访问资源值。
    // 在 Future 解析之前,这将响应式地返回 None,
    // 并在解析后更新为 Some(T)。
    // 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()
            .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()}
        </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/>

<Suspense/>

在上一章中,我们展示了如何创建一个简单的加载界面,在资源加载时显示一些回退内容(fallback)。

In the previous chapter, we showed how you can create a simple loading screen to show some fallback while a resource is loading.

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() {
        // 加载中...
        // Loading...
        None => view! { <p>"Loading..."</p> }.into_any(),
        Some(data) => view! { <ShowData data/> }.into_any()
    }}
}

但是,如果我们有两个资源,并且想等待它们两个都加载完呢?

But what if we have two resources, and want to wait for both of them?

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_any(),
        // 加载中...
        // Loading...
        _ => view! { <p>"Loading..."</p> }.into_any()
    }}
}

这看起来还算可以,但确实有点烦人。如果我们能反转控制流(invert the flow of control)呢?

That’s not so bad, but it’s kind of annoying. What if we could invert the flow of control?

<Suspense/> 组件正是为了实现这一点。你给它一个 fallback 属性和子组件,其中一个或多个子组件通常涉及从资源中读取数据。在 <Suspense/> “下方”(即在其子组件之一中)读取资源会将该资源注册到 <Suspense/>。如果它仍在等待资源加载,它就会显示 fallback。当资源全部加载完成后,它就会显示子组件。

The <Suspense/> component lets us do exactly that. You give it a fallback prop and children, one or more of which usually involves reading from a resource. Reading from a resource “under” a <Suspense/> (i.e., in one of its children) registers that resource with the <Suspense/>. If it’s still waiting for resources to load, it shows the fallback. When they’ve all loaded, it shows the children.

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
        // 加载中...
        // Loading...
        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..." 回退内容就会再次显示。

Every time one of the resources is reloading, the "Loading..." fallback will show again.

这种控制流的反转使得添加或删除单个资源变得更加容易,因为你不需要自己处理匹配逻辑。它还在服务端渲染期间开启了一些巨大的性能提升,我们将在后面的章节中讨论这一点。

This inversion of the flow of control makes it easier to add or remove individual resources, as you don’t need to handle the matching yourself. It also unlocks some massive performance improvements during server-side rendering, which we’ll talk about during a later chapter.

使用 <Suspense/> 还允许我们使用一种非常有用的方式直接 .await 资源,从而消除上述代码中的一层嵌套。Suspend 类型允许我们创建一个可在视图中使用的可渲染 Future

Using <Suspense/> also gives us access to a useful way to directly .await resources, allowing us to remove a level of nesting, above. The Suspend type lets us create a renderable Future which can be used in the view:

view! {
    <h1>"My Data"</h1>
    <Suspense
        // 加载中...
        // Loading...
        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 让我们能够避免对每个资源进行空值检查,并从代码中消除了一些额外的复杂性。

Suspend allows us to avoid null-checking each resource, and removes some additional complexity from the code.

<Await/>

<Await/>

如果你只是想在渲染之前等待某个 Future 解析,你可能会发现 <Await/> 组件在减少样板代码方面很有帮助。<Await/> 实际上是将 OnceResource 与没有回退内容的 <Suspense/> 结合在了一起。

If you’re simply trying to wait for some Future to resolve before rendering, you may find the <Await/> component helpful in reducing boilerplate. <Await/> essentially combines a OnceResource with a <Suspense/> with no fallback.

换句话说:

In other words:

  1. 它只轮询(poll) Future 一次,并且不响应任何响应式变化。
  2. It only polls the Future once, and does not respond to any reactive changes.
  3. Future 解析之前,它不会渲染任何内容。
  4. It does not render anything until the Future resolves.
  5. Future 解析后,它将数据绑定到你选择的任何变量名上,然后在该变量的作用域内渲染其子组件。
  6. After the Future resolves, it binds its data to whatever variable name you choose and then renders its children with that variable in scope.
async fn fetch_monkeys(monkey: i32) -> i32 {
    // 也许这并不需要是异步的
    // maybe this didn't need to be async
    monkey * 2
}
view! {
    <Await
        // `future` 提供要解析的 `Future`
        // `future` provides the `Future` to be resolved
        future=fetch_monkeys(3)
        // 数据被绑定到你提供的任何变量名
        // the data is bound to whatever variable name you provide
        let:data
    >
        // 你通过引用接收数据,并可以在此处的视图中使用它
        // you receive the data by reference and can use it in your view here
        // 只小猴子在床上跳。
        //  little monkeys, jumping on the bed.
        <p>{*data} " little monkeys, jumping on the bed."</p>
    </Await>
}

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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());

    // 每当 `name` 变化时,这里都会重新加载
    // 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
            // 每当在 suspense “下方”读取的资源正在加载时,
            // 就会显示回退内容。
            // the fallback will show whenever a resource
            // read "under" the suspense is loading
            fallback=move || view! { <p>"Loading..."</p> }
        >
            // Suspend 允许你在视图中使用异步块
            // 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
            // 每当在 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/>

<Transition/>

你会注意到在 <Suspense/> 的例子中,如果你不断地重新加载数据,它会不停地闪回 "Loading..." 状态。有时这没问题,但在另一些时候,我们可以使用 <Transition/>

You’ll notice in the <Suspense/> example that if you keep reloading the data, it keeps flickering back to "Loading...". Sometimes this is fine. For other times, there’s <Transition/>.

<Transition/> 的行为与 <Suspense/> 完全相同,但它不是每次都回退到 fallback,而仅在第一次加载时显示 fallback。在随后的所有加载中,它会继续显示旧数据,直到新数据准备就绪。这对于防止闪烁效果以及允许用户继续与你的应用程序交互非常方便。

<Transition/> behaves exactly the same as <Suspense/>, but instead of falling back every time, it only shows the fallback the first time. On all subsequent loads, it continues showing the old data until the new data are ready. This can be really handy to prevent the flickering effect, and to allow users to continue interacting with your application.

这个例子展示了如何使用 <Transition/> 创建一个简单的标签式联系人列表。当你选择一个新标签时,它会继续显示当前的联系人,直到新数据加载完毕。这比不断回退到加载消息能提供好得多的用户体验。

This example shows how you can create a simple tabbed contact list with <Transition/>. When you select a new tab, it continues showing the current contact until the new data loads. This can be a much better user experience than constantly falling back to a loading message.

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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);

    // 每当 `tab` 变化时,这里都会重新加载
    // 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
            // 回退内容(fallback)仅在最初显示
            // 在随后的重新加载中,当前的子组件将继续显示
            // the fallback will show initially
            // on subsequent reloads, the current child will
            // continue showing
            fallback=move || view! { <p>"Loading initial data..."</p> }
            // 每当过渡正在进行时,这里将被设置为 `true`
            // 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)
}

使用 Action 修改数据

Mutating Data with Actions

我们已经讨论了如何使用资源(Resource)加载 async 数据。资源会立即加载数据,并与 <Suspense/><Transition/> 组件紧密配合,以显示应用中数据的加载状态。但是,如果你只想调用某个任意的 async 函数并跟踪其执行情况,该怎么办呢?

We’ve talked about how to load async data with resources. Resources immediately load data and work closely with <Suspense/> and <Transition/> components to show whether data is loading in your app. But what if you just want to call some arbitrary async function and keep track of what it’s doing?

嗯,你总是可以使用 spawn_local。这允许你通过将 Future 交给浏览器(或者在服务器上交给 Tokio 或你正在使用的其他运行时),在同步环境中派生(spawn)一个 async 任务。但是你如何知道它是否仍在挂起(pending)呢?好吧,你可以设置一个信号(Signal)来显示它是否正在加载,再设置另一个信号来显示结果……

Well, you could always use spawn_local. This allows you to just spawn an async task in a synchronous environment by handing the Future off to the browser (or, on the server, Tokio or whatever other runtime you’re using). But how do you know if it’s still pending? Well, you could just set a signal to show whether it’s loading, and another one to show the result...

这些都可以做到。或者你可以使用最后一个 async 原语:Action

All of this is true. Or you could use the final async primitive: Action.

Action 和资源看起来很相似,但它们代表了根本不同的东西。如果你试图通过运行 async 函数来加载数据(无论是加载一次还是在其他值变化时加载),你可能想要使用资源。如果你试图偶尔运行一个 async 函数来响应某些操作(例如用户点击按钮),你可能想要使用 Action

Actions and resources seem similar, but they represent fundamentally different things. If you’re trying to load data by running an async function, either once or when some other value changes, you probably want to use a resource. If you’re trying to occasionally run an async function in response to something like a user clicking a button, you probably want to use an Action.

假设我们有一个想要运行的 async 函数。

Say we have some async function we want to run.

async fn add_todo_request(new_title: &str) -> Uuid {
    /* 在服务器上执行一些操作以添加新的待办事项 */
    /* do some stuff on the server to add a new todo */
}

Action::new() 接受一个 async 函数,该函数接受对单个参数的引用,你可以将其视为它的“输入类型”。

Action::new() takes an async function that takes a reference to a single argument, which you could think of as its “input type.”

输入始终是单一类型。如果你想传入多个参数,可以使用结构体或元组。

The input is always a single type. If you want to pass in multiple arguments, you can do it with a struct or tuple.

// 如果只有一个参数,直接使用它即可
// if there's a single argument, just use that
let action1 = Action::new(|input: &String| {
   let input = input.clone();
   async move { todo!() }
});

// 如果没有参数,使用单元类型 `()`
// if there are no arguments, use the unit type `()`
let action2 = Action::new(|input: &()| async { todo!() });

// 如果有多个参数,使用元组
// if there are multiple arguments, use a tuple
let action3 = Action::new(
  |input: &(usize, String)| async { todo!() }
);

因为 Action 函数接受引用,但 Future 需要具有 'static 生命周期,所以通常需要克隆该值以将其传递到 Future 中。诚然这有点笨拙,但它开启了一些强大的功能,比如乐观 UI(optimistic UI)。我们将在以后的章节中对此进行更多介绍。

Because the action function takes a reference but the Future needs to have a 'static lifetime, you’ll usually need to clone the value to pass it into the Future. This is admittedly awkward but it unlocks some powerful features like optimistic UI. We’ll see a little more about that in future chapters.

所以在这种情况下,我们创建 Action 所需要做的就是

So in this case, all we need to do to create an action is

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

我们不会直接调用 add_todo_action,而是通过 .dispatch() 调用它,如下所示

Rather than calling add_todo_action directly, we’ll call it with .dispatch(), as in

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

你可以从事件监听器、超时或任何地方执行此操作;因为 .dispatch() 不是 async 函数,所以它可以从同步上下文中调用。

You can do this from an event listener, a timeout, or anywhere; because .dispatch() isn’t an async function, it can be called from a synchronous context.

Action 提供了对几个信号的访问,这些信号在正在调用的异步 Action 和同步响应式系统之间进行同步:

Actions provide access to a few signals that synchronize between the asynchronous action you’re calling and the synchronous reactive system:

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”变得容易。

This makes it easy to track the current state of your request, show a loading indicator, or do “optimistic UI” based on the assumption that the submission will succeed.

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_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>
    // 使用我们的加载状态
    // use our loading state
    <p>{move || pending.get().then_some("Loading...")}</p>
}

现在,这一切可能看起来有点过于复杂,或者限制太多。我想在这里把 Action 与资源并列介绍,作为拼图中缺失的一块。在一个真实的 Leptos 应用中,实际上你最常将 Action 与服务器函数(Server Function)、ServerAction 以及 <ActionForm/> 组件结合使用,以创建功能强大的渐进式增强表单。所以,如果这个原语对你来说似乎没用……别担心!也许以后它就有意义了。(或者现在就去查看我们的 todo_app_sqlite 示例。)

Now, there’s a chance this all seems a little over-complicated, or maybe too restricted. I wanted to include actions here, alongside resources, as the missing piece of the puzzle. In a real Leptos app, you’ll actually most often use actions alongside server functions, ServerAction, and the <ActionForm/> component to create really powerful progressively-enhanced forms. So if this primitive seems useless to you... Don’t worry! Maybe it will make sense later. (Or check out our todo_app_sqlite example now.)

在线示例

点击打开 CodeSandbox。

Click to open CodeSandbox.

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

// 在这里我们定义一个异步函数。
// 这可以是任何东西:网络请求、数据库读取等。
// 把它看作是一个变更(mutation):你运行的一些命令式异步操作,
// 而资源将是你加载的一些异步数据。
// 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 允许我们使用这个 !Send 浏览器 API;别担心。
    // SendWrapper allows us to use this !Send browser API; don't worry about it
    send_wrapper::SendWrapper::new(TimeoutFuture::new(1_000)).await;
    // 假设这是一个帖子 ID 之类的东西
    // pretend this is a post ID or something
    Uuid::new_v4()
}

#[component]
pub fn App() -> impl IntoView {
    // 一个 Action 接受一个带有单个参数的异步函数。
    // 它可以是简单类型、结构体或 ()。
    // 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| {
        // 输入是一个引用,但我们需要 Future 拥有它。
        // 这很重要:我们需要克隆并移动到 Future 中,
        // 这样它就具有了 'static 生命周期。
        // 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 }
    });

    // Action 提供了一组同步的响应式变量,
    // 它们告诉我们关于 Action 状态的不同信息。
    // 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

在构建组件时,你有时可能希望通过多层组件来“投影”子组件。

As you build components you may occasionally find yourself wanting to “project” children through multiple layers of components.

问题所在

The Problem

考虑以下代码:

Consider the following:

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,我们只渲染 (),即不显示任何内容。

This is pretty straightforward: if the inner condition is true, we want to show children. If not, we want to show fallback. And if the outer condition is false, we just render (), i.e., nothing.

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

In other words, we want to pass the children of <NestedShow/> through the outer <Show/> component to become the children of the inner <Show/>. This is what I mean by “projection.”

这段代码无法通过编译。

This won’t compile.

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

每个 <Show/> 都需要能够多次构造其 children。当你第一次构造外部 <Show/> 的子组件时,它会获取 fallbackchildren 的所有权并将它们移动到内部 <Show/> 的调用中,但随后它们在未来的外部 <Show/> 子组件构造中就不可用了。

Each <Show/> needs to be able to construct its children multiple times. The first time you construct the outer <Show/>’s children, it takes fallback and children to move them into the invocation of the inner <Show/>, but then they're not available for future outer-<Show/> children construction.

细节解析

The Details

请随意跳转到解决方案。 Feel free to skip ahead to the solution.

如果你想真正理解这个问题,查看展开后的 view 宏可能会有所帮助。这是一个清理后的版本:

If you want to really understand the issue here, it may help to look at the expanded view macro. Here’s a cleaned-up version:

Show(
    ShowProps::builder()
        .when(|| todo!())
        .fallback(|| ())
        .children({
            // 这里 children 和 fallback 被移动到了闭包中
            // children and fallback are moved into a closure here
            ::leptos::children::ToChildren::to_children(move || {
                Show(
                    ShowProps::builder()
                        .when(|| todo!())
                        // fallback 在这里被消耗了
                        // fallback is consumed here
                        .fallback(fallback)
                        .children({
                            // children 在这里被捕获
                            // children is captured here
                            ::leptos::children::ToChildren::to_children(
                                move || children(),
                            )
                        })
                        .build(),
                )
            })
        })
        .build(),
)

所有组件都拥有它们属性(props)的所有权;因此在这种情况下无法调用 <Show/>,因为它只捕获了对 fallbackchildren 的引用。

All components own their props; so the <Show/> in this case can’t be called because it only has captured references to fallback and children.

解决方案

Solution

然而,<Suspense/><Show/> 都接收 ChildrenFn,也就是说,它们的 children 应该实现 Fn trait,以便只需不可变引用即可多次调用。这意味着我们不需要拥有 childrenfallback 的所有权;我们只需要能够传递对它们的 'static 引用。

However, both <Suspense/> and <Show/> take ChildrenFn, i.e., their children should implement the Fn type so they can be called multiple times with only an immutable reference. This means we don’t need to own children or fallback; we just need to be able to pass 'static references to them.

我们可以使用 StoredValue 原语来解决这个问题。它本质上是在响应式系统中存储一个值,将所有权移交给框架,以换取一个像信号(signal)一样具有 Copy'static 特性的引用,我们可以通过某些方法来访问或修改它。

We can solve this problem by using the StoredValue primitive. This essentially stores a value in the reactive system, handing ownership off to the framework in exchange for a reference that is, like signals, Copy and 'static, which we can access or modify through certain methods.

在这种情况下,操作非常简单:

In this case, it’s really simple:

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
                // 检查用户是否已验证
                // check whether user is verified
                // 通过读取资源
                // by reading from the resource
                when=move || todo!()
                fallback=move || fallback.read_value()()
            >
                {children.read_value()()}
            </Show>
        </Show>
    }
}

在顶层,我们将 fallbackchildren 存储在由 NestedShow 拥有的响应式作用域中。现在我们可以简单地将这些引用向下传递到其他层级中的 <Show/> 组件,并在那里调用它们。

At the top level, we store both fallback and children in the reactive scope owned by NestedShow. Now we can simply move those references down through the other layers into the <Show/> component and call them there.

最后一点说明

A Final Note

请注意,这之所以有效,是因为 <Show/> 只需要对其子组件的不可变引用(.read_value 可以提供),而不需要所有权。

Note that this works because <Show/> only needs an immutable reference to their children (which .read_value can give), not ownership.

在其他情况下,你可能需要将拥有的属性(props)投影到一个接收 ChildrenFn 且因此需要多次调用的函数中。在这种情况下,你会发现 view 宏中的 clone: 辅助语法非常有用。

In other cases, you may need to project owned props through a function that takes ChildrenFn and therefore needs to be called more than once. In this case, you may find the clone: helper in theview macro helpful.

考虑这个例子:

Consider this example

#[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(),这也会报错:

Even with name=name.clone(), this gives the error

cannot move out of `name`, a captured variable in an `Fn` closure

它是通过多层需要运行多次的子组件捕获的,而且没有明显的方法可以将其克隆 子组件。

It’s captured through multiple levels of children that need to run more than once, and there’s no obvious way to clone it into the children.

在这种情况下,clone: 语法就派上用场了。调用 clone:name 会在将 name 移动到 <Inner/> 的子组件 之前 对其进行克隆,这解决了我们的所有权问题。

In this case, the clone: syntax comes in handy. Calling clone:name will clone name before moving it into <Inner/>’s children, which solves our ownership issue.

view! {
	<Outer>
		<Inner clone:name>
			<Inmost name=name.clone()/>
		</Inner>
	</Outer>
}

由于 view 宏的不透明性,这些问题可能有点难以理解或调试。但总的来说,它们总是可以被解决的。

These issues can be a little tricky to understand or debug, because of the opacity of the view macro. But in general, they can always be solved.

全局状态管理

Global State Management

到目前为止,我们只在组件中使用局部状态,并了解了如何在父子组件之间协调状态。有时,人们会寻求一种更通用的全局状态管理解决方案,以便在整个应用程序中使用。

So far, we've only been working with local state in components, and we’ve seen how to coordinate state between parent and child components. On occasion, there are times when people look for a more general solution for global state management that can work throughout an application.

通常情况下,你并不需要这一章。 典型的模式是由组件组合成应用程序,每个组件管理自己的局部状态,而不是将所有状态存储在一个全局结构中。然而,在某些情况下(如主题设置、保存用户设置或在 UI 不同部分的组件之间共享数据),你可能希望使用某种形式的全局状态管理。

In general, you do not need this chapter. The typical pattern is to compose your application out of components, each of which manages its own local state, not to store all state in a global structure. However, there are some cases (like theming, saving user settings, or sharing data between components in different parts of your UI) in which you may want to use some kind of global state management.

全局状态的三种最佳方法是:

The three best approaches to global state are

  1. 使用路由通过 URL 驱动全局状态
  2. Using the router to drive global state via the URL
  3. 通过上下文(Context)传递信号
  4. Passing signals through context
  5. 使用 Store 创建全局状态结构体
  6. Creating a global state struct using stores

选项 #1:将 URL 作为全局状态

Option #1: URL as Global State

在许多方面,URL 实际上是存储全局状态的最佳方式。它可以从组件树中任何位置的任何组件访问。原生 HTML 元素如 <form><a> 的存在纯粹是为了更新 URL。而且它在页面重载和设备之间保持持久;你可以与朋友分享 URL,或将其从手机发送到笔记本电脑,其中存储的任何状态都将被复制。

In many ways, the URL is actually the best way to store global state. It can be accessed from any component, anywhere in your tree. There are native HTML elements like <form> and <a> that exist solely to update the URL. And it persists across page reloads and between devices; you can share a URL with a friend or send it from your phone to your laptop and any state stored in it will be replicated.

本教程接下来的几个部分将围绕路由器展开,我们将更深入地探讨这些话题。

The next few sections of the tutorial will be about the router, and we’ll get much more into these topics.

但现在,我们只看选项 #2 和 #3。

But for now, we'll just look at options #2 and #3.

选项 #2:通过上下文传递信号

Option #2: Passing Signals through Context

在关于父子通信的章节中,我们看到可以使用 provide_context 将信号从父组件传递给子组件,并使用 use_context 在子组件中读取它。但是 provide_context 跨越任何距离都有效。如果你想创建一个持有某部分状态的全局信号,你可以在提供它的组件的任何后代中提供并访问它。

In the section on parent-child communication, we saw that you can use provide_context to pass signal from a parent component to a child, and use_context to read it in the child. But provide_context works across any distance. If you want to create a global signal that holds some piece of state, you can provide it and access it via context anywhere in the descendants of the component where you provide it.

通过上下文提供的信号仅在读取它的地方引起响应式更新,而不会在中间的任何组件中引起更新,因此即使在远距离也保持了细粒度响应式更新的能力。

A signal provided via context only causes reactive updates where it is read, not in any of the components in between, so it maintains the power of fine-grained reactive updates, even at a distance.

我们首先在应用程序的根部创建一个信号,并使用 provide_context 将其提供给所有子组件和后代组件。

We start by creating a signal in the root of the app and providing it to all its children and descendants using provide_context.

#[component]
fn App() -> impl IntoView {
    // 这里我们在根部创建一个可以在应用中任何地方消费的信号。
    // here we create a signal in the root that can be consumed
    // anywhere in the app.
    let (count, set_count) = signal(0);
    // 我们将把 setter 传递给特定组件,
    // 但通过 context 将 count 本身提供给整个应用
    // we'll pass the setter to specific components,
    // but provide the count itself to the whole app via context
    provide_context(count);

    view! {
        // SetterButton 被允许修改 count
        // SetterButton is allowed to modify the count
        <SetterButton set_count/>
        // 这些消费者只能从中读取
        // 但如果我们愿意,也可以通过传递 `set_count` 给它们写入权限
        // These consumers can only read from it
        // But we could give them write access by passing `set_count` if we wanted
        <FancyMath/>
        <ListItems/>
    }
}

<SetterButton/> 是我们已经写过好几次的那种计数器。

<SetterButton/> is the kind of counter we’ve written several times now.

<FancyMath/><ListItems/> 都通过 use_context 消费我们提供的信号并对其进行操作。

<FancyMath/> and <ListItems/> both consume the signal we’re providing via use_context and do something with it.

/// 一个使用全局 count 信号进行“花哨”数学运算的组件
/// A component that does some "fancy" math with the global count
#[component]
fn FancyMath() -> impl IntoView {
    // 这里我们通过 `use_context` 消费全局 count 信号
    // here we consume the global count signal with `use_context`
    let count = use_context::<ReadSignal<u32>>()
        // 我们知道刚才在父组件中提供了这个信号
        // we know we just provided this in the parent component
        .expect("there to be a `count` signal provided");
    let is_even = move || count.get() & 1 == 0;

    view! {
        <div class="consumer blue">
            "The number "
            <strong>{count}</strong>
            {move || if is_even() {
                " is"
            } else {
                " is not"
            }}
            " even."
        </div>
    }
}

选项 #3:创建全局状态 Store

Option #3: Create a Global State Store

此内容的一部分重复自关于 Store 复杂迭代的章节 此处。由于这两个章节都是中级/可选内容,我认为有些重复没有坏处。

Some of this content is duplicated from the section on complex iteration with stores here. Both sections are intermediate/optional content, so I thought some duplication couldn’t hurt.

Store 是一种新的响应式原语,在 Leptos 0.7 中通过配套的 reactive_stores crate 提供。(这个 crate 目前是单独发布的,这样我们就可以在不要求整个框架变更版本的情况下继续开发它。)

Stores are a new reactive primitive, available in Leptos 0.7 through the accompanying reactive_stores crate. (This crate is shipped separately for now so we can continue to develop it without requiring a version change to the whole framework.)

Store 允许你包装整个结构体,并响应式地读取和更新单个字段,而不会跟踪对其他字段的更改。

Stores allow you to wrap an entire struct, and reactively read from and update individual fields without tracking changes to other fields.

通过在结构体上添加 #[derive(Store)] 来使用它们。(你可以使用 use reactive_stores::Store; 来导入宏。)当结构体被包装在 Store<_> 中时,这将创建一个扩展 trait,为结构体的每个字段提供 getter。

They are used by adding #[derive(Store)] onto a struct. (You can use reactive_stores::Store; to import the macro.) This creates an extension trait with a getter for each field of the struct, when the struct is wrapped in a Store<_>.

#[derive(Clone, Debug, Default, Store)]
struct GlobalState {
    count: i32,
    name: String,
}

这会创建一个名为 GlobalStateStoreFields 的 trait,它向 Store<GlobalState> 添加了 countname 方法。每个方法返回一个响应式 Store 字段

This creates a trait named GlobalStateStoreFields which adds with methods count and name to a Store<GlobalState>. Each method returns a reactive store field.

#[component]
fn App() -> impl IntoView {
    provide_context(Store::new(GlobalState::default()));

    // 等等。
    // etc.
}

/// 一个更新全局状态中 count 的组件。
/// A component that updates the count in the global state.
#[component]
fn GlobalStateCounter() -> impl IntoView {
    let state = expect_context::<Store<GlobalState>>();

    // 这仅让我们对 `count` 字段拥有响应式访问权限
    // this gives us reactive access to the `count` field only
    let count = state.count();

    view! {
        <div class="consumer blue">
            <button
                on:click=move |_| {
                    *count.write() += 1;
                }
            >
                "Increment Global Count"
            </button>
            <br/>
            <span>"Count is: " {move || count.get()}</span>
        </div>
    }
}

点击此按钮仅更新 state.count。如果我们在其他地方读取 state.name,点击该按钮将不会通知它。这使你能够结合自顶向下数据流和细粒度响应式更新的优点。

Clicking this button only updates state.count. If we read from state.name somewhere else, click the button won’t notify it. This allows you to combine the benefits of a top-down data flow and of fine-grained reactive updates.

查看仓库中的 stores 示例以获取更详尽的示例。

Check out the stores example in the repo for a more extensive example.

路由

Routing

基础知识

The Basics

路由驱动着大多数网站。路由管理器(Router)是对“给定这个 URL,页面上应该显示什么?”这一问题的回答。

Routing drives most websites. A router is the answer to the question, “Given this URL, what should appear on the page?”

一个 URL 由许多部分组成。例如,URL https://my-cool-blog.com/blog/search?q=Search#results 包含:

A URL consists of many parts. For example, the URL https://my-cool-blog.com/blog/search?q=Search#results consists of

  • 一个 协议 (scheme)https

  • a scheme: https

  • 一个 域名 (domain)my-cool-blog.com

  • a domain: my-cool-blog.com

  • 一个 路径 (path)/blog/search

  • a path: /blog/search

  • 一个 查询 (query)(或 搜索 (search)):?q=Search

  • a query (or search): ?q=Search

  • 一个 哈希 (hash)#results

  • a hash: #results

Leptos 路由管理器处理路径和查询(/blog/search?q=Search)。给定这段 URL,应用程序应该在页面上渲染什么?

The Leptos Router works with the path and query (/blog/search?q=Search). Given this piece of the URL, what should the app render on the page?

核心理念

The Philosophy

在大多数情况下,路径应该驱动页面上显示的内容。从用户的角度来看,对于大多数应用程序,应用状态的大多数主要变化都应该反映在 URL 中。如果你复制并粘贴该 URL 并在另一个标签页中打开,你应该会发现自己或多或少处于同一个位置。

In most cases, the path should drive what is displayed on the page. From the user’s perspective, for most applications, most major changes in the state of the app should be reflected in the URL. If you copy and paste the URL and open it in another tab, you should find yourself more or less in the same place.

从这个意义上说,路由管理器实际上是你应用程序全局状态管理的核心。它比任何其他东西都更能驱动页面上显示的内容。

In this sense, the router is really at the heart of the global state management for your application. More than anything else, it drives what is displayed on the page.

路由管理器通过将当前位置映射到特定的组件,为你处理了大部分此类工作。

The router handles most of this work for you by mapping the current location to particular components.

定义路由

Defining Routes

入门

Getting Started

使用这个路由管理器入门很容易。

It’s easy to get started with the router.

首先,确保你已将 leptos_router 包添加到依赖项中。与 leptos 不同,它没有单独的 csrhydrate 特性;它确实有一个 ssr 特性,仅限在服务端使用,因此请在你的服务端构建中激活它。

First things first, make sure you’ve added the leptos_router package to your dependencies. Unlike leptos, this does not have separate csr and hydrate features; it does have an ssr feature, intended for use only on the server side, so activate that for your server-side build.

重要的是,路由管理器是一个独立于 leptos 本身的包。这意味着路由管理器中的所有内容都可以在用户层代码中定义。如果你想创建自己的路由管理器,或者不使用路由管理器,你完全可以自由地这样做!

It’s important that the router is a separate package from leptos itself. This means that everything in the router can be defined in user-land code. If you want to create your own router, or use no router, you’re completely free to do that!

并从路由管理器中导入相关的类型,可以像这样导入:

And import the relevant types from the router, either with something like

use leptos_router::components::{Router, Route, Routes};

提供 <Router/>

Providing the <Router/>

路由行为由 <Router/> 组件提供。它通常应该位于应用程序根目录附近,包裹住应用的其余部分。

Routing behavior is provided by the <Router/> component. This should usually be somewhere near the root of your application, wrapping the rest of the app.

你不应该在应用中尝试使用多个 <Router/>。请记住,路由管理器驱动全局状态:如果你有多个路由管理器,当 URL 改变时,由哪一个来决定要做什么呢?

You shouldn’t try to use multiple <Router/>s in your app. Remember that the router drives global state: if you have multiple routers, which one decides what to do when the URL changes?

让我们从一个使用路由管理器的简单 <App/> 组件开始:

Let’s start with a simple <App/> component using the router:

use leptos::prelude::*;
use leptos_router::components::Router;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          /* ... */
        </main>
      </Router>
    }
}

定义 <Routes/>

Defining <Routes/>

<Routes/> 组件是你定义用户在应用程序中可以导航到的所有路由的地方。每个可能的路由都由一个 <Route/> 组件定义。

The <Routes/> component is where you define all the routes to which a user can navigate in your application. Each possible route is defined by a <Route/> component.

你应该将 <Routes/> 组件放置在应用中你希望渲染路由的位置。<Routes/> 之外的所有内容都将显示在每个页面上,因此你可以将导航栏或菜单之类的东西留在 <Routes/> 之外。

You should place the <Routes/> component at the location within your app where you want routes to be rendered. Everything outside <Routes/> will be present on every page, so you can leave things like a navigation bar or menu outside the <Routes/>.

use leptos::prelude::*;
use leptos_router::components::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          // 我们所有的路由都会出现在 <main> 内部
          // all our routes will appear inside <main>
          <Routes fallback=|| "Not found.">
            /* ... */
          </Routes>
        </main>
      </Router>
    }
}

<Routes/> 还应该有一个 fallback,这是一个定义如果没有匹配到路由时应该显示什么的函数。

<Routes/> should also have a fallback, a function that defines what should be shown if no route is matched.

通过使用 <Route/> 组件为 <Routes/> 提供子组件来定义单个路由。<Route/> 接受一个 path 和一个 view。当当前位置匹配 path 时,view 将被创建并显示。

Individual routes are defined by providing children to <Routes/> with the <Route/> component. <Route/> takes a path and a view. When the current location matches path, the view will be created and displayed.

使用 path 宏可以最轻松地定义 path,它可以包括:

The path is most easily defined using the path macro, and can include

  • 静态路径(/users),

  • 以冒号开头的动态命名参数(/:id),

  • 以及/或者以星号开头的通配符(/user/*any

  • a static path (/users),

  • dynamic, named parameters beginning with a colon (/:id),

  • and/or a wildcard beginning with an asterisk (/user/*any)

view 是一个返回视图的函数。任何没有属性(props)的组件都可以在这里工作,返回某些视图的闭包也可以。

The view is a function that returns a view. Any component with no props works here, as does a closure that returns some 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。如果一个组件没有属性(props),它可以直接传递到 view 中。在这种情况下,view=Home 只是 view=|| view! { <Home/> } 的简写。

view takes a Fn() -> impl IntoView. If a component has no props, it can be passed directly into the view. In this case, view=Home is just a shorthand for view=|| view! { <Home/> }.

现在,如果你导航到 //users,你将获得主页或 <Users/>。如果你去 /users/3/blahblah,你将获得用户个人资料或你的 404 页面(<NotFound/>)。在每次导航时,路由管理器都会确定应该匹配哪个 <Route/>,从而确定在定义 <Routes/> 组件的位置应该显示什么内容。

Now if you navigate to / or to /users you’ll get the home page or the <Users/>. If you go to /users/3 or /blahblah you’ll get a user profile or your 404 page (<NotFound/>). On every navigation, the router determines which <Route/> should be matched, and therefore what content should be displayed where the <Routes/> component is defined.

足够简单吧?

Simple enough?

嵌套路由

Nested Routing

我们刚刚定义了下面这组路由:

We just defined the following set of 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。对于小型应用来说这没问题,但你可能已经预见到它的扩展性并不好。如果我们能嵌套这些路由,岂不是更好?

There’s a certain amount of duplication here: /users and /users/:id. This is fine for a small app, but you can probably already tell it won’t scale well. Wouldn’t it be nice if we could nest these routes?

事实上……你可以!

Well... you can!

<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/> 内部。看起来很直观。

You can nest a <Route/> inside a <ParentRoute/>. Seems straightforward.

但等等。我们刚刚微妙地改变了应用程序的行为。

But wait. We’ve just subtly changed what our application does.

接下来的部分是本指南整个路由章节中最重要的部分之一。请仔细阅读,如果有任何不理解的地方,欢迎随时提问。

The next section is one of the most important in this entire routing section of the guide. Read it carefully, and feel free to ask questions if there’s anything you don’t understand.

嵌套路由即布局

Nested Routes as Layout

嵌套路由是一种布局形式,而不仅仅是定义路由的方法。

Nested routes are a form of layout, not a method of route definition.

换句话说:定义嵌套路由的主要目的并不是为了在输入路由定义的路径时避免重复。它的实际作用是告诉路由管理器在页面上同时并排显示多个 <Route/>

Let me put that another way: The goal of defining nested routes is not primarily to avoid repeating yourself when typing out the paths in your route definitions. It is actually to tell the router to display multiple <Route/>s on the page at the same time, side by side.

让我们回顾一下之前的实际例子。

Let’s look back at our practical example.

<Routes fallback=|| "Not found.">
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
</Routes>

这意味着:

This means:

  • 如果我访问 /users,我会得到 <Users/> 组件。

  • If I go to /users, I get the <Users/> component.

  • 如果我访问 /users/3,我会得到 <UserProfile/> 组件(参数 id 被设置为 3;稍后会详细介绍)。

  • If I go to /users/3, I get the <UserProfile/> component (with the parameter id set to 3; more on that later)

假设我改用嵌套路由:

Let’s say I use nested routes instead:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
  </ParentRoute>
</Routes>

这意味着:

This means:

  • 如果我访问 /users/3,路径会匹配两个 <Route/><Users/><UserProfile/>

  • If I go to /users/3, the path matches two <Route/>s: <Users/> and <UserProfile/>.

  • 如果我访问 /users,路径将无法匹配。

  • If I go to /users, the path is not matched.

我实际上需要添加一个回退路由:

I actually need to add a fallback route

<Routes>
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
    <Route path=path!("") view=NoUser/>
  </ParentRoute>
</Routes>

现在:

Now:

  • 如果我访问 /users/3,路径匹配 <Users/><UserProfile/>

  • If I go to /users/3, the path matches <Users/> and <UserProfile/>.

  • 如果我访问 /users,路径匹配 <Users/><NoUser/>

  • If I go to /users, the path matches <Users/> and <NoUser/>.

换句话说,当我使用嵌套路由时,每个 路径(path) 可以匹配多个 路由(routes):每个 URL 可以在同一页面的同一时间渲染由多个 <Route/> 组件提供的视图。

When I use nested routes, in other words, each path can match multiple routes: each URL can render the views provided by multiple <Route/> components, at the same time, on the same page.

这可能违反直觉,但它非常强大,原因你很快就会看到。

This may be counter-intuitive, but it’s very powerful, for reasons you’ll hopefully see in a few minutes.

为什么要使用嵌套路由?

Why Nested Routing?

为什么要费心使用它呢?

Why bother with this?

大多数 Web 应用程序都包含对应于布局不同部分的导航层级。例如,在一个邮件应用中,你可能有一个像 /contacts/greg 这样的 URL,它在屏幕左侧显示联系人列表,在屏幕右侧显示 Greg 的联系人详情。联系人列表和联系人详情应该始终同时出现在屏幕上。如果没有选择联系人,也许你想显示一些说明文字。

Most web applications contain levels of navigation that correspond to different parts of the layout. For example, in an email app you might have a URL like /contacts/greg, which shows a list of contacts on the left of the screen, and contact details for Greg on the right of the screen. The contact list and the contact details should always appear on the screen at the same time. If there’s no contact selected, maybe you want to show a little instructional text.

你可以使用嵌套路由轻松定义这一点:

You can easily define this with nested routes

<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>

你甚至可以嵌套得更深。假设你想为每个联系人的地址、电子邮件/电话以及你与他们的对话设置标签页(tabs)。你可以在 :id 内部添加 另一组 嵌套路由:

You can go even deeper. Say you want to have tabs for each contact’s address, email/phone, and your conversations with them. You can add another set of nested routes inside :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 > an invoice。

The main page of the Remix website, a React framework from the creators of React Router, has a great visual example if you scroll down, with three levels of nested routing: Sales > Invoices > an invoice.

<Outlet/>

父路由不会自动渲染它们的嵌套路由。毕竟,它们只是组件;它们不知道应该在哪里渲染它们的子路由,“直接把它塞到父组件的末尾”并不是一个好的答案。

Parent routes do not automatically render their nested routes. After all, they are just components; they don’t know exactly where they should render their children, and “just stick it at the end of the parent component” is not a great answer.

相反,你需要通过 <Outlet/> 组件告诉父组件在哪里渲染任何嵌套组件。<Outlet/> 简单地渲染以下两件事之一:

Instead, you tell a parent component where to render any nested components with an <Outlet/> component. The <Outlet/> simply renders one of two things:

  • 如果没有匹配到嵌套路由,它什么也不显示。

  • if there is no nested route that has been matched, it shows nothing

  • 如果匹配到了嵌套路由,它显示该路由的 view

  • if there is a nested route that has been matched, it shows its view

就这么多!但这非常重要,务必记住,因为它是“为什么这不起作用?”这类挫败感的常见来源。如果你不提供 <Outlet/>,嵌套路由就不会被显示。

That’s all! But it’s important to know and to remember, because it’s a common source of “Why isn’t this working?” frustration. If you don’t provide an <Outlet/>, the nested route won’t be displayed.

#[component]
pub fn ContactList() -> impl IntoView {
  let contacts = todo!();

  view! {
    <div style="display: flex">
      // 联系人列表
      // the contact list
      <For each=contacts
        key=|contact| contact.id
        children=|contact| todo!()
      />
      // 嵌套子路由(如果有),
      // 别忘了这个!
      // the nested child, if any
      // don’t forget this!
      <Outlet/>
    </div>
  }
}

重构路由定义

Refactoring Route Definitions

如果你愿意,不需要在一个地方定义所有的路由。你可以将任何 <Route/> 及其子路由重构为单独的组件。

You don’t need to define all your routes in one place if you don’t want to. You can refactor any <Route/> and its children out into a separate component.

例如,你可以将上面的示例重构为使用两个独立的组件:

For example, you can refactor the example above to use two separate components:

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <Routes fallback=|| "Not found.">
          <ParentRoute path=path!("/contacts") view=ContactList>
            <ContactInfoRoutes/>
            <Route path=path!("") view=|| view! {
              <p>"Select a contact to view more info."</p>
            }/>
          </ParentRoute>
        </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()
    .into_any_nested_route()
}

第二个组件是 #[component(transparent)],这意味着它只返回数据,而不是视图;同样,它使用 .into_inner() 来移除 view 宏添加的一些调试信息,并仅返回由 <ParentRoute/> 创建的路由定义。

This second component is a #[component(transparent)], meaning it just returns its data, not a view; likewise, it uses .into_inner() to remove some debug info added by the view macro and just return the route definitions created by <ParentRoute/>.

嵌套路由与性能

Nested Routing and Performance

从概念上讲,所有这些都很棒,但再次强调——这有什么大不了的?

All of this is nice, conceptually, but again—what’s the big deal?

性能。

Performance.

在像 Leptos 这样细粒度响应式的库中,尽可能减少渲染工作量始终很重要。因为我们处理的是真实的 DOM 节点,而不是对虚拟 DOM 进行 diff,所以我们希望尽可能少地“重新渲染”组件。嵌套路由使这一点变得极其容易。

In a fine-grained reactive library like Leptos, it’s always important to do the least amount of rendering work you can. Because we’re working with real DOM nodes and not diffing a virtual DOM, we want to “rerender” components as infrequently as possible. Nested routing makes this extremely easy.

想象一下我的联系人列表例子。如果我从 Greg 导航到 Alice 再到 Bob,最后回到 Greg,联系人信息在每次导航时都需要更改。但是 <ContactList/> 永远不应该被重新渲染。这不仅节省了渲染性能,还保持了 UI 中的状态。例如,如果我在 <ContactList/> 的顶部有一个搜索栏,从 Greg 导航到 Alice 再到 Bob 不会清除搜索内容。

Imagine my contact list example. If I navigate from Greg to Alice to Bob and back to Greg, the contact information needs to change on each navigation. But the <ContactList/> should never be rerendered. Not only does this save on rendering performance, it also maintains state in the UI. For example, if I have a search bar at the top of <ContactList/>, navigating from Greg to Alice to Bob won’t clear the search.

事实上,在这种情况下,在联系人之间移动时,我们甚至不需要重新渲染 <Contact/> 组件。路由管理器只会在我们导航时响应式地更新 :id 参数,从而允许我们进行细粒度的更新。当我们在联系人之间导航时,我们将更新单个文本节点来更改联系人的姓名、地址等,而无需进行 任何 额外的重新渲染。

In fact, in this case, we don’t even need to rerender the <Contact/> component when moving between contacts. The router will just reactively update the :id parameter as we navigate, allowing us to make fine-grained updates. As we navigate between contacts, we’ll update single text nodes to change the contact’s name, address, and so on, without doing any additional rerendering.

这个沙盒包含本节和前一节讨论的一些特性(如嵌套路由),以及本章其余部分将涵盖的一些特性。路由是一个集成度很高的系统,因此提供一个完整的示例是有意义的,所以如果有什么你不理解的地方,不要感到惊讶。

This sandbox includes a couple features (like nested routing) discussed in this section and the previous one, and a couple we’ll cover in the rest of this chapter. The router is such an integrated system that it makes sense to provide a single example, so don’t be surprised if there’s anything you don’t understand.

:::admonish sandbox title="在线示例" collapsible=true

点击打开 CodeSandbox。

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>
            // 这个 <nav> 将显示在所有路由上,
            // 因为它在 <Routes/> 之外。
            // 注意:我们可以直接使用普通的 <a> 标签,
            // 路由管理器将使用客户端导航。
            // 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.">
                    // / 只有一个未嵌套的 "Home"
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果指定了 id,匹配对应的嵌套路由
                        // if id specified, matches nested routes
                        <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>
                        // 如果没有指定 id,回退到此
                        // 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/> 将显示嵌套的子路由。
            // 我们可以将此 outlet 放置在布局中的任何位置。
            // <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 {
    // 我们可以使用 `use_params_map` 响应式地访问 :id 参数
    // 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();

    // 假设我们在这里从 API 加载数据
    // 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/> 是嵌套在 /contacts/:id 路由下的标签页
            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

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

参数与查询参数

Params and Queries

静态路径对于区分不同页面非常有用,但几乎每个应用程序在某些时候都需要通过 URL 传递数据。

Static paths are useful for distinguishing between different pages, but almost every application wants to pass data through the URL at some point.

你可以通过两种方式实现:

There are two ways you can do this:

  1. 命名路由 参数 (params),如 /users/:id 中的 id

  2. named route params like id in /users/:id

  3. 命名路由 查询参数 (queries),如 /search?q=Foo 中的 q

  4. named route queries like q in /search?q=Foo

由于 URL 的构建方式,你可以从 任何 <Route/> 视图访问查询参数。而路由参数则可以从定义它们的 <Route/> 及其任何嵌套子路由中访问。

Because of the way URLs are built, you can access the query from any <Route/> view. You can access route params from the <Route/> that defines them or any of its nested children.

使用几个钩子(hooks)访问参数和查询参数非常简单:

Accessing params and queries is pretty simple with a couple of hooks:

它们每一个都包含一个强类型选项(use_queryuse_params)和一个无类型选项(use_query_mapuse_params_map)。

Each of these comes with a typed option (use_query and use_params) and an untyped option (use_query_map and use_params_map).

无类型版本持有一个简单的键值对映射。要使用强类型版本,请在结构体上派生 Params trait。

The untyped versions hold a simple key-value map. To use the typed versions, derive the Params trait on a struct.

Params 是一个非常轻量级的 trait,通过对每个字段应用 FromStr,将字符串的扁平键值对映射转换为结构体。由于路由参数和 URL 查询参数的扁平结构,它的灵活性远不如像 serde 之类的库;但它为你的二进制文件增加的负担也小得多。

Params is a very lightweight trait to convert a flat key-value map of strings into a struct by applying FromStr to each field. Because of the flat structure of route params and URL queries, it’s significantly less flexible than something like serde; it also adds much less weight to your binary.

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

使用 stable 版本时,你只能在参数中使用 Option<T>。如果你使用了 nightly 特性,则可以使用 TOption<T>

Note: The Params derive macro is located at leptos_router::params::Params.

Using stable, you can only use Option<T> in params. If you are using the nightly feature, you can use either T or Option<T>.

现在我们可以在组件中使用它们了。想象一个既有参数又有查询参数的 URL,例如 /contacts/:id?q=Search

Now we can use them in a component. Imagine a URL that has both params and a query, like /contacts/:id?q=Search.

强类型版本返回 Memo<Result<T, _>>。它是一个 Memo,因此它会响应 URL 的变化。它是一个 Result,因为参数或查询参数需要从 URL 解析,结果可能有效也可能无效。

The typed versions return Memo<Result<T, _>>. It’s a Memo so it reacts to changes in the URL. It’s a Result because the params or query need to be parsed from the URL, and may or may not be valid.

use leptos_router::hooks::{use_params, use_query};

let params = use_params::<ContactParams>();
let query = use_query::<ContactSearch>();

// id: || -> usize
// 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>

The untyped versions return Memo<ParamsMap>. Again, it’s a Memo to react to changes in the URL. ParamsMap behaves a lot like any other map type, with a .get() method that returns 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>
// id: || -> Option<String>
let id = move || params.read().get("id");

这可能会变得有点麻烦:导出一个包装了 Option<_>Result<_> 的信号可能需要几个步骤。但这样做是值得的,原因有二:

This can get a little messy: deriving a signal that wraps an Option<_> or Result<_> can involve a couple steps. But it’s worth doing this for two reasons:

  1. 它是正确的,也就是说,它迫使你考虑这些情况:“如果用户没有为这个查询字段传递值怎么办?如果他们传递了一个无效的值怎么办?”

  2. It’s correct, i.e., it forces you to consider the cases, “What if the user doesn’t pass a value for this query field? What if they pass an invalid value?”

  3. 它是高性能的。具体来说,当你在匹配同一个 <Route/> 的不同路径之间导航,且只有参数或查询参数发生变化时,你可以对应用程序的不同部分进行细粒度更新,而无需重新渲染。例如,在我们的联系人列表示例中,在不同联系人之间导航会对姓名栏(以及最终的联系人信息)进行定向更新,而无需替换或重新渲染包装它的 <Contact/>。这就是细粒度响应式的意义所在。

  4. It’s performant. Specifically, when you navigate between different paths that match the same <Route/> with only params or the query changing, you can get fine-grained updates to different parts of your app without rerendering. For example, navigating between different contacts in our contact-list example does a targeted update to the name field (and eventually contact info) without needing to replace or rerender the wrapping <Contact/>. This is what fine-grained reactivity is for.

这是上一节中的同一个示例。路由管理器是一个集成度很高的系统,因此提供一个突出多个特性的单一示例是有意义的,即使我们还没有解释完所有特性。

This is the same example from the previous section. The router is such an integrated system that it makes sense to provide a single example highlighting multiple features, even if we haven’t explained them all yet.

:::admonish sandbox title="在线示例" collapsible=true

点击打开 CodeSandbox。

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>
            // 这个 <nav> 将显示在所有路由上,
            // 因为它在 <Routes/> 之外
            // this <nav> will show on every routes,
            // because it's outside the <Routes/>
            // 注意:我们可以直接使用普通的 <a> 标签
            // 路由管理器将使用客户端导航
            // 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.">
                    // / 只有一个未嵌套的 "Home"
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果没有指定 id,回退
                        // 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>
                        // 如果没有指定 id,回退
                        // 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/> 将显示嵌套的子路由
            // 我们可以将此 outlet 放置在布局中的任何位置
            // <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 {
    // 我们可以使用 `use_params_map` 响应式地访问 :id 参数
    // 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();

    // 想象我们在这里从 API 加载数据
    // 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/> 是嵌套在
            // /contacts/:id 路由下的标签页
            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

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

<A/> 组件

The <A/> Component

使用普通的 HTML <a> 元素,客户端导航也能完美运行。路由管理器添加了一个监听器,用于处理对 <a> 元素的每一次点击,并尝试在客户端处理它,即无需再次往返服务器请求 HTML。这就是让你在大多数现代 Web 应用中倍感亲切的、迅速的“单页应用”导航。

Client-side navigation works perfectly fine with ordinary HTML <a> elements. The router adds a listener that handles every click on a <a> element and tries to handle it on the client side, i.e., without doing another round trip to the server to request HTML. This is what enables the snappy “single-page app” navigations you’re probably familiar with from most modern web apps.

在以下几种情况下,路由管理器会放弃处理 <a> 点击:

The router will bail out of handling an <a> click under a number of situations

  • 点击事件已调用 prevent_default()

  • the click event has had prevent_default() called on it

  • 点击时按住了 MetaAltCtrlShift

  • the Meta, Alt, Ctrl, or Shift keys were held during click

  • <a> 具有 targetdownload 属性,或者 rel="external"

  • the <a> has a target or download attribute, or rel="external"

  • 链接的源(origin)与当前位置不同

  • the link has a different origin from the current location

换句话说,路由管理器只有在非常确定能够处理时才会尝试进行客户端导航,并且它会升级每一个 <a> 元素以获得这种特殊行为。

In other words, the router will only try to do a client-side navigation when it’s pretty sure it can handle it, and it will upgrade every <a> element to get this special behavior.

这也意味着如果你需要退出客户端路由,可以很容易地做到。例如,如果你有一个指向同一域名下但不属于 Leptos 应用的其他页面的链接,你只需使用 <a rel="external"> 来告诉路由管理器这不是它能处理的东西。

This also means that if you need to opt out of client-side routing, you can do so easily. For example, if you have a link to another page on the same domain, but which isn’t part of your Leptos app, you can just use <a rel="external"> to tell the router it isn’t something it can handle.

路由管理器还提供了一个 <A> 组件,它额外做了两件事:

The router also provides an <A> component, which does two additional things:

  1. 正确解析相对嵌套路由。使用普通的 <a> 标签进行相对路由可能会很棘手。例如,如果你有一个像 /post/:id 这样的路由,<A href="1"> 会生成正确的相对路由,但 <a href="1"> 很可能不会(取决于它在视图中出现的位置)。<A/> 会相对于它所在的嵌套路由路径来解析路由。

  2. Correctly resolves relative nested routes. Relative routing with ordinary <a> tags can be tricky. For example, if you have a route like /post/:id, <A href="1"> will generate the correct relative route, but <a href="1"> likely will not (depending on where it appears in your view.) <A/> resolves routes relative to the path of the nested route within which it appears.

  3. 如果此链接是当前活动链接(即指向你当前所在页面的链接),则将 aria-current 属性设置为 page。这对于可访问性和样式设置非常有帮助。例如,如果你想在链接指向当前页面时将其设置为不同的颜色,你可以使用 CSS 选择器匹配此属性。

  4. Sets the aria-current attribute to page if this link is the active link (i.e., it’s a link to the page you’re on). This is helpful for accessibility and for styling. For example, if you want to set the link a different color if it’s a link to the page you’re currently on, you can match this attribute with a CSS selector.

以编程方式导航

在页面之间导航最常用的方法应该是使用 <a><form> 元素,或者使用增强的 <A/><Form/> 组件。使用链接和表单进行导航是实现可访问性和平稳退化的最佳方案。

Your most-used methods of navigating between pages should be with <a> and <form> elements or with the enhanced <A/> and <Form/> components. Using links and forms to navigate is the best solution for accessibility and graceful degradation.

不过,有时你可能想以编程方式导航,即调用一个可以导航到新页面的函数。在这种情况下,你应该使用 use_navigate 函数。

On occasion, though, you’ll want to navigate programmatically, i.e., call a function that can navigate to a new page. In that case, you should use the use_navigate function.

let navigate = leptos_router::hooks::use_navigate();
navigate("/somewhere", Default::default());

你几乎永远不应该做类似 <button on:click=move |_| navigate(/* ... */)> 这样的事情。出于可访问性的原因,任何触发导航的 on:click 都应该是一个 <a>

You should almost never do something like <button on:click=move |_| navigate(/* ... */)>. Any on:click that navigates should be an <a>, for reasons of accessibility.

这里的第二个参数是一组 NavigateOptions,其中包括像 <A/> 组件那样相对于当前路由解析导航、在导航栈中替换当前记录、包含一些导航状态以及在导航时保持当前滚动状态等选项。

The second argument here is a set of NavigateOptions, which includes options to resolve the navigation relative to the current route as the <A/> component does, replace it in the navigation stack, include some navigation state, and maintain the current scroll state on navigation.

再次强调,这是同一个示例。请查看相对路径的 <A/> 组件,并查看 index.html 中的 CSS 以了解基于 ARIA 的样式设置。

Once again, this is the same example. Check out the relative <A/> components, and take a look at the CSS in index.html to see the ARIA-based styling.

:::admonish sandbox title="在线示例" collapsible=true

点击打开 CodeSandbox。

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>
            // 此 <nav> 将显示在每个路由上,
            // 因为它在 <Routes/> 之外
            // this <nav> will show on every route,
            // because it's outside the <Routes/>
            // 注意:我们可以直接使用普通的 <a> 标签
            // 路由管理器将使用客户端导航
            // 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.">
                    // / 只有一个未嵌套的 "Home"
                    // / just has an un-nested "Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    // /contacts has nested routes
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果未指定 id,则回退
                        // 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>
                        // 如果未指定 id,则回退
                        // 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/> 将显示嵌套的子路由
            // 我们可以将此 outlet 放置在布局中的任何位置
            // <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 {
    // 我们可以使用 `use_params_map` 响应式地访问 :id 参数
    // 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();

    // 假设我们在这里从 API 加载数据
    // 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/> 是嵌套在
            // /contacts/:id 路由下的标签页
            // <Outlet/> here is the tabs that are nested
            // underneath the /contacts/:id route
            <Outlet/>
        </div>
    }
}

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

<Form/> 组件

The <Form/> Component

链接和表单有时看起来完全不相关。但事实上,它们的工作方式非常相似。

Links and forms sometimes seem completely unrelated. But, in fact, they work in very similar ways.

在纯 HTML 中,有三种导航到另一个页面的方式:

In plain HTML, there are three ways to navigate to another page:

  1. 一个链接到另一个页面的 <a> 元素:使用 GET HTTP 方法导航到其 href 属性中的 URL。

  2. An <a> element that links to another page: Navigates to the URL in its href attribute with the GET HTTP method.

  3. 一个 <form method="GET">:使用 GET HTTP 方法导航到其 action 属性中的 URL,并将来自输入框的表单数据编码在 URL 查询字符串中。

  4. A <form method="GET">: Navigates to the URL in its action attribute with the GET HTTP method and the form data from its inputs encoded in the URL query string.

  5. 一个 <form method="POST">:使用 POST HTTP 方法导航到其 action 属性中的 URL,并将来自输入框的表单数据编码在请求体(body)中。

  6. A <form method="POST">: Navigates to the URL in its action attribute with the POST HTTP method and the form data from its inputs encoded in the body of the request.

既然我们有了客户端路由管理器,我们就可以在不重新加载页面的情况下进行客户端链接导航,也就是说,不需要服务器与客户端之间的完整往返。同理,我们也可以用同样的方式进行客户端表单导航。

Since we have a client-side router, we can do client-side link navigations without reloading the page, i.e., without a full round-trip to the server and back. It makes sense that we can do client-side form navigations in the same way.

路由管理器提供了一个 <Form> 组件,它的工作方式类似于 HTML 的 <form> 元素,但使用客户端导航而不是完整的页面刷新。<Form/> 同时支持 GETPOST 请求。使用 method="GET" 时,它将导航到编码在表单数据中的 URL。使用 method="POST" 时,它将发起一个 POST 请求并处理服务器的响应。

The router provides a <Form> component, which works like the HTML <form> element, but uses client-side navigations instead of full page reloads. <Form/> works with both GET and POST requests. With method="GET", it will navigate to the URL encoded in the form data. With method="POST" it will make a POST request and handle the server’s response.

<Form/> 为我们将在后续章节中看到的 <ActionForm/><MultiActionForm/> 等组件奠定了基础。但它本身也支持一些强大的模式。

<Form/> provides the basis for some components like <ActionForm/> and <MultiActionForm/> that we’ll see in later chapters. But it also enables some powerful patterns of its own.

例如,假设你想创建一个搜索框,它在用户搜索时实时更新搜索结果,无需重新加载页面,但同时也将搜索内容存储在 URL 中,以便用户可以复制并粘贴链接与他人分享结果。

For example, imagine that you want to create a search field that updates search results in real time as the user searches, without a page reload, but that also stores the search in the URL so a user can copy and paste it to share results with someone else.

事实证明,我们到目前为止学到的模式使得实现这一点非常容易。

It turns out that the patterns we’ve learned so far make this easy to implement.

async fn fetch_results() {
    // 一些用于获取搜索结果的异步函数
    // some async function to fetch our search results
}

#[component]
pub fn FormExample() -> impl IntoView {
    // 响应式地访问 URL 查询字符串
    // reactive access to URL query strings
    let query = use_query_map();
    // 搜索内容存储为 ?q=
    // search stored as ?q=
    let search = move || query.read().get("q").unwrap_or_default();
    // 由搜索字符串驱动的资源(Resource)
    // a resource driven by the search string
    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 || ()>
            /* 渲染搜索结果 */
            /* render search results */
            {todo!()}
        </Transition>
    }
}

每当你点击“提交(Submit)”时,<Form/> 都会“导航”到 ?q={search}。但由于这种导航是在客户端完成的,所以不会有页面闪烁或重新加载。URL 查询字符串发生了变化,这触发了 search 的更新。因为 searchsearch_results 资源的源信号,这会触发 search_results 重新加载其资源。在加载新结果期间,<Transition/> 会继续显示当前的搜索结果。加载完成后,它会切换到显示新结果。

Whenever you click Submit, the <Form/> will “navigate” to ?q={search}. But because this navigation is done on the client side, there’s no page flicker or reload. The URL query string changes, which triggers search to update. Because search is the source signal for the search_results resource, this triggers search_results to reload its resource. The <Transition/> continues displaying the current search results until the new ones have loaded. When they are complete, it switches to displaying the new result.

这是一个很棒的模式。数据流极其清晰:所有数据从 URL 流向资源,最后进入 UI。应用程序的当前状态存储在 URL 中,这意味着你可以刷新页面或将链接发给朋友,它会完全按照你的预期显示。一旦我们引入服务器端渲染,这个模式也将被证明具有很强的容错性:因为它在底层使用了 <form> 元素和 URL,所以即使在客户端没有加载 WASM 的情况下,它也能很好地工作。

This is a great pattern. The data flow is extremely clear: all data flows from the URL to the resource into the UI. The current state of the application is stored in the URL, which means you can refresh the page or text the link to a friend and it will show exactly what you’re expecting. And once we introduce server rendering, this pattern will prove to be really fault-tolerant, too: because it uses a <form> element and URLs under the hood, it actually works really well without even loading your WASM on the client.

实际上,我们可以更进一步,做一些巧妙的处理:

We can actually take it a step further and do something kind of clever:

view! {
	<Form method="GET" action="">
		<input type="search" name="q" value=search
			oninput="this.form.requestSubmit()"
		/>
	</Form>
}

你会注意到这个版本去掉了“提交(Submit)”按钮。相反,我们在输入框中添加了一个 oninput 属性。请注意,这 不是 on:input,后者会监听 input 事件并运行 Rust 代码。没有冒号的 oninput 是纯 HTML 属性。所以该字符串实际上是一个 JavaScript 字符串。this.form 为我们提供了输入框所属的表单。requestSubmit() 会在 <form> 上触发 submit 事件,这会被 <Form/> 捕获,就像我们点击了“提交”按钮一样。现在,表单会在每次按键或输入时“导航”,从而使 URL(以及搜索内容)与用户输入的内容保持完美同步。

You’ll notice that this version drops the Submit button. Instead, we add an oninput attribute to the input. Note that this is not on:input, which would listen for the input event and run some Rust code. Without the colon, oninput is the plain HTML attribute. So the string is actually a JavaScript string. this.form gives us the form the input is attached to. requestSubmit() fires the submit event on the <form>, which is caught by <Form/> just as if we had clicked a Submit button. Now the form will “navigate” on every keystroke or input to keep the URL (and therefore the search) perfectly in sync with the user’s input as they type.

:::admonish sandbox title="在线示例" collapsible=true

点击打开 CodeSandbox。

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 {
    // 响应式访问 URL 查询参数
    // 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! {
        // 读取出 URL 查询字符串
        // 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/> 都会进行导航
        // <Form/> will navigate whenever submitted
        <h2>"手动提交"</h2>
        // <h2>"Manual Submission"</h2>
        <Form method="GET" action="">
            // input 的 name 决定了查询字符串的键(key)
            // input names determine query string key
            <input type="text" name="name" value=name/>
            <input type="number" name="number" value=number/>
            <select name="select">
                // `selected` 将设置起始选中状态
                // `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>
        // 这个 <Form/> 使用了一些 JavaScript 来在每次输入时提交
        // This <Form/> uses some JavaScript to submit
        // on every input
        <h2>"自动提交"</h2>
        // <h2>"Automatic Submission"</h2>
        <Form method="GET" action="">
            <input
                type="text"
                name="name"
                value=name
                // 这个 oninput 属性会导致表单在每次字段输入时提交
                // 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)
}

插曲:样式设计

Interlude: Styling

任何创建网站或应用的人很快都会遇到样式设计的问题。对于小型应用,单个 CSS 文件可能足以支撑用户界面的样式。但随着应用规模的扩大,许多开发者发现原生 CSS 变得越来越难以管理。

Anyone creating a website or application soon runs into the question of styling. For a small app, a single CSS file is probably plenty to style your user interface. But as an application grows, many developers find that plain CSS becomes increasingly hard to manage.

一些前端框架(如 Angular、Vue 和 Svelte)提供了内置的方法来将 CSS 限定在特定组件的作用域内,从而更容易在整个应用中管理样式,而不会让旨在修改一个小组件的样式产生全局影响。其他框架(如 React 或 Solid)不提供内置的 CSS 作用域化功能,而是依赖生态系统中的库来完成。Leptos 属于后者:框架本身对 CSS 没有任何倾向,但提供了一些工具和原语,允许其他人构建样式库。

Some frontend frameworks (like Angular, Vue, and Svelte) provide built-in ways to scope your CSS to particular components, making it easier to manage styles across a whole application without styles meant to modify one small component having a global effect. Other frameworks (like React or Solid) don’t provide built-in CSS scoping, but rely on libraries in the ecosystem to do it for them. Leptos is in this latter camp: the framework itself has no opinions about CSS at all, but provides a few tools and primitives that allow others to build styling libraries.

这里有几种不同的 Leptos 应用样式设计方法,从原生 CSS 开始。

Here are a few different approaches to styling your Leptos app, starting with plain CSS.

原生 CSS

Plain CSS

使用 Trunk 进行客户端渲染

Client-Side Rendering with Trunk

trunk 可用于将 CSS 文件和图像与你的网站捆绑在一起。为此,你可以通过在 index.html<head> 中定义它们,将其作为 Trunk 资源添加。例如,要添加位于 style.css 的 CSS 文件,你可以添加标签 <link data-trunk rel="css" href="./style.css"/>

trunk can be used to bundle CSS files and images with your site. To do this, you can add them as Trunk assets by defining them in your index.html in the <head>. For example, to add a CSS file located at style.css you can add the tag <link data-trunk rel="css" href="./style.css"/>.

你可以在 Trunk 文档关于 资产(assets) 的部分找到更多信息。

You can find more information in the Trunk documentation for assets.

使用 cargo-leptos 进行服务端渲染

Server-Side Rendering with cargo-leptos

cargo-leptos 模板默认配置为使用 SASS 捆绑 CSS 文件,并将其输出到 /pkg/{project_name}.css。如果你想加载额外的 CSS 文件,既可以将它们导入到该 style.scss 文件中,也可以将它们添加到 public 目录中。(例如,public/foo.css 处的文件将通过 /foo.css 提供服务。)

The cargo-leptos templates are configured by default to use SASS to bundle CSS files and output them at /pkg/{project_name}.css. If you want to load additional CSS files, you can do so either by importing them into that style.scss file, or by adding them to the public directory. (A file at public/foo.css, for example, is served at /foo.css.)

要在组件中加载样式表,你可以使用 Stylesheet 组件。

To load stylesheets in a component, you can use the Stylesheet component.

TailwindCSS:实用程序优先的 CSS

TailwindCSS: Utility-first CSS

TailwindCSS 是一个流行的实用程序优先(utility-first)的 CSS 库。它允许你通过使用内联实用程序类来设计应用样式,并配合一个自定义的 CLI 工具扫描你的文件中的 Tailwind 类名,然后捆绑必要的 CSS。

TailwindCSS is a popular utility-first CSS library. It allows you to style your application by using inline utility classes, with a custom CLI tool that scans your files for Tailwind class names and bundles the necessary CSS.

这允许你编写如下组件:

This allows you to write components like this:

#[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 集成可能会有点复杂,但你可以查看我们的两个示例,了解如何将 Tailwind 与 客户端渲染的 trunk 应用服务端渲染的 cargo-leptos 应用 配合使用。cargo-leptos 还有一些 内置的 Tailwind 支持,你可以将其作为 Tailwind CLI 的替代方案。

It can be a little complicated to set up the Tailwind integration at first, but you can check out our two examples of how to use Tailwind with a client-side-rendered trunk application or with a server-rendered cargo-leptos application. cargo-leptos also has some built-in Tailwind support that you can use as an alternative to Tailwind’s CLI.

Stylers:编译时 CSS 提取

Stylers: Compile-time CSS Extraction

Stylers 是一个编译时作用域 CSS 库,允许你在组件主体中声明作用域 CSS。Stylers 会在编译时将这些 CSS 提取到 CSS 文件中,然后你可以将其导入到应用中,这意味着它不会增加应用的 WASM 二进制文件大小。

Stylers is a compile-time scoped CSS library that lets you declare scoped CSS in the body of your component. Stylers will extract this CSS at compile time into CSS files that you can then import into your app, which means that it doesn’t add anything to the WASM binary size of your application.

这允许你编写如下组件:

This allows you to write components like this:

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 文件中编写作用域 CSS

Stylance: Scoped CSS Written in CSS Files

Stylers 让你在 Rust 代码中内联编写 CSS,在编译时提取它并对其进行作用域化。Stylance 允许你在组件旁边的 CSS 文件中编写 CSS,将这些文件导入到组件中,并将 CSS 类限定在你的组件作用域内。

Stylers lets you write CSS inline in your Rust code, extracts it at compile time, and scopes it. Stylance allows you to write your CSS in CSS files alongside your components, import those files into your components, and scope the CSS classes to your components.

这与 trunkcargo-leptos 的热重载功能配合得很好,因为编辑后的 CSS 文件可以立即在浏览器中更新。

This works well with the live-reloading features of trunk and cargo-leptos because edited CSS files can be updated immediately in the browser.

import_style!(style, "app.module.scss");

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <div class=style::jumbotron/>
    }
}

你可以直接编辑 CSS,而不会触发 Rust 重新编译。

You can edit the CSS directly without causing a Rust recompile.

.jumbotron {
  background: blue;
}

Styled:运行时 CSS 作用域化

Styled: Runtime CSS Scoping

Styled 是一个与 Leptos 集成良好的运行时作用域 CSS 库。它允许你在组件函数体中声明作用域 CSS,然后在运行时应用这些样式。

Styled is a runtime scoped CSS library that integrates well with Leptos. It lets you declare scoped CSS in the body of your component function, and then applies those styles at runtime.

use styled::style;

#[component]
pub fn MyComponent() -> impl IntoView {
    let styles = style!(
      div {
        background-color: red;
        color: white;
      }
    );

    styled::view! { styles,
        <div>"This text should be red with white text."</div>
    }
}

欢迎贡献

Contributions Welcome

Leptos 对于你如何设计网站或应用的样式没有任何倾向,但我们非常乐意为你尝试创建的任何旨在简化此过程的工具提供支持。如果你正在开发一种 CSS 或样式处理方法,并希望将其添加到此列表中,请告诉我们!

Leptos has no opinions on how you style your website or app, but we’re very happy to provide support to any tools you’re trying to create to make it easier. If you’re working on a CSS or styling approach that you’d like to add to this list, please let us know!

元数据

Metadata

到目前为止,我们渲染的所有内容都在 HTML 文档的 <body> 内。这合情合理,毕竟你在网页上看到的所有内容都位于 <body> 之中。

So far, everything we’ve rendered has been inside the <body> of the HTML document. And this makes sense. After all, everything you can see on a web page lives inside the <body>.

然而,在很多情况下,你可能希望使用与 UI 相同的响应式原语和组件模式来更新文档 <head> 中的某些内容。

However, there are plenty of occasions where you might want to update something inside the <head> of the document using the same reactive primitives and component patterns you use for your UI.

这就是 leptos_meta 包的用武之地。

That’s where the leptos_meta package comes in.

元数据组件

Metadata Components

leptos_meta 提供了特殊的组件,允许你从应用中任何组件内部向 <head> 注入数据:

leptos_meta provides special components that let you inject data from inside components anywhere in your application into the <head>:

<Title/> 允许你从任何组件设置文档的标题。它还接收一个 formatter 函数,可用于对其他页面设置的标题应用相同的格式。例如,如果你在 <App/> 组件中放入 <Title formatter=|text| format!("{text} — 我的超赞网站")/>,然后在你的路由组件中放入 <Title text="页面 1"/><Title text="页面 2"/>,你将得到 页面 1 — 我的超赞网站页面 2 — 我的超赞网站

<Title/> allows you to set the document’s title from any component. It also takes a formatter function that can be used to apply the same format to the title set by other pages. So, for example, if you put <Title formatter=|text| format!("{text} — My Awesome Site")/> in your <App/> component, and then <Title text="Page 1"/> and <Title text="Page 2"/> on your routes, you’ll get Page 1 — My Awesome Site and Page 2 — My Awesome Site.

<Link/><head> 注入一个 <link> 元素。

<Link/> injects a <link> element into the <head>.

<Stylesheet/> 使用你提供的 href 创建一个 <link rel="stylesheet">

<Stylesheet/> creates a <link rel="stylesheet"> with the href you give.

<Style/> 使用你传入的子内容(通常是字符串)创建一个 <style>。你可以使用它在编译时从另一个文件导入自定义 CSS:<Style>{include_str!("my_route.css")}</Style>

<Style/> creates a <style> with the children you pass in (usually a string). You can use this to import some custom CSS from another file at compile time <Style>{include_str!("my_route.css")}</Style>.

<Meta/> 允许你设置带有描述和其他元数据的 <meta> 标签。

<Meta/> lets you set <meta> tags with descriptions and other metadata.

警告

注意:这些组件应该在应用的主体部分(即某个组件内部)使用。它们不应该直接用在 <head> 中(例如,如果你正在使用服务端渲染)。你应该直接使用相应的 HTML 元素,而不是将 leptos_meta 组件放入 <head>

<Script/><script>

<Script/> and <script>

leptos_meta 还提供了一个 <Script/> 组件,这里值得停下来稍作说明。我们考虑过的所有其他组件都是向 <head> 注入仅限 <head> 使用的元素。但 <script> 也可以包含在 body 中。

leptos_meta also provides a <Script/> component, and it’s worth pausing here for a second. All of the other components we’ve considered inject <head>-only elements in the <head>. But a <script> can also be included in the body.

有一个非常简单的方法来确定你应该使用大写 S 的 <Script/> 组件还是小写 s 的 <script> 元素:<Script/> 组件将在 <head> 中渲染,而 <script> 元素将渲染在你用户界面 <body> 中放置它的位置,与其他普通 HTML 元素并列。这两者会导致 JavaScript 在不同的时间加载和运行,因此请根据你的需要选择合适的一个。

There’s a very simple way to determine whether you should use a capital-S <Script/> component or a lowercase-s <script> element: the <Script/> component will be rendered in the <head>, and the <script> element will be rendered wherever in the <body> of your user interface you put it in, alongside other normal HTML elements. These cause JavaScript to load and run at different times, so use whichever is appropriate to your needs.

<Body/><Html/>

<Body/> and <Html/>

甚至还有几个旨在简化语义化 HTML 和样式设计的元素。<Body/><Html/> 旨在允许你向页面上的 <html><body> 标签添加任意属性。你可以使用展开运算符({..})后的常规 Leptos 语法添加任意数量的属性,这些属性将直接添加到相应的元素中。

There are even a couple elements designed to make semantic HTML and styling easier. <Body/> and <Html/> are designed to allow you to add arbitrary attributes to the <html> and <body> tags on your page. You can add any number of attributes using the usual Leptos syntax after the spread operator ({..}) and those will be added directly to the appropriate element.

<Html
    {..}
    lang="he"
    dir="rtl"
    data-theme="dark"
/>

元数据与服务端渲染

Metadata and Server Rendering

现在,其中一些功能在任何场景下都很有用,但有些对于搜索引擎优化(SEO)尤为重要。确保你有合适的 <title><meta> 标签至关重要。现代搜索引擎爬虫确实可以处理客户端渲染,即以空的 index.html 发送并完全在 JS/WASM 中渲染的应用。但它们更希望接收到应用已被渲染为实际 HTML 且 <head> 中包含元数据的页面。

Now, some of this is useful in any scenario, but some of it is especially important for search-engine optimization (SEO). Making sure you have things like appropriate <title> and <meta> tags is crucial. Modern search engine crawlers do handle client-side rendering, i.e., apps that are shipped as an empty index.html and rendered entirely in JS/WASM. But they prefer to receive pages in which your app has been rendered to actual HTML, with metadata in the <head>.

这正是 leptos_meta 的用途。事实上,在服务端渲染期间,它所做的正是:收集你在整个应用中使用其组件声明的所有 <head> 内容,然后将其注入到实际的 <head> 中。

This is exactly what leptos_meta is for. And in fact, during server rendering, this is exactly what it does: collect all the <head> content you’ve declared by using its components throughout your application, and then inject it into the actual <head>.

不过我扯远了。我们还没真正讨论过服务端渲染。下一章将讨论与 JavaScript 库的集成。然后我们将结束对客户端的讨论,并转向服务端渲染。

But I’m getting ahead of myself. We haven’t actually talked about server-side rendering yet. The next chapter will talk about integrating with JavaScript libraries. Then we’ll wrap up the discussion of the client side, and move onto server side rendering.

与 JavaScript 集成:wasm-bindgen、web_sys 和 HtmlElement

Integrating with JavaScript: wasm-bindgen, web_sys and HtmlElement

Leptos 提供了各种工具,让你无需离开框架的世界就能构建声明式 Web 应用程序。诸如响应式系统、componentview 宏以及路由(router)等功能,让你无需直接与浏览器提供的 Web API 交互就能构建用户界面。而且它们让你直接在 Rust 中完成这一切,这非常棒——假设你喜欢 Rust。(既然你已经读到了本书的这一部分,我们假设你是喜欢 Rust 的。)

Leptos provides a variety of tools to allow you to build declarative web applications without leaving the world of the framework. Things like the reactive system, component and view macros, and router allow you to build user interfaces without directly interacting with the Web APIs provided by the browser. And they let you do it all directly in Rust, which is great—assuming you like Rust. (And if you’ve gotten this far in the book, we assume you like Rust.)

生态系统中的 crate,例如 leptos-use 提供的极佳实用工具集,可以通过为许多 Web API 提供 Leptos 特有的响应式封装,带你走得更远。

Ecosystem crates like the fantastic set of utilities provided by leptos-use can take you even further, by providing Leptos-specific reactive wrappers around many Web APIs.

尽管如此,在许多情况下,你仍需要直接访问 JavaScript 库或 Web API。本章可以提供帮助。

Nevertheless, in many cases you will need to access JavaScript libraries or Web APIs directly. This chapter can help.

使用 wasm-bindgen 调用 JS 库

Using JS Libraries with wasm-bindgen

你的 Rust 代码可以编译为 WebAssembly (WASM) 模块并加载到浏览器中运行。然而,WASM 无法直接访问浏览器 API。相反,Rust/WASM 生态系统依赖于从你的 Rust 代码生成绑定(bindings)到托管它的 JavaScript 浏览器环境。

Your Rust code can be compiled to a WebAssembly (WASM) module and loaded to run in the browser. However, WASM does not have direct access to browser APIs. Instead, the Rust/WASM ecosystem depends on generating bindings from your Rust code to the JavaScript browser environment that hosts it.

wasm-bindgen crate 是该生态系统的核心。它提供了一个接口,用于使用注解标记 Rust 代码的部分,告诉它如何调用 JS,并提供了一个 CLI 工具用于生成必要的 JS 粘合代码。你其实一直在不知不觉中使用它:trunkcargo-leptos 底层都依赖于 wasm-bindgen

The wasm-bindgen crate is at the center of that ecosystem. It provides both an interface for marking parts of Rust code with annotations telling it how to call JS, and a CLI tool for generating the necessary JS glue code. You’ve been using this without knowing it all along: both trunk and cargo-leptos rely on wasm-bindgen under the hood.

如果你想从 Rust 调用某个 JavaScript 库,你应该参考 wasm-bindgen 文档中关于从 JS 导入函数的部分。将单个函数、类或值从 JavaScript 导入到你的 Rust 应用程序中相对容易。

If there is a JavaScript library that you want to call from Rust, you should refer to the wasm-bindgen docs on importing functions from JS. It is relatively easy to import individual functions, classes, or values from JavaScript to use in your Rust app.

直接将 JS 库集成到应用中并不总是那么容易。特别是,任何依赖于特定 JS 框架(如 React)的库可能都难以集成。以某种方式操作 DOM 状态的库(例如富文本编辑器)也应谨慎使用:Leptos 和 JS 库可能都会假设它们是应用状态的最终事实来源,因此你应该小心划分它们的职责。

It is not always easy to integrate JS libraries into your app directly. In particular, any library that depends on a particular JS framework like React may be hard to integrate. Libraries that manipulate DOM state in some way (for example, rich text editors) should also be used with care: both Leptos and the JS library will probably assume that they are the ultimate source of truth for the app’s state, so you should be careful to separate their responsibilities.

使用 web-sys 访问 Web API

Accessing Web APIs with web-sys

如果你只需要访问一些浏览器 API,而不需要引入单独的 JS 库,你可以使用 web_sys crate。它为浏览器提供的所有 Web API 提供了绑定,将浏览器类型和函数 1:1 地映射到 Rust 结构体和方法。

If you just need to access some browser APIs without pulling in a separate JS library, you can do so using the web_sys crate. This provides bindings for all of the Web APIs provided by the browser, with 1:1 mappings from browser types and functions to Rust structs and methods.

总的来说,如果你在问 “如何用 Leptos 做到某事?”,而该事涉及访问某些 Web API,那么寻找一个纯 JavaScript 的解决方案并使用 web-sys 文档将其翻译成 Rust 是一个不错的方法。

In general, if you’re asking “how do I do X with Leptos?” where do X is accessing some Web API, looking up a vanilla JavaScript solution and translating it to Rust using the web-sys docs is a good approach.

在本节之后,你可能会发现 wasm-bindgen 指南中关于 web-sys 的章节对深入阅读很有帮助。

After this section, you might find the wasm-bindgen guide chapter on web-sys useful for additional reading.

启用特性 (Features)

Enabling features

为了保持较低的编译时间,web_sys 高度依赖特性门控(feature-gated)。如果你想使用它众多的 API 之一,你可能需要启用某个特性。

web_sys is heavily feature-gated to keep compile times low. If you would like to use one of its many APIs, you may need to enable a feature to use it.

使用某个条目所需的特性总是列在其文档中。例如,要使用 Element::get_bounding_rect_client,你需要启用 DomRectElement 特性。

The features required to use an item are always listed in its documentation. For example, to use Element::get_bounding_rect_client, you need to enable the DomRect and Element features.

Leptos 已经启用了一大堆特性——如果所需的特性已经在此启用,你就不必在自己的应用中启用了。否则,将其添加到你的 Cargo.toml 中即可!

Leptos already enables a whole bunch of features - if the required feature is already enabled here, you won't have to enable it in your own app. Otherwise, add it to your Cargo.toml and you’re good to go!

[dependencies.web-sys]
version = "0.3"
features = ["DomRect"]

然而,随着 JavaScript 标准的演进和 API 的编写,你可能希望使用一些技术上尚未完全稳定的浏览器功能,例如 WebGPUweb_sys 会紧跟(可能频繁变动的)标准,这意味着不提供稳定性保证。

However, as the JavaScript standard evolves and APIs are being written, you may want to use browser features that are technically not fully stable yet, such as WebGPU. web_sys will follow the (potentially frequently changing) standard, which means that no stability guarantees are made.

为了使用这些 API,你需要添加 RUSTFLAGS=--cfg=web_sys_unstable_apis 作为环境变量。这可以通过将其添加到每个命令中,或者添加到仓库的 .cargo/config.toml 中来完成。

In order to use this, you need to add RUSTFLAGS=--cfg=web_sys_unstable_apis as an environment variable. This can either be done by adding it to every command, or add it to .cargo/config.toml in your repository.

作为命令的一部分:

As part of a command:

RUSTFLAGS=--cfg=web_sys_unstable_apis cargo # ...

.cargo/config.toml 中:

In .cargo/config.toml:

[env]
RUSTFLAGS = "--cfg=web_sys_unstable_apis"

在视图中访问原始 HtmlElement

Accessing raw HtmlElements from your view

框架的声明式风格意味着你无需直接操作 DOM 节点来构建用户界面。但在某些情况下,你希望直接访问代表视图一部分的底层 DOM 元素。本书关于“非受控输入”的章节展示了如何使用 NodeRef 类型来实现这一点。

The declarative style of the framework means that you don’t need to directly manipulate DOM nodes to build up your user interface. However, in some cases you want direct access to the underlying DOM element that represents part of your view. The section of the book on “uncontrolled inputs” showed how to do this using the NodeRef type.

NodeRef::get 返回一个类型正确的 web-sys 元素,可以直接进行操作。

NodeRef::get returns a correctly-typed web-sys element that can be directly manipulated.

例如,考虑以下代码:

For example, consider the following:

#[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。这允许我们调用任何相应的方法。

Inside the effect here, node is simply a web_sys::HtmlInputElement. This allows us to call any appropriate methods.

(请注意,.get() 在这里返回一个 Option,因为在实际创建 DOM 元素并填充之前,NodeRef 是空的。副作用在组件运行后的一个 tick 运行,因此在大多数情况下,当副作用运行时,<input> 已经创建好了。)

(Note that .get() returns an Option here, because the NodeRefis empty until it is filled when the DOM elements are actually created. Effects run a tick after the component runs, so in most cases the` will already have been created by the time the effect runs.)

第一部分总结:客户端渲染

Wrapping Up Part 1: Client-Side Rendering

到目前为止,我们编写的所有内容几乎完全是在浏览器中渲染的。当我们使用 Trunk 创建应用时,它是由本地开发服务器提供的。如果你为生产环境构建并部署它,它将由你使用的任何服务器或 CDN 提供。在任何一种情况下,提供的内容都是一个 HTML 页面,包含:

So far, everything we’ve written has been rendered almost entirely in the browser. When we create an app using Trunk, it’s served using a local development server. If you build it for production and deploy it, it’s served by whatever server or CDN you’re using. In either case, what’s served is an HTML page with

  1. 你的 Leptos 应用的 URL,它已被编译为 WebAssembly (WASM)
  2. the URL of your Leptos app, which has been compiled to WebAssembly (WASM)
  3. 用于初始化此 WASM 二进制块(blob)的 JavaScript 的 URL
  4. the URL of the JavaScript used to initialize this WASM blob
  5. 一个空的 <body> 元素
  6. an empty <body> element

当 JS 和 WASM 加载完成后,Leptos 将把你的应用渲染到 <body> 中。这意味着在 JS/WASM 加载并运行之前,屏幕上不会显示任何内容。这有一些缺点:

When the JS and WASM have loaded, Leptos will render your app into the <body>. This means that nothing appears on the screen until JS/WASM have loaded and run. This has some drawbacks:

  1. 它增加了加载时间,因为在下载完额外资源之前,用户的屏幕是空白的。
  2. It increases load time, as your user’s screen is blank until additional resources have been downloaded.
  3. 它不利于 SEO,因为加载时间更长,且你提供的 HTML 没有实质性内容。
  4. It’s bad for SEO, as load times are longer and the HTML you serve has no meaningful content.
  5. 对于由于某种原因(例如,他们正在火车上,在 WASM 加载完成前进入了隧道;他们使用的是不支持 WASM 的旧设备;他们由于某种原因关闭了 JavaScript 或 WASM 等)无法加载 JS/WASM 的用户来说,应用是失效的。
  6. It’s broken for users for whom JS/WASM don’t load for some reason (e.g., they’re on a train and just went into a tunnel before WASM finished loading; they’re using an older device that doesn’t support WASM; they have JavaScript or WASM turned off for some reason; etc.)

这些缺点存在于整个 Web 生态系统中,对于 WASM 应用尤为突出。

These downsides apply across the web ecosystem, but especially to WASM apps.

然而,根据你项目的需求,你可能可以接受这些限制。

However, depending on the requirements of your project, you may be fine with these limitations.

如果你只想部署你的客户端渲染(CSR)网站,请跳至 “部署 (Deployment)” 章节——在那里,你会找到关于如何最好地部署 Leptos CSR 站点的指导。

If you just want to deploy your Client-Side Rendered website, skip ahead to the chapter on "Deployment" - there, you'll find directions on how best to deploy your Leptos CSR site.

但是,如果你想在 index.html 页面中返回不仅仅是一个空的 <body> 标签,该怎么办呢?请使用“服务端渲染(Server-Side Rendering)”!

But what do you do if you want to return more than just an empty <body> tag in your index.html page? Use “Server-Side Rendering”!

关于这个话题可以写(而且可能已经写了)整本书,但其核心其实非常简单:SSR 不再返回一个空的 <body> 标签,而是返回一个反映应用或网站实际初始状态的初始 HTML 页面,这样在 JS/WASM 加载期间以及加载完成之前,用户都可以访问纯 HTML 版本。

Whole books could be (and probably have been) written about this topic, but at its core, it’s really simple: rather than returning an empty <body> tag, with SSR, you'll return an initial HTML page that reflects the actual starting state of your app or site, so that while JS/WASM are loading, and until they load, the user can access the plain HTML version.

本书的第二部分关于 Leptos SSR,将详细介绍这个主题!

Part 2 of this book, on Leptos SSR, will cover this topic in some detail!

第二部分:服务器端渲染

Part 2: Server Side Rendering

正如你在上一章中所读到的,使用客户端渲染(Client-Side Rendered)的 Web 应用程序存在一些局限性。本书的第二部分将讨论如何使用服务器端渲染(Server-Side Rendering)来克服这些限制,并让你的 Leptos 应用获得最佳性能和 SEO。

As you read in the last chapter, there are some limitations to using client-side rendered web applications. This second part of the book will discuss how to use server-side rendering to overcome these limitations and get the best performance and SEO out of your Leptos apps.

:::admonish info

在服务器端使用 Leptos 时,你可以自由选择官方支持的 Actix 或 Axum 集成,或者是社区支持的选择之一。官方选择提供 Leptos 的全套功能,社区选择支持的功能可能较少。详情请查看它们的文档。

我们有多种社区支持的选择,包括与 WinterCG 兼容的运行时(如 Deno 或 Cloudflare)以及服务器端 WASM 运行时(如 Spin)。Viz 和 Pavex 的社区支持集成为传统的服务器选择提供了更多可能性。不建议初学者自己编写集成,但中/高级 Rust 用户可能希望这样做。如果你对此有疑问,欢迎在我们的 Discord 或 Github 上联系我们。

我建议初学者选择 Axum 或 Actix。两者都功能完备,选择哪一个取决于个人偏好。没有错误的选择,但如果你在寻求建议,Leptos 团队目前为新项目默认选择 Axum。

:::

:::admonish info

When working with Leptos on the server side, you’re free to choose either an officially supported Actix or Axum integrations, or one of our community supported choices. The full feature set of Leptos is available with the official choices, the community ones may support less. Check their documentation for details.

We have a variety of community supported choices, including WinterCG-compatible runtimes like Deno or Cloudflare and server-side WASM runtimes like Spin. Community-supported integrations for Viz and Pavex offer more traditional server choices. Writing an integration yourself isn't recommended as a beginner, but medium/advanced Rust users may wish to. Feel free to reach out if you have questions about that on our Discord or Github.

I'd recommend either Axum or Actix for beginners. Both are fully functional and choosing between them is a matter of personal preference. There is no wrong choice there, but if you’re looking for a recommendation, the Leptos team currently defaults to Axum for new projects. :::

cargo-leptos 简介

Introducing cargo-leptos

到目前为止,我们只是在浏览器中运行代码,并使用 Trunk 来协调构建过程以及运行本地开发流程。如果我们要添加服务器端渲染,我们将需要在服务器上运行我们的应用程序代码。这意味着我们需要构建两个独立的二进制文件,一个编译为本地代码(native code)并在服务器上运行,另一个编译为 WebAssembly (WASM) 并在用户的浏览器中运行。此外,服务器需要知道如何将这个 WASM 版本(以及初始化它所需的 JavaScript)提供给浏览器。

So far, we’ve just been running code in the browser and using Trunk to coordinate the build process and run a local development process. If we’re going to add server-side rendering, we’ll need to run our application code on the server as well. This means we’ll need to build two separate binaries, one compiled to native code and running the server, the other compiled to WebAssembly (WASM) and running in the user’s browser. Additionally, the server needs to know how to serve this WASM version (and the JavaScript required to initialize it) to the browser.

这并不是一个不可逾越的任务,但它增加了一些复杂性。为了方便起见,并提供更轻松的开发体验,我们构建了 cargo-leptos 构建工具。cargo-leptos 的存在基本上是为了协调应用的构建过程,处理在你进行更改时对服务器和客户端两部分进行重新编译,并为 Tailwind、SASS 和测试等功能添加了一些内置支持。

This is not an insurmountable task but it adds some complication. For convenience and an easier developer experience, we built the cargo-leptos build tool. cargo-leptos basically exists to coordinate the build process for your app, handling recompiling the server and client halves when you make changes, and adding some built-in support for things like Tailwind, SASS, and testing.

入门非常简单。只需运行:

Getting started is pretty easy. Just run

cargo install --locked cargo-leptos

然后,要创建一个新项目,你可以运行:

And then to create a new project, you can run either

# 使用 Actix 模板
# for an Actix template
cargo leptos new --git https://github.com/leptos-rs/start-actix

或者:

or

# 使用 Axum 模板
# for an Axum template
cargo leptos new --git https://github.com/leptos-rs/start-axum

确保你已经添加了 wasm32-unknown-unknown 目标,这样 Rust 才能将你的代码编译为 WebAssembly 以在浏览器中运行。

Make sure you've added the wasm32-unknown-unknown target so that Rust can compile your code to WebAssembly to run in the browser.

rustup target add wasm32-unknown-unknown

现在 cd 进入你创建的目录并运行:

Now cd into the directory you’ve created and run

cargo leptos watch

一旦应用编译完成,你就可以在浏览器中打开 http://localhost:3000 来查看它。

Once your app has compiled you can open up your browser to http://localhost:3000 to see it.

cargo-leptos 拥有许多额外功能和内置工具。你可以在 它的 README 中了解更多。

cargo-leptos has lots of additional features and built in tools. You can learn more in its README.

但是,当你打开浏览器访问 localhost:3000 时,到底发生了什么?请继续阅读以寻找答案。

But what exactly is happening when you open your browser to localhost:3000? Well, read on to find out.

页面加载的生命周期

The Life of a Page Load

在深入细节之前,先进行一次高层级的概览可能会有所帮助。从你输入服务器渲染的 Leptos 应用 URL 的那一刻,到你点击按钮且计数器增加的那一刻,到底发生了什么?

Before we get into the weeds it might be helpful to have a higher-level overview. What exactly happens between the moment you type in the URL of a server-rendered Leptos app, and the moment you click a button and a counter increases?

我假设你具备一些关于互联网如何运作的基础知识,并且不会深入探讨 HTTP 之类的细节。相反,我将尝试展示 Leptos API 的不同部分如何映射到该过程的每个环节。

I’m assuming some basic knowledge of how the Internet works here, and won’t get into the weeds about HTTP or whatever. Instead, I’ll try to show how different parts of the Leptos APIs map onto each part of the process.

此描述还基于一个前提:你的应用正在为两个独立的目标进行编译:

This description also starts from the premise that your app is being compiled for two separate targets:

  1. 一个服务器版本,通常运行在 Actix 或 Axum 上,使用 Leptos 的 ssr 特性进行编译。

  2. A server version, often running on Actix or Axum, compiled with the Leptos ssr feature

  3. 一个浏览器版本,使用 Leptos 的 hydrate 特性编译为 WebAssembly (WASM)。

  4. A browser version, compiled to WebAssembly (WASM) with the Leptos hydrate feature

cargo-leptos 构建工具的存在是为了协调为这两个不同目标编译应用的过程。

The cargo-leptos build tool exists to coordinate the process of compiling your app for these two different targets.

在服务器上

On the Server

  • 你的浏览器向服务器发起针对该 URL 的 GET 请求。此时,浏览器对将要渲染的页面几乎一无所知。(“浏览器如何知道去哪里请求页面?”是一个有趣的问题,但超出了本教程的范围!)

  • Your browser makes a GET request for that URL to your server. At this point, the browser knows almost nothing about the page that’s going to be rendered. (The question “How does the browser know where to ask for the page?” is an interesting one, but out of the scope of this tutorial!)

  • 服务器接收到该请求,并检查它是否有办法处理该路径下的 GET 请求。这就是 leptos_axumleptos_actix 中的 .leptos_routes() 方法的作用。当服务器启动时,这些方法会遍历你在 <Routes/> 中提供的路由结构,生成应用可以处理的所有可能路由的列表,并告诉服务器的路由管理器:“对于这些路由中的每一个,如果你收到请求……就把它交给 Leptos。”

  • The server receives that request, and checks whether it has a way to handle a GET request at that path. This is what the .leptos_routes() methods in leptos_axum and leptos_actix are for. When the server starts up, these methods walk over the routing structure you provide in <Routes/>, generating a list of all possible routes your app can handle and telling the server’s router “for each of these routes, if you get a request... hand it off to Leptos.”

  • 服务器看到该路由可以由 Leptos 处理。因此它渲染你的根组件(通常称为 <App/> 之类的名称),并为其提供请求的 URL 以及 HTTP 标头和请求元数据等其他数据。

  • The server sees that this route can be handled by Leptos. So it renders your root component (often called something like <App/>), providing it with the URL that’s being requested and some other data like the HTTP headers and request metadata.

  • 你的应用程序在服务器上运行一次,构建将在该路由下渲染的组件树的 HTML 版本。(下一章中关于资源和 <Suspense/> 还有更多内容。)

  • Your application runs once on the server, building up an HTML version of the component tree that will be rendered at that route. (There’s more to be said here about resources and <Suspense/> in the next chapter.)

  • 服务器返回此 HTML 页面,并注入关于如何加载已编译为 WASM 的应用版本的信息,以便它可以在浏览器中运行。

  • The server returns this HTML page, also injecting information on how to load the version of your app that has been compiled to WASM so that it can run in the browser.

返回的 HTML 页面本质上是你的应用,“脱水(dehydrated)”或“冻干(freeze-dried)”后的状态:它是没有任何响应式系统或你添加的事件监听器的 HTML。浏览器将通过添加响应式系统并将事件监听器附加到服务器渲染的 HTML 来“注水(rehydrate)”此 HTML 页面。因此,有两个特性标志(feature flags)适用于此过程的两半:服务器上的 ssr 用于“服务器端渲染(server-side rendering)”,浏览器中的 hydrate 用于“注水”过程。

The HTML page that’s returned is essentially your app, “dehydrated” or “freeze-dried”: it is HTML without any of the reactivity or event listeners you’ve added. The browser will “rehydrate” this HTML page by adding the reactive system and attaching event listeners to that server-rendered HTML. Hence the two feature flags that apply to the two halves of this process: ssr on the server for “server-side rendering”, and hydrate in the browser for that process of rehydration.

在浏览器中

In the Browser

  • 浏览器从服务器接收此 HTML 页面。它立即回到服务器开始加载运行交互式、客户端版本应用所需的 JS 和 WASM。

  • The browser receives this HTML page from the server. It immediately goes back to the server to begin loading the JS and WASM necessary to run the interactive, client side version of the app.

  • 与此同时,它渲染 HTML 版本。

  • In the meantime, it renders the HTML version.

  • 当 WASM 版本加载完成后,它执行与服务器相同的路由匹配过程。因为 <Routes/> 组件在服务器和客户端上是完全相同的,所以浏览器版本将读取 URL 并渲染与服务器已经返回的相同的页面。

  • When the WASM version has reloaded, it does the same route-matching process that the server did. Because the <Routes/> component is identical on the server and in the client, the browser version will read the URL and render the same page that was already returned by the server.

  • 在这个初始的“注水(hydration)”阶段,WASM 版本的应用不会重新创建组成应用的 DOM 节点。相反,它遍历现有的 HTML 树,“拾取”现有的元素并添加必要的交互性。

  • During this initial “hydration” phase, the WASM version of your app doesn’t re-create the DOM nodes that make up your application. Instead, it walks over the existing HTML tree, “picking up” existing elements and adding the necessary interactivity.

请注意,这里存在一些权衡。在注水过程完成之前,页面将 看起来 是可交互的,但实际上不会响应交互。例如,如果你有一个计数器按钮并在 WASM 加载完成之前点击它,计数将不会增加,因为必要的事件监听器和响应式系统尚未添加。我们将在未来的章节中研究一些构建“平稳退化(graceful degradation)”的方法。

Note that there are some trade-offs here. Before this hydration process is complete, the page will appear interactive but won’t actually respond to interactions. For example, if you have a counter button and click it before WASM has loaded, the count will not increment, because the necessary event listeners and reactivity have not been added yet. We’ll look at some ways to build in “graceful degradation” in future chapters.

客户端导航

Client-Side Navigation

下一步非常重要。想象一下,用户现在点击一个链接以导航到应用程序中的另一个页面。

The next step is very important. Imagine that the user now clicks a link to navigate to another page in your application.

浏览器 不会 再次往返服务器,像在普通 HTML 页面之间导航或在仅使用服务器渲染(例如使用 PHP)但没有客户端部分的应用中导航那样重新加载整个页面。

The browser will not make another round trip to the server, reloading the full page as it would for navigating between plain HTML pages or an application that uses server rendering (for example with PHP) but without a client-side half.

相反,WASM 版本的应用将在浏览器中直接加载新页面,而无需向服务器请求另一个页面。本质上,你的应用从服务器加载的“多页应用”升级为浏览器渲染的“单页应用”。这产生了鱼与熊掌兼得的效果:由于服务器渲染的 HTML 具有快速的初始加载时间,而由于客户端路由具有快速的二次导航。

Instead, the WASM version of your app will load the new page, right there in the browser, without requesting another page from the server. Essentially, your app upgrades itself from a server-loaded “multi-page app” into a browser-rendered “single-page app.” This yields the best of both worlds: a fast initial load time due to the server-rendered HTML, and fast secondary navigations because of the client-side routing.

接下来的章节中描述的一些内容——比如服务器函数、资源和 <Suspense/> 之间的交互——可能看起来过于复杂。你可能会问自己:“如果我的页面在服务器上被渲染为 HTML,为什么我不能直接在服务器上 .await 它?如果我可以直接在服务器函数中调用库 X,为什么我不能在我的组件中调用它?”原因很简单:为了实现从服务器渲染到客户端渲染的升级,应用程序中的所有内容都必须能够在客户端和服务器上运行。

Some of what will be described in the following chapters—like the interactions between server functions, resources, and <Suspense/>—may seem overly complicated. You might find yourself asking, “If my page is being rendered to HTML on the server, why can’t I just .await this on the server? If I can just call library X in a server function, why can’t I call it in my component?” The reason is pretty simple: to enable the upgrade from server rendering to client rendering, everything in your application must be able to run on both the client and the server.

当然,这并不是创建网站或 Web 框架的唯一方式。但这是最常见的方式,而且我们恰好认为这是一种非常好的方式,可以为你的用户创造尽可能流畅的体验。

This is not the only way to create a website or web framework, of course. But it’s the most common way, and we happen to think it’s quite a good way, to create the smoothess possible experience for your users.

异步渲染与 SSR “模式”

Async Rendering and SSR “Modes”

服务器渲染一个仅使用同步数据的页面非常简单:你只需遍历组件树,将每个元素渲染为 HTML 字符串。但这是一个很大的前提:它没有回答我们应该如何处理包含异步数据的页面,即在客户端会在 <Suspense/> 节点下渲染的那些内容。

Server-rendering a page that uses only synchronous data is pretty simple: You just walk down the component tree, rendering each element to an HTML string. But this is a pretty big caveat: it doesn’t answer the question of what we should do with pages that includes asynchronous data, i.e., the sort of stuff that would be rendered under a <Suspense/> node on the client.

当页面加载需要渲染的异步数据时,我们该怎么办?我们应该等待所有异步数据加载完毕,然后一次性渲染所有内容吗?(我们称之为“异步”渲染)我们应该走向完全相反的方向,立即将现有的 HTML 发送给客户端,让客户端加载资源并填补空缺吗?(我们称之为“同步”渲染)或者是否存在某种折中方案,能同时超越这两者?(提示:确实存在。)

When a page loads async data that it needs to render, what should we do? Should we wait for all the async data to load, and then render everything at once? (Let’s call this “async” rendering) Should we go all the way in the opposite direction, just sending the HTML we have immediately down to the client and letting the client load the resources and fill them in? (Let’s call this “synchronous” rendering) Or is there some middle-ground solution that somehow beats them both? (Hint: There is.)

如果你曾经在线听过流媒体音乐或看过去视频,我确信你意识到 HTTP 支持流式传输(streaming),允许单个连接陆续发送数据块,而无需等待全部内容加载完毕。你可能没有意识到浏览器也非常擅长渲染部分 HTML 页面。综上所述,这意味着你实际上可以通过 流式传输 HTML (streaming HTML) 来增强用户体验:这是 Leptos 开箱即用的功能,完全不需要任何配置。实际上,流式传输 HTML 有多种方式:你可以按顺序流式传输组成页面的 HTML 块,就像视频帧一样;或者你可以……嗯,不按顺序流式传输。

If you’ve ever listened to streaming music or watched a video online, I’m sure you realize that HTTP supports streaming, allowing a single connection to send chunks of data one after another without waiting for the full content to load. You may not realize that browsers are also really good at rendering partial HTML pages. Taken together, this means that you can actually enhance your users’ experience by streaming HTML: and this is something that Leptos supports out of the box, with no configuration at all. And there’s actually more than one way to stream HTML: you can stream the chunks of HTML that make up your page in order, like frames of a video, or you can stream them... well, out of order.

让我详细说明一下我的意思。

Let me say a little more about what I mean.

Leptos 支持包含异步数据的 HTML 渲染的所有主要方式:

Leptos supports all the major ways of rendering HTML that includes asynchronous data:

  1. 同步渲染

  2. Synchronous Rendering

  3. 异步渲染

  4. Async Rendering

  5. 顺序流式传输

  6. In-Order streaming

  7. 乱序流式传输(以及一种部分阻塞变体)

  8. Out-of-Order Streaming (and a partially-blocked variant)

同步渲染

Synchronous Rendering

  1. 同步 (Synchronous):提供一个 HTML 外壳,其中包含任何 <Suspense/>fallback(回退内容)。在客户端使用 create_local_resource 加载数据,一旦资源加载完毕,就替换 fallback
  • 优点:应用外壳出现得非常快:首字节时间 (TTFB) 极佳。

  • Pros: App shell appears very quickly: great TTFB (time to first byte).

  • 缺点

    • 资源加载相对较慢;你甚至在发出请求之前需要等待 JS + WASM 加载。
    • 无法在 <title> 或其他 <meta> 标签中包含来自异步资源的数据,这会损害 SEO 和社交媒体链接预览等功能。
  • Cons

    • Resources load relatively slowly; you need to wait for JS + WASM to load before even making a request.
    • No ability to include data from async resources in the <title> or other <meta> tags, hurting SEO and things like social media link previews.

从性能角度来看,如果你正在使用服务器端渲染,同步模式几乎从来不是你真正想要的。这是因为它错过了一个重要的优化。如果你在服务器渲染期间加载异步资源,你实际上可以在服务器上就开始加载数据。服务器渲染可以在客户端首次发起请求时就开始加载资源,而不是等待客户端接收 HTML 响应,然后加载其 JS + WASM,然后 意识到它需要资源并开始加载它们。从这个意义上说,在服务器渲染期间,异步资源就像一个在服务器上开始加载并在客户端上解析(resolve)的 Future。只要资源实际上是可序列化的,这总会带来更快的总加载时间。

If you’re using server-side rendering, the synchronous mode is almost never what you actually want, from a performance perspective. This is because it misses out on an important optimization. If you’re loading async resources during server rendering, you can actually begin loading the data on the server. Rather than waiting for the client to receive the HTML response, then loading its JS + WASM, then realize it needs the resources and begin loading them, server rendering can actually begin loading the resources when the client first makes the request. In this sense, during server rendering an async resource is like a Future that begins loading on the server and resolves on the client. As long as the resources are actually serializable, this will always lead to a faster total load time.

这就是为什么 Resource 需要其数据是可序列化的,以及为什么你应该对任何不可序列化且因此只能在浏览器本身中加载的异步数据使用 LocalResource。当你本可以创建可序列化资源时却创建本地资源,这总是一种去优化(deoptimization)。

This is why a Resource needs its data to be serializable, and why you should use LocalResource for any async data that is not serializable and should therefore only be loaded in the browser itself. Creating a local resource when you could create a serializable resource is always a deoptimization.

异步渲染

Async Rendering

  1. 异步 (async):在服务器上加载所有资源。等到所有数据加载完毕,然后一次性渲染 HTML。
  • 优点:对元标签的处理更好(因为你在渲染 <head> 之前就已经知道了异步数据)。比 同步 加载完成得更快,因为异步资源在服务器上就开始加载。

  • Pros: Better handling for meta tags (because you know async data even before you render the <head>). Faster complete load than synchronous because async resources begin loading on server.

  • 缺点:加载时间/TTFB 较慢:你需要等待所有异步资源加载完毕才能在客户端显示任何内容。在所有内容加载完毕之前,页面完全是空白的。

  • Cons: Slower load time/TTFB: you need to wait for all async resources to load before displaying anything on the client. The page is totally blank until everything is loaded.

顺序流式传输

In-Order Streaming

  1. 顺序流式传输 (In-order streaming):遍历组件树,渲染 HTML 直到遇到 <Suspense/>。将到目前为止获得的所有 HTML 作为一个块发送到流中,等待该 <Suspense/> 下访问的所有资源加载完毕,然后将其渲染为 HTML 并继续遍历,直到遇到另一个 <Suspense/> 或页面结束。
  • 优点:与空白屏幕相比,在数据准备好之前至少能显示 某些内容

  • Pros: Rather than a blank screen, shows at least something before the data are ready.

  • 缺点

    • 加载外壳的速度比同步渲染(或乱序流式传输)慢,因为它需要在每个 <Suspense/> 处暂停。
    • 无法显示 <Suspense/> 的回退(fallback)状态。
    • 在整个页面加载完毕之前无法开始注水(hydration),因此在悬停块加载完毕之前,页面的早期部分将不可交互。
  • Cons

    • Loads the shell more slowly than synchronous rendering (or out-of-order streaming) because it needs to pause at every <Suspense/>.
    • Unable to show fallback states for <Suspense/>.
    • Can’t begin hydration until the entire page has loaded, so earlier pieces of the page will not be interactive until the suspended chunks have loaded.

乱序流式传输

Out-of-Order Streaming

  1. 乱序流式传输 (Out-of-order streaming):类似于同步渲染,提供一个包含任何 <Suspense/>fallback 的 HTML 外壳。但在 服务器 上加载数据,并在数据解析(resolve)时将其流式传输到客户端,同时流式传输 <Suspense/> 节点的 HTML,这些 HTML 会被交换以替换 fallback。
  • 优点:结合了 同步异步 (async) 的优点。

    • 初始响应/TTFB 快,因为它立即发送整个同步外壳。
    • 总时间快,因为资源在服务器上就开始加载。
    • 能够显示回退加载状态并动态替换它,而不是为未加载的数据显示空白部分。
  • Pros: Combines the best of synchronous and async.

    • Fast initial response/TTFB because it immediately sends the whole synchronous shell
    • Fast total time because resources begin loading on the server.
    • Able to show the fallback loading state and dynamically replace it, instead of showing blank sections for un-loaded data.
  • 缺点:需要启用 JavaScript,悬停片段才能以正确的顺序出现。(这段少量的 JS 随包含已渲染 <Suspense/> 片段的 <template> 标签一起通过 <script> 标签流式传输,因此不需要加载任何额外的 JS 文件。)

  • Cons: Requires JavaScript to be enabled for suspended fragments to appear in correct order. (This small chunk of JS streamed down in a <script> tag alongside the <template> tag that contains the rendered <Suspense/> fragment, so it does not need to load any additional JS files.)

  1. 部分阻塞流式传输 (Partially-blocked streaming):当页面上有多个独立的 <Suspense/> 组件时,“部分阻塞”流式传输非常有用。它通过在路由上设置 ssr=SsrMode::PartiallyBlocked 触发,并取决于视图中的阻塞资源。如果其中一个 <Suspense/> 组件读取一个或多个“阻塞资源”(见下文),则不会发送 fallback;相反,服务器将等待该 <Suspense/> 解析完毕,然后在服务器上用解析后的片段替换 fallback,这意味着它包含在初始 HTML 响应中,即使 JavaScript 被禁用或不支持也能显示。其他 <Suspense/> 则以乱序方式流入,类似于默认的 SsrMode::OutOfOrder

当页面上有多个 <Suspense/> 且其中一个比另一个更重要时,这非常有用:想象一下博客文章和评论,或者产品信息和评论。如果只有一个 <Suspense/>,或者每个 <Suspense/> 都读取阻塞资源,那么它就 有用。在这些情况下,它是 async 渲染的一种较慢的形式。

This is useful when you have multiple <Suspense/> on the page, and one is more important than the other: think of a blog post and comments, or product information and reviews. It is not useful if there’s only one <Suspense/>, or if every <Suspense/> reads from blocking resources. In those cases it is a slower form of async rendering.

  • 优点:在用户设备上禁用或不支持 JavaScript 的情况下也能工作。

  • Pros: Works if JavaScript is disabled or not supported on the user’s device.

  • 缺点

    • 初始响应时间比乱序流式传输慢。
    • 由于服务器上的额外工作,整体响应略慢。
    • 不显示回退状态。
  • Cons

    • Slower initial response time than out-of-order.
    • Marginally slower overall response due to additional work on the server.
    • No fallback state shown.

使用 SSR 模式

Using SSR Modes

因为乱序流式传输提供了性能特征的最佳结合,Leptos 默认使用乱序流式传输。但选择加入这些不同的模式非常简单。你可以通过在你的一个或多个 <Route/> 组件上添加 ssr 属性来实现,就像 ssr_modes 示例 中那样。

Because it offers the best blend of performance characteristics, Leptos defaults to out-of-order streaming. But it’s really simple to opt into these different modes. You do it by adding an ssr property onto one or more of your <Route/> components, like in the ssr_modes example.

<Routes fallback=|| "Not found.">
	// 我们将使用乱序流式传输和 <Suspense/> 加载主页
	// We’ll load the home page with out-of-order streaming and <Suspense/>
	<Route path=path!("") view=HomePage/>

	// 我们将使用异步渲染加载文章,以便它们可以在加载数据 *后* 设置标题和元数据
	// We'll load the posts with async rendering, so they can set
	// the title and metadata *after* loading the data
	<Route
		path=path!("/post/:id")
		view=BlogPost
		ssr=SsrMode::Async
	/>
</Routes>

对于包含多个嵌套路由的路径,将使用限制最严格的模式:即,即使只有一个嵌套路由要求 async 渲染,整个初始请求都将以 async 方式渲染。async 是要求最严格的,其次是顺序流式(in-order),然后是乱序流式(out-of-order)。(如果你思考几分钟,这可能就讲得通了。)

For a path that includes multiple nested routes, the most restrictive mode will be used: i.e., if even a single nested route asks for async rendering, the whole initial request will be rendered async. async is the most restricted requirement, followed by in-order, and then out-of-order. (This probably makes sense if you think about it for a few minutes.)

阻塞资源

Blocking Resources

阻塞资源可以使用 Resource::new_blocking 创建。阻塞资源仍然像 Rust 中的任何其他 async/.await 一样异步加载。它不会阻塞服务器线程或类似的东西。相反,在 <Suspense/> 下读取阻塞资源会阻塞 HTML 返回任何内容(包括其初始同步外壳),直到该 <Suspense/> 解析完毕。

Blocking resources can be created with Resource::new_blocking. A blocking resource still loads asynchronously like any other async/.await in Rust. It doesn’t block a server thread, or anything like that. Instead, reading from a blocking resource under a <Suspense/> blocks the HTML stream from returning anything, including its initial synchronous shell, until that <Suspense/> has resolved.

从性能角度来看,这并不理想。在资源准备就绪之前,页面的同步外壳都不会加载。然而,什么都不渲染意味着你可以在真实的 HTML 中设置 <head> 中的 <title><meta> 标签等内容。这听起来很像 async 渲染,但有一个很大的区别:如果你有多个 <Suspense/> 部分,你可以阻塞其中 一个,但仍然渲染占位符,然后流式传输另一个。

From a performance perspective, this is not ideal. None of the synchronous shell for your page will load until that resource is ready. However, rendering nothing means that you can do things like set the <title> or <meta> tags in your <head> in actual HTML. This sounds a lot like async rendering, but there’s one big difference: if you have multiple <Suspense/> sections, you can block on one of them but still render a placeholder and then stream in the other.

例如,考虑一篇博客文章。为了 SEO 和社交分享,我肯定希望在初始 HTML 的 <head> 中包含我的博客文章标题和元数据。但我真的不在乎评论是否已经加载;我想尽可能懒惰地加载它们。

For example, think about a blog post. For SEO and for social sharing, I definitely want my blog post’s title and metadata in the initial HTML <head>. But I really don’t care whether comments have loaded yet or not; I’d like to load those as lazily as possible.

通过阻塞资源,我可以这样做:

With blocking resources, I can do something like this:

#[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 tags)和其他头部元素将被渲染。

The first <Suspense/>, with the body of the blog post, will block my HTML stream, because it reads from a blocking resource. Meta tags and other head elements awaiting the blocking resource will be rendered before the stream is sent.

结合以下使用 SsrMode::PartiallyBlocked 的路由定义,阻塞资源将在服务器端被完全渲染,使得禁用了 WebAssembly 或 JavaScript 的用户也可以访问。

Combined with the following route definition, which uses SsrMode::PartiallyBlocked, the blocking resource will be fully rendered on the server side, making it accessible to users who disable WebAssembly or JavaScript.

<Routes fallback=|| "Not found.">
	// 我们将使用乱序流式传输和 <Suspense/> 加载主页
	// We’ll load the home page with out-of-order streaming and <Suspense/>
	<Route path=path!("") view=HomePage/>

	// 我们将使用异步渲染加载文章,以便它们可以在加载数据 *后* 设置标题和元数据
	// We'll load the posts with async rendering, so they can set
	// the title and metadata *after* loading the data
	<Route
		path=path!("/post/:id")
		view=BlogPost
		ssr=SsrMode::PartiallyBlocked
	/>
</Routes>

第二个带有评论的 <Suspense/> 将不会阻塞流。阻塞资源恰好赋予了我所需的权力和细粒度,以针对 SEO 和用户体验优化我的页面。

The second <Suspense/>, with the comments, will not block the stream. Blocking resources gave me exactly the power and granularity I needed to optimize my page for SEO and user experience.

注水错误 (以及如何避免它们)

Hydration Bugs (and how to avoid them)

一个思想实验

A Thought Experiment

让我们尝试一个实验来测试你的直觉。打开一个你正在使用 cargo-leptos 进行服务器渲染的应用。(如果你到目前为止只是使用 trunk 来玩示例,为了这个练习,请去 克隆一个 cargo-leptos 模板。)

Let’s try an experiment to test your intuitions. Open up an app you’re server-rendering with cargo-leptos. (If you’ve just been using trunk so far to play with examples, go clone a cargo-leptos template just for the sake of this exercise.)

在你的根组件中放一个日志。(我通常把我的根组件称为 <App/>,但任何组件都可以。)

Put a log somewhere in your root component. (I usually call mine <App/>, but anything will do.)

#[component]
pub fn App() -> impl IntoView {
	logging::log!("where do I run?");
	// ... 随便什么内容
}

然后让我们启动它:

And let’s fire it up

cargo leptos watch

你期望 where do I run? 在哪里打印日志?

Where do you expect where do I run? to log?

  • 在你运行服务器的命令行中?

  • In the command line where you’re running the server?

  • 在你加载页面时的浏览器控制台中?

  • In the browser console when you load the page?

  • 都没有?

  • Neither?

  • 都有?

  • Both?

试一试。

Try it out.

...

...

...

好了,剧透警告。

Okay, consider the spoiler alerted.

你当然会注意到它在两个地方都打印了日志,假设一切都按计划进行。事实上,在服务器上它打印了两次——第一次是在初始服务器启动时,Leptos 渲染一次你的应用以提取路由树,第二次是在你发起请求时。每次你重新加载页面,where do I run? 应该在服务器上打印一次,在客户端打印一次。

You’ll notice of course that it logs in both places, assuming everything goes according to plan. In fact on the server it logs twice—first during the initial server startup, when Leptos renders your app once to extract the route tree, then a second time when you make a request. Each time you reload the page, where do I run? should log once on the server and once on the client.

如果你思考一下前几节的描述,希望这能讲得通。你的应用程序在服务器上运行一次,在那里它构建一个 HTML 树并发送到客户端。在这次初始渲染期间,where do I run? 在服务器上记录。

If you think about the description in the last couple sections, hopefully this makes sense. Your application runs once on the server, where it builds up a tree of HTML which is sent to the client. During this initial render, where do I run? logs on the server.

一旦 WASM 二进制文件在浏览器中加载,你的应用程序将第二次运行,遍历同一个用户界面树并添加交互性。

Once the WASM binary has loaded in the browser, your application runs a second time, walking over the same user interface tree and adding interactivity.

这听起来像是种浪费吗?在某种意义上是的。但减少这种浪费是一个真正困难的问题。这是一些像 Qwik 这样的 JS 框架旨在解决的问题,尽管现在判断它相对于其他方法是否具有净性能收益可能还为时过早。

Does that sound like a waste? It is, in a sense. But reducing that waste is a genuinely hard problem. It’s what some JS frameworks like Qwik are intended to solve, although it’s probably too early to tell whether it’s a net performance gain as opposed to other approaches.

潜在的 Bug

The Potential for Bugs

好了,希望所有这些都能理解。但这与本章的标题“注水错误(以及如何避免它们)”有什么关系呢?

Remember that the application needs to run on both the server and the client. This generates a few different sets of potential issues you need to know how to avoid.

请记住,应用程序需要在服务器和客户端上都运行。这会产生几组不同的潜在问题,你需要知道如何避免。

服务器和客户端代码之间的不匹配

Mismatches between server and client code

创建 Bug 的一种方法是让服务器发送的 HTML 与客户端渲染的内容之间产生不匹配。我认为非故意地做到这一点其实相当困难(至少根据我从人们那里收到的 bug 报告来看是这样)。但想象一下我这样做:

One way to create a bug is by creating a mismatch between the HTML that’s sent down by the server and what’s rendered on the client. It’s actually fairly hard to do this unintentionally, I think (at least judging by the bug reports I get from people.) But imagine I do something like this

#[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 编译的,它有三个项目;否则它是空的。

In other words, if this is being compiled to WASM, it has three items; otherwise it’s empty.

当我在浏览器中加载页面时,我什么也看不见。如果我打开控制台,我会看到一个 panic:

When I load the page in the browser, I see nothing. If I open the console I see a panic:

ssr_modes.js:423 panicked at /.../tachys/src/html/element/mod.rs:352:14:
called `Option::unwrap()` on a `None` value

你的 WASM 版本应用在浏览器中运行,它期望找到一个元素(实际上,它期望找到三个元素!)。但服务器发送的 HTML 中一个也没有。

The WASM version of your app, running in the browser, is expecting to find an element (in fact, it’s expecting three elements!) But the HTML sent from the server has none.

解决方案

Solution

你故意这样做的情况非常罕见,但它可能会由于在服务器和浏览器中运行不同的逻辑而发生。如果你看到这样的警告并且你认为这不是你的错,那么更有可能是 <Suspense/> 或其他东西的 bug。请随时在 GitHub 上打开一个 issuediscussion 以寻求帮助。

It’s pretty rare that you do this intentionally, but it could happen from somehow running different logic on the server and in the browser. If you’re seeing warnings like this and you don’t think it’s your fault, it’s much more likely that it’s a bug with <Suspense/> or something. Feel free to go ahead and open an issue or discussion on GitHub for help.

无效/边缘情况 HTML,以及 HTML 与 DOM 之间的不匹配

Invalid/edge-case HTML, and mismatches between HTML and the DOM

服务器以 HTML 响应请求。然后浏览器将该 HTML 解析为一个称为文档对象模型 (DOM) 的树。在注水期间,Leptos 遍历应用的视图树,注水一个元素,然后进入其子元素,注水第一个子元素,然后移动到其兄弟元素,依此类推。这假设你的应用在服务器上生成的 HTML 树直接映射到浏览器解析该 HTML 后的 DOM 树。

Servers respond to requests with HTML. The browser then parses that HTML into a tree called the Document Object Model (DOM). During hydration, Leptos walks over the view tree of your application, hydrating an element, then moving into its children, hydrating the first child, then moving to its siblings, and so on. This assumes that the tree of HTML produced by the your application on the server maps directly onto the DOM tree into which the browser parses that HTML.

在某些情况下,你的 view 创建的 HTML 树和 DOM 树可能不完全对应:这些情况会导致注水错误。

There are a few cases to be aware of in which the tree of HTML created by your view and the DOM tree might not correspond exactly: these can cause hydration errors.

无效 HTML

Invalid HTML

这是一个引起注水错误的非常简单的应用:

Here’s a very simple application that causes a hydration error:

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <p>
            <div class:blue=move || count.get() == 2>
                 "First"
            </div>
        </p>
    }
}

这将给出一个类似这样的错误消息:

This will give an error message like

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 中出现的位置,这很方便。)

(In most browser devtools, you can right-click on that <p></p> to show where it appears in the DOM, which is handy.)

如果你查看 DOM 查看器,你会看到它不是在 <p> 内部有一个 <div>,而是显示为:

If you look in the DOM inspector, you’ll see that it instead of a <div> inside a <p>, it shows:

<p></p>
<div>First</div>
<p></p>

那是因为这是无效的 HTML!<div> 不能放在 <p> 内部。当浏览器解析那个 <div> 时,它实际上关闭了前面的 <p>,然后打开了 <div>;然后,当它看到(现在不匹配的)闭合标签 </p> 时,它将其视为一个新的空 <p>

That’s because this is invalid HTML! A <div> cannot go inside a <p>. When the browser parses that <div>, it actually closes the preceding <p>, then opens the <div>; then, when it sees the (now-unmatched) closing </p>, it treats it as a new, empty <p>.

结果,我们的 DOM 树不再匹配预期的视图树,注水错误随之发生。

As a result, our DOM tree no longer matches the expected view tree, and a hydration error ensues.

不幸的是,使用我们当前的模型,在不影响整体编译时间的情况下,很难在编译时确保视图中 HTML 的有效性。目前,如果你遇到此类问题,请考虑通过验证器运行 HTML 输出。(在上面的例子中,W3C HTML 验证器确实显示了一个错误!)

Unfortunately, it is difficult to ensure the validity of HTML in the view at compile time using our current model, and without an effect on compile times across the board. For now, if you run into issues like this, consider running the HTML output through a validator. (In the case above, the W3C HTML Validator does in fact show an error!)

:::admonish info

你可能会注意到,在从 0.6 迁移到 0.7 时会出现一些此类 bug。这是由于注水工作方式的改变。

Leptos 0.1-0.6 使用了一种注水方法,其中每个 HTML 元素都被赋予一个唯一的 ID,然后用于通过 ID 在 DOM 中查找它。Leptos 0.7 改为直接遍历 DOM,在遇到每个元素时对其进行注水。这具有更好的性能特征(更短、更干净的 HTML 输出和更快的注水时间),但对上述无效或边缘情况 HTML 示例的弹性较差。或许更重要的是,这种方法还修复了注水中的许多 其他 边缘情况和 bug,使框架总体上更具弹性。

:::

:::admonish info You may notice some bugs of this arise when migrating from 0.6 to 0.7. This is due to a change in how hydration works.

Leptos 0.1-0.6 used a method of hydration in which each HTML element was given a unique ID, which was then used to find it in the DOM by ID. Leptos 0.7 instead began walking over the DOM directly, hydrating each element as it came. This has much better performance characteristics (shorter, cleaner HTML output and faster hydration times) but is less resilient to the invalid or edge-case HTML examples above. Perhaps more importantly, this approach also fixes a number of other edge cases and bugs in hydration, making the framework more resilient on net. :::

没有 <tbody><table>

<table> without <tbody>

我所知道的还有一个额外的边缘情况,其中 有效 的 HTML 会产生与视图树不同的 DOM 树,那就是 <table>。当(大多数)浏览器解析 HTML <table> 时,无论你是否包含 <tbody>,它们都会在 DOM 中插入一个 <tbody>

There’s one additional edge case I’aware of, in which valid HTML produces a DOM tree that differs from the view tree, and that’s <table>. When (most) browsers parse an HTML <table>, they insert a <tbody> into the DOM, whether you included one or not.

#[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>
    }
}

同样,这会产生注水错误,因为浏览器在 DOM 树中插入了一个视图中不存在的额外 <tbody>

Again, this generates a hydration error, because the browser has inserted an additional <tbody> into the DOM tree that was not in your view.

在这里,修复很简单:添加 <tbody>

Here, the fix is simple: adding <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>
    }
}

(未来值得探讨的是,我们是否可以比验证有效 HTML 更容易地对这种特定的怪异行为进行 lint。)

(It would be worth exploring in the future whether we can lint for this particular quirk more easily than linting for valid HTML.)

一般建议

General Advice

这类不匹配可能很棘手。总的来说,我对调试的建议是:

In general, my recommendation for debugging:

  1. 右键点击消息中的元素,看看框架最初是在哪里 注意到 问题的。

  2. Right-click on the element in the message to see where the framework first notices the problem.

  3. 比较该点及其上方的 DOM,检查与视图树的不匹配。是否有额外的元素?缺少的元素?

  4. Compare the DOM at that point and above it, checking for mismatches with your view tree. Are there extra elements? Missing elements?

并非所有客户端代码都能在服务器上运行

Not all client code can run on the server

想象一下,你开心地导入了一个像 gloo-net 这样的依赖项,你以前一直用它在浏览器中发起请求,并将其用于服务器渲染应用中的 create_resource

Imagine you happily import a dependency like gloo-net that you’ve been used to using to make requests in the browser, and use it in a create_resource in a server-rendered app.

你可能会立即看到那条可怕的消息:

You’ll probably instantly see the dreaded message

panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets'

糟糕。

Uh-oh.

但这当然是有道理的。我们刚刚说过你的应用需要在客户端和服务器上都运行。

But of course this makes sense. We’ve just said that your app needs to run on the client and the server.

解决方案

Solution

有几种方法可以避免这种情况:

There are a few ways to avoid this:

  1. 只使用能在服务器和客户端上运行的库。例如,reqwest 在两种环境下都可以用于发起 HTTP 请求。

  2. Only use libraries that can run on both the server and the client. reqwest, for example, works for making HTTP requests in both settings.

  3. 在服务器和客户端上使用不同的库,并使用 #[cfg] 宏对它们进行门控。(点击此处查看示例。)

  4. Use different libraries on the server and the client, and gate them using the #[cfg] macro. (Click here for an example.)

  5. 将仅限客户端的代码包装在 Effect::new 中。因为副作用(effects)只在客户端运行,这可以是访问初始渲染不需要的浏览器 API 的有效方式。

  6. Wrap client-only code in Effect::new. Because effects only run on the client, this can be an effective way to access browser APIs that are not needed for initial rendering.

例如,假设我想在每当信号改变时将某些东西存储在浏览器的 localStorage 中。

For example, say that I want to store something in the browser’s localStorage whenever a signal changes.

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
	let storage = gloo_storage::LocalStorage::raw();
	logging::log!("{storage:?}");
}

这会 panic,因为我在服务器渲染期间无法访问 LocalStorage

This panics because I can’t access LocalStorage during server rendering.

但如果我把它包装在一个 effect 中……

But if I wrap it in an effect...

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
    Effect::new(move |_| {
        let storage = gloo_storage::LocalStorage::raw();
		log!("{storage:?}");
    });
}

没问题!这将在服务器上适当地渲染,忽略仅限客户端的代码,然后在浏览器上访问存储并记录消息。

It’s fine! This will render appropriately on the server, ignoring the client-only code, and then access the storage and log a message on the browser.

并非所有服务器代码都能在客户端运行

Not all server code can run on the client

在浏览器中运行的 WebAssembly 是一个非常受限的环境。你无法访问文件系统或标准库习惯于拥有的许多其他东西。并非每个 crate 都能被编译为 WASM,更不用说在 WASM 环境中运行了。

WebAssembly running in the browser is a pretty limited environment. You don’t have access to a file-system or to many of the other things the standard library may be used to having. Not every crate can even be compiled to WASM, let alone run in a WASM environment.

特别是,你有时会看到关于 crate mio 或缺少 core 里的东西的错误。这通常是一个信号,表明你正尝试将一些无法被编译为 WASM 的东西编译为 WASM。如果你添加了仅限服务器的依赖项,你需要将它们在 Cargo.toml 中标记为 optional = true,然后在 ssr 特性定义中启用它们。(查看一个模板 Cargo.toml 文件以了解更多细节。)

In particular, you’ll sometimes see errors about the crate mio or missing things from core. This is generally a sign that you are trying to compile something to WASM that can’t be compiled to WASM. If you’re adding server-only dependencies, you’ll want to mark them optional = true in your Cargo.toml and then enable them in the ssr feature definition. (Check out one of the template Cargo.toml files to see more details.)

你可以创建一个 Effect 来指定某些内容应该只在客户端运行,而不应在服务器上运行。有没有办法指定某些内容应该只在服务器上运行,而不在客户端上运行?

You can create an Effect to specify that something should only run on the client, and not on the server. Is there a way to specify that something should run only on the server, and not the client?

事实上,有的。下一章将详细介绍服务器函数(server functions)。(与此同时,你可以查看它们的文档 此处。)

In fact, there is. The next chapter will cover the topic of server functions in some detail. (In the meantime, you can check out their docs here.)

与服务器协作

Working with the Server

上一节描述了服务器端渲染的过程,即使用服务器生成页面的 HTML 版本,该页面随后在浏览器中变得可交互。到目前为止,一切都是“同构”的;换句话说,你的应用在客户端和服务器上具有“相同(iso)的形状(morphe)”。

The previous section described the process of server-side rendering, using the server to generate an HTML version of the page that will become interactive in the browser. So far, everything has been “isomorphic”; in other words, your app has had the “same (iso) shape (morphe)” on the client and the server.

但服务器能做的不不仅仅是渲染 HTML!事实上,服务器可以做很多浏览器 做不到 的事情,比如从 SQL 数据库中读取和写入数据。

But a server can do a lot more than just render HTML! In fact, a server can do a whole bunch of things your browser can’t, like reading from and writing to a SQL database.

如果你习惯于构建 JavaScript 前端应用,你可能习惯于调用某种 REST API 来执行这类服务器工作。如果你习惯于使用 PHP、Python、Ruby(或 Java、C#……)构建网站,那么这种服务器端工作就是你的拿手好戏,而客户端交互往往只是事后才想到的。

If you’re used to building JavaScript frontend apps, you’re probably used to calling out to some kind of REST API to do this sort of server work. If you’re used to building sites with PHP or Python or Ruby (or Java or C# or...), this server-side work is your bread and butter, and it’s the client-side interactivity that tends to be an afterthought.

有了 Leptos,你可以同时做到这两点:不仅使用相同的语言,不仅共享相同的类型,甚至在同一个文件中!

With Leptos, you can do both: not only in the same language, not only sharing the same types, but even in the same files!

本节将讨论如何构建应用程序中独特的服务器端部分。

This section will talk about how to build the uniquely-server-side parts of your application.

服务器函数

Server Functions

如果你正在创建任何超出玩具应用(toy app)范畴的项目,你就需要经常在服务器上运行代码:从仅在服务器运行的数据库读取或向其写入数据,使用你不希望发送到客户端的库运行昂贵的计算,访问由于 CORS 原因或因为需要存储在服务器上且绝对不应发送到用户浏览器的秘密 API 密钥而必须从服务器调用的 API。

If you’re creating anything beyond a toy app, you’ll need to run code on the server all the time: reading from or writing to a database that only runs on the server, running expensive computations using libraries you don’t want to ship down to the client, accessing APIs that need to be called from the server rather than the client for CORS reasons or because you need a secret API key that’s stored on the server and definitely shouldn’t be shipped down to a user’s browser.

传统上,这是通过分离服务器和客户端代码,并设置像 REST API 或 GraphQL API 之类的东西来允许你的客户端获取和变动服务器上的数据。这没问题,但它要求你在多个不同的地方编写和维护代码(客户端代码用于获取,服务器端函数用于运行),同时还需要创建一个额外的东西来管理,即两者之间的 API 协约。

Traditionally, this is done by separating your server and client code, and by setting up something like a REST API or GraphQL API to allow your client to fetch and mutate data on the server. This is fine, but it requires you to write and maintain your code in multiple separate places (client-side code for fetching, server-side functions to run), as well as creating a third thing to manage, which is the API contract between the two.

Leptos 是引入 服务器函数 (server functions) 概念的众多现代框架之一。服务器函数有两个关键特征:

Leptos is one of a number of modern frameworks that introduce the concept of server functions. Server functions have two key characteristics:

  1. 服务器函数与你的组件代码 同地协作 (co-located),这样你就可以按功能而不是按技术来组织工作。例如,你可能有一个“深色模式”功能,它应该跨会话持久化用户的深色/浅色模式偏好,并在服务器渲染期间应用,从而避免闪烁。这需要一个在客户端上具有交互性的组件,以及一些在服务器上完成的工作(设置 cookie,甚至在数据库中存储用户信息)。传统上,这个功能最终可能会分散在代码中的两个不同位置,一个在“前端”,一个在“后端”。有了服务器函数,你可能只需将它们都写在一个 dark_mode.rs 中,然后就可以不用管它了。

  2. Server functions are co-located with your component code, so that you can organize your work by feature, not by technology. For example, you might have a “dark mode” feature that should persist a user’s dark/light mode preference across sessions, and be applied during server rendering so there’s no flicker. This requires a component that needs to be interactive on the client, and some work to be done on the server (setting a cookie, maybe even storing a user in a database.) Traditionally, this feature might end up being split between two different locations in your code, one in your “frontend” and one in your “backend.” With server functions, you’ll probably just write them both in one dark_mode.rs and forget about it.

  3. 服务器函数是 同构的 (isomorphic),即它们既可以从服务器调用,也可以从浏览器调用。这是通过为两个平台生成不同的代码来实现的。在服务器上,服务器函数只需运行。在浏览器中,服务器函数的主体被替换为一个存根(stub),该存根实际上向服务器发起 fetch 请求,将参数序列化到请求中,并从响应中反序列化返回值。但在任何一端,该函数都可以简单地被调用:你可以创建一个写入数据库的 add_todo 函数,并只需从浏览器中按钮的点击处理器调用它即可!

  4. Server functions are isomorphic, i.e., they can be called either from the server or the browser. This is done by generating code differently for the two platforms. On the server, a server function simply runs. In the browser, the server function’s body is replaced with a stub that actually makes a fetch request to the server, serializing the arguments into the request and deserializing the return value from the response. But on either end, the function can simply be called: you can create an add_todo function that writes to your database, and simply call it from a click handler on a button in the browser!

使用服务器函数

Using Server Functions

事实上,我很喜欢那个例子。它看起来会是什么样子?其实非常简单。

Actually, I kind of like that example. What would it look like? It’s pretty simple, actually.

// todo.rs
// 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>
	}
}

你会立即注意到这里有几件事:

You’ll notice a couple things here right away:

  • 服务器函数可以使用仅限服务器的依赖项,如 sqlx,并且可以访问仅限服务器的资源,如我们的数据库。

  • Server functions can use server-only dependencies, like sqlx, and can access server-only resources, like our database.

  • 服务器函数是 async 的。即使它们在服务器上只执行同步工作,函数签名仍然需要是 async 的,因为从浏览器调用它们 必须 是异步的。

  • Server functions are async. Even if they only did synchronous work on the server, the function signature would still need to be async, because calling them from the browser must be asynchronous.

  • 服务器函数返回 Result<T, ServerFnError>。同样,即使它们在服务器上只执行绝不会出错(infallible)的工作,这也是成立的,因为 ServerFnError 的变体包含了在发起网络请求过程中可能出现的各种错误。

  • Server functions return Result<T, ServerFnError>. Again, even if they only do infallible work on the server, this is true, because ServerFnError’s variants include the various things that can be wrong during the process of making a network request.

  • 服务器函数可以从客户端调用。看看我们的点击处理器。这是 永远只会 在客户端运行的代码。但它可以像调用普通的异步函数一样调用函数 add_todo(使用 spawn_local 来运行 Future):

  • Server functions can be called from the client. Take a look at our click handler. This is code that will only ever run on the client. But it can call the function add_todo (using spawn_local to run the Future) as if it were an ordinary async function:

move |_| {
	spawn_local(async {
		add_todo("So much to do!".to_string()).await;
	});
}
  • 服务器函数是用 fn 定义的顶级函数。与事件监听器、派生信号以及 Leptos 中的绝大多数其他事物不同,它们不是闭包!作为 fn 调用,它们无法访问应用的响应式状态或任何未作为参数传入的内容。同样,这非常合理:当你向服务器发起请求时,服务器无法访问客户端状态,除非你显式发送它。(否则我们将不得不序列化整个响应式系统,并在每次请求时通过线路发送。这可不是个好主意。)

  • Server functions are top-level functions defined with fn. Unlike event listeners, derived signals, and most everything else in Leptos, they are not closures! As fn calls, they have no access to the reactive state of your app or anything else that is not passed in as an argument. And again, this makes perfect sense: When you make a request to the server, the server doesn’t have access to client state unless you send it explicitly. (Otherwise we’d have to serialize the whole reactive system and send it across the wire with every request. This would not be a great idea.)

  • 服务器函数的参数和返回值都需要是可序列化的。同样,希望这也能理解:虽然通常函数参数不需要被序列化,但从浏览器调用服务器函数意味着序列化参数并通过 HTTP 发送它们。

  • Server function arguments and return values both need to be serializable. Again, hopefully this makes sense: while function arguments in general don’t need to be serialized, calling a server function from the browser means serializing the arguments and sending them over HTTP.

关于定义服务器函数的方式,也有几点需要注意。

There are a few things to note about the way you define a server function, too.

  • 服务器函数是通过使用 #[server] 来标注定义的,它是一个可以定义在任何地方的顶级函数。

  • Server functions are created by using the #[server] macro to annotate a top-level function, which can be defined anywhere.

服务器函数通过使用条件编译来工作。在服务器上,服务器函数创建一个 HTTP 端点,该端点接收其参数作为 HTTP 请求,并将其结果作为 HTTP 响应返回。对于客户端/浏览器构建,服务器函数的主体被替换为一个 HTTP 请求存根。

Server functions work by using conditional compilation. On the server, the server function creates an HTTP endpoint that receives its arguments as an HTTP request, and returns its result as an HTTP response. For the client-side/browser build, the body of the server function is stubbed out with an HTTP request.

:::admonish warning title="关于安全性的重要提示"

关于安全性的重要提示

An Important Note about Security

服务器函数是一项很酷的技术,但请务必记住:服务器函数不是魔法;它们只是定义公共 API 的语法糖。 服务器函数的 主体 (body) 永远不会公开;它只是你服务器二进制文件的一部分。但服务器函数是一个可以公开访问的 API 端点,其返回值只是一个 JSON 或类似的二进制大对象(blob)。除非信息是公开的,或者你已经实施了适当的安全程序,否则不要从服务器函数返回信息。这些程序可能包括验证传入请求、确保适当的加密、速率限制访问等。

Server functions are a cool technology, but it’s very important to remember. Server functions are not magic; they’re syntax sugar for defining a public API. The body of a server function is never made public; it’s just part of your server binary. But the server function is a publicly accessible API endpoint, and its return value is just a JSON or similar blob. Do not return information from a server function unless it is public, or you've implemented proper security procedures. These procedures might include authenticating incoming requests, ensuring proper encryption, rate limiting access, and more. :::

自定义服务器函数

Customizing Server Functions

默认情况下,服务器函数将其参数编码为 HTTP POST 请求(使用 serde_qs),将其返回值编码为 JSON(使用 serde_json)。这种默认设置旨在促进与 <form> 元素的兼容性,即使在 WASM 被禁用、不受支持或尚未加载的情况下,<form> 元素也原生支持发起 POST 请求。它们将端点挂载在一个经过哈希处理的 URL 上,旨在防止命名冲突。

By default, server functions encode their arguments as an HTTP POST request (using serde_qs) and their return values as JSON (using serde_json). This default is intended to promote compatibility with the <form> element, which has native support for making POST requests, even when WASM is disabled, unsupported, or has not yet loaded. They mount their endpoints at a hashed URL intended to prevent name collisions.

然而,自定义服务器函数的方法有很多,支持各种输入和输出编码、设置特定端点的能力等等。

However, there are many ways to customize server functions, with a variety of supported input and output encodings, the ability to set specific endpoints, and so on.

查看 #[server]server_fn crate 的文档,以及仓库中广泛的 server_fns_axum 示例,以获取更多信息和示例。

Take a look at the docs for the #[server] macro and server_fn crate, and the extensive server_fns_axum example in the repo for more information and examples.

使用自定义错误

Using Custom Errors

服务器函数可以返回任何实现了 FromServerFnError trait 的错误类型。 这使得错误处理更加符合人体工程学,并允许你向客户端提供特定领域的错误信息:

Server functions can return any kind of errors that implement the FromServerFnError trait. This makes error handling much more ergonomic and allows you to provide domain-specific error information to your clients:

use leptos::prelude::*;
use server_fn::codec::JsonEncoding;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum AppError {
    ServerFnError(ServerFnErrorErr),
    DbError(String),
}

impl FromServerFnError for AppError {
    type Encoder = JsonEncoding;

    fn from_server_fn_error(value: ServerFnErrorErr) -> Self {
        AppError::ServerFnError(value)
    }
}

#[server]
pub async fn create_user(name: String, email: String) -> Result<User, AppError> {
    // 尝试在数据库中创建用户
    // Try to create user in database
    match insert_user_into_db(&name, &email).await {
        Ok(user) => Ok(user),
        Err(e) => Err(AppError::DbError(e.to_string())),
    }
}

注意事项

Quirks to Note

服务器函数有一些值得注意的特性(quirks):

Server functions come with a few quirks that are worth noting:

  • 使用指针大小的整数类型,如 isizeusize,在 32 位 WASM 架构和 64 位服务器架构之间进行调用时可能会导致错误;如果服务器返回的值超过了 32 位所能容纳的范围,这将导致反序列化错误。使用固定大小的类型(如 i32i64)可以缓解这个问题。

  • Using pointer-sized integer types such as isize and usize can lead to errors when making calls between the 32-bit WASM architecture and a 64-bit server architecture; if the server responds with a value that doesn't fit in 32 bits, this will lead to a deserialization error. Use fixed size types such as i32 or i64 to mitigate this problem.

  • 发送到服务器的参数默认使用 serde_qs 进行 URL 编码。这使它们能很好地与 <form> 元素配合工作,但也可能有一些特性:例如,当前版本的 serde_qs 在处理可选类型(见 此处此处)或带有元组变体的枚举(见 此处)时并不总是能很好地工作。你可以使用这些 issue 中描述的变通方法,或者 切换到备选输入编码

  • Arguments sent to the server are URL-encoded using serde_qs by default. This allows them to work well with <form> elements, but can have some quirks: for example, the current version of serde_qs does not always work well with optional types (see here or here) or with enums that have tuple variants (see here). You can use the workarounds described in those issues, or switch to an alternate input encoding.

将服务器函数与 Leptos 集成

Integrating Server Functions with Leptos

到目前为止,我所说的一切实际上都是与框架无关的。(事实上,Leptos 的服务器函数 crate 也已经被集成到了 Dioxus 中!)服务器函数只是一种定义类似于函数的 RPC 调用的方式,它依赖于 HTTP 请求和 URL 编码等 Web 标准。

So far, everything I’ve said is actually framework agnostic. (And in fact, the Leptos server function crate has been integrated into Dioxus as well!) Server functions are simply a way of defining a function-like RPC call that leans on Web standards like HTTP requests and URL encoding.

但在某种程度上,它们也为我们到目前为止的故事提供了最后缺失的原语。因为服务器函数只是一个普通的 Rust 异步函数,它能与我们 之前 讨论过的异步 Leptos 原语完美集成。因此,你可以轻松地将服务器函数与应用程序的其他部分集成:

But in a way, they also provide the last missing primitive in our story so far. Because a server function is just a plain Rust async function, it integrates perfectly with the async Leptos primitives we discussed earlier. So you can easily integrate your server functions with the rest of your applications:

  • 创建 资源 (resources),通过调用服务器函数从服务器加载数据。

  • Create resources that call the server function to load data from the server

  • <Suspense/><Transition/> 下读取这些资源,以便在数据加载时启用流式 SSR 和回退状态。

  • Read these resources under <Suspense/> or <Transition/> to enable streaming SSR and fallback states while data loads.

  • 创建 操作 (actions),通过调用服务器函数在服务器上变动数据。

  • Create actions that call the server function to mutate data on the server

本书的最后一部分将通过介绍使用渐进增强(progressively-enhanced)的 HTML 表单来运行这些服务器操作的模式,使这一点变得更加具体。

The final section of this book will make this a little more concrete by introducing patterns that use progressively-enhanced HTML forms to run these server actions.

但在接下来的几章中,我们将实际研究一下你可能想用服务器函数做的一些细节,包括与 Actix 和 Axum 服务器框架提供的强大提取器(extractors)集成的最佳方法。

But in the next few chapters, we’ll actually take a look at some of the details of what you might want to do with your server functions, including the best ways to integrate with the powerful extractors provided by the Actix and Axum server frameworks.

提取器

Extractors

我们在上一章中看到的服务器函数展示了如何在服务器上运行代码,并将其与你在浏览器中渲染的用户界面集成。但它们并没有展示如何真正发挥服务器的全部潜力。

The server functions we looked at in the last chapter showed how to run code on the server, and integrate it with the user interface you’re rendering in the browser. But they didn’t show you much about how to actually use your server to its full potential.

服务器框架

Server Frameworks

我们称 Leptos 为“全栈”框架,但“全栈”总是一个误称(毕竟它从来不意味着从浏览器到你的电力公司的所有环节)。对我们来说,“全栈”意味着你的 Leptos 应用可以在浏览器中运行,也可以在服务器上运行,并能将两者集成,汇聚各方独有的特性;正如我们在本书中目前所看到的,浏览器上的一个按钮点击可以驱动服务器上的数据库读取,而这两者都写在同一个 Rust 模块中。但 Leptos 本身并不提供服务器(或数据库、操作系统、固件、电缆……)。

We call Leptos a “full-stack” framework, but “full-stack” is always a misnomer (after all, it never means everything from the browser to your power company.) For us, “full stack” means that your Leptos app can run in the browser, and can run on the server, and can integrate the two, drawing together the unique features available in each; as we’ve seen in the book so far, a button click on the browser can drive a database read on the server, both written in the same Rust module. But Leptos itself doesn’t provide the server (or the database, or the operating system, or the firmware, or the electrical cables...)

相反,Leptos 为两个最流行的 Rust Web 服务器框架提供了集成:Actix Web (leptos_actix) 和 Axum (leptos_axum)。我们已经与每个服务器的路由管理器构建了集成,这样你就可以通过 .leptos_routes() 简单地将 Leptos 应用插入到现有的服务器中,并轻松处理服务器函数调用。

Instead, Leptos provides integrations for the two most popular Rust web server frameworks, Actix Web (leptos_actix) and Axum (leptos_axum). We’ve built integrations with each server’s router so that you can simply plug your Leptos app into an existing server with .leptos_routes(), and easily handle server function calls.

如果你还没看过我们的 ActixAxum 模板,现在是查看它们的好时机。

If you haven’t seen our Actix and Axum templates, now’s a good time to check them out.

使用提取器

Using Extractors

Actix 和 Axum 的处理器(handlers)都建立在同一个强大的概念之上:提取器 (extractors)。提取器从 HTTP 请求中“提取”类型化的数据,让你能够轻松访问服务器特定的数据。

Both Actix and Axum handlers are built on the same powerful idea of extractors. Extractors “extract” typed data from an HTTP request, allowing you to access server-specific data easily.

Leptos 提供了 extract 辅助函数,让你可以直接在服务器函数中使用这些提取器,其语法非常类似于每个框架的处理器,非常方便。

Leptos provides extract helper functions to let you use these extractors directly in your server functions, with a convenient syntax very similar to handlers for each framework.

Actix 提取器

Actix Extractors

leptos_actix 中的 extract 函数 接收一个处理函数作为参数。该处理函数遵循类似于 Actix 处理器的规则:它是一个异步函数,接收将从请求中提取的参数,并返回某些值。该处理函数接收提取的数据作为其参数,并可以在 async move 块的主体内部对它们执行进一步的 async 操作。它会将你返回的任何值返回到服务器函数中。

The extract function in leptos_actix takes a handler function as its argument. The handler follows similar rules to an Actix handler: it is an async function that receives arguments that will be extracted from the request and returns some value. The handler function receives that extracted data as its arguments, and can do further async work on them inside the body of the async move block. It returns whatever value you return back out into the server function.

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 提取器

Axum Extractors

leptos_axum::extract 函数的语法非常相似。

The syntax for the leptos_axum::extract function is very similar.

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:?}"))
}

这些是从服务器访问基本数据的相对简单的例子。但你可以使用完全相同的 extract() 模式,通过提取器访问诸如 header、cookie、数据库连接池等内容。

These are relatively simple examples accessing basic data from the server. But you can use extractors to access things like headers, cookies, database connection pools, and more, using the exact same extract() pattern.

Axum 的 extract 函数仅支持状态(state)为 () 的提取器。如果你需要一个使用 State 的提取器,你应该使用 extract_with_state。这要求你提供状态。你可以通过使用 Axum 的 FromRef 模式扩展现有的 LeptosOptions 状态来实现,这在渲染期间以及带有自定义处理器的服务器函数中通过上下文(context)提供状态。

The Axum extract function only supports extractors for which the state is (). If you need an extractor that uses State, you should use extract_with_state. This requires you to provide the state. You can do this by extending the existing LeptosOptions state using the Axum FromRef pattern, which providing the state as context during render and server functions with custom handlers.

use axum::extract::FromRef;

/// 派生 FromRef 以允许状态中包含多个项目,
/// 使用 Axum 的 SubStates 模式。
/// Derive FromRef to allow multiple items in state, using Axum’s
/// SubStates pattern.
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
    pub leptos_options: LeptosOptions,
    pub pool: SqlitePool
}

点击此处查看在自定义处理器中提供上下文的示例

Click here for an example of providing context in custom handlers.

Axum 状态 (State)

Axum State

Axum 典型的依赖注入模式是提供一个 State,然后可以在你的路由处理器中提取它。Leptos 通过上下文(context)提供了它自己的依赖注入方法。上下文通常可以用来代替 State 来提供共享的服务器数据(例如,数据库连接池)。

Axum's typical pattern for dependency injection is to provide a State, which can then be extracted in your route handler. Leptos provides its own method of dependency injection via context. Context can often be used instead of State to provide shared server data (for example, a database connection pool).

let connection_pool = /* 这里是某些共享状态 */;

let app = Router::new()
    .leptos_routes_with_context(
        &leptos_options,
        routes,
        move || provide_context(connection_pool.clone()),
        {
            let leptos_options = leptos_options.clone();
            move || shell(leptos_options.clone())
        },
    )
    // 等等。

然后,可以在你的服务器函数内部通过简单的 use_context::<T>() 访问此上下文。

This context can then be accessed with a simple use_context::<T>() inside your server functions.

如果你 需要 在服务器函数中使用 State——例如,如果你有一个现有的 Axum 提取器需要 State——通过 Axum 的 FromRef 模式和 extract_with_state 也是可能的。本质上,你需要同时通过上下文和 Axum 路由状态提供该状态:

If you need to use State in a server function—for example, if you have an existing Axum extractor that requires State—that is also possible using Axum's FromRef pattern and extract_with_state. Essentially you'll need to provide the state both via context and via Axum router state:

#[derive(FromRef, Debug, Clone)]
pub struct MyData {
    pub value: usize,
    pub leptos_options: LeptosOptions,
}

let app_state = MyData {
    value: 42,
    leptos_options,
};

// 使用路由构建我们的应用
// build our application with a route
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::<MyData>();
    let SomeStateExtractor(data) = extract_with_state(&state).await?;
    // todo
}

泛型状态 (Generic State)

Generic State

在某些情况下,你可能希望在状态中使用泛型。让我们使用以下示例:

In some cases, you may want to use a generic type for your state. Let's use the following example:

pub struct AppState<TS: ThingService> {
    pub thing_service: Arc<TS>,
} 

在 Axum 中,你通常会在处理器中使用泛型参数,如下所示:

In Axum you would typically use a generic parameter with your handler, like so:

pub async fn do_thing<TS: ThingService>(
    State(state): State<AppState<TS>>,
) -> Result<(), ThingError> {
    state.thing_service.do_thing()
}

不幸的是,Leptos 服务器函数目前不支持泛型参数。但是,你可以通过在服务器函数中使用具体类型来调用内部泛型函数来规避此限制。你可以这样做:

Unfortunately, generic parameters are not currently supported in Leptos server functions. However, you can work around this limitation by using a concrete type in your server function to call an inner generic function. Here's how you can do it:

pub async do_thing_inner<TS: ThingService>() -> Result<(), ServerFnError> {
    let state = expect_context::<AppState<TS>>(); // 可以工作!
    state.thing_service.do_thing()
}

#[server]
pub async do_thing() -> Result<(), ServerFnError> {
    use crate::thing::service::Service as ConcreteThingService;
    use crate::thing::some_dep::SomeDep;

    do_thing_inner::<ConcreteThingService<SomeDep>>().await
}

关于加载数据模式的说明

A Note about Data-Loading Patterns

由于 Actix 和(尤其是)Axum 建立在单次往返 HTTP 请求和响应的思想之上,你通常在应用程序的“顶部”(即开始渲染之前)运行提取器,并使用提取的数据来决定应如何渲染。在渲染一个 <button> 之前,你已经加载了应用可能需要的所有数据。并且任何给定的路由处理器都需要知道该路由需要提取的所有数据。

Because Actix and (especially) Axum are built on the idea of a single round-trip HTTP request and response, you typically run extractors near the “top” of your application (i.e., before you start rendering) and use the extracted data to determine how that should be rendered. Before you render a <button>, you load all the data your app could need. And any given route handler needs to know all the data that will need to be extracted by that route.

但 Leptos 集成了客户端和服务器,因此能够在不强制重新加载所有数据的情况下,使用来自服务器的新数据刷新 UI 的一小部分是很重要的。所以 Leptos 喜欢将数据加载在应用中“下沉”,尽可能靠近用户界面的叶子节点。当你点击一个 <button> 时,它可以只刷新它需要的数据。这正是服务器函数的用武之地:它们为你提供了对要加载和重新加载的数据的粒度访问。

But Leptos integrates both the client and the server, and it’s important to be able to refresh small pieces of your UI with new data from the server without forcing a full reload of all the data. So Leptos likes to push data loading “down” in your application, as far towards the leaves of your user interface as possible. When you click a <button>, it can refresh just the data it needs. This is exactly what server functions are for: they give you granular access to data to be loaded and reloaded.

extract() 函数通过在服务器函数中使用提取器,让你能够结合这两种模型。你可以获得路由提取器的全部能力,同时将需要提取什么内容的知识分散到各个组件中。这使得重构和重新组织路由变得更加容易:你不需要预先指定一个路由所需的所有数据。

The extract() functions let you combine both models by using extractors in your server functions. You get access to the full power of route extractors, while decentralizing knowledge of what needs to be extracted down to your individual components. This makes it easier to refactor and reorganize routes: you don’t need to specify all the data a route needs up front.

响应与重定向

Responses and Redirects

提取器提供了一种在服务器函数内部访问请求数据的简便方法。Leptos 还提供了一种修改 HTTP 响应的方法,即使用 ResponseOptions 类型(参见 ActixAxum 类型的文档)和 redirect 辅助函数(参见 ActixAxum 的文档)。

Extractors provide an easy way to access request data inside server functions. Leptos also provides a way to modify the HTTP response, using the ResponseOptions type (see docs for Actix or Axum types) and the redirect helper function (see docs for Actix or Axum).

ResponseOptions

在初始服务器渲染响应期间以及随后的任何服务器函数调用期间,ResponseOptions 通过上下文(context)提供。它允许你轻松设置 HTTP 响应的状态码,或向 HTTP 响应添加标头(headers),例如设置 cookie。

ResponseOptions is provided via context during the initial server rendering response and during any subsequent server function call. It allows you to easily set the status code for the HTTP response, or to add headers to the HTTP response, e.g., to set cookies.

#[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
    // pull ResponseOptions from context
    let response = expect_context::<ResponseOptions>();

    // 设置 HTTP 状态码
    // set the HTTP status code
    response.set_status(StatusCode::IM_A_TEAPOT);

    // 在 HTTP 响应中设置 cookie
    // set a cookie in the HTTP response
    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 函数,使这一操作变得非常简单。

One common modification to an HTTP response is to redirect to another page. The Actix and Axum integrations provide a redirect function to make this easy to do.

#[server]
pub async fn login(
    username: String,
    password: String,
    remember: Option<String>,
) -> Result<(), ServerFnError> {
    const INVALID_CREDENTIALS: fn() -> ServerFnError = || -> ServerFnError {
        ServerFnError::ServerError("Invalid credentials".into())
    };

    // 从上下文中获取 DB 连接池和身份验证提供者
    // pull the DB pool and auth provider from context
    let pool = pool()?;
    let auth = auth()?;

    // 检查用户是否存在
    // check whether the user exists
    let user: User = User::get_from_username(username, &pool)
        .await
        .ok_or_else(INVALID_CREDENTIALS)?;

    // 检查用户提供的密码是否正确
    // check whether the user has provided the correct password
    match verify(password, &user.password)? {
        // 如果密码正确...
        // if the password is correct...
        true => {
            // 登录用户
            // log the user in
            auth.login_user(user.id);
            auth.remember_user(remember.is_some());

            // 并重定向到主页
            // and redirect to the home page
            leptos_axum::redirect("/");
            Ok(())
        }
        // 如果密码错误,返回错误
        // if not, return an error
        false => Err(INVALID_CREDENTIALS()),
    }
}

这个服务器函数随后可以在你的应用程序中使用。这个 redirect 与渐进增强的 <ActionForm/> 组件配合得很好:在没有 JS/WASM 的情况下,服务器响应将根据状态码和标头进行重定向。在有 JS/WASM 的情况下,<ActionForm/> 会检测服务器函数响应中的重定向,并使用客户端导航重定向到新页面。

This server function can then be used from your application. This redirect works well with the progressively-enhanced <ActionForm/> component: without JS/WASM, the server response will redirect because of the status code and header. With JS/WASM, the <ActionForm/> will detect the redirect in the server function response, and use client-side navigation to redirect to the new page.

参考:异步闭包/Future

Reference: Asynchronous Closures/Futures

随着各种不同类型的异步闭包和 Future 的引入,跟踪它们在不同上下文中执行的时间和地点可能会变得困难。下表简要描述了引入的每种异步闭包类型在何时何地执行。请注意每种异步闭包类型在服务器和客户端上,以及结合相关的执行阶段(SSR/注水/CSR)时的行为差异。

With all the different types of asynchronous closures and futures having been introduced, it may become difficult to keep track of where and when they execute in different contexts. The following table briefly describes when and where the code is executed for each of the introduced asynchronous closure types. Note how each asynchronous closure type behaves differently when executed on the server and on the client in conjunction with the relevant stage of execution (SSR/hydration/CSR).

服务器端渲染 (SSR)注水 (Hydration) 期间(为 CSR 做准备)客户端渲染 (CSR)
Server-side renderingDuring hydration (preparing for CSR)Client-side rendering
服务器函数 (Server 函数体)作为普通的异步函数调用,直接执行函数体中定义的代码并将结果返回给调用者。在组件内部执行时,可以访问完整的响应式上下文。-(通过响应对服务器函数独立端点的 HTTP 请求来调用,响应将是序列化的结果。由于此端点是完全独立的,响应式上下文通常不可用。)
Server Functions (Server)Invoked as a normal asynchronous function, with the code defined in the body directly executed and the results returned to the caller. When executed inside a component, the full reactive context is accessible.-(Invoked by responding to the HTTP request made to the server function's standalone endpoint, where the response will be the serialized result. Since this endpoint is fully standalone, reactive context is typically not available.)
服务器函数 (Client 调用侧)-不执行。(当定义在 Resource 类型的异步闭包内时。)向服务器函数的端点发起 HTTP 请求,响应体将被反序列化为结果。请注意,函数体中的代码从未被编译进客户端。
Server Functions (Client)-Not executed. (When defined inside resource typed async closures.)Makes an HTTP request to the server function's endpoint, the response body will be deserialized into the result. Take note that the code in the function body is never compiled into the client.
资源 (Resources) (Server)它们的异步闭包由异步执行器尽可能地 并行 执行;结果被编码到响应体内的 <script> 标签中。--
Resources (Server)Their async closures are executed in parallel when possible by the async executor; results are encoded into the response body inside <script> tags.--
资源 (Resources) (Client)-不执行;响应体内的 <script> 标签提供结果。每当响应式系统需要返回任何新结果时,就会执行它们的异步闭包。
Resources (Client)-Not executed; the <script> tags within the response body provides the results.Their async closures are executed whenever required by the reactive system to return any new results.
本地资源 (Local Resources) (仅限 Client)-不执行。(注水后将执行一次以提供初始值。)每当响应式系统需要返回任何新结果时,就会执行它们的异步闭包。
Local Resources (Client Only)-Not executed. (Will be executed once after hydration to provide the initial value.)Their async closures are executed whenever required by the reactive system to return any new results.
悬停 (Suspend) (Server)这些 Future 由异步执行器尽可能地 并行 执行。选定的 SsrMode 可以修改:1) 这些 Future 何时被轮询,2) 输出流的顺序。--
Suspend (Server)These futures are executed in parallel when possible by the async executor. The selected SsrMode can modify how and when: 1) these futures are polled, 2) the ordering of the stream of outputs.--
悬停 (Suspend) (Client)-这些 Future 作为注水的一部分在客户端完整执行一次;返回的 view! {} 将用于注水 HTML。每当响应式系统需要时,就会执行这些 Future;返回的 view! {} 将在它们定义的地方渲染。
Suspend (Client)-These futures are fully executed once more in the client as part of hydration; returned view! {} will be used to hydrate the HTML.These futures are executed whenever required by the reactive system; returned view! {} will be rendered wherever they are defined.

需要记住的一点是,执行阶段多达三个,分布在服务器和客户端之间。其中一个后果是,如果 Resource 或 Suspend 内部存在副作用,可能会出现注水错误或预料之外的程序行为。这些副作用在不同的执行阶段可能不会在预期的时间或以相同的顺序执行,这种不匹配可能导致注水错误。

A useful note to keep in mind is that there are up to three different stages of execution, split between both the server and the client. One consequence of this are the potential for hydration bugs or unexpected program behavior if there are side effects inside a Resource or Suspend. These side effects may not be executed in the expected time or in the identical order across the different stages of execution, and this mismatch may lead to hydration errors.

例如,副作用可能源于设置信号的资源。资源可能会在其异步闭包中通过响应式信号传递一个异步获取的值——这在 SSR 和 CSR 下表现完美,但如果涉及注水,则表现不符合预期。在注水期间,异步闭包的主体 不会 被执行,从而导致信号在注水期间未被设置;而由于该闭包在 SSR 下会被执行并设置信号,SSR 下信号被设置与注水期间未设置之间的这种不匹配可能导致注水错误。为了纠正这一点,让资源返回该值,以便 Suspend 在通过响应式信号传递该值之前可以 .await 它,从而确保在所有三个执行阶段都设置了信号。

For example, a side effect might arise from a resource that sets a signal. The resource might pass a value that's sourced asynchronously through a reactive signal in its asychronous closure - this behaves perfectly fine under SSR and CSR, but will not behave in the expected manner if hydration is involved. During hydration, the body of the asynchronous closure will not be executed, thus resulting in the signal not being set during hydration; and since that closure under SSR would be executed and thus the signal would be set there, this mismatch between the signal being set under SSR and not set during hydration may lead to a hydration error. To correct for this, have the resource return the value, so that a Suspend may .await for it before passing it through the reactive signal, ensuring the signal is set under all three execution stages.

产生这种不匹配的另一种方式是当多个 Suspend 使用同一个信号时。Suspend 在服务器上的轮询顺序可能与客户端上的轮询顺序不同;这仅仅是由于这两个上下文使用了不同的异步执行器。这实际上导致了不稳定的执行顺序,从而改变了值传递给信号的顺序。最终结果是又一个 SSR 与注水之间值不匹配的潜在可能,而在这种特定情况下,注水错误可能变得非确定性,从而难以缩小范围。

Another way this mismatch may arise is when a single signal is used by multiple Suspends. The Suspends may be polled in a different order on the server versus how they may be polled on the client; this is simply due to the different async executors being used in these two contexts. This effectively results in an unstable order of execution, leading to the changing of the order of which the values are passed through the signal. Ultimately the result is yet another potential for values being mismatched under SSR versus hydration, and in this particular instance the hydration error may become non-deterministic and thus difficult to narrow down.

再举一个例子,在 SSR 期间,服务器函数将可以访问由应用程序设置的全套上下文,因为它们在应用程序渲染期间将作为标准异步函数被调用。另一方面,它们的 API 端点对应物作为更简单的独立端点处理器存在,因此它们运行时没有任何应用程序设置的上下文。因此,对应用程序设置的类型使用 expect_contextuse_context 将不起作用,除非这些值是通过 .leptos_routes_with_context() 提供给上下文的。

As another example, during SSR, server functions will have access to the full set of contexts set up by the application, as they will be invoked as standard async functions during rendering of the application. On the otherhand, their API endpoint counterparts exist as much more simple standalone endpoint handlers, thus they will run without any of the contexts that the application have set up. Hence the use of expect_context and use_context on types that are set up by the application will not work, unless the values were provided to context by .leptos_routes_with_context().

以上仅是一些示例,用作在典型文档配置下(即服务器函数仅在 Resource 内部被调用/等待,Resource 仅在 Suspend 内部被等待)不同类型行为的快速参考,因为以其他方式组合或嵌套这些异步闭包可能会导致其他行为。

The above are just some examples to serve as a quick reference of the behavior of the different types under the typical documented configuration (i.e. server functions are only invoked/awaited inside Resources, and Resources are only awaited inside Suspends), as combining or nesting of these asynchronous closures in some other manner can cause other behaviors.

渐进式增强(与优雅降级)

Progressive Enhancement (and Graceful Degradation)

我在波士顿开车已经大约十五年了。如果你不了解波士顿,让我告诉你:马萨诸塞州拥有世界上一些最激进的司机(和行人!)。我学会了践行一种有时被称为“防御性驾驶”的习惯:假设在你有优先通行权的交叉路口,有人正准备突然切到你前面;随时准备好应对可能冲向马路的行人,并据此驾驶。

I’ve been driving around Boston for about fifteen years. If you don’t know Boston, let me tell you: Massachusetts has some of the most aggressive drivers (and pedestrians!) in the world. I’ve learned to practice what’s sometimes called “defensive driving”: assuming that someone’s about to swerve in front of you at an intersection when you have the right of way, preparing for a pedestrian to cross into the street at any moment, and driving accordingly.

“渐进式增强”是网页设计的“防御性驾驶”。或者更准确地说,是“优雅降级”,虽然它们是同一枚硬币的两面,或者是从两个不同方向进行的同一个过程。

“Progressive enhancement” is the “defensive driving” of web design. Or really, that’s “graceful degradation,” although they’re two sides of the same coin, or the same process, from two different directions.

在本文语境下,渐进式增强(Progressive enhancement)意味着从一个简单的 HTML 网站或应用开始,它能为任何访问你页面的用户提供服务,并逐渐通过添加功能层来增强它:CSS 用于样式设计,JavaScript 用于交互,WebAssembly 用于由 Rust 驱动的交互;如果某些特定的 Web API 可用且有需要,则使用它们来获得更丰富的体验。

Progressive enhancement, in this context, means beginning with a simple HTML site or application that works for any user who arrives at your page, and gradually enhancing it with layers of additional features: CSS for styling, JavaScript for interactivity, WebAssembly for Rust-powered interactivity; using particular Web APIs for a richer experience if they’re available and as needed.

优雅降级(Graceful degradation)意味着当这一叠增强功能中的某些部分不可用时,能够优雅地处理故障。以下是用户在你的应用中可能遇到的一些故障来源:

Graceful degradation means handling failure gracefully when parts of that stack of enhancement aren’t available. Here are some sources of failure your users might encounter in your app:

  • 他们的浏览器不支持 WebAssembly,因为需要更新。

  • 他们的浏览器无法支持 WebAssembly,因为浏览器更新仅限于较新的操作系统版本,而这些版本无法安装在设备上。(说的就是你,苹果。)

  • 出于安全或隐私原因,他们关闭了 WASM。

  • 出于安全或隐私原因,他们关闭了 JavaScript。

  • 他们的设备不支持 JavaScript(例如,某些辅助设备仅支持 HTML 浏览)。

  • JavaScript(或 WASM)从未送达他们的设备,因为他们走到户外并丢失了 WiFi。

  • 他们在加载初始页面后踏入了地铁车厢,随后的导航无法加载数据。

  • ……等等。

  • Their browser doesn’t support WebAssembly because it needs to be updated.

  • Their browser can’t support WebAssembly because browser updates are limited to newer OS versions, which can’t be installed on the device. (Looking at you, Apple.)

  • They have WASM turned off for security or privacy reasons.

  • They have JavaScript turned off for security or privacy reasons.

  • JavaScript isn’t supported on their device (for example, some accessibility devices only support HTML browsing)

  • The JavaScript (or WASM) never arrived at their device because they walked outside and lost WiFi.

  • They stepped onto a subway car after loading the initial page and subsequent navigations can’t load data.

  • ... and so on.

如果其中一个成立,你的应用还有多少功能可以运作?两个呢?三个呢?

How much of your app still works if one of these holds true? Two of them? Three?

如果答案类似于“95%……好吧,然后是 90%……好吧,然后是 75%”,那就是优雅降级。如果答案是“除非一切正常工作,否则我的应用会显示白屏”,那就是……意外的快速解体(rapid unscheduled disassembly)。

If the answer is something like “95%... okay, then 90%... okay, then 75%,” that’s graceful degradation. If the answer is “my app shows a blank screen unless everything works correctly,” that’s... rapid unscheduled disassembly.

优雅降级对 WASM 应用尤为重要,因为 WASM 是浏览器运行的四种语言(HTML、CSS、JS、WASM)中最新且最不可能被普遍支持的一种。

Graceful degradation is especially important for WASM apps, because WASM is the newest and least-likely-to-be-supported of the four languages that run in the browser (HTML, CSS, JS, WASM).

幸运的是,我们有一些工具可以提供帮助。

Luckily, we’ve got some tools to help.

防御性设计

Defensive Design

有一些实践可以帮助你的应用更优雅地降级:

There are a few practices that can help your apps degrade more gracefully:

  1. 服务端渲染(SSR)。 如果没有 SSR,如果没有加载 JS 和 WASM,你的应用根本无法工作。在某些情况下这可能是合适的(想想受登录限制的内部应用),但在其他情况下,它就是坏掉了。

  2. 原生 HTML 元素。 使用能实现你想要的功能且无需额外代码的 HTML 元素:用于导航的 <a>(包括指向页面内锚点的链接)、用于手风琴效果的 <details>、用于在 URL 中持久化信息的 <form> 等。

  3. URL 驱动的状态。 全局状态存储在 URL 中(作为路由参数或查询字符串的一部分)越多,页面的更多部分就可以在服务端渲染期间生成,并通过 <a><form> 进行更新,这意味着不仅导航,状态更改也可以在没有 JS/WASM 的情况下工作。

  4. SsrMode::PartiallyBlockedSsrMode::InOrder 乱序流(Out-of-order streaming)需要少量的内联 JS,但如果 1) 连接在响应中途断开,或者 2) 客户端设备不支持 JS,则可能会失败。异步流(Async streaming)将提供完整的 HTML 页面,但仅在所有资源加载之后。顺序流(In-order streaming)则会按照从上到下的顺序更早地开始显示页面的各个部分。“部分阻塞(Partially-blocked)”的 SSR 建立在乱序流的基础上,它通过替换服务端读取阻塞资源的 <Suspense/> 片段来实现。这会略微增加初始响应时间(由于 O(n) 的字符串替换工作),以换取更完整的初始 HTML 响应。对于那些“更重要”和“不那么重要”的内容之间有明显区别的情况,这可能是一个不错的选择,例如:博客文章对比评论,或者产品信息对比评论。如果你选择阻塞所有内容,你实际上就重新创建了异步渲染。

  5. 倚重 <form> 最近出现了一股 <form> 的复兴趋势,这并不令人意外。<form> 以易于增强的方式管理复杂的 POSTGET 请求的能力,使其成为优雅降级的强大工具。例如,在 <Form/> 章节中的例子在没有 JS/WASM 的情况下也能正常工作:因为它使用 <form method="GET"> 将状态持久化在 URL 中,它通过发起正常的 HTTP 请求来配合纯 HTML 工作,然后渐进式地增强为使用客户端导航。

  6. Server-side rendering. Without SSR, your app simply doesn’t work without both JS and WASM loading. In some cases this may be appropriate (think internal apps gated behind a login) but in others it’s simply broken.

  7. Native HTML elements. Use HTML elements that do the things that you want, without additional code: <a> for navigation (including to hashes within the page), <details> for an accordion, <form> to persist information in the URL, etc.

  8. URL-driven state. The more of your global state is stored in the URL (as a route param or part of the query string), the more of the page can be generated during server rendering and updated by an <a> or a <form>, which means that not only navigations but state changes can work without JS/WASM.

  9. SsrMode::PartiallyBlocked or SsrMode::InOrder. Out-of-order streaming requires a small amount of inline JS, but can fail if 1) the connection is broken halfway through the response or 2) the client’s device doesn’t support JS. Async streaming will give a complete HTML page, but only after all resources load. In-order streaming begins showing pieces of the page sooner, in top-down order. “Partially-blocked” SSR builds on out-of-order streaming by replacing <Suspense/> fragments that read from blocking resources on the server. This adds marginally to the initial response time (because of the O(n) string replacement work), in exchange for a more complete initial HTML response. This can be a good choice for situations in which there’s a clear distinction between “more important” and “less important” content, e.g., blog post vs. comments, or product info vs. reviews. If you choose to block on all the content, you’ve essentially recreated async rendering.

  10. Leaning on <form>s. There’s been a bit of a <form> renaissance recently, and it’s no surprise. The ability of a <form> to manage complicated POST or GET requests in an easily-enhanced way makes it a powerful tool for graceful degradation. The example in the <Form/> chapter, for example, would work fine with no JS/WASM: because it uses a <form method="GET"> to persist state in the URL, it works with pure HTML by making normal HTTP requests and then progressively enhances to use client-side navigations instead.

框架还有一个我们尚未见过的特性,它建立在表单的这些特性之上,用以构建强大的应用:<ActionForm/>

There’s one final feature of the framework that we haven’t seen yet, and which builds on this characteristic of forms to build powerful applications: the <ActionForm/>.

<ActionForm/> 组件

<ActionForm/>

<ActionForm/> 是一个专门的 <Form/>,它接收一个服务器动作(server action),并在表单提交时自动调度它。这允许你直接从 <form> 调用服务器函数,甚至在没有 JS/WASM 的情况下也可以。

<ActionForm/> is a specialized <Form/> that takes a server action, and automatically dispatches it on form submission. This allows you to call a server function directly from a <form>, even without JS/WASM.

过程非常简单:

The process is simple:

  1. 使用 #[server] 定义一个服务器函数(参见 服务器函数)。

  2. Define a server function using the #[server] macro (see Server Functions.)

  3. 使用 ServerAction::new() 创建一个动作,指定你定义的服务器函数类型。

  4. Create an action using ServerAction::new(), specifying the type of the server function you’ve defined.

  5. 创建一个 <ActionForm/>,在 action 属性中提供该服务器动作。

  6. Create an <ActionForm/>, providing the server action in the action prop.

  7. 将命名参数作为具有相同名称的表单字段传递给服务器函数。

  8. Pass the named arguments to the server function as form fields with the same names.

注意: <ActionForm/> 仅适用于服务器函数默认的 URL 编码 POST 方式,以确保作为 HTML 表单时的平稳退化/正确行为。

Note: <ActionForm/> only works with the default URL-encoded POST encoding for server functions, to ensure graceful degradation/correct behavior as an HTML form.

#[server]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
    todo!()
}

#[component]
fn AddTodo() -> impl IntoView {
    let add_todo = ServerAction::<AddTodo>::new();
    // 保存从服务器返回的最新值
    // holds the latest *returned* value from the server
    let value = add_todo.value();
    // 检查服务器是否返回了错误
    // check if the server has returned an error
    let has_error = move || value.with(|val| matches!(val, Some(Err(_))));

    view! {
        <ActionForm action=add_todo>
            <label>
                "Add a Todo"
                // `title` 与 `add_todo` 的 `title` 参数匹配
                // `title` matches the `title` argument to `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 和同构渲染(isomorphic rendering)的力量意味着即使没有 JS/WASM,你的 <ActionForm/> 也能正常工作。

It’s really that easy. With JS/WASM, your form will submit without a page reload, storing its most recent submission in the .input() signal of the action, its pending status in .pending(), and so on. (See the Action docs for a refresher, if you need.) Without JS/WASM, your form will submit with a page reload. If you call a redirect function (from leptos_axum or leptos_actix) it will redirect to the correct page. By default, it will redirect back to the page you’re currently on. The power of HTML, HTTP, and isomorphic rendering mean that your <ActionForm/> simply works, even with no JS/WASM.

客户端验证

Client-Side Validation

因为 <ActionForm/> 只是一个 <form>,它会触发 submit 事件。你可以在 on:submit:capture 处理器中使用 HTML 验证或你自己的客户端验证逻辑。只需调用 ev.prevent_default() 即可阻止提交。

Because the <ActionForm/> is just a <form>, it fires a submit event. You can use either HTML validation, or your own client-side validation logic in an on:submit:capture handler. Just call ev.prevent_default() to prevent submission.

FromFormData trait 在这里很有用,可以尝试从提交的表单中解析服务器函数的数据类型。

The FromFormData trait can be helpful here, for attempting to parse your server function’s data type from the submitted form.

let on_submit = move |ev| {
	let data = AddTodo::from_event(&ev);
	// 验证的一个简单示例:如果待办事项是 "nope!",则拒绝它
	// silly example of validation: if the todo is "nope!", nope it
	if data.is_err() || data.unwrap().title == "nope!" {
		// ev.prevent_default() 将阻止表单提交
		// ev.prevent_default() will prevent form submission
		ev.prevent_default();
	}
}

// ... 将 `submit` 处理器添加到 `ActionForm`
// ... add the `submit` handler to an `ActionForm`

<ActionForm on:submit:capture=on_submit /* ... */>

:::tip{title="注意"} 请注意使用的是 on:submit:capture 而不是 on:submit。这会添加一个在浏览器事件处理的“捕获”(capture)阶段触发的事件监听器,而不是在“冒泡”(bubble)阶段触发,这意味着你的事件处理器将在 ActionForm 内置的 submit 处理器之前运行。有关更多信息,请查看此 issue

:::

注意

Note the use of on:submit:capture rather than on:submit. This adds an event listener that will fire during the browser’s “capture” phase of event handling, rather than during the “bubble” phase, which means that your event handler will run before the built-in submit handler of the ActionForm. For more information, check out this issue.

复杂输入

Complex Inputs

如果服务器函数参数是带有嵌套可序列化字段的结构体,则应使用 serde_qs 的索引表示法。

Server function arguments that are structs with nested serializable fields should make use of indexing notation of serde_qs.

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct Settings {
    display_name: String,
}

#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
struct HeftyData {
    first_name: String,
    last_name: String,
    settings: Settings,
}

#[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="text"
          name="hefty_arg[settings][display_name]"
          value="my alias"
        />
        <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");
    assert_eq!(hefty_arg.settings.display_name.as_str(), "my alias");
    Ok(())
}

部署

Deployment

部署 Web 应用程序的方法与开发者一样多,更不用说应用程序本身了。但在部署应用程序时,有几个有用的技巧需要牢记。

There are as many ways to deploy a web application as there are developers, let alone applications. But there are a couple useful tips to keep in mind when deploying an app.

一般建议

General Advice

  1. 记住:始终部署以 --release 模式构建的 Rust 应用,而不是调试(debug)模式。这对性能和二进制大小都有巨大影响。

  2. Remember: Always deploy Rust apps built in --release mode, not debug mode. This has a huge effect on both performance and binary size.

  3. 也要在 release 模式下进行本地测试。框架在 release 模式下会应用某些在 debug 模式下不应用的优化,因此此时可能会出现一些错误。(如果你的应用行为不同或确实遇到了 bug,这很可能是框架层面的 bug,你应该在 GitHub 上提交包含复现步骤的 issue。)

  4. Test locally in release mode as well. The framework applies certain optimizations in release mode that it does not apply in debug mode, so it’s possible for bugs to surface at this point. (If your app behaves differently or you do encounter a bug, it’s likely a framework-level bug and you should open a GitHub issue with a reproduction.)

  5. 请参阅“优化 WASM 二进制大小”章节,了解更多技巧和方法,以进一步改善 WASM 应用首次加载的可交互时间(Time-to-Interactive)指标。

  6. See the chapter on "Optimizing WASM Binary Size" for additional tips and tricks to further improve the time-to-interactive metric for your WASM app on first load.

我们请用户提交他们的部署设置以协助完成本章。我将在下面引用其中的内容,但你可以在这里阅读完整的主题帖。

We asked users to submit their deployment setups to help with this chapter. I’ll quote from them below, but you can read the full thread here.

部署客户端渲染应用

Deploying a Client-Side-Rendered App

如果你一直在构建一个仅使用客户端渲染的应用,并使用 Trunk 作为开发服务器和构建工具,那么这个过程非常简单。

If you’ve been building an app that only uses client-side rendering, working with Trunk as a dev server and build tool, the process is quite easy.

trunk build --release

trunk build 将在 dist/ 目录中生成若干构建产物。将 dist 目录发布到网上的某个地方就是你部署应用所需的全部操作。这与部署任何 JavaScript 应用程序非常相似。

trunk build will create a number of build artifacts in a dist/ directory. Publishing dist somewhere online should be all you need to deploy your app. This should work very similarly to deploying any JavaScript application.

我们创建了几个示例仓库,展示了如何将 Leptos CSR 应用设置并部署到各种托管服务。

We've created several example repositories which show how to set up and deploy a Leptos CSR app to various hosting services.

注:Leptos 不背书任何特定的托管服务——请随意使用任何支持静态网站部署的服务。

Note: Leptos does not endorse the use of any particular hosting service - feel free to use any service that supports static site deploys.

示例:

Examples:

Github Pages

Github Pages

将 Leptos CSR 应用部署到 Github Pages 是件简单的事情。首先,进入你的 Github 仓库设置,点击左侧菜单中的 "Pages"。在页面的 "Build and deployment" 部分,将 "source" 更改为 "Github Actions"。然后将以下内容复制到类似 .github/workflows/gh-pages-deploy.yml 的文件中。

Deploying a Leptos CSR app to Github pages is a simple affair. First, go to your Github repo's settings and click on "Pages" in the left side menu. In the "Build and deployment" section of the page, change the "source" to "Github Actions". Then copy the following into a file such as .github/workflows/gh-pages-deploy.yml

示例

name: Release to Github Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: write # 用于提交到 gh-pages 分支。
  # 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)

      # 安装 Rust Nightly 工具链,包含 Clippy 和 Rustfmt
      # 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


      # 如果使用 tailwind...
      # If using tailwind...
      # - name: Download and install tailwindcss binary
      #   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # 运行 tailwind (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#*/}" 计算为仓库名称
        # 使用 --public-url 某值将允许 trunk 修改所有 href 路径,例如将 favicon.ico 改为 repo_name/favicon.ico。
        # 这对于 github pages 是必要的,因为站点部署在 username.github.io/repo_name,所有文件必须以
        # 相对路径请求。如果我们跳过 public-url 选项,href 路径将转而请求 username.github.io/favicon.ico,
        # 这显然会返回 404 错误。
        # "${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#*/}"

      # 将 index.html 复制到 404.html 以支持 SPA 路由
      # 如果客户端从任何路由进入,将允许路由工作
      # Copy index.html to 404.html for SPA routing
      # Will allow routing to work if client enters from any route
      # - name: Copy index.html to 404.html
      #   run: cp dist/index.html dist/404.html

      # 部署到 gh-pages 分支
      # Deploy to gh-pages branch
      # - name: Deploy 🚀
      #   uses: JamesIves/github-pages-deploy-action@v4
      #   with:
      #     folder: dist


      # 使用 Github Static Pages 部署
      # Deploy with Github Static Pages

      - name: Setup Pages
        uses: actions/configure-pages@v5
        with:
          enablement: true
          # token:

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v3
        with:
          # 上传 dist 目录
          # Upload dist dir
          path: './dist'

      - name: Deploy to GitHub Pages 🚀
        id: deployment
        uses: actions/deploy-pages@v4

关于部署到 Github Pages 的更多信息,请参阅此处的示例仓库

For more on deploying to Github Pages see the example repo here

Vercel

Vercel

第一步:设置 Vercel

Step 1: Set Up Vercel

在 Vercel 网页界面中...

In the Vercel Web UI...

  1. 创建一个新项目

  2. Create a new project

  3. 确保

  4. Ensure

    • "Build Command" 保持为空且 Override 开启

    • The "Build Command" is left empty with Override on

    • "Output Directory" 更改为 dist(这是 Trunk 构建的默认输出目录)且 Override 开启

    • The "Output Directory" is changed to dist (which is the default output directory for Trunk builds) and the Override is on

第二步:为 GitHub Actions 添加 Vercel 凭据

Step 2: Add Vercel Credentials for GitHub Actions

注意:预览和部署操作都需要在 GitHub secrets 中设置你的 Vercel 凭据。

Note: Both the preview and deploy actions will need your Vercel credentials setup in GitHub secrets

  1. 通过进入 "Account Settings" > "Tokens" 并创建一个新令牌来获取你的 Vercel 访问令牌 (Access Token)——保存该令牌以在下面的第 5 小步中使用。

  2. Retrieve your Vercel Access Token by going to "Account Settings" > "Tokens" and creating a new token - save the token to use in sub-step 5, below.

  3. 使用 npm i -g vercel 命令安装 Vercel CLI,然后运行 vercel login 登录你的账户。

  4. Install the Vercel CLI using the npm i -g vercel command, then run vercel login to login to your acccount.

  5. 在你的文件夹内,运行 vercel link 来创建一个新的 Vercel 项目;在 CLI 中,你会被问到 'Link to an existing project?'——回答 yes,然后输入你在第一步中创建的名称。系统会为你创建一个新的 .vercel 文件夹。

  6. Inside your folder, run vercel link to create a new Vercel project; in the CLI, you will be asked to 'Link to an existing project?' - answer yes, then enter the name you created in step 1. A new .vercel folder will be created for you.

  7. 在生成的 .vercel 文件夹中,打开 project.json 文件并保存 "projectId" 和 "orgId" 以用于下一步。

  8. Inside the generated .vercel folder, open the the project.json file and save the "projectId" and "orgId" for the next step.

  9. 在 GitHub 中,进入仓库的 "Settings" > "Secrets and Variables" > "Actions",并添加以下内容作为 Repository secrets

  10. Inside GitHub, go the repo's "Settings" > "Secrets and Variables" > "Actions" and add the following as Repository secrets:

    • 将你的 Vercel 访问令牌(来自第 1 小步)保存为 VERCEL_TOKEN secret

    • save your Vercel Access Token (from sub-step 1) as the VERCEL_TOKEN secret

    • .vercel/project.json 中将 "projectID" 添加为 VERCEL_PROJECT_ID

    • from the .vercel/project.json add "projectID" as VERCEL_PROJECT_ID

    • .vercel/project.json 中将 "orgId" 添加为 VERCEL_ORG_ID

    • from the .vercel/project.json add "orgId" as VERCEL_ORG_ID

有关完整说明,请参阅 "如何在 Vercel 中使用 Github Actions"

For full instructions see "How can I use Github Actions with Vercel"

第三步:添加 Github Action 脚本

Step 3: Add Github Action Scripts

最后,你只需从下方或示例仓库的 .github/workflows/ 文件夹中复制并粘贴这两个文件——一个用于部署,一个用于 PR 预览——到你自己的 github workflows 文件夹中。这样,在你下次 commit 或 PR 时,部署将自动发生。

Finally, you're ready to simply copy and paste the two files - one for deployment, one for PR previews - from below or from the example repo's .github/workflows/ folder into your own github workflows folder - then, on your next commit or PR deploys will occur automatically.

生产环境部署脚本:vercel_deploy.yml

Production deployment script: vercel_deploy.yml

示例

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

Preview deployments script: vercel_preview.yml

示例

# 有关 vercel action 的更多信息请参阅:
# 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

有关更多信息,请参阅此处的示例仓库

See the example repo here for more.

Spin - 无服务器 WebAssembly

Spin - Serverless WebAssembly

另一个选择是使用 Spin 之类的无服务器平台。尽管 Spin 是开源的,你可以在自己的基础设施(例如 Kubernetes 内部)上运行它,但在生产环境中开始使用 Spin 的最简单方法是使用 Fermyon Cloud。

Another option is using a serverless platform such as Spin. Although Spin is open source and you can run it on your own infrastructure (eg. inside Kubernetes), the easiest way to get started with Spin in production is to use the Fermyon Cloud.

首先按照此处的说明安装 Spin CLI,如果你还没有为你的 Leptos CSR 项目创建 Github 仓库,请先创建一个。

Start by installing the Spin CLI using the instructions, here, and creating a Github repo for your Leptos CSR project, if you haven't done so already.

  1. 打开 "Fermyon Cloud" > "User Settings"。如果你未登录,请选择 Login With GitHub 按钮。

  2. Open "Fermyon Cloud" > "User Settings". If you’re not logged in, choose the Login With GitHub button.

  3. 在 “Personal Access Tokens” 中,选择 “Add a Token”。输入名称 “gh_actions” 并点击 “Create Token”。

  4. In the “Personal Access Tokens”, choose “Add a Token”。Enter the name “gh_actions” and click “Create Token”.

  5. Fermyon Cloud 会显示令牌;点击复制按钮将其复制到剪贴板。

  6. Fermyon Cloud displays the token; click the copy button to copy it to your clipboard.

  7. 进入你的 Github 仓库并打开 "Settings" > "Secrets and Variables" > "Actions",使用变量名 "FERMYON_CLOUD_TOKEN" 将 Fermyon 云令牌添加到 "Repository secrets" 中。

  8. Go into your Github repo and open "Settings" > "Secrets and Variables" > "Actions" and add the Fermyon cloud token to "Repository secrets" using the variable name "FERMYON_CLOUD_TOKEN"

  9. 将以下 Github Actions 脚本复制并粘贴到你的 .github/workflows/<SCRIPT_NAME>.yml 文件中。

  10. Copy and paste the following Github Actions scripts (below) into your .github/workflows/<SCRIPT_NAME>.yml files

  11. 启用 'preview' 和 'deploy' 脚本后,Github Actions 现在会在 pull 请求时生成预览,并在 'main' 分支更新时自动部署。

  12. With the 'preview' and 'deploy' scripts active, Github Actions will now generate previews on pull requests & deploy automatically on updates to your 'main' branch.

生产环境部署脚本:spin_deploy.yml

Production deployment script: spin_deploy.yml

示例

# 有关 Fermyon Cloud 所需的设置说明,请参阅:
# 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

# 有关 Fermyon gh actions 本身,请参阅:
# 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)

	# 安装 Rust Nightly 工具链,包含 Clippy 和 Rustfmt
	# 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


	# 如果使用 tailwind...
	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # 运行 tailwind (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


	# 安装 Spin CLI 并部署
	# 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 }}

	# 创建一个明确的消息来显示部署应用的 URL,并显示在任务图中
	# 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

Preview deployment script: spin_preview.yml

示例

# 有关 Fermyon Cloud 所需的设置说明,请参阅:
# For setup instructions needed for Fermyon Cloud, see:
# https://developer.fermyon.com/cloud/github-actions


# 有关 Fermyon gh 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)

	# 安装 Rust Nightly 工具链,包含 Clippy 和 Rustfmt
	# 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


	# 如果使用 tailwind...
	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # 运行 tailwind (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


	# 安装 Spin CLI 并部署
	# 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

有关更多信息,请参阅此处的示例仓库

See the example repo here.

Netlify

Netlify

将 Leptos CSR 应用部署到 Netlify 所需的一切就是创建一个项目并在项目根目录中添加两个简单的配置文件。让我们从后者开始。

All it takes to deploy a Leptos CSR app to Netlify is to create a project and to add two simple configuration files in your project root. Let's begin with the latter.

配置文件

Configuration Files

在你的项目根目录创建一个 netlify.toml 文件,内容如下:

Create a netlify.toml file in your project root with the following content:

[build]
command = "rustup target add wasm32-unknown-unknown && cargo install trunk --locked && trunk build --release"
publish = "dist"

[build.environment]
RUST_VERSION = "stable"

[[redirects]]
from = "/*"
to = "/index.html"
status = 200

在你的项目根目录创建一个 rust-toolchain.toml 文件,内容如下:

Create a rust-toolchain.toml file in your project root with the following content:

[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]

部署

Deployment

  1. 通过连接你的 Git 仓库将你的项目添加到 Netlify

  2. Add your project to Netlify by connecting your Git repository

  3. Netlify 将自动检测你的 netlify.toml 配置。

  4. Netlify will automatically detect your netlify.toml configuration

  5. 如果你需要额外的环境变量,请在 Netlify 的环境变量设置中进行配置。

  6. If you need additional environment variables, configure them in Netlify's environment variables settings

rust-toolchain.toml 确保在构建过程中正确的 Rust 工具链和 WASM 目标可用。netlify.toml 中的重定向规则确保通过为所有路径提供 index.html 来让你的 SPA 路由正常工作。

The rust-toolchain.toml ensures the correct Rust toolchain and WASM target are available during the build process. The redirect rule in netlify.toml ensures your SPA routes work correctly by serving index.html for all paths.

部署全栈 SSR 应用

Deploying a Full-Stack SSR App

可以将 Leptos 全栈 SSR 应用部署到任意数量的服务器或容器托管服务中。将 Leptos SSR 应用投入生产最简单的方法可能是使用 VPS 服务,并在虚拟机中原生运行 Leptos(点击此处查看更多详情)。或者,你可以将 Leptos 应用容器化,并在任何托管或云服务器上的 PodmanDocker 中运行。

It's possible to deploy Leptos fullstack, SSR apps to any number of server or container hosting services. The most simple way to get a Leptos SSR app into production might be to use a VPS service and either run Leptos natively in a VM (see here for more details). Alternatively, you could containerize your Leptos app and run it in Podman or Docker on any colocated or cloud server.

目前存在多种不同的部署设置和托管服务,总的来说,Leptos 本身与你使用的部署设置无关。考虑到部署目标的多样性,本页将介绍:

There are a multitude of different deployment setups and hosting services, and in general, Leptos itself is agnostic to the deployment setup you use. With this diversity of deployment targets in mind, on this page we will go over:

注意:Leptos 不认可使用任何特定的部署方法或托管服务。

Note: Leptos does not endorse the use of any particular method of deployment or hosting service.

创建 Containerfile

Creating a Containerfile

人们部署使用 cargo-leptos 构建的全栈应用最流行的方式是使用支持通过 Podman 或 Docker 构建部署的云托管服务。这是一个示例 Containerfile / Dockerfile,它基于我们用于部署 Leptos 网站的文件。

The most popular way for people to deploy full-stack apps built with cargo-leptos is to use a cloud hosting service that supports deployment via a Podman or Docker build. Here’s a sample Containerfile / Dockerfile, which is based on the one we use to deploy the Leptos website.

Debian

Debian

# 使用 Rust nightly 构建环境开始
# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-trixie as builder

# 如果你使用的是 stable,请改用这个
# If you’re using stable, use this instead
# FROM rust:1.92.0-trixie as builder # 在此处查看当前的官方 Rust 标签:https://hub.docker.com/_/rust
# FROM rust:1.92.0-trixie as builder # See current official Rust tags here: https://hub.docker.com/_/rust

# 安装 cargo-binstall,它可以更轻松地安装其他 cargo 扩展,如 cargo-leptos
# 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

# 安装 cargo-leptos
# Install cargo-leptos
RUN cargo binstall cargo-leptos -y

# 添加 WASM 目标
# Add the WASM target
RUN rustup target add wasm32-unknown-unknown

# 创建 /app 目录,所有内容最终都将存放在这里
# 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:trixie-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/*

# -- 注意:将二进制文件名从 "leptos_start" 更新为 Cargo.toml 中的应用名称 --
# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# 将服务端二进制文件复制到 /app 目录
# Copy the server binary to the /app directory
COPY --from=builder /app/target/release/leptos_start /app/

# /target/site 包含我们的 JS/WASM/CSS 等文件
# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site

# 如果运行时需要,复制 Cargo.toml
# 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

# -- 注意:将二进制文件名从 "leptos_start" 更新为 Cargo.toml 中的应用名称 --
# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# 运行服务器
# Run the server
CMD ["/app/leptos_start"]

Alpine

Alpine

# 使用 Rust nightly 构建环境开始
# 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.3 -LsSf https://github.com/leptos-rs/cargo-leptos/releases/latest/download/cargo-leptos-installer.sh | sh

# 添加 WASM 目标
# 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 构建文件

Read more: gnu and musl build files for Leptos apps.

关于反向代理的说明

A Note on Reverse Proxies

虽然你可以直接暴露你的 Leptos 应用,但通常最好将其放在反向代理之后。这允许你在专用层处理 SSL/TLS、压缩和安全标头,而不是在 Rust 二进制文件中处理。

While you can expose your Leptos app directly, it's usually better to put it behind a reverse proxy. This allows you to handle SSL/TLS, compression, and security headers in a dedicated layer rather than in your Rust binary.

有几种流行的反向代理选项。Caddy 常因其自动 HTTPS 证书管理而被选中,而 Nginx、Traefik 或 Apache 也被广泛使用,具体取决于你的需求和熟悉程度。

There are several popular reverse proxy options. Caddy is often chosen for its automatic HTTPS certificate management, while Nginx, Traefik, or Apache are also widely used depending on your requirements and familiarity.

如果你使用 Caddy,你的配置可以简单到将域名指向你的容器名称或 IP:

If you are using Caddy, your configuration can be as simple as pointing a domain to your container name or IP:

# 简单配置
# Simple setup
example.com {
    reverse_proxy leptos-app:8080
}

# 进阶:基础认证和 HSTS 标头
# Advanced: Basic auth and HSTS headers
app.example.com {
    # 使用基础认证保护预发布站点
    # Protect a staging site with basic auth
    basic_auth {
        admin $2a$14$CIW9S... 
    }
    
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
    }

    reverse_proxy leptos-app:8080
}

有关更多详细信息,请参阅 Caddy 反向代理快速入门Caddyfile 概念文档 等资源。

For more details, see the Caddy Reverse Proxy Quick-start and the Caddyfile concept documentation, among other resources.

云端部署

Cloud Deployments

部署到 Fly.io

Deploy to Fly.io

部署 Leptos SSR 应用的一个选择是使用 Fly.io 这样的服务,它采用 Leptos 应用的 Dockerfile 定义,并将其运行在快速启动的微型虚拟机(micro-VM)中;Fly 还提供多种存储选项和托管数据库供你的项目使用。以下示例将展示如何部署一个简单的 Leptos 入门应用,仅供你上手使用;如果需要,请点击此处查看有关在 Fly.io 上使用存储选项的更多信息

One option for deploying your Leptos SSR app is to use a service like Fly.io, which takes a Dockerfile definition of your Leptos app and runs it in a quick-starting micro-VM; Fly also offers a variety of storage options and managed DBs to use with your projects. The following example will show how to deploy a simple Leptos starter app, just to get you up and going; see here for more about working with storage options on Fly.io if and when required.

首先,在应用的根目录中创建一个 Dockerfile 并填入建议的内容(见上文);确保将 Dockerfile 示例中的二进制文件名更新为你自己应用的文件名,并根据需要进行其他调整。

First, create a Dockerfile in the root of your application and fill it in with the suggested contents (above); make sure to update the binary names in the Dockerfile example to the name of your own application, and make other adjustments as necessary.

此外,请确保你已经安装了 flyctl 命令行工具,并已在 Fly.io 上注册了账户。要在 MacOS、Linux 或 Windows WSL 上安装 flyctl,请运行:

Also, ensure you have the flyctl CLI tool installed, and have an account set up at Fly.io. To install flyctl on MacOS, Linux, or Windows WSL, run:

curl -L https://fly.io/install.sh | sh

如果你遇到问题,或者要在其他平台上安装,请参阅此处的完整说明

If you have issues, or for installing to other platforms see the full instructions here

然后登录 Fly.io:

Then login to Fly.io

fly auth login

并使用以下命令手动启动你的应用:

and manually launch your app using the command

fly launch

flyctl 命令行工具将引导你完成将应用部署到 Fly.io 的过程。

The flyctl CLI tool will walk you through the process of deploying your app to Fly.io.

:::admonish note title="注意" 默认情况下,Fly.io 会自动停止在一段时间内没有流量的机器。虽然 Fly.io 的轻量级虚拟机启动很快,但如果你想最大限度地减少 Leptos 应用的延迟并确保其始终响应迅速,请进入生成的 fly.toml 文件,将 min_machines_running 从默认的 0 更改为 1。

有关更多详细信息,请参阅 Fly.io 文档中的此页面

:::

Note

By default, Fly.io will auto-stop machines that don't have traffic coming to them after a certain period of time. Although Fly.io's lightweight VM's start up quickly, if you want to minimize the latency of your Leptos app and ensure it's always swift to respond, go into the generated fly.toml file and change the min_machines_running to 1 from the default of 0.

See this page in the Fly.io docs for more details.

如果你更喜欢使用 Github Actions 来管理部署,你需要通过 Fly.io Web 界面创建一个新的访问令牌。

If you prefer to use Github Actions to manage your deployments, you will need to create a new access token via the Fly.io web UI.

转到 "Account" > "Access Tokens" 并创建一个名为 "github_actions" 之类的令牌,然后通过进入你项目的 Github 仓库,点击 "Settings" > "Secrets and Variables" > "Actions" 并创建一个名为 "FLY_API_TOKEN" 的 "New repository secret",将该令牌添加到仓库的机密信息中。

Go to "Account" > "Access Tokens" and create a token named something like "github_actions", then add the token to your Github repo's secrets by going into your project's Github repo, then clicking "Settings" > "Secrets and Variables" > "Actions" and creating a "New repository secret" with the name "FLY_API_TOKEN".

要生成用于部署到 Fly.io 的 fly.toml 配置文件,你必须首先在项目源目录中运行以下命令:

To generate a fly.toml config file for deployment to Fly.io, you must first run the following from within the project source directory

fly launch --no-deploy

以此创建一个新的 Fly 应用并将其注册到服务中。Git 提交你新生成的 fly.toml 文件。

to create a new Fly app and register it with the service. Git commit your new fly.toml file.

要设置 Github Actions 部署工作流,请将以下内容复制到 .github/workflows/fly_deploy.yml 文件中:

To set up the Github Actions deployment workflow, copy the following into a .github/workflows/fly_deploy.yml file:

示例

# 更多详情请参阅:https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
# 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。

On the next commit to your Github main branch, your project will automatically deploy to Fly.io.

查看 此处的示例仓库

See the example repo here.

Railway

Railway

云端部署的另一个提供商是 Railway。Railway 与 GitHub 集成以自动部署你的代码。

Another provider for cloud deployments is Railway. Railway integrates with GitHub to automatically deploy your code.

有一个预配置好的社区模板可以帮助你快速开始:

There is an opinionated community template that gets you started quickly:

在 Railway 上部署

Deploy on Railway

该模板配置了 renovate 以保持依赖项最新,并支持 GitHub Actions 以在部署发生之前测试你的代码。

The template has renovate setup to keep dependencies up to date and supports GitHub Actions to test your code before a deploy happens.

Railway 提供不需要信用卡的免费层级,考虑到 Leptos 所需资源非常少,该免费层级应该可以持续很长时间。

Railway has a free tier that does not require a credit card, and with how little resources Leptos needs that free tier should last a long time.

查看 此处的示例仓库

See the example repo here.

部署到无服务器运行时

Deploy to Serverless Runtimes

Leptos 支持部署到 FaaS(函数即服务)或“无服务器(serverless)”运行时,如 AWS Lambda,以及兼容 WinterCG 的 JS 运行时,如 Deno 和 Cloudflare。请注意,与虚拟机或容器类型部署相比,无服务器环境确实会对 SSR 应用的可用功能施加一些限制(见下文说明)。

Leptos supports deploying to FaaS (Function as a Service) or 'serverless' runtimes such as AWS Lambda as well as WinterCG-compatible JS runtimes such as Deno and Cloudflare. Just be aware that serverless environments do place some restrictions on the functionality available to your SSR app when compared with VM or container type deployments (see notes, below).

AWS Lambda

AWS Lambda

Cargo Lambda 工具的帮助下,Leptos SSR 应用可以部署到 AWS Lambda。在 leptos-rs/start-aws 提供了一个使用 Axum 作为服务器的入门模板仓库;那里的说明也可以调整为你使用 Leptos+Actix-web 服务器。入门仓库包括用于 CI/CD 的 Github Actions 脚本,以及设置 Lambda 函数和获取云部署所需凭据的说明。

With a little help from the Cargo Lambda tool, Leptos SSR apps can be deployed to AWS Lambda. A starter template repo using Axum as the server is available at leptos-rs/start-aws; the instructions there can be adapted for you to use a Leptos+Actix-web server as well. The starter repo includes a Github Actions script for CI/CD, as well as instructions for setting up your Lambda functions and getting the necessary credentials for cloud deployment.

然而,请记住,一些原生服务器功能在 Lambda 等 FaaS 服务中无法使用,因为环境在不同请求之间不一定是一致的。特别是,'start-aws' 文档 指出,“由于 AWS Lambda 是一个无服务器平台,你需要更加小心地管理长期存在的状态。写入磁盘或使用状态提取器在跨请求时无法可靠工作。相反,你需要一个数据库或其他微服务,你可以从 Lambda 函数中查询。”

However, please keep in mind that some native server functionality does not work with FaaS services like Lambda because the environment is not necessarily consistent from one request to the next. In particular, the 'start-aws' docs state that "since AWS Lambda is a serverless platform, you'll need to be more careful about how you manage long-lived state. Writing to disk or using a state extractor will not work reliably across requests. Instead, you'll need a database or other microservices that you can query from the Lambda function."

另一个需要牢记的因素是函数即服务的“冷启动”时间 —— 取决于你的用例和你使用的 FaaS 平台,这可能满足也可能无法满足你的延迟要求;你可能需要让一个函数始终运行,以优化请求速度。

The other factor to bear in mind is the 'cold-start' time for functions as a service - depending on your use case and the FaaS platform you use, this may or may not meet your latency requirements; you may need to keep one function running at all times to optimize the speed of your requests.

Deno & Cloudflare Workers

Deno & Cloudflare Workers

目前,Leptos-Axum 支持在托管 Javascript 的 WebAssembly 运行时(如 Deno、Cloudflare Workers 等)中运行。此选项需要对源代码设置进行一些更改(例如,在 Cargo.toml 中,你必须使用 crate-type = ["cdylib"] 定义应用,并且必须为 leptos_axum 启用 "wasm" 特性)。Leptos HackerNews JS-fetch 示例 演示了所需的修改,并展示了如何在 Deno 运行时运行应用。此外,在为托管 JS 的 WASM 运行时设置自己的 Cargo.toml 文件时,leptos_axum crate 文档 也是一个很有帮助的参考。

Currently, Leptos-Axum supports running in Javascript-hosted WebAssembly runtimes such as Deno, Cloudflare Workers, etc. This option requires some changes to the setup of your source code (for example, in Cargo.toml you must define your app using crate-type = ["cdylib"] and the "wasm" feature must be enabled for leptos_axum). The Leptos HackerNews JS-fetch example demonstrates the required modifications and shows how to run an app in the Deno runtime. Additionally, the leptos_axum crate docs are a helpful reference when setting up your own Cargo.toml file for JS-hosted WASM runtimes.

虽然托管 JS 的 WASM 运行时的初始设置并不繁重,但需要牢记的更重要的限制是,由于你的应用在服务端和客户端都将被编译为 WebAssembly (wasm32-unknown-unknown),你必须确保你在应用中使用的 crate 都是兼容 WASM 的;根据你应用的需求,这可能是也可能不是一个决定性因素,因为 Rust 生态系统中的所有 crate 并不都支持 WASM。

While the initial setup for JS-hosted WASM runtimes is not onerous, the more important restriction to keep in mind is that since your app will be compiled to WebAssembly (wasm32-unknown-unknown) on the server as well as the client, you must ensure that the crates you use in your app are all WASM-compatible; this may or may not be a deal-breaker depending on your app's requirements, as not all crates in the Rust ecosystem have WASM support.

如果你愿意接受 WASM 在服务端的局限性,目前开始的最佳地点是查看官方 Leptos Github 仓库中 在 Deno 中运行 Leptos 的示例

If you're willing to live with the limitations of WASM server-side, the best place to get started right now is by checking out the example of running Leptos with Deno in the official Leptos Github repo.

正在适配 Leptos 的平台

Platforms Working on Leptos Support

部署到 Spin Serverless WASI (带有 Leptos SSR)

Deploy to Spin Serverless WASI (with Leptos SSR)

服务端的 WebAssembly 最近势头强劲,开源无服务器 WebAssembly 框架 Spin 的开发人员正在努力原生支持 Leptos。虽然 Leptos-Spin SSR 集成仍处于早期阶段,但有一个你可以尝试的运行示例。

WebAssembly on the server has been gaining steam lately, and the developers of the open source serverless WebAssembly framework Spin are working on natively supporting Leptos. While the Leptos-Spin SSR integration is still in its early stages, there is a working example you may wish to try out.

Fermyon 博客上的一篇文章 中提供了让 Leptos SSR 和 Spin 协同工作的完整说明,或者如果你想跳过文章直接开始尝试一个运行正常的入门仓库,请点击此处

The full set of instructions to get Leptos SSR & Spin working together are available as a post on the Fermyon blog, or if you want to skip the article and just start playing around with a working starter repo, see here.

部署到 Shuttle.rs

Deploy to Shuttle.rs

几位 Leptos 用户询问了使用对 Rust 友好的 Shuttle.rs 服务部署 Leptos 应用的可能性。不幸的是,目前 Shuttle.rs 服务尚未正式支持 Leptos。

Several Leptos users have asked about the possibility of using the Rust-friendly Shuttle.rs service to deploy Leptos apps. Unfortunately, Leptos is not officially supported by the Shuttle.rs service at the moment.

然而,Shuttle.rs 的团队致力于在未来提供 Leptos 支持;如果你想了解该工作的最新进展,请关注 这个 Github issue

However, the folks at Shuttle.rs are committed to getting Leptos support in the future; if you would like to keep up-to-date on the status of that work, keep an eye on this Github issue.

此外,虽然有人在尝试让 Shuttle 与 Leptos 配合工作,但到目前为止,向 Shuttle 云的部署仍未按预期工作。如果你想亲自调查或贡献修复,该工作可以从此处获得:针对 Shuttle.rs 的 Leptos Axum 入门模板

Additionally, some effort has been made to get Shuttle working with Leptos, but to date, deploys to the Shuttle cloud are still not working as expected. That work is available here, if you would like to investigate for yourself or contribute fixes: Leptos Axum Starter Template for Shuttle.rs.

在非根路径下部署

Deploying at Non-Root Paths

到目前为止,部署步骤都假设你的应用部署在域名的根路径(/)。然而,也可以将应用部署在非根路径,例如 /my-app

So far, the deployment steps have assumed that your application is deployed at the root path of your domain (/). However, it is also possible to deploy your application at a non-root path, such as /my-app.

如果你正在非根路径部署,你需要采取一些步骤来告诉应用的各个部分新的基础路径是什么。

If you are deploying at a non-root path, you’ll need to take a few steps to tell the various parts of the application what that new base path is.

更新路由器的 base

Update the Router base

<Router/> 组件有一个 base 属性,用于指定路由的基础路径。例如,如果你正在部署一个包含 //about/contact 三个页面的应用,并且你想将它们部署在 /my-app 下,使得三个路由变为 /my-app/my-app/about/my-app/contact,你需要将 base 属性设置为 /my-app

The <Router/> component has a base prop that specifies the base path for routing. For example, if you are deploying an application with three pages /, /about, and /contact, and you want them to be deployed at /my-app so that the three routes are /my-app, /my-app/about, and /my-app/contact, you would set the base prop to /my-app:

<Router base="/my-app">
    <Routes fallback=|| "Not found.">
        <Route path=path!("/") view=Home/>
        <Route path=path!("/about") view=About/>
        <Route path=path!("/contact") view=Contact/>
    </Routes>
</Router>

如果你正在使用反向代理,你的服务器可能会认为它正在提供 / 路径的服务,而实际上它是在提供 /my-app。但在浏览器中,路由器仍然会将 URL 视为 /my-app。在这种情况下,你应该使用条件编译来条件性地设置 base 属性:

If you are using a reverse proxy, it’s likely that your server will think it’s serving / when it is actually serving /my-app. But in the browser, the router will still see the URL as /my-app. In this situation, you should set the base prop conditionally using conditional compilation:

let base = if cfg!(feature = "hydrate") {
    "/my-app"
} else {
    "/"
};
// ...
<Router base> // ...

更新 <HydrationScripts root/>

Update the <HydrationScripts root/>

如果你正在使用服务端渲染,<HydrationScripts/> 组件负责加载用于应用水合(hydrate)的 JS/WASM。它有自己的 root 属性,用于指定水合脚本的基础路径。如果这些脚本也是从子目录提供的,你应该将该基础路径包含在 root 属性中。

If you’re using server rendering, the <HydrationScripts/> component is responsible for loading the JS/WASM to hydrate the app. This has its own root prop that specifies the base path for the hydration scripts. If they are also being served from a subdirectory, you should include that base path as the root prop.

更新服务器函数 URL

Update the Server Function URL

如果你正在使用服务器函数,它们默认会向 / 发送请求。如果你的服务器函数处理器挂载在不同的路径,你可以使用 set_server_url 来设置。

If you are using server functions, they will default to sending requests to /. If your server function handler is mounted at a different path, you can set that with set_server_url.

Trunk 配置

Trunk configuration

如果你正在使用 Trunk 进行客户端渲染,请查阅 Trunk 文档以了解如何通过 --public-url 设置公共 URL。

If you’re using client-side rendering with Trunk, consult the Trunk docs on how to set the public URL via --public-url.

优化 WASM 二进制大小

Optimizing WASM Binary Size

WebAssembly 二进制文件通常比同等应用程序的 JavaScript 包要大得多。因为 WASM 格式是为流式编译设计的,所以每千字节 WASM 文件的编译速度比 JavaScript 文件快得多。(欲了解更多信息,你可以阅读 Mozilla 团队关于流式 WASM 编译的这篇精彩文章。)尽管如此,向用户发送尽可能小的 WASM 二进制文件仍然很重要,因为这会减少他们的网络使用,并使你的应用尽快达到可交互状态。

WebAssembly binaries are significantly larger than the JavaScript bundles you’d expect for the equivalent application. Because the WASM format is designed for streaming compilation, WASM files are much faster to compile per kilobyte than JavaScript files. (For a deeper look, you can read this great article from the Mozilla team on streaming WASM compilation.) Still, it’s important to ship the smallest WASM binary to users that you can, as it will reduce their network usage and make your app interactive as quickly as possible.

那么有哪些实际的步骤呢?

So what are some practical steps?

该做的事情

Things to Do

  1. 确保你查看的是发布(release)构建。(调试构建要大得多。)

  2. Make sure you’re looking at a release build. (Debug builds are much, much larger.)

  3. 为 WASM 添加一个针对大小(而不是速度)进行优化的发布配置(profile)。

  4. Add a release profile for WASM that optimizes for size, not speed.

例如,对于一个 cargo-leptos 项目,你可以将以下内容添加到 Cargo.toml 中:

For a cargo-leptos project, for example, you can add this to your Cargo.toml:

[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1

# ....

[package.metadata.leptos]
# ....
lib-profile-release = "wasm-release"

这将为你的发布构建极度优化 WASM 的大小,同时保持你的服务器构建针对速度进行优化。(对于没有服务器考量的纯客户端渲染应用,只需将 [profile.wasm-release] 块作为你的 [profile.release] 即可。)

This will hyper-optimize the WASM for your release build for size, while keeping your server build optimized for speed. (For a pure client-rendered app without server considerations, just use the [profile.wasm-release] block as your [profile.release].)

  1. 在生产环境中始终提供压缩后的 WASM。WASM 的压缩效果通常非常好,压缩后的大小通常不到未压缩大小的 50%,而且在 Actix 或 Axum 中为静态文件启用压缩非常简单。

  2. Always serve compressed WASM in production. WASM tends to compress very well, typically shrinking to less than 50% its uncompressed size, and it’s trivial to enable compression for static files being served from Actix or Axum.

  3. 如果你使用的是 nightly Rust,你可以使用相同的配置重新构建标准库,而不是使用随 wasm32-unknown-unknown 目标分发的预编译标准库。

  4. If you’re using nightly Rust, you can rebuild the standard library with this same profile rather than the prebuilt standard library that’s distributed with the wasm32-unknown-unknown target.

为此,请在项目的 .cargo/config.toml 中创建一个文件:

To do this, create a file in your project at .cargo/config.toml

[unstable]
build-std = ["std", "panic_abort", "core", "alloc"]
build-std-features = ["panic_immediate_abort"]

请注意,如果你也将其用于 SSR,则会应用相同的 Cargo 配置。你需要明确指定你的目标:

Note that if you're using this with SSR too, the same Cargo profile will be applied. You'll need to explicitly specify your target:

[build]
target = "x86_64-unknown-linux-gnu" # 或其他目标 (or whatever)

另外请注意,在某些情况下,cfg 特性 has_std 将不会被设置,这可能会导致一些检查 has_std 的依赖项出现构建错误。你可以通过添加以下内容来修复由此产生的任何构建错误:

Also note that in some cases, the cfg feature has_std will not be set, which may cause build errors with some dependencies which check for has_std. You may fix any build errors due to this by adding:

[build]
rustflags = ["--cfg=has_std"]

并且你需要在 Cargo.toml[profile.release] 中添加 panic = "abort"。请注意,这会将相同的 build-std 和 panic 设置应用于你的服务器二进制文件,这可能不是你想要的。这里可能需要进一步探索。

And you'll need to add panic = "abort" to [profile.release] in Cargo.toml. Note that this applies the same build-std and panic settings to your server binary, which may not be desirable. Some further exploration is probably needed here.

  1. WASM 二进制文件大小的来源之一可能是 serde 序列化/反序列化代码。Leptos 默认使用 serde 来序列化和反序列化使用 Resource::new() 创建的资源。leptos_server 包含额外的特性,通过添加额外的 new_ 方法来激活替代编码。例如,激活 leptos_server crate 上的 miniserde 特性会添加 Resource::new_miniserde() 方法,而 serde-lite 特性会添加 new_serde_liteminiserdeserde-lite 仅实现了 serde 功能的一个子集,但通常在优化时更看重二进制大小而非速度。

  2. One of the sources of binary size in WASM binaries can be serde serialization/deserialization code. Leptos uses serde by default to serialize and deserialize resources created with Resource::new(). leptos_server includes additional features to activate alternative encodings by adding additional new_ methods. For example, activating the miniserde feature on the leptos_server crate adds a Resource::new_miniserde() method, and the serde-lite feature adds new_serde_lite. miniserde and serde-lite only implement subsets of serde’s functionality, but typically optimize for binary size over speed.

该避免的事情

Things to Avoid

某些 crate 往往会膨胀二进制文件的大小。例如,带有默认特性的 regex crate 会增加大约 500kb 的 WASM 二进制大小(主要是因为它必须拉入 Unicode 表数据!)。在对大小敏感的环境中,你可能会考虑通常避免使用正则表达式,甚至降级调用浏览器 API 来使用内置的正则引擎。(这就是 leptos_router 在少数需要正则表达式的情况下所做的。)

There are certain crates that tend to inflate binary sizes. For example, the regex crate with its default features adds about 500kb to a WASM binary (largely because it has to pull in Unicode table data!). In a size-conscious setting, you might consider avoiding regexes in general, or even dropping down and calling browser APIs to use the built-in regex engine instead. (This is what leptos_router does on the few occasions it needs a regular expression.)

一般来说,Rust 对运行时性能的承诺有时会与对小体积二进制文件的承诺相冲突。例如,Rust 会对泛型函数进行单态化(monomorphizes),这意味着它会为调用的每个泛型类型创建一个该函数的独特副本。这比动态分发快得多,但增加了二进制大小。Leptos 尝试非常仔细地平衡运行时性能与二进制大小的考量;但你可能会发现,编写使用许多泛型的代码往往会增加二进制大小。例如,如果你有一个主体代码很多的泛型组件,并使用四种不同的类型调用它,请记住编译器可能会包含该代码的四个副本。重构为使用具体的内部函数或辅助函数,通常可以在保持性能和人体工程学的同时减小二进制大小。

In general, Rust’s commitment to runtime performance is sometimes at odds with a commitment to a small binary. For example, Rust monomorphizes generic functions, meaning it creates a distinct copy of the function for each generic type it’s called with. This is significantly faster than dynamic dispatch, but increases binary size. Leptos tries to balance runtime performance with binary size considerations pretty carefully; but you might find that writing code that uses many generics tends to increase binary size. For example, if you have a generic component with a lot of code in its body and call it with four different types, remember that the compiler could include four copies of that same code. Refactoring to use a concrete inner function or helper can often maintain performance and ergonomics while reducing binary size.

代码分割

Code Splitting

cargo-leptos 以及 Leptos 框架和路由支持 WASM 二进制分割。(请注意,此支持于 2025 年夏季发布;取决于你阅读本文的时间,我们可能仍在修复 bug。)

cargo-leptos and the Leptos framework and router have support for WASM binary splitting. (Note that this support was released during the summer of 2025; depending on when you’re reading this, we may still be ironing out bugs.)

这可以通过组合使用三种工具来实现:cargo leptos (serve|watch|build) --split#[lazy] 宏以及 #[lazy_route] 宏(配合 LazyRoute trait)。

This can be used through the combination of three tools: cargo leptos (serve|watch|build) --split, the #[lazy] macro, and the #[lazy_route] macro (paired with the LazyRoute trait).

#[lazy]

#[lazy] 宏表示一个函数可以从单独的 WebAssembly (WASM) 二进制文件中延迟加载。它可以用来标注同步或异步函数;在任何一种情况下,它都会生成一个异步函数。你第一次调用该延迟加载函数时,该独立的代码块将从服务器加载并被调用。随后,它将在没有额外加载步骤的情况下被调用。

The #[lazy] macro indicates that a function can be lazy-loaded from a separate WebAssembly (WASM) binary. It can be used to annotate a synchronous or async function; in either case, it will produce an async function. The first time you call the lazy-loaded function, that separate chunk of code will be loaded from the server and called. Subsequently, it will be called without an additional loading step.

#[lazy]
fn lazy_synchronous_function() -> String {
    "Hello, lazy world!".to_string()
}

#[lazy]
async fn lazy_async_function() -> String {
    /* 执行一些需要异步工作的任务 */
    /* do something that requires async work */
    "Hello, lazy async world!".to_string()
}

async fn use_lazy_functions() {
    // 同步函数已被转换为异步
    // synchronous function has been converted to async
    let value1 = lazy_synchronous_function().await;

    // 异步函数仍然是异步的
    // async function is still async
    let value1 = lazy_async_function().await;
}

这对于一次性的延迟函数很有用。但当延迟加载与路由配合使用时,它的功能最为强大。

This can be useful for one-off lazy functions. But lazy-loading is most powerful when it’s paired with the router.

#[lazy_route]

延迟路由(Lazy routes)允许你将路由视图的代码拆分出来,并在导航时与该路由的数据并发地延迟加载它。通过使用嵌套路由,可以嵌套多个延迟加载路由:每个路由都将并发加载其自身的数据和自身的延迟视图。

Lazy routes allow you to split out the code for a route’s view, and to lazily load it concurrently with data for that route while navigating. Through the use of nested routing, multiple lazy-loaded routes can be nested: each will load its own data and its own lazy view concurrently.

将数据加载与(延迟加载的)视图分离,可以防止“瀑布流”现象,即你等待延迟视图加载,然后才开始加载数据。

Splitting the data loading from the (lazy-loaded) view allows you to prevent a “waterfall,” in which you wait for the lazy view to load, then begin loading data.

use leptos::prelude::*;
use leptos_router::{lazy_route, LazyRoute};

// 路由定义
// the route definition
#[derive(Debug)]
struct BlogListingRoute {
    titles: Resource<Vec<String>>
}

#[lazy_route]
impl LazyRoute for BlogListingRoute {
    fn data() -> Self {
        Self {
            titles: Resource::new(|| (), |_| async {
                vec![/* 待办:加载博客文章 (todo: load blog posts) */]
            })
        }
    }

    // 此函数将被延迟加载,与 data() 并发执行
    // this function will be lazy-loaded, concurrently with data()
    fn view(this: Self) -> AnyView {
        let BlogListingRoute { titles } = this;

        // ... 现在你可以将 `posts` 资源与 Suspense 等一起使用,
        // 并通过在视图上调用 .into_any() 返回 AnyView
        // ... now you can use the `posts` resource with Suspense, etc.,
        // and return AnyView by calling .into_any() on a view
    }
}

示例与更多信息

Examples and More Information

你可以在这个 YouTube 视频中找到更深入的讨论,并在仓库中找到完整的 lazy_routes 示例。

You can find more in-depth discussion in this YouTube video, and a full lazy_routes example in the repo.

指南:Islands

Guide: Islands

Leptos 0.5 引入了新的 islands 特性。本指南将带你了解 islands 特性及其核心概念,并实现一个使用 islands 架构的演示应用。

Leptos 0.5 introduced the new islands feature. This guide will walk through the islands feature and core concepts, while implementing a demo app using the islands architecture.

Islands 架构

The Islands Architecture

主流的 JavaScript 前端框架(React、Vue、Svelte、Solid、Angular)最初都是作为构建客户端渲染单页应用(SPA)的框架而诞生的。初始页面加载渲染为 HTML,然后进行水合(hydrate),随后的导航直接在客户端处理。(因此被称为“单页”:即使之后有客户端路由,一切都源于服务器的一次页面加载。)这些框架后来都增加了服务端渲染(SSR)以缩短初始加载时间、优化 SEO 并提升用户体验。

The dominant JavaScript frontend frameworks (React, Vue, Svelte, Solid, Angular) all originated as frameworks for building client-rendered single-page apps (SPAs). The initial page load is rendered to HTML, then hydrated, and subsequent navigations are handled directly in the client. (Hence “single page”: everything happens from a single page load from the server, even if there is client-side routing later.) Each of these frameworks later added server-side rendering to improve initial load times, SEO, and user experience.

这意味着默认情况下,整个应用都是可交互的。这也意味着整个应用必须作为 JavaScript 发送到客户端才能进行水合。Leptos 也遵循了这一模式。

This means that by default, the entire app is interactive. It also means that the entire app has to be shipped to the client as JavaScript in order to be hydrated. Leptos has followed this same pattern.

你可以在关于 服务端渲染 的章节中了解更多。 You can read more in the chapters on server-side rendering.

但也可以朝相反的方向工作。与其采用一个完全交互式的应用,在服务器上将其渲染为 HTML,然后在浏览器中进行水合,你可以从一个纯 HTML 页面开始,并添加小块交互区域。这是 2010 年代之前任何网站或应用的传统格式:你的浏览器向服务器发起一系列请求,服务器返回每个新页面的 HTML 作为响应。在“单页应用”(SPA)兴起之后,这种方法相比之下有时被称为“多页应用”(MPA)。

But it’s also possible to work in the opposite direction. Rather than taking an entirely-interactive app, rendering it to HTML on the server, and then hydrating it in the browser, you can begin with a plain HTML page and add small areas of interactivity. This is the traditional format for any website or app before the 2010s: your browser makes a series of requests to the server and returns the HTML for each new page in response. After the rise of “single-page apps” (SPA), this approach has sometimes become known as a “multi-page app” (MPA) by comparison.

“Islands 架构”(孤岛架构)这一术语最近出现,用于描述从服务端渲染的 HTML 页面“海洋”开始,并在整个页面中添加交互式“孤岛(islands)”的方法。

The phrase “islands architecture” has emerged recently to describe the approach of beginning with a “sea” of server-rendered HTML pages, and adding “islands” of interactivity throughout the page.

延伸阅读

Additional Reading

本指南的其余部分将探讨如何在 Leptos 中使用 islands。关于该方法的更多背景信息,请查看以下文章:

The rest of this guide will look at how to use islands with Leptos. For more background on the approach in general, check out some of the articles below:

激活 Islands 模式

Activating Islands Mode

让我们从一个新的 cargo-leptos 应用开始:

Let’s start with a fresh cargo-leptos app:

cargo leptos new --git leptos-rs/start-axum

在此示例中,Actix 和 Axum 之间应该没有实质性区别。 There should be no real differences between Actix and Axum in this example.

我将在后台运行

I’m just going to run

cargo leptos build

同时启动编辑器并继续编写代码。

in the background while I fire up my editor and keep writing.

我要做的第一件事是在 Cargo.toml 中添加 islands 特性。我只需要在 leptos crate 中添加它。

The first thing I’ll do is to add the islands feature in my Cargo.toml. I only need to add this to the leptos crate.

leptos = { version = "0.7", features = ["islands"] }

接下来,我将修改从 src/lib.rs 导出的 hydrate 函数。我将删除调用 leptos::mount::hydrate_body(App) 的那行,并将其替换为:

Next I’m going to modify the hydrate function exported from src/lib.rs. I’m going to remove the line that calls leptos::mount::hydrate_body(App) and replace it with

leptos::mount::hydrate_islands();

这将按顺序水合每个单独的 island,而不是运行整个应用程序并水合它创建的视图。

Rather than running the whole application and hydrating the view that it creates, this will hydrate each individual island, in order.

app.rsshell 函数中,我们还需要在 HydrationScripts 组件中添加 islands=true

In app.rs, in the shell functions, we’ll also need to add islands=true to the HydrationScripts component:

<HydrationScripts options islands=true/>

好了,现在启动 cargo leptos watch 并访问 http://localhost:3000(或其他地址)。

Okay, now fire up your cargo leptos watch and go to http://localhost:3000 (or wherever).

点击按钮,然后……

Click the button, and...

什么都没发生!

Nothing happens!

太完美了。

Perfect.

注意

入门模板在其 hydrate() 函数定义中包含 use app::*;。一旦切换到 islands 模式,你就不再使用导入的主 App 函数,因此你可能认为可以删除它。(事实上,如果你不删,Rust lint 工具可能会发出警告!)

然而,如果你使用的是工作区(workspace)设置,这可能会导致问题。我们使用 wasm-bindgen 为每个函数独立导出入口点。根据我的经验,如果你使用工作区设置,并且 frontend crate 中没有任何东西实际使用 app crate,那么这些绑定将无法正确生成。点击此处查看更多讨论

使用 Islands

Using Islands

什么都没发生,因为我们刚刚彻底颠覆了应用的思维模型。应用现在默认是纯 HTML 的,而不是默认可交互且水合一切,我们需要主动选择加入交互性。

Nothing happens because we’ve just totally inverted the mental model of our app. Rather than being interactive by default and hydrating everything, the app is now plain HTML by default, and we need to opt into interactivity.

这对 WASM 二进制文件大小有很大影响:如果我以发布模式编译,这个应用的 WASM 只有区区 24kb(未压缩),而相比之下,非 islands 模式下为 274kb。(对于一个“Hello, world!”来说 274kb 相当大,这其实是所有与客户端路由相关的代码,而在演示中并未使用。)

This has a big effect on WASM binary sizes: if I compile in release mode, this app is a measly 24kb of WASM (uncompressed), compared to 274kb in non-islands mode. (274kb is quite large for a “Hello, world!” It’s really just all the code related to client-side routing, which isn’t being used in the demo.)

当我们点击按钮时,什么都没发生,因为我们的整个页面都是静态的。

When we click the button, nothing happens, because our whole page is static.

那么我们该如何让事情发生呢?

So how do we make something happen?

让我们把 HomePage 组件变成一个 island!

Let’s turn the HomePage component into an island!

这是之前的非交互版本:

Here was the non-interactive version:

#[component]
fn HomePage() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    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>
    }
}

这是交互版本:

Here’s the interactive version:

#[island]
fn HomePage() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    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>
    }
}

现在当我点击按钮时,它起作用了!

Now when I click the button, it works!

#[island] 宏的工作方式与 #[component] 宏完全相同,不同之处在于在 islands 模式下,它将其指定为交互式 island。如果我们再次检查二进制文件大小,发布模式下未压缩为 166kb;虽然比 24kb 的完全静态版本大得多,但比 355kb 的完全水合版本小得多。

The #[island] macro works exactly like the #[component] macro, except that in islands mode, it designates this as an interactive island. If we check the binary size again, this is 166kb uncompressed in release mode; much larger than the 24kb totally static version, but much smaller than the 355kb fully-hydrated version.

如果你现在打开页面的源代码,你会看到你的 HomePage island 已经被渲染为一个特殊的 <leptos-island> HTML 元素,它指定了应该使用哪个组件来水合它:

If you open up the source for the page now, you’ll see that your HomePage island has been rendered as a special <leptos-island> HTML element which specifies which component should be used to hydrate it:

<leptos-island data-component="HomePage_7432294943247405892">
  <h1>Welcome to Leptos!</h1>
  <button>
    Click Me:
    <!>0
  </button>
</leptos-island>

只有这个 <leptos-island> 内部的代码才会被编译为 WASM,水合时也只有该代码会运行。

Only code for what's inside this <leptos-island> is compiled to WASM, only that code runs when hydrating.

有效地使用 Islands

Using Islands Effectively

请记住,只有 #[island] 内部的代码才需要被编译成 WASM 并发送到浏览器。这意味着 islands 应该尽可能小且具体。例如,我的 HomePage 最好拆分为一个普通组件和一个 island:

Remember that only code within an #[island] needs to be compiled to WASM and shipped to the browser. This means that islands should be as small and specific as possible. My HomePage, for example, would be better broken apart into a regular component and an island:

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <Counter/>
    }
}

#[island]
fn Counter() -> impl IntoView {
    // 创建响应式值以更新按钮
    // Creates a reactive value to update the button
    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> 不需要包含在客户端包中,也不需要水合。这现在看起来像是一个微不足道的区别;但请注意,你现在可以向 HomePage 本身添加任意数量的静态 HTML 内容,而 WASM 二进制文件的大小将保持完全相同。

Now the <h1> doesn’t need to be included in the client bundle, or hydrated. This seems like a silly distinction now; but note that you can now add as much inert HTML content as you want to the HomePage itself, and the WASM binary size will remain exactly the same.

在普通水合模式下,你的 WASM 二进制文件大小随应用的大小/复杂度而增长。在 islands 模式下,你的 WASM 二进制文件随应用中交互性的多少而增长。你可以在 islands 之外添加任意数量的非交互式内容,它不会增加二进制文件的大小。

In regular hydration mode, your WASM binary size grows as a function of the size/complexity of your app. In islands mode, your WASM binary grows as a function of the amount of interactivity in your app. You can add as much non-interactive content as you want, outside islands, and it will not increase that binary size.

解锁超能力

Unlocking Superpowers

所以,WASM 二进制文件大小减少 50% 固然很好。但真正的意义是什么呢?

So, this 50% reduction in WASM binary size is nice. But really, what’s the point?

当你结合两个关键事实时,意义就显现出来了:

The point comes when you combine two key facts:

  1. #[component] 函数内部的代码现在在服务器上运行,除非你在 island 中使用它。*

  2. 子组件和属性可以从服务器传递给 islands,而无需包含在 WASM 二进制文件中。

  3. Code inside #[component] functions now only runs on the server, unless you use it in an island.*

  4. Children and props can be passed from the server to islands, without being included in the WASM binary.

这意味着你可以直接在组件体中运行仅限服务器运行的代码,并将其直接传递到子组件中。在完全水合的应用中,某些需要服务器函数和 Suspense 复杂配合的任务,在 islands 中可以内联完成。

This means you can run server-only code directly in the body of a component, and pass it directly into the children. Certain tasks that take a complex blend of server functions and Suspense in fully-hydrated apps can be done inline in islands.

* 这个“除非你在 island 中使用它”很重要。#[component] 组件并非只在服务器上运行。更准确地说,它们是“共享组件”,只有当它们被用在 #[island] 的主体中时,才会被编译进 WASM 二进制文件。但如果你不在 island 中使用它们,它们就不会在浏览器中运行。 * This “unless you use it in an island” is important. It is not the case that #[component] components only run on the server. Rather, they are “shared components” that are only compiled into the WASM binary if they’re used in the body of an #[island]. But if you don’t use them in an island, they won’t run in the browser.

在本演示的其余部分,我们将依赖第三个事实:

We’re going to rely on a third fact in the rest of this demo:

  1. 上下文(context)可以在彼此独立的 islands 之间传递。

  2. Context can be passed between otherwise-independent islands.

所以,与其用计数器演示,不如让我们做点更有趣的东西:一个从服务器文件读取数据的选项卡(tabs)界面。

So, instead of our counter demo, let’s make something a little more fun: a tabbed interface that reads data from files on the server.

向 Islands 传递服务端子组件

Passing Server Children to Islands

Islands 最强大的地方之一是你可以将服务端渲染的子组件传递给 island,而 island 无需了解它们的任何信息。Islands 只水合它们自己的内容,而不水合传递给它们的子组件。

One of the most powerful things about islands is that you can pass server-rendered children into an island, without the island needing to know anything about them. Islands hydrate their own content, but not children that are passed to them.

正如 React 的 Dan Abramov(在非常类似的 RSC 背景下)所说,islands 并不真的是岛屿:它们是甜甜圈。你可以直接将仅限服务器的内容传递到“甜甜圈洞”中,从而允许你创建极小的交互式环礁,其两侧都被惰性的服务器 HTML 海洋包围。

As Dan Abramov of React put it (in the very similar context of RSCs), islands aren’t really islands: they’re donuts. You can pass server-only content directly into the “donut hole,” as it were, allowing you to create tiny atolls of interactivity, surrounded on both sides by the sea of inert server HTML.

在下面包含的演示代码中,我添加了一些样式,将所有服务器内容显示为浅蓝色的“海洋”,将所有 islands 显示为浅绿色的“陆地”。希望这有助于你想象我所说的内容! In the demo code included below, I added some styles to show all server content as a light-blue “sea,” and all islands as light-green “land.” Hopefully that will help picture what I’m talking about!

继续演示:我将创建一个 Tabs 组件。在选项卡之间切换需要一些交互性,所以这当然会是一个 island。我们现在先从简单的开始:

To continue with the demo: I’m going to create a Tabs component. Switching between tabs will require some interactivity, so of course this will be an island. Let’s start simple for now:

#[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>
    }
}

哎呀。这给了我一个错误:

Oops. This gives me an error

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 属性。

Easy fix: let’s cargo add serde --features=derive. The #[island] macro wants to pull in serde here because it needs to serialize and deserialize the labels prop.

现在让我们更新 HomePage 以使用 Tabs

Now let’s update the HomePage to use Tabs.

#[component]
fn HomePage() -> impl IntoView {
	// 这些是我们要读取的文件
    // these are the files we’re going to read
    let files = ["a.txt", "b.txt", "c.txt"];
	// 选项卡标签将只是文件名
	// the tab labels will just be the file names
	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 现在类似于:

If you take a look in the DOM inspector, you’ll see the island is now something like

<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 属性正被序列化为 JSON 并存储在 HTML 属性中,以便用于水合 island。

Our labels prop is getting serialized to JSON and stored in an HTML attribute so it can be used to hydrate the island.

现在让我们添加一些选项卡。目前,Tab island 将非常简单:

Now let’s add some tabs. For the moment, a Tab island will be really simple:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    view! {
        <div>{children()}</div>
    }
}

目前,每个选项卡都只是一个包裹其子组件的 <div>

Each tab, for now will just be a <div> wrapping its children.

我们的 Tabs 组件也将获得一些子组件:目前,让我们把它们全部显示出来。

Our Tabs component will also get some children: for now, let’s just show them all.

#[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。我们要创建选项卡列表放入我们的选项卡框中。

Okay, now let’s go back into the HomePage. We’re going to create the list of tabs to put into our tab box.

#[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>
    }
}

呃……什么?

Uh... What?

如果你习惯使用 Leptos,你就知道你不能这样做。组件主体中的所有代码都必须在服务器上运行(以渲染为 HTML)并在浏览器中运行(以水合),所以你不能直接调用 std::fs;它会发生 panic,因为在浏览器中无法访问本地文件系统(当然更不能访问服务器文件系统!)。这将是一场安全噩梦!

If you’re used to using Leptos, you know that you just can’t do this. All code in the body of components has to run on the server (to be rendered to HTML) and in the browser (to hydrate), so you can’t just call std::fs; it will panic, because there’s no access to the local filesystem (and certainly not to the server filesystem!) in the browser. This would be a security nightmare!

除非……等等。我们现在处于 islands 模式。这个 HomePage 组件真的只在服务器上运行。所以,事实上,我们可以像这样使用普通的服务器代码。

Except... wait. We’re in islands mode. This HomePage component really does only run on the server. So we can, in fact, just use ordinary server code like this.

这是一个愚蠢的例子吗? 是的!在 .map() 中同步读取三个不同的本地文件在现实生活中并不是一个好的选择。这里的重点只是为了证明这绝对是仅限服务器的内容。 Is this a dumb example? Yes! Synchronously reading from three different local files in a .map() is not a good choice in real life. The point here is just to demonstrate that this is, definitely, server-only content.

去吧,在项目根目录下创建三个名为 a.txtb.txtc.txt 的文件,并填入你喜欢的任何内容。

Go ahead and create three files in the root of the project called a.txt, b.txt, and c.txt, and fill them in with whatever content you’d like.

刷新页面,你应该就能在浏览器中看到内容。编辑文件并再次刷新;内容将会更新。

Refresh the page and you should see the content in the browser. Edit the files and refresh again; it will be updated.

你可以将仅限服务器的内容从 #[component] 传递到 #[island] 的子组件中,而 island 无需知道任何关于如何访问该数据或渲染该内容的信息。

You can pass server-only content from a #[component] into the children of an #[island], without the island needing to know anything about how to access that data or render that content.

这非常重要。 向 islands 传递服务器 children 意味着你可以保持 islands 的精简。理想情况下,你不想在页面的整个大块周围套上 #[island]。你应该将该块拆分为一个交互式部分(可以是 #[island])和一大堆额外的服务器内容(作为 children 传递给该 island),这样就可以将页面交互部分的非交互子部分排除在 WASM 二进制文件之外。

This is really important. Passing server children to islands means that you can keep islands small. Ideally, you don’t want to slap an #[island] around a whole chunk of your page. You want to break that chunk out into an interactive piece, which can be an #[island], and a bunch of additional server content that can be passed to that island as children, so that the non-interactive subsections of an interactive part of the page can be kept out of the WASM binary.

在 Islands 之间传递上下文

Passing Context Between Islands

这些现在还不算真正的“选项卡”:它们只是时刻显示每一个选项卡。所以让我们为我们的 TabsTab 组件添加一些简单的逻辑。

These aren’t really “tabs” yet: they just show every tab, all the time. So let’s add some simple logic to our Tabs and Tab components.

我们将修改 Tabs 以创建一个简单的 selected 信号。我们通过上下文提供读取端,并在有人点击我们的按钮时设置信号的值。

We’ll modify Tabs to create a simple selected signal. We provide the read half via context, and set the value of the signal whenever someone clicks one of our buttons.

#[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();
// ...

让我们修改 Tab island,使用该上下文来显示或隐藏自己:

And let’s modify the Tab island to use that context to show or hide itself:

#[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 通过上下文将信号传递给每个 Tab,后者使用它来确定自己是否应该打开。

Now the tabs behave exactly as I’d expect. Tabs passes the signal via context to each Tab, which uses it to determine whether it should be open or not.

这就是为什么在 HomePage 中,我把 let tabs = move || 做成了一个函数,并像 {tabs()} 这样调用它:以这种懒加载的方式创建选项卡意味着当每个 Tab 去寻找它时,Tabs island 已经提供了 selected 上下文。 That’s why in HomePage, I made let tabs = move || a function, and called it like {tabs()}: creating the tabs lazily this way meant that the Tabs island would already have provided the selected context by the time each Tab went looking for it.

我们的完整选项卡演示大约是 200kb(未压缩):虽然不是世界上最小的演示,但仍比我们开始时使用客户端路由的“Hello, world”小得多!出于好奇,我构建了同样但不使用 islands 模式、而是使用 #[server] 函数和 Suspense 的演示,结果超过了 400kb。所以这再次节省了大约 50% 的二进制文件大小。而且这个应用只包含了极少的仅限服务器的内容:记住,随着我们添加更多的仅限服务器的组件和页面,这 200kb 不会增加。

Our complete tabs demo is about 200kb uncompressed: not the smallest demo in the world, but still significantly smaller than the “Hello, world” using client side routing that we started with! Just for kicks, I built the same demo without islands mode, using #[server] functions and Suspense. and it was over 400kb. So again, this was about a 50% savings in binary size. And this app includes quite minimal server-only content: remember that as we add additional server-only components and pages, this 200kb will not grow.

概览

Overview

这个演示可能看起来非常基础。事实确实如此。但有一些立竿见影的收获:

This demo may seem pretty basic. It is. But there are a number of immediate takeaways:

  • WASM 二进制文件大小减少 50%,这意味着客户端的可交互时间和初始加载时间有了可衡量的改进。

  • 降低了数据序列化成本。 创建资源并在客户端读取意味着你需要序列化数据,以便将其用于水合。如果你还在 Suspense 中读取该数据以创建 HTML,你最终会得到“双重数据”,即完全相同的数据既被渲染为 HTML,又被序列化为 JSON,增加了响应的大小,从而减慢了响应速度。

  • 可以轻松使用仅限服务器的 API,只需在 #[component] 内部使用,就像它是运行在服务器上的普通、原生 Rust 函数一样——在 islands 模式下,它确实是!

  • 减少了加载服务器数据的 #[server] / create_resource / Suspense 样板代码。

  • 50% WASM binary size reduction, which means measurable improvements in time to interactivity and initial load times for clients.

  • Reduced data serialization costs. Creating a resource and reading it on the client means you need to serialize the data, so it can be used for hydration. If you’ve also read that data to create HTML in a Suspense, you end up with “double data,” i.e., the same exact data is both rendered to HTML and serialized as JSON, increasing the size of responses, and therefore slowing them down.

  • Easily use server-only APIs inside a #[component] as if it were a normal, native Rust function running on the server—which, in islands mode, it is!

  • Reduced #[server]/create_resource/Suspense boilerplate for loading server data.

未来探索

Future Exploration

islands 特性反映了目前前端 Web 框架探索的最前沿成果。就目前而言,我们的 islands 方法与 Astro 非常相似(在其最近支持视图过渡(View Transitions)之前):它允许你构建传统的服务端渲染、多页应用,并能非常无缝地集成交互式的 islands。

The islands feature reflects work at the cutting edge of what frontend web frameworks are exploring right now. As it stands, our islands approach is very similar to Astro (before its recent View Transitions support): it allows you to build a traditional server-rendered, multi-page app and pretty seamlessly integrate islands of interactivity.

有一些小的改进将很容易添加。例如,我们可以做一些非常类似于 Astro 的视图过渡方法:

There some small improvements that will be easy to add. For example, we can do something very much like Astro's View Transitions approach:

  • 为 islands 应用添加客户端路由,方法是从服务器获取后续导航并用新 HTML 文档替换旧文档。

  • 使用视图过渡(View Transitions)API 在新旧文档之间添加动画过渡。

  • 支持显式的持久化 islands,即你可以用唯一 ID 标记的 islands(类似于视图中组件上的 persist:searchbar),它们可以从旧文档复制到新文档,而不会丢失其当前状态。

  • add client-side routing for islands apps by fetching subsequent navigations from the server and replacing the HTML document with the new one

  • add animated transitions between the old and new document using the View Transitions API

  • support explicit persistent islands, i.e., islands that you can mark with unique IDs (something like persist:searchbar on the component in the view), which can be copied over from the old to the new document without losing their current state

还有其他更大的架构变动,我目前还不确定

There are other, larger architectural changes that I’m not sold on yet.

附加信息

Additional Information

查看 islands 示例路线图Hackernews 演示 以获取更多讨论。

Check out the islands example, roadmap, and Hackernews demo for additional discussion.

演示代码

Demo Code

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>
    }
}

附录:响应式系统是如何工作的?

Appendix: How does the Reactive System Work?

为了成功使用本库,你并不需要了解太多关于响应式系统实际运行机制的知识。但一旦你开始在高级水平上使用该框架,了解幕后发生的事情总是很有用的。

You don’t need to know very much about how the reactive system actually works in order to use the library successfully. But it’s always useful to understand what’s going on behind the scenes once you start working with the framework at an advanced level.

你使用的响应式原语分为三组:

The reactive primitives you use are divided into three sets:

  • 信号 (Signals) (ReadSignal/WriteSignalRwSignalResourceTrigger):你可以主动更改以触发响应式更新的值。
  • Signals (ReadSignal/WriteSignal, RwSignal, Resource, Trigger) Values you can actively change to trigger reactive updates.
  • 计算 (Computations) (Memo):依赖于信号(或其他计算),并通过某些纯计算推导出新的响应式值。
  • Computations (Memo) Values that depend on signals (or other computations) and derive a new reactive value through some pure computation.
  • 副作用 (Effects):监听某些信号或计算的变化并运行一个函数,从而引起某些副作用的观察者。
  • Effects Observers that listen to changes in some signals or computations and run a function, causing some side effect.

派生信号(Derived signals)是一种非原语计算:作为普通闭包,它们仅仅允许你将一些重复的基于信号的计算重构为可在多处调用的可重用函数,但它们本身并不在响应式系统中表示。

Derived signals are a kind of non-primitive computation: as plain closures, they simply allow you to refactor some repeated signal-based computation into a reusable function that can be called in multiple places, but they are not represented in the reactive system itself.

所有其他原语实际上都作为响应式图(reactive graph)中的节点存在于响应式系统中。

All the other primitives actually exist in the reactive system as nodes in a reactive graph.

响应式系统的大部分工作在于将变更从信号传播到副作用,期间可能经过一些中间的 Memo。

Most of the work of the reactive system consists of propagating changes from signals to effects, possibly through some intervening memos.

响应式系统的假设是,副作用(如渲染到 DOM 或发出网络请求)比更新应用内部的 Rust 数据结构要昂贵几个数量级。

The assumption of the reactive system is that effects (like rendering to the DOM or making a network request) are orders of magnitude more expensive than things like updating a Rust data structure inside your app.

因此,响应式系统的首要目标尽可能不频繁地运行副作用

So the primary goal of the reactive system is to run effects as infrequently as possible.

Leptos 通过构建响应式图来实现这一点。

Leptos’s current reactive system is based heavily on the Reactively library for JavaScript. You can read Milo’s article “Super-Charging Fine-Grained Reactivity” for an excellent account of its algorithm, as well as fine-grained reactivity in general—including some beautiful diagrams!

响应式图

The Reactive Graph

信号、Memo 和副作用都共有三个特征:

Signals, memos, and effects all share three characteristics:

  • 值 (Value):它们拥有一个当前值:要么是信号的值,要么是(对于 Memo 和副作用)之前运行返回的值(如果有)。
  • Value They have a current value: either the signal’s value, or (for memos and effects) the value returned by the previous run, if any.
  • 源 (Sources):它们依赖的任何其他响应式原语。(对于信号,这是一个空集。)
  • Sources Any other reactive primitives they depend on. (For signals, this is an empty set.)
  • 订阅者 (Subscribers):依赖于它们的任何其他响应式原语。(对于副作用,这是一个空集。)
  • Subscribers Any other reactive primitives that depend on them. (For effects, this is an empty set.)

实际上,信号、Memo 和副作用只是响应式图中一个通用“节点”概念的惯用名称。信号始终是“根节点”,没有源/父节点。副作用始终是“叶节点”,没有订阅者。Memo 通常既有源又有订阅者。

In reality then, signals, memos, and effects are just conventional names for one generic concept of a “node” in a reactive graph. Signals are always “root nodes,” with no sources/parents. Effects are always “leaf nodes,” with no subscribers. Memos typically have both sources and subscribers.

在接下来的示例中,我将使用 nightly 语法,纯粹是为了减少这份旨在让你阅读而非复制粘贴的文档的冗余!

In the following examples, I’m going to use the nightly syntax, simply for the sake of reducing verbosity in a document that’s intended for you to read, not to copy-and-paste from!

简单依赖

Simple Dependencies

想象以下代码:

So imagine the following code:

// 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。

You can easily imagine the reactive graph here: name is the only signal/origin node, the Effect::new is the only effect/terminal node, and there’s one intervening memo.

A   (name)
|
B   (name_upper)
|
C   (the effect)

分支拆分

Splitting Branches

让我们让它复杂一点。

Let’s make it a little more complex.

// 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/Bname_len/C,每条轨道都有一个依赖它的副作用。

This is also pretty straightforward: a signal source signal (name/A) divides into two parallel tracks: name_upper/B and name_len/C, each of which has an effect that depends on it.

 __A__
|     |
B     C
|     |
E     D

现在让我们更新信号。

Now let’s update the signal.

set_name("Bob");

我们立即记录:

We immediately log

len = 3
name = BOB

再做一次。

Let’s do it again.

set_name("Tim");

日志应该显示:

The log should shows

name = TIM

len = 3 不会再次记录。

len = 3 does not log again.

请记住:响应式系统的目标是尽可能不频繁地运行副作用。将 name"Bob" 更改为 "Tim" 会导致每个 Memo 重新运行。但只有当它们的值确实发生变化时,它们才会通知订阅者。"BOB""TIM" 是不同的,所以该副作用会再次运行。但两个名字的长度都是 3,所以该副作用不会再次运行。

Remember: the goal of the reactive system is to run effects as infrequently as possible. Changing name from "Bob" to "Tim" will cause each of the memos to re-run. But they will only notify their subscribers if their value has actually changed. "BOB" and "TIM" are different, so that effect runs again. But both names have the length 3, so they do not run again.

分支合并

Reuniting Branches

最后一个例子,有时被称为钻石问题 (the diamond problem)

One more example, of what’s sometimes called the 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());
});

这个图看起来像什么?

What does the graph look like for this?

 __A__
|     |
B     C
|     |
|__D__|

你可以看到为什么它被称为“钻石问题”。如果我用直线连接节点而不是蹩脚的 ASCII 艺术,它会形成一个钻石形状:两个 Memo 都依赖于一个信号,并共同馈入同一个副作用。

You can see why it's called the “diamond problem.” If I’d connected the nodes with straight lines instead of bad ASCII art, it would form a diamond: two memos, each of which depend on a signal, which feed into the same effect.

一个原始的基于推送(push-based)的响应式实现会导致这个副作用运行两次,这很糟糕。(记住,我们的目标是尽可能减少副作用运行次数。)例如,你可以实现这样一个响应式系统:信号和 Memo 立即将其更改一直向下传播到图中,穿过每个依赖项,本质上是深度优先地遍历图。换句话说,更新 A 会通知 BB 会通知 D;然后 A 会通知 CC 会再次通知 D。这既低效(D 运行了两次),又容易产生故障(glitchy)(在 D 第一次运行时,第二个 Memo 的值实际上是不正确的)。

A naive, push-based reactive implementation would cause this effect to run twice, which would be bad. (Remember, our goal is to run effects as infrequently as we can.) For example, you could implement a reactive system such that signals and memos immediately propagate their changes all the way down the graph, through each dependency, essentially traversing the graph depth-first. In other words, updating A would notify B, which would notify D; then A would notify C, which would notify D again. This is both inefficient (D runs twice) and glitchy (D actually runs with the incorrect value for the second memo during its first run.)

解决钻石问题

Solving the Diamond Problem

任何名副其实的响应式实现都致力于解决这个问题。有许多不同的方法(同样,参见 Milo 的文章 以获得出色的概述)。

Any reactive implementation worth its salt is dedicated to solving this issue. There are a number of different approaches (again, see Milo’s article for an excellent overview).

简而言之,我们的方法是这样的。

Here’s how ours works, in brief.

一个响应式节点始终处于以下三种状态之一:

A reactive node is always in one of three states:

  • Clean(干净):已知没有发生变化
  • Check(待查):可能发生了变化
  • Dirty(脏):肯定发生了变化

更新一个信号会将该信号标记为 Dirty,并递归地将其所有后代标记为 Check。其作为副作用的任何后代都会被添加到一个待重新运行的队列中。

Updating a signal Dirty marks that signal Dirty, and marks all its descendants Check, recursively. Any of its descendants that are effects are added to a queue to be re-run.

    ____A (DIRTY)___
   |               |
B (CHECK)    C (CHECK)
   |               |
   |____D (CHECK)__|

现在运行这些副作用。(此时所有的副作用都将被标记为 Check。)在重新运行其计算之前,副作用会检查其父节点以查看它们是否为 Dirty

Now those effects are run. (All of the effects will be marked Check at this point.) Before re-running its computation, the effect checks its parents to see if they are dirty.

  • 因此 D 找到 B 并检查它是否为 Dirty

  • B 也被标记为 Check。所以 B 做同样的事情:

    • B 找到 A,发现它是 Dirty
    • 这意味着 B 需要重新运行,因为它的一个源发生了变化。
    • B 重新运行,生成一个新值,并将自己标记为 Clean
    • 因为 B 是一个 Memo,它随后会将之前的值与新值进行对比。
    • 如果相同,B 返回“无变化”。否则,它返回“是的,我变了”。
  • 如果 B 返回“是的,我变了”,D 就知道它肯定需要运行,并在检查任何其他源之前立即重新运行。

  • 如果 B 返回“不,我没变”,D 继续检查 C(见上述 B 的过程)。

  • 如果 BC 都没有变化,则副作用不需要重新运行。

  • 如果 BC 中任一发生了变化,副作用现在重新运行。

  • So D goes to B and checks if it is Dirty.

  • But B is also marked Check. So B does the same thing:

    • B goes to A, and finds that it is Dirty.
    • This means B needs to re-run, because one of its sources has changed.
    • B re-runs, generating a new value, and marks itself Clean
    • Because B is a memo, it then checks its prior value against the new value.
    • If they are the same, B returns "no change." Otherwise, it returns "yes, I changed."
  • If B returned “yes, I changed,” D knows that it definitely needs to run and re-runs immediately before checking any other sources.

  • If B returned “no, I didn’t change,” D continues on to check C (see process above for B.)

  • If neither B nor C has changed, the effect does not need to re-run.

  • If either B or C did change, the effect now re-runs.

因为副作用只被标记为 Check 一次且只入队一次,所以它只运行一次。

Because the effect is only marked Check once and only queued once, it only runs once.

如果原始版本是“基于推送”的响应式系统(只是将响应式变更一直推送到图的底端,从而导致副作用运行两次),那么这个版本可以被称为“推拉结合(push-pull)”。它将 Check 状态一直向下推送,然后“拉取”回溯。事实上,对于大型图,当它试图确定具体哪些节点需要重新运行时,最终可能会在图中上下左右弹跳。

If the naive version was a “push-based” reactive system, simply pushing reactive changes all the way down the graph and therefore running the effect twice, this version could be called “push-pull.” It pushes the Check status all the way down the graph, but then “pulls” its way back up. In fact, for large graphs it may end up bouncing back up and down and left and right on the graph as it tries to determine exactly which nodes need to re-run.

请注意这一重要的权衡:基于推送的响应式系统能更快地传播信号变更,代价是过度重新运行 Memo 和副作用。请记住:响应式系统旨在最大程度地减少重新运行副作用的次数,这是基于一个(准确的)假设,即副作用比这种完全在库的 Rust 代码内部发生的缓存友好型图遍历要昂贵几个数量级。一个好的响应式系统的衡量标准不是它传播变更的速度有多快,而是它在_不过度通知_的情况下传播变更的速度有多快。

Note this important trade-off: Push-based reactivity propagates signal changes more quickly, at the expense of over-re-running memos and effects. Remember: the reactive system is designed to minimize how often you re-run effects, on the (accurate) assumption that side effects are orders of magnitude more expensive than this kind of cache-friendly graph traversal happening entirely inside the library’s Rust code. The measurement of a good reactive system is not how quickly it propagates changes, but how quickly it propagates changes without over-notifying.

Memo vs. 信号

Memos vs. Signals

请注意,信号总是会通知其子项;即,信号在更新时始终被标记为 Dirty,即使它的新值与旧值相同。否则,我们将不得不要求信号实现 PartialEq,而这对某些类型来说实际上是非常昂贵的检查。(例如,当显然已经发生变化时,给诸如 some_vec_signal.update(|n| n.pop()) 之类的操作添加不必要的相等性检查。)

Note that signals always notify their children; i.e., a signal is always marked Dirty when it updates, even if its new value is the same as the old value. Otherwise, we’d have to require PartialEq on signals, and this is actually quite an expensive check on some types. (For example, add an unnecessary equality check to something like some_vec_signal.update(|n| n.pop()) when it’s clear that it has in fact changed.)

另一方面,Memo 在通知子项之前会检查自己是否发生了变化。无论你 .get() 多少次结果,它们都只运行一次计算,但只要其信号源发生变化,它们就会运行。这意味着如果 Memo 的计算_非常_昂贵,你实际上可能也希望对其输入进行 Memo 化,这样 Memo 就只有在确定其输入已更改时才会重新计算。

Memos, on the other hand, check whether they change before notifying their children. They only run their calculation once, no matter how many times you .get() the result, but they run whenever their signal sources change. This means that if the memo’s computation is very expensive, you may actually want to memoize its inputs as well, so that the memo only re-calculates when it is sure its inputs have changed.

Memo vs. 派生信号

Memos vs. Derived Signals

这一切都很酷,Memo 也非常棒。但大多数实际应用的响应式图都非常浅且非常宽:你可能有 100 个源信号和 500 个副作用,但没有 Memo,或者在极少数情况下,信号和副作用之间有三四个 Memo。Memo 在其所擅长的事情上非常出色:限制通知其订阅者发生变更的频率。但正如对响应式系统的这段描述所示,它们带来了两种形式的开销:

All of this is cool, and memos are pretty great. But most actual applications have reactive graphs that are quite shallow and quite wide: you might have 100 source signals and 500 effects, but no memos or, in rare case, three or four memos between the signal and the effect. Memos are extremely good at what they do: limiting how often they notify their subscribers that they have changed. But as this description of the reactive system should show, they come with overhead in two forms:

  1. PartialEq 检查,这可能昂贵也可能不昂贵。

  2. 在响应式系统中存储另一个节点的额外内存成本。

  3. 响应式图遍历的额外计算成本。

  4. A PartialEq check, which may or may not be expensive.

  5. Added memory cost of storing another node in the reactive system.

  6. Added computational cost of reactive graph traversal.

如果计算本身比这些响应式工作更便宜,你应该避免使用 Memo 进行“过度包装”,而只需使用派生信号。这里有一个永远不应该使用 Memo 的典型例子:

In cases in which the computation itself is cheaper than this reactive work, you should avoid “over-wrapping” with memos and simply use derived signals. Here’s a great example in which you should never use a memo:

let (a, set_a) = signal(1);
// 将这些用作 Memo 都没有意义
// none of these make sense as memos
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 的计算,但这些计算本身比响应式算法更便宜。

Even though memoizing would technically save an extra calculation of d between setting a to 3 and 5, these calculations are themselves cheaper than the reactive algorithm.

最多,你可以考虑在运行某些昂贵的副作用之前对最终节点进行 Memo 化:

At the very most, you might consider memoizing the final node before running some expensive side effect:

let text = Memo::new(move |_| {
    d()
});
Effect::new(move |_| {
    // 将文本刻入金条
    engrave_text_into_bar_of_gold(&text());
});

附录:信号的生命周期

Appendix: The Life Cycle of a Signal

在使用 Leptos 时,中级开发者通常会产生三个疑问:

  1. 我该如何连接到组件生命周期,在组件挂载(mount)或卸载(unmount)时运行某些代码?
  2. 我该如何知道信号何时被销毁(disposed),以及为什么在尝试访问已销毁的信号时偶尔会发生 panic?
  3. 为什么信号是 Copy 的,并且可以在不显式克隆的情况下移动到闭包和其他结构中?

Three questions commonly arise at the intermediate level when using Leptos:

  1. How can I connect to the component lifecycle, running some code when a component mounts or unmounts?
  2. How do I know when signals are disposed, and why do I get an occasional panic when trying to access a disposed signal?
  3. How is it possible that signals are Copy and can be moved into closures and other structures without being explicitly cloned?

这三个问题的答案紧密相关,且都有些复杂。本附录将尝试为你提供理解这些答案的背景,以便你可以正确地推理应用程序的代码及其运行方式。

The answers to these three questions are closely inter-related, and are each somewhat complicated. This appendix will try to give you the context for understanding the answers, so that you can reason correctly about your application's code and how it runs.

组件树与决策树

The Component Tree vs. The Decision Tree

考虑以下简单的 Leptos 应用:

Consider the following simple Leptos app:

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 |_| {
        // count 是奇数,其值为 {}
        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>
    }
}

它所做的只是显示一个计数器按钮,如果是偶数则显示一条消息,如果是奇数则显示另一条消息。如果是奇数,它还会在控制台中记录这些值。

All it does is show a counter button, and then one message if it's even, and a different message if it's odd. If it's odd, it also logs the values in the console.

映射这个简单应用的一种方法是画一个嵌套组件树: One way to map out this simple application would be to draw a tree of nested components:

App 
|_ InnerComponent
   |_ OddDuck

另一种方法是画出决策点树: Another way would be to draw the tree of decision points:

root (根)
|_ count 是偶数吗?
   |_ 是
   |_ 否

如果你将两者结合起来,你会注意到它们并没有完美地映射在一起。决策树将我们在 InnerComponent 中创建的视图切成了三块,并将 InnerComponent 的一部分与 OddDuck 组件结合在了一起:

If you combine the two together, you'll notice that they don't map onto one another perfectly. The decision tree slices the view we created in InnerComponent into three pieces, and combines part of InnerComponent with the OddDuck component:

DECISION (决策)      COMPONENT (组件)    DATA (数据)    SIDE EFFECTS (副作用)
root                <App/>              (count)       渲染 <button>
|_ count 是偶数吗?  <InnerComponent/>
   |_ 是                                               渲染偶数 <p>
   |_ 否                                               开始记录 count
                    <OddDuck/>                        渲染奇数 <p>
                                                      渲染奇数 <p> (在 <InnerComponent/> 中!)

看着这张表,我注意到了以下几点:

  1. 组件树和决策树并不匹配:“count 是偶数吗?”这个决策将 <InnerComponent/> 分成了三个部分(一个永远不变的部分,一个偶数部分,一个奇数部分),并将其中之一与 <OddDuck/> 组件合并。
  2. 决策树和副作用列表完美对应:每个副作用都在特定的决策点创建。
  3. 决策树和数据树也对应上了。虽然表中只有一个信号很难看出来,但与组件(一个可以包含多个决策或不包含决策的函数)不同,信号总是创建在决策树的特定行中。

Looking at this table, I notice the following things:

  1. The component tree and the decision tree don't match one another: the "is count even?" decision splits <InnerComponent/> into three parts (one that never changes, one if even, one if odd), and merges one of these with the <OddDuck/> component.
  2. The decision tree and the list of side effects correspond perfectly: each side effect is created at a specific decision point.
  3. The decision tree and the tree of data also line up. It's hard to see with only one signal in the table, but unlike a component, which is a function that can include multiple decisions or none, a signal is always created at a specific line in the tree of decisions.

关键点在于:你的数据结构和副作用结构影响了应用程序的实际功能。你的组件结构只是为了编写方便。你不在乎,也不应该在乎哪个组件渲染了哪个 <p> 标签,或者哪个组件创建了记录值的副作用。唯一重要的是它们在正确的时间发生。

Here's the thing: The structure of your data and the structure of side effects affect the actual functionality of your application. The structure of your components is just a convenience of authoring. You don't care, and you shouldn't care, which component rendered which <p> tag, or which component created the effect to log the values. All that matters is that they happen at the right times.

在 Leptos 中,组件并不存在。也就是说:你可以将应用程序写成组件树,因为这样很方便,而且我们围绕组件提供了一些调试工具和日志记录,因为那也很方便。但你的组件在运行时并不存在:组件不是变更检测或渲染的单位。它们仅仅是函数调用。你可以将整个应用程序写在一个大组件中,或者将其拆分成一百个组件,这都不会影响运行时行为,因为组件并不真正存在。

In Leptos, components do not exist. That is to say: You can write your application as a tree of components, because that's convenient, and we provide some debugging tools and logging built around components, because that's convenient too. But your components do not exist at runtime: Components are not a unit of change detection or of rendering. They are simply function calls. You can write your whole application in one big component, or split it into a hundred components, and it does not affect the runtime behavior, because components don't really exist.

另一方面,决策树确实存在。而且它非常重要!

The decision tree, on the other hand, does exist. And it's really important!

决策树、渲染与所有权

The Decision Tree, Rendering, and Ownership

每个决策点都是某种响应式语句:一个可以随时间变化的信号或函数。当你将信号或函数传递给渲染器时,它会自动将其包装在一个副作用(effect)中,该副作用订阅它包含的任何信号,并随时间相应地更新视图。

Every decision point is some kind of reactive statement: a signal or a function that can change over time. When you pass a signal or a function into the renderer, it automatically wraps it in an effect that subscribes to any signals it contains, and updates the view accordingly over time.

这意味着当你的应用程序渲染时,它会创建一个完美镜像决策树的嵌套副作用树。用伪代码表示:

This means that when your application is rendered, it creates a tree of nested effects that perfectly mirrors the decision tree. In pseudo-code:

// root (根)
let button = /* 渲染一次 <button> */;

// 渲染器围绕 `move || if count() ...` 包装了一个副作用
// the renderer wraps an effect around the `move || if count() ...`
Effect::new(|_| {
    if count.get() % 2 == 0 {
        let p = /* 渲染偶数 <p> */;
    } else {
        // 用户创建了一个记录 count 的副作用
        // the user created an effect to log the count
        Effect::new(|_| {
            log!("count is odd and is {}", count.get());
        });

        let p1 = /* 渲染来自 OddDuck 的 <p> */;
        let p2 = /* 渲染第二个 <p> */ 

        // 渲染器创建了一个副作用来更新第二个 <p>
        // the renderer creates an effect to update the second <p>
        Effect::new(|_| {
            // 用信号更新 <p> 的文本内容
            // update the content of the <p> with the signal
            p2.set_text_content(count.get());
        });
    }
})

每个响应式值都被包装在它自己的副作用中,以更新 DOM,或运行信号变更产生的任何其他副作用。但你不需要这些副作用永远运行下去。例如,当 count 从奇数切回偶数时,第二个 <p> 不再存在,因此持续更新它的副作用也不再有用。副作用不会永远运行,而是在创建它们的决策发生变化时被取消。换句话说,更准确地说:每当创建副作用时正在运行的父副作用重新运行时,该副作用就会被取消。如果它们是在条件分支中创建的,并且重新运行父副作用时经过了相同的分支,则会再次创建该副作用;如果没有经过,则不会。

Each reactive value is wrapped in its own effect to update the DOM, or run any other side effects of changes to signals. But you don't need these effects to keep running forever. For example, when count switches from an odd number back to an even number, the second <p> no longer exists, so the effect to keep updating it is no longer useful. Instead of running forever, effects are canceled when the decision that created them changes. In other words, and more precisely: effects are canceled whenever the effect that was running when they were created re-runs. If they were created in a conditional branch, and re-running the effect goes through the same branch, the effect will be created again: if not, it will not.

从响应式系统本身的角度来看,你应用的“决策树”实际上是一个响应式“所有权树”。简单地说,响应式“所有者”(owner)是当前正在运行的副作用(effect)或备忘(memo)。它拥有在其内部创建的副作用,这些副作用又拥有它们自己的子副作用,依此类推。当一个副作用准备重新运行时,它首先“清理”其子项,然后再次运行。

From the perspective of the reactive system itself, your application's "decision tree" is really a reactive "ownership tree." Simply put, a reactive "owner" is the effect or memo that is currently running. It owns effects created within it, they own their own children, and so on. When an effect is going to re-run, it first "cleans up" its children, then runs again.

到目前为止,这种模型与 S.js 或 Solid 等 JavaScript 框架中存在的响应式系统是共有的,其中所有权的概念存在是为了自动取消副作用。

So far, this model is shared with the reactive system as it exists in JavaScript frameworks like S.js or Solid, in which the concept of ownership exists to automatically cancel effects.

Leptos 增加的是我们为所有权赋予了第二个类似的含义:响应式所有者不仅拥有其子副作用,以便它可以取消它们;它还拥有其信号(备忘等),以便它可以销毁它们。

What Leptos adds is that we add a second, similar meaning to ownership: a reactive owner not only owns its child effects, so that it can cancel them; it also owns its signals (memos, etc.) so that it can dispose of them.

所有权与 Copy 竞技场 (Arena)

Ownership and the Copy Arena

这就是使 Leptos 能够作为 Rust UI 框架使用的创新点。传统上,在 Rust 中管理 UI 状态非常困难,因为 UI 核心就是共享可变性。(一个简单的计数器按钮就足以说明问题:你既需要不可变访问来设置显示计数器值的文本节点,又需要在点击处理程序中进行可变访问,而每个 Rust UI 框架的设计都是为了应对 Rust 旨在防止这种情况发生这一事实!)在 Rust 中使用像事件处理程序这样的东西,传统上依赖于通过具有内部可变性的共享内存进行通信的原语(Rc<RefCell<_>>Arc<Mutex<_>>),或者通过通道通信进行共享内存,这两者通常都需要显式的 .clone() 才能移动到事件监听器中。这虽然可行,但也带来了巨大的不便。

This is the innovation that allows Leptos to be usable as a Rust UI framework. Traditionally, managing UI state in Rust has been hard, because UI is all about shared mutability. (A simple counter button is enough to see the problem: You need both immutable access to set the text node showing the counter's value, and mutable access in the click handler, and every Rust UI framework is designed around the fact that Rust is designed to prevent exactly that!) Using something like an event handler in Rust traditionally relies on primitives for communicating via shared memory with interior mutability (Rc<RefCell<_>>, Arc<Mutex<_>>) or for shared memory by communicating via channels, either of which often requires explicit .clone()ing to be moved into an event listener. This is kind of fine, but also an enormous inconvenience.

Leptos 一直对信号使用一种形式的竞技场分配(arena allocation)。信号本身本质上是存储在别处的数据结构的索引。它是一个廉价拷贝的整数类型,其自身不进行引用计数,因此可以在不显式克隆的情况下到处拷贝、移动到事件监听器等。

Leptos has always used a form of arena allocation for signals instead. A signal itself is essentially an index into a data structure that's held elsewhere. It's a cheap-to-copy integer type that does not do reference counting on its own, so it can be copied around, moved into event listeners, etc. without explicit cloning.

这些信号的生命周期并非由 Rust 的生命周期(lifetimes)或引用计数决定,而是由所有权树决定的。

Instead of Rust lifetimes or reference counting, the life cycles of these signals are determined by the ownership tree.

正如所有副作用都属于一个父级所有者副作用,并且子项在所有者重新运行时被取消一样,所有信号也属于一个所有者,并在父级重新运行时被销毁。

Just as all effects belong to an owning parent effect, and the children are canceled when the owner reruns, so too all signals belong to an owner, and are disposed of when the parent reruns.

在大多数情况下,这完全没问题。想象在上面的例子中,<OddDuck/> 创建了用于更新其部分 UI 的其他信号。在大多数情况下,该信号将用于该组件中的局部状态,或者作为 prop 传递给另一个组件。将它从决策树中提升出来并在应用程序的其他地方使用是不寻常的。当 count 切回偶数时,它不再需要,可以被销毁。

In most cases, this is completely fine. Imagine that in our example above, <OddDuck/> created some other signal that it used to update part of its UI. In most cases, that signal will be used for local state in that component, or maybe passed down as a prop to another component. It's unusual for it to be hoisted up out of the decision tree and used somewhere else in the application. When the count switches back to an even number, it is no longer needed and can be disposed.

然而,这意味着可能会出现两个问题。

However, this means there are two possible issues that can arise.

信号在销毁后仍可能被使用

Signals can be used after they are disposed

你持有的 ReadSignalWriteSignal 只是一个整数:比如,如果它是应用程序中的第 3 个信号,它就是 3。(实际上要复杂一些,但复杂得不多。)你可以到处拷贝那个数字,并用它来说:“嘿,给我 3 号信号。”当所有者清理时,3 号信号的将失效;但你拷贝到各处的数字 3 无法失效。(除非有完整的垃圾回收器!)这意味着如果你将信号往决策树“上方”推送,并将其存储在应用程序中概念上比它被创建时更“高”的地方,那么在销毁后仍可以访问它们。

The ReadSignal or WriteSignal that you hold is just an integer: say, 3 if it's the 3rd signal in the application. (As always, the reality is a bit more complicated, but not much.) You can copy that number all over the place and use it to say, "Hey, get me signal 3." When the owner cleans up, the value of signal 3 will be invalidated; but the number 3 that you've copied all over the place can't be invalidated. (Not without a whole garbage collector!) That means that if you push signals back "up" the decision tree, and store them somewhere conceptually "higher" in your application than they were created, they can be accessed after being disposed.

如果你尝试在信号销毁后更新它,并不会发生什么坏事。框架只会警告你尝试更新一个已不存在的信号。但如果你尝试访问一个已销毁的信号,除了发生 panic 外没有其他合理的回答:因为没有值可以返回。(.get().with() 方法有对应的 try_ 版本,如果信号已被销毁,它们将直接返回 None)。

If you try to update a signal after it was disposed, nothing bad really happens. The framework will just warn you that you tried to update a signal that no longer exists. But if you try to access one, there's no coherent answer other than panicking: there is no value that could be returned. (There are try_ equivalents to the .get() and .with() methods that will simply return None if a signal has been disposed).

如果你在更高的作用域创建信号且从未销毁,可能会发生泄漏

Signals can be leaked if you create them in a higher scope and never dispose of them

反之亦然,当处理信号集合时(例如 RwSignal<Vec<RwSignal<_>>>)这种情况尤为突出。如果你在较高级别创建一个信号,并将其传递给较低级别的组件,那么在较高级别的所有者清理之前,它不会被销毁。

The opposite is also true, and comes up particularly when working with collections of signals, like an RwSignal<Vec<RwSignal<_>>>. If you create a signal at a higher level, and pass it down to a component at a lower level, it is not disposed until the higher-up owner is cleaned up.

例如,如果你有一个待办事项应用,为每个待办事项创建一个新的 RwSignal<Todo>,将其存储在 RwSignal<Vec<RwSignal<Todo>>> 中,然后将其传递给 <Todo/>,当你从列表中删除该待办事项时,该信号并不会自动销毁,而必须手动销毁,否则只要其所有者还存活,它就会发生“泄漏”。(更多讨论请参见 TodoMVC 示例。)

For example, if you have a todo app that creates a new RwSignal<Todo> for each todo, stores it in an RwSignal<Vec<RwSignal<Todo>>>, and then passes it down to a <Todo/>, that signal is not automatically disposed when you remove the todo from the list, but must be manually disposed, or it will "leak" for as long as its owner is still alive. (See the TodoMVC example for more discussion.)

这只有在你创建信号、将其存储在集合中、并从集合中移除它们时没有同时手动销毁它们的情况下才会成为问题。

This is only an issue when you create signals, store them in a collection, and remove them from the collection without manually disposing of them as well.

使用引用计数信号解决这些问题

Solving these Problems with Reference-Counted Signals

0.7 版本为我们的每个竞技场分配原语引入了引用计数等效项:对于每个 RwSignal,都有一个 ArcRwSignal(以及 ArcReadSignalArcWriteSignalArcMemo 等)。

0.7 introduces a reference-counted equivalent for each of our arena-allocated primitive: for every RwSignal there is an ArcRwSignal (ArcReadSignal, ArcWriteSignal, ArcMemo, and so on).

这些原语的内存和销毁由引用计数管理,而不是由所有权树管理。

These have their memory and disposal managed by reference counting, rather than the ownership tree.

这意味着它们可以安全地用于竞技场分配等效项可能会泄漏或在销毁后被使用的情况。

This means that they can safely be used in situations in which the arena-allocated equivalents would either be leaked or used after being disposed.

这在创建信号集合时特别有用:例如,你可能会创建 ArcRwSignal<_> 而不是 RwSignal<_>,然后在表格的每一行中将其转换为 RwSignal<_>

This is especially useful when creating collections of signals: you might create ArcRwSignal<_> instead of RwSignal<_>, and then convert it into an RwSignal<_> in each row of a table, for example.

查看 counters 示例ArcRwSignal<i32> 的用法,以获得更具体的例子。

See the use of ArcRwSignal<i32> in the counters example for a more concrete example.

串联知识点

Connecting the Dots

我们开始时提出的问题的答案现在应该能解释通了。

The answers to the questions we started with should probably make some sense now.

组件生命周期

Component Life-Cycle

组件生命周期并不存在,因为组件并不真正存在。但存在所有权生命周期,你可以利用它来完成相同的事情:

  • 挂载前 (before mount):在组件主体中直接运行代码即在“组件挂载前”运行。
  • 挂载时 (on mount)create_effect 在组件其余部分运行后的一个刻度(tick)运行,因此对于需要等待视图挂载到 DOM 的副作用非常有用。
  • 卸载时 (on unmount):你可以使用 on_cleanup 向响应式系统提供在当前所有者清理时(即再次运行前)应运行的代码。因为所有者围绕着一个“决策”,这意味着当你的组件卸载时,on_cleanup 将运行:如果某个东西可以卸载,渲染器一定创建了一个正在卸载它的副作用!

There is no component life-cycle, because components don't really exist. But there is an ownership lifecycle, and you can use it to accomplish the same things:

  • before mount: simply running code in the body of a component will run it "before the component mounts"
  • on mount: create_effect runs a tick after the rest of the component, so it can be useful for effects that need to wait for the view to be mounted to the DOM.
  • on unmount: You can use on_cleanup to give the reactive system code that should run while the current owner is cleaning up, before running again. Because an owner is around a "decision," this means that on_cleanup will run when your component unmounts: if something can unmount, the renderer must have created an effect that's unmounting it!

已销毁信号的问题

Issues with Disposed Signals

一般来说,只有当你正在所有权树的较低处创建信号并将其存储在较高处时,才会出现问题。如果你在这里遇到问题,你应该将信号的创建“提升”到父组件中,然后将创建的信号向下传递——并确保在需要时在移除它们后进行销毁!

Generally speaking, problems can only arise here if you are creating a signal lower down in the ownership tree and storing it somewhere higher up. If you run into issues here, you should instead "hoist" the signal creation up into the parent, and then pass the created signals down—making sure to dispose of them on removal, if needed!

Copy 信号

Copy signals

整个可拷贝(Copy)包装类型系统(信号、StoredValue 等)使用所有权树来近似模拟 UI 不同部分的生命周期。实际上,它用一个基于 UI 区域的生命周期系统来平行于 Rust 语言基于代码块的生命周期系统。这虽然无法总是在编译时被完美检查,但总的来说,我们认为它是利大于弊的。

The whole system of Copyable wrapper types (signals, StoredValue, and so on) uses the ownership tree as a close approximation of the life-cycle of different parts of your UI. In effect, it parallels the Rust language's system of lifetimes based on blocks of code with a system of lifetimes based on sections of UI. This can't always be perfectly checked at compile time, but overall we think it's a net positive.