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

介绍

本书旨在介绍 Leptos Web 框架。 它将引导您了解构建应用程序所需的基本概念, 从在浏览器中渲染的简单应用程序开始,逐步构建到 具有服务端渲染和 hydration 的全栈应用程序。

本指南不假设您了解细粒度响应式系统或现代 Web 框架的细节。 但它假设您熟悉 Rust 编程语言、HTML、CSS、DOM 和基本的 Web API。

Leptos 与 Solid (JavaScript) 和 Sycamore (Rust) 等框架最为相似。 它与其他框架如 React (JavaScript)、Svelte (JavaScript)、Yew (Rust) 和 Dioxus (Rust) 也有一些相似之处, 因此了解其中任何一个框架都可能有助于理解 Leptos。

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

本书的源代码可在这里获得。欢迎提交 PR 来修正错别字或澄清内容。

开始使用

开始使用Leptos有两个基本路径:

  1. 使用Trunk进行客户端渲染(CSR) - 如果您只想用Leptos制作一个快速网站,或与现有服务器或API一起工作,这是一个很好的选择。 在CSR模式下,Trunk将您的Leptos应用程序编译为WebAssembly(WASM)并在浏览器中运行,就像典型的Javascript单页应用程序(SPA)一样。Leptos CSR的优势包括更快的构建时间和更快的迭代开发周期,以及更简单的心理模型和更多部署应用程序的选项。CSR应用程序确实有一些缺点:与服务器端渲染方法相比,最终用户的初始加载时间较慢,使用JS单页应用程序模型带来的常见SEO挑战也适用于Leptos CSR应用程序。还要注意,在底层,使用自动生成的JS片段来加载Leptos WASM包,因此客户端设备上_必须_启用JS,您的CSR应用程序才能正确显示。与所有软件工程一样,这里有您需要考虑的权衡。

  2. 使用cargo-leptos进行全栈服务器端渲染(SSR) - 如果您希望Rust为前端和后端提供动力,SSR是构建CRUD风格网站和自定义Web应用程序的绝佳选择。 使用Leptos SSR选项,您的应用程序在服务器上渲染为HTML并发送到浏览器;然后,使用WebAssembly来检测HTML,使您的应用程序变得交互式 - 这个过程称为"水合"。在服务器端,Leptos SSR应用程序与您选择的Actix-webAxum服务器库紧密集成,因此您可以利用这些社区的crate来帮助构建您的Leptos服务器。 使用Leptos采用SSR路线的优势包括帮助您获得最佳的初始加载时间和Web应用程序的最佳SEO分数。SSR应用程序还可以通过称为"服务器函数"的Leptos功能大大简化跨服务器/客户端边界的工作,该功能允许您从客户端代码透明地调用服务器上的函数(稍后会详细介绍此功能)。不过,全栈SSR并不全是彩虹和蝴蝶 - 缺点包括较慢的开发者迭代循环(因为在进行Rust代码更改时需要重新编译服务器和客户端),以及水合带来的一些额外复杂性。

在本书结束时,您应该很好地了解要做出哪些权衡以及采取哪条路线 - CSR或SSR - 取决于您项目的要求。

在本书的第1部分中,我们将从客户端渲染Leptos站点开始,使用Trunk将我们的JS和WASM包提供给浏览器来构建响应式UI。

我们将在本书的第2部分中介绍cargo-leptos,这完全是关于在其全栈SSR模式下使用Leptos的全部功能。

Note

如果您来自Javascript世界,并且客户端渲染(CSR)和服务器端渲染(SSR)等术语对您来说不熟悉,理解差异的最简单方法是通过类比:

Leptos的CSR模式类似于使用React(或基于"signal"的框架如SolidJS),专注于生成客户端UI,您可以在服务器上与任何技术栈一起使用。

使用Leptos的SSR模式类似于使用全栈框架,如React世界中的Next.js(或Solid的"SolidStart"框架) - SSR帮助您构建在服务器上渲染然后发送到客户端的站点和应用程序。SSR可以帮助改善您站点的加载性能和可访问性,并使一个人更容易同时在客户端和服务器端工作,而无需在前端和后端的不同语言之间进行上下文切换。

Leptos框架可以在CSR模式下使用来制作UI(如React),或者您可以在全栈SSR模式下使用Leptos(如Next.js),以便您可以用一种语言构建UI和服务器:Rust。

Hello World! 为Leptos CSR开发做好准备

首先,确保Rust已安装并且是最新的(如果您需要说明,请参见此处)。

如果您还没有安装,可以通过在命令行上运行以下命令来安装用于运行Leptos CSR站点的"Trunk"工具:

cargo install trunk

然后创建一个基本的Rust项目

cargo init leptos-tutorial

cd进入您的新leptos-tutorial项目并添加leptos作为依赖项

cargo add leptos --features=csr

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

rustup target add wasm32-unknown-unknown

leptos-tutorial目录的根目录中创建一个简单的index.html

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

并在您的main.rs中添加一个简单的"Hello, world!"

use leptos::prelude::*;

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

您的目录结构现在应该看起来像这样

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

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

欢迎来到使用Rust和WebAssembly(WASM)进行UI开发的世界,由Leptos和Trunk提供支持!

Note

如果您使用Windows,请注意trunk serve --open可能不起作用。如果您在使用--open时遇到问题, 只需使用trunk serve并手动打开浏览器选项卡。


现在,在我们开始使用Leptos构建您的第一个真正的应用程序之前,有几件事您可能想知道,以帮助使您使用Leptos的体验更轻松一些。

Leptos开发者体验改进

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

1) 设置console_error_panic_hook

默认情况下,在浏览器中运行WASM代码时发生的panic只会在浏览器中抛出一个错误,显示像Unreachable executed这样的无用消息和指向您的WASM二进制文件的堆栈跟踪。

使用console_error_panic_hook,您可以获得包含Rust源代码中行的实际Rust堆栈跟踪。

设置非常简单:

  1. 在您的项目中运行cargo add console_error_panic_hook
  2. 在您的main函数中,添加console_error_panic_hook::set_once();

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

现在您应该在浏览器控制台中有更好的panic消息!

2) 在#[component]#[server]内的编辑器自动完成

由于宏的性质(它们可以从任何东西扩展到任何东西,但只有在输入在那个瞬间完全正确时),rust-analyzer很难进行适当的自动完成和其他支持。

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

Note

从Leptos版本0.5.3开始,为#[component]宏添加了rust-analyzer支持,但如果您遇到问题,您可能也想将#[component]添加到宏忽略列表中(见下文)。 请注意,这意味着rust-analyzer不知道您的组件props,这可能在IDE中生成自己的错误或警告集。

VSCode settings.json

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

VSCode with cargo-leptos settings.json

"rust-analyzer.procMacro.ignored": {
	"leptos_macro": [
        // 可选:
		// "component",
		"server"
	],
},
// 如果为`ssr`功能cfg-gated的代码显示为非活动状态,
// 您可能想告诉rust-analyzer默认启用`ssr`功能
//
// 您也可以使用`rust-analyzer.cargo.allFeatures`来启用所有功能
"rust-analyzer.cargo.features": ["ssr"]

Neovim:

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

Helix,在.helix/languages.toml中:

[[language]]
name = "rust"

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

Zed,在settings.json中:

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

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

// 这里的设置覆盖"LSP-rust-analyzer/LSP-rust-analyzer.sublime-settings"中的设置
{
  "rust-analyzer.procMacro.ignored": {
    "leptos_macro": [
      // 可选:
      // "component",
      "server"
    ],
  },
}

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

默认情况下,rust-analyzer只会针对Rust项目中的默认功能运行。Leptos使用不同的功能来控制编译。对于客户端渲染项目,我们在不同地方使用csr,对于服务器端渲染应用程序,它们可以包括用于服务器代码的ssr和用于我们只在浏览器中运行的代码的hydrate

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

VSCode,在settings.json中:

{
  "rust-analyzer.cargo.features": "all",  // 启用所有功能
}

Neovim,在init.lua中:

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

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

[[language]]
name = "rust"

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

Zed,在settings.json中:

{
  -- Other Settings ...
  "lsp": {
    "rust-analyzer": {
      "initialization_options": {
        "cargo": {
          "allFeatures": true // 启用所有功能
        }
      }
	}
  }
}

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

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

4) 设置leptosfmt(可选)

leptosfmt是Leptos view!宏的格式化程序(您通常会在其中编写UI代码)。因为view!宏启用了编写UI的'RSX'(类似JSX)风格,cargo-fmt在自动格式化view!宏内的代码时遇到困难。leptosfmt是一个解决格式化问题并保持RSX风格UI代码看起来美观整洁的crate!

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

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

如果您只想从命令行使用默认选项,只需从项目根目录运行leptosfmt ./**/*.rs来使用leptosfmt格式化所有rust文件。

在Rust Analyzer IDE中自动运行

如果您希望设置编辑器与leptosfmt一起工作,或者如果您希望自定义您的leptosfmt体验,请参见leptosfmt github repo的README.md页面上提供的说明。

只需注意,建议在每个工作区的基础上设置您的编辑器与leptosfmt以获得最佳结果。

在RustRover中自动运行

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

  • Name: Leptosfmt
  • File type: Rust files
  • Program: /path/to/leptosfmt(如果它在您的$PATH环境变量中,可以简单地是leptosfmt
  • Arguments: $FilePath$
  • Output paths to refresh: $FilePath$

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

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

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

# 使用您自己的本机目标
[target.aarch64-apple-darwin]
rustflags = [
  "--cfg",
  "erase_components",
]

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

Leptos社区和leptos-* Crate

社区

在我们开始使用Leptos构建之前的最后一个说明:如果您还没有,请随时加入Leptos DiscordGithub上不断增长的社区。我们的Discord频道特别活跃和友好 - 我们很乐意在那里见到您!

Note

如果您在阅读Leptos书籍时发现某个章节或解释不清楚,只需在"docs-and-education"频道中提及,或在"help"中提问,这样我们就可以澄清并为其他人更新书籍。

当您在Leptos之旅中走得更远,发现您对"如何用Leptos做'x'"有疑问时,搜索Discord"help"频道,看看是否之前有人问过类似的问题,或者随时发布您自己的问题 - 社区非常有帮助且响应迅速。

Github上的"讨论"也是提问和跟上Leptos公告的好地方。

当然,如果您在使用Leptos开发时遇到任何错误或想要提出功能请求(或贡献错误修复/新功能),请在Github问题跟踪器上开启一个问题。

Leptos-* Crate

社区已经构建了越来越多的Leptos相关crate,这将帮助您更快地在Leptos项目中提高生产力 - 查看Github上Awesome Leptos repo中构建在Leptos之上并由社区贡献的crate列表。

如果您想找到最新的、新兴的Leptos相关crate,请查看Leptos Discord的"Tools and Libraries"部分。在该部分中,有Leptos view!宏格式化程序的频道(在"leptosfmt"频道中);有实用程序库"leptos-use"的频道;UI组件库"thaw-ui"的另一个频道;以及一个"libraries"频道,在新的leptos-* crate进入Awesome Leptos上不断增长的crate和资源列表之前,会在那里讨论。

第一部分:构建用户界面

在本书的第一部分,我们将学习使用 Leptos 在客户端构建用户界面。在底层,Leptos 和 Trunk 正在打包一段 JavaScript 代码片段,它将加载已编译为 WebAssembly 的 Leptos UI,以驱动您的 CSR(客户端渲染)网站中的交互性。

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

Info

为了充分利用本书,我们鼓励您跟随提供的示例进行编码。 在入门指南Leptos DX 章节中,我们向您展示了如何使用 Leptos 和 Trunk 设置基本项目,包括浏览器中的 WASM 错误处理。 这个基本设置足以让您开始使用 Leptos 进行开发。

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

start-trunk 模板需要您安装 Trunkcargo-generate,您可以通过运行 cargo install trunkcargo install cargo-generate 来获得。

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

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

然后运行

trunk serve --port 3000 --open

在新创建的应用程序目录中开始开发您的应用程序。 Trunk 服务器将在文件更改时重新加载您的应用程序,使开发相对无缝。

基础组件

那个 "Hello, world!" 是一个_非常_简单的例子。让我们转向更像普通应用程序的东西。

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

use leptos::mount::mount_to_body;

fn main() {
    mount_to_body(App);
}

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

use leptos::prelude::*;

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

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

导入 Prelude

use leptos::prelude::*;

Leptos 提供了一个 prelude,其中包括常用的 traits 和函数。如果您更愿意使用单独的导入,请随意这样做;编译器将为每个导入提供有用的建议。

组件签名

#[component]

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

fn App() -> impl IntoView

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

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

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

组件主体

组件函数的主体是一个运行一次的设置函数,而不是多次重新运行的渲染函数。您通常会使用它来创建一些响应式变量,定义响应这些值变化而运行的任何副作用,并描述用户界面。

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

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

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

视图

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

view! {
    <button
        // 使用 on: 定义事件监听器
        on:click=move |_| set_count.set(3)
    >
        // 文本节点用引号包装
        "Click me: "

        // 块包含 Rust 代码
        // 在这种情况下,它渲染 signal 的值
        {count}
    </button>
    <p>
        "Double count: "
        {move || count.get() * 2}
    </p>
}

这应该大部分都容易理解:它看起来主要像 HTML,有一个特殊的 on:click 语法来定义 click 事件监听器和一些看起来像 Rust 字符串的文本节点。支持所有 HTML 元素,包括内置元素(如 <p>)和自定义元素/Web 组件(如 <my-custom-element>)。

Info

无引号文本view 宏确实对无引号文本节点有一些支持,这在 HTML 或 JSX 中是常见的(即 <p>Hello!</p> 而不是 <p>"Hello!"</p>)。由于 Rust proc 宏的限制,使用无引号文本偶尔会在标点符号周围造成间距问题,并且不支持所有 Unicode 字符串。如果这是您的偏好,您可以使用无引号文本;请注意,如果您遇到任何问题,总是可以通过将文本节点引用为普通 Rust 字符串来解决。

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

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

不管那是什么。

人们有时开玩笑说,他们在第一个 Leptos 应用程序中使用的闭包比他们一生中使用的都多。这很公平。

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

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

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

这意味着 {count}{count.get()} 在您的视图中做非常不同的事情。{count} 传入一个 signal,告诉框架每次 count 更改时更新视图。{count.get()} 访问 count 的值一次,并将 i32 传递到视图中,渲染一次,非响应式地。

同样,{move || count.get() * 2}{count.get() * 2} 的行为不同。第一个是函数,所以它是响应式渲染的。第二个是值,所以它只渲染一次,当 count 更改时不会更新。

您可以在下面的 CodeSandbox 中看到差异!

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

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

您可以在这里看到,虽然 set_count 只是设置值,set_count.write() 给我们一个可变引用并就地改变值。任何一个都会在我们的 UI 中触发响应式更新。

在整个教程中,我们将使用 CodeSandbox 来显示交互式示例。 悬停在任何变量上以显示 Rust-Analyzer 详细信息 和正在发生的事情的文档。随时 fork 示例来自己玩!

Live example

点击打开 CodeSandbox。

要在沙盒中显示浏览器,您可能需要点击 Add DevTools > Other Previews > 8080.

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

// #[component] 宏将函数标记为可重用组件
// 组件是用户界面的构建块
// 它们定义了一个可重用的行为单元
#[component]
fn App() -> impl IntoView {
    // 在这里我们创建一个响应式 signal
    // 并获得一个 (getter, setter) 对
    // signals 是框架中变化的基本单元
    // 我们稍后会更多地讨论它们
    let (count, set_count) = signal(0);

    // `view` 宏是我们定义用户界面的方式
    // 它使用类似 HTML 的格式,可以接受某些 Rust 值
    view! {
        <button
            // on:click 将在 `click` 事件触发时运行
            // 每个事件处理程序都定义为 `on:{eventname}`

            // 我们能够将 `set_count` 移动到闭包中
            // 因为 signals 是 Copy 和 'static 的

            on:click=move |_| *set_count.write() += 1
        >
            // RSX 中的文本节点应该用引号包装,
            // 像普通的 Rust 字符串
            "Click me: "
            {count}
        </button>
        <p>
            <strong>"Reactive: "</strong>
            // 您可以通过将 Rust 表达式包装在大括号中
            // 将它们作为值插入到 DOM 中
            // 如果您传入一个函数,它将响应式更新
            {move || count.get()}
        </p>
        <p>
            <strong>"Reactive shorthand: "</strong>
            // 您可以直接在视图中使用 signals,作为
            // 只包装 getter 的函数的简写
            {count}
        </p>
        <p>
            <strong>"Not reactive: "</strong>
            // 注意:如果您只写 {count.get()},这将*不*是响应式的
            // 它只是获取 count 的值一次
            {count.get()}
        </p>
    }
}

// 这个 `main` 函数是应用程序的入口点
// 它只是将我们的组件挂载到 <body>
// 因为我们将其定义为 `fn App`,我们现在可以在
// 模板中使用它作为 <App/>
fn main() {
    leptos::mount::mount_to_body(App)
}

view:动态类、样式和属性

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

但当然,您可能想要在用户界面中更新其他内容。在本节中,我们将看看如何动态更新类、样式和属性,我们将介绍派生 signal 的概念。

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

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

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

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

动态类

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

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

class: 属性接受

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

当值为 true 时,添加类。当值为 false 时,移除类。如果值是访问 signal 的函数,类将在 signal 更改时响应式更新。

现在每次我点击按钮,文本应该在红色和黑色之间切换,因为数字在偶数和奇数之间切换。

<button
    on:click=move |_| {
        *set_count.write() += 1;
    }
    // class: 语法响应式更新单个类
    // 在这里,当 `count` 为奇数时我们将设置 `red` 类
    class:red=move || count.get() % 2 == 1
>
    "Click me"
</button>

如果您正在跟随,请确保进入您的 index.html 并添加类似这样的内容:

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

一些 CSS 类名不能被 view 宏直接解析,特别是如果它们包含破折号和数字或其他字符的混合。在这种情况下,您可以使用元组语法:class=("name", value) 仍然直接更新单个类。

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

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

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

动态样式

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

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

view! {
    <button
        on:click=move |_| {
            *set_count.write() += 10;
        }
        // 设置 `style` 属性
        style="position: absolute"
        // 并使用 `style:` 切换单个 CSS 属性
        style:left=move || format!("{}px", count.get() + 100)
        style:background-color=move || format!("rgb({}, {}, 100)", count.get(), 100)
        style:max-width="400px"
        // 为样式表使用设置 CSS 变量
        style=("--columns", move || count.get().to_string())
    >
        "Click to Move"
    </button>
}

动态属性

同样适用于普通属性。将普通字符串或原始值传递给属性会给它一个静态值。将函数(包括 signal)传递给属性会使其响应式更新其值。让我们向视图添加另一个元素:

<progress
    max="50"
    // signals 是函数,所以 `value=count` 和 `value=move || count.get()`
    // 是可互换的。
    value=count
/>

现在每次我们设置计数时,不仅 <button>class 会被切换,而且 <progress> 条的 value 会增加,这意味着我们的进度条会向前移动。

派生 Signals

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

您已经知道我们只需将函数传递到 view 中就可以创建响应式界面。这意味着我们可以轻松更改我们的进度条。例如,假设我们希望它移动得快两倍:

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

但想象一下我们想在多个地方重用该计算。您可以使用派生 signal 来做到这一点:访问 signal 的闭包。

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

/* 插入视图的其余部分 */
<progress
    max="50"
    // 我们在这里使用它一次
    value=double_count
/>
<p>
    "Double Count: "
    // 在这里再次使用
    {double_count}
</p>

派生 signals 让您创建可以在应用程序的多个地方使用的响应式计算值,开销最小。

注意:像这样使用派生 signal 意味着计算每次 signal 更改时运行一次(当 count() 更改时)和每次我们访问 double_count 时运行一次;换句话说,两次。这是一个非常便宜的计算,所以没关系。我们将在后面的章节中查看 memos,它们是为了解决昂贵计算的这个问题而设计的。

高级主题:注入原始 HTML

view 宏为附加属性 inner_html 提供支持,可用于直接设置任何元素的 HTML 内容,清除您给它的任何其他子元素。请注意,这_不会_转义您提供的 HTML。您应该确保它只包含受信任的输入或任何 HTML 实体都被转义,以防止跨站脚本(XSS)攻击。

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

点击这里查看完整的 view 宏文档

Live example

点击打开 CodeSandbox。

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

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

    // "派生 signal" 是访问其他 signals 的函数
    // 我们可以使用它来创建依赖于
    // 一个或多个其他 signals 值的响应式值
    let double_count = move || count.get() * 2;

    view! {
        <button
            on:click=move |_| {
                *set_count.write() += 1;
            }
            // class: 语法响应式更新单个类
            // 在这里,当 `count` 为奇数时我们将设置 `red` 类
            class:red=move || count.get() % 2 == 1
            class=("button-20", move || count.get() % 2 == 1)
        >
            "Click me"
        </button>
        // 注意:像 <br> 这样的自闭合标签需要显式的 /
        <br/>

        // 每次 `count` 更改时我们都会更新这个进度条
        <progress
            // 静态属性像在 HTML 中一样工作
            max="50"

            // 将函数传递给属性
            // 响应式设置该属性
            // signals 是函数,所以 `value=count` 和 `value=move || count.get()`
            // 是可互换的。
            value=count
        >
        </progress>
        <br/>

        // 这个进度条将使用 `double_count`
        // 所以它应该移动得快两倍!
        <progress
            max="50"
            // 派生 signals 是函数,所以它们也可以
            // 响应式更新 DOM
            value=double_count
        >
        </progress>
        <p>"Count: " {count}</p>
        <p>"Double Count: " {double_count}</p>
    }
}

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

组件和 Props

到目前为止,我们一直在单个组件中构建整个应用程序。这对于非常小的示例来说是可以的,但在任何真实的应用程序中,您需要将用户界面分解为多个组件,这样您就可以将界面分解为更小的、可重用的、可组合的块。

让我们以进度条示例为例。想象一下您想要两个进度条而不是一个:一个每次点击前进一个刻度,一个每次点击前进两个刻度。

您_可以_通过创建两个 <progress> 元素来做到这一点:

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

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

但当然,这不能很好地扩展。如果您想添加第三个进度条,您需要再次添加此代码。如果您想编辑任何相关内容,您需要三次编辑它。

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

#[component]
fn ProgressBar() -> impl IntoView {
    view! {
        <progress
            max="50"
            // 嗯...我们从哪里得到这个?
            value=progress
        />
    }
}

只有一个问题:progress 没有定义。它应该从哪里来?当我们手动定义所有内容时,我们只是使用了本地变量名。现在我们需要某种方法将参数传递到组件中。

组件 Props

我们使用组件属性或"props"来做到这一点。如果您使用过其他前端框架,这可能是一个熟悉的概念。基本上,属性对于组件就像属性对于 HTML 元素一样:它们让您将附加信息传递到组件中。

在 Leptos 中,您通过为组件函数提供附加参数来定义 props。

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

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

#[component]
fn App() -> impl IntoView {
    let (count, set_count) = signal(0);
    view! {
        <button on:click=move |_| *set_count.write() += 1>
            "Click me"
        </button>
        // 现在我们使用我们的组件!
        <ProgressBar progress=count/>
    }
}

在视图中使用组件看起来很像使用 HTML 元素。您会注意到您可以轻松区分元素和组件,因为组件总是有 PascalCase 名称。您将 progress prop 传入,就像它是 HTML 元素属性一样。简单。

响应式和静态 Props

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

组件 props 没有附加特殊含义。组件只是运行一次以设置用户界面的函数。告诉界面响应更改的唯一方法是传递 signal 类型。所以如果您有一个会随时间变化的组件属性,比如我们的 progress,它应该是一个 signal。

optional Props

现在 max 设置是硬编码的。让我们也将其作为 prop。但让我们使这个 prop 可选。我们可以通过用 #[prop(optional)] 注释它来做到这一点。

#[component]
fn ProgressBar(
    // 将此 prop 标记为可选
    // 您可以在使用 <ProgressBar/> 时指定它或不指定
    #[prop(optional)]
    max: u16,
    progress: ReadSignal<i32>
) -> impl IntoView {
    view! {
        <progress
            max=max
            value=progress
        />
    }
}

现在,我们可以使用 <ProgressBar max=50 progress=count/>,或者我们可以省略 max 来使用默认值(即 <ProgressBar progress=count/>)。optional 的默认值是其 Default::default() 值,对于 u16 来说是 0。在进度条的情况下,最大值为 0 不是很有用。

所以让我们给它一个特定的默认值。

default props

您可以使用 #[prop(default = ...)] 相当简单地指定除 Default::default() 之外的默认值。

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

泛型 Props

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

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

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

嗯...这不会编译。应该很容易理解为什么:我们已经声明 progress prop 接受 ReadSignal<i32>,而 double_count 不是 ReadSignal<i32>。正如 rust-analyzer 会告诉您的,它的类型是 || -> i32,即它是一个返回 i32 的闭包。

有几种方法来处理这个问题。一种是说:"好吧,我知道为了让视图是响应式的,它需要接受一个函数或一个 signal。我总是可以通过将 signal 包装在闭包中来将 signal 转换为函数...也许我可以只接受任何函数?"

如果您使用带有 nightly 功能的 nightly Rust,signals 是函数,所以您可以使用泛型组件并接受任何 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
        />
        // 添加换行符以避免重叠
        <br/>
    }
}

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

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

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

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

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

into Props

如果您使用稳定版 Rust,signals 不直接实现 Fn()。我们可以将 signal 包装在闭包中(move || progress.get()),但这有点混乱。

我们可以实现这个的另一种方法是使用 #[prop(into)]。此属性自动在您作为 props 传递的值上调用 .into(),这允许您轻松传递具有不同值的 props。

在这种情况下,了解 Signal 类型很有帮助。Signal 是一个枚举类型,表示任何类型的可读响应式 signal 或普通值。在定义您想要重用的组件 API 时,它可能很有用,同时传递不同类型的 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`
        <ProgressBar progress=count/>
        // 使用 `Signal::derive()` 将派生 signal 包装为 `Signal` 类型
        <ProgressBar progress=Signal::derive(double_count)/>
    }
}

可选泛型 Props

注意您不能为组件指定可选泛型 props。让我们看看如果您尝试会发生什么:

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

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

Rust 有用地给出错误

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

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

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

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

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

因为 Rust 编译器现在知道 prop 的具体类型,因此即使在 None 情况下也知道其在内存中的大小,这编译得很好。

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

记录组件

这是本书中最不重要但最重要的部分之一。严格来说,记录您的组件及其 props 并不是必需的。根据您的团队和应用程序的大小,这可能非常重要。但这很容易,并且立即产生效果。

要记录组件及其 props,您可以简单地在组件函数和每个 props 上添加文档注释:

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

这就是您需要做的全部。这些行为像普通的 Rust 文档注释,除了您可以记录单个组件 props,这在 Rust 函数参数中是做不到的。

这将自动为您的组件、其 Props 类型和用于添加 props 的每个字段生成文档。在您悬停在组件名称或 props 上并看到 #[component] 宏与 rust-analyzer 结合的强大功能之前,可能有点难以理解这有多强大。

将属性展开到组件上

有时您希望用户能够向组件添加附加属性。例如,您可能希望用户能够为样式或其他目的添加自己的 classid 属性。

您_可以_通过创建 classid props 然后将它们应用到适当的元素来做到这一点。但 Leptos 也支持将附加属性"展开"到组件上。添加到组件的属性将应用于从其视图返回的所有顶级 HTML 元素。

// 您可以通过使用视图宏和展开 {..} 作为标签名来创建属性列表
let spread_onto_component = view! {
    <{..} aria-label="a component with attribute spreading"/>
};


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

        // class:, style:, prop:, on: 语法就像在元素上一样工作
        class:foo=true
        style:font-weight="bold"
        prop:cool=42
        on:click=move |_| alert("clicked ComponentThatTakesSpread")

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

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

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

Note

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

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

fn spread_onto_component() -> impl Attribute {
    view!{
        <{..} aria-label="a component with attribute spreading"/>
    }
}

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

如果您想将属性展开到组件上,但想将属性应用于除所有顶级元素之外的其他内容,请使用 AttributeInterceptor

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

Live example

点击打开 CodeSandbox。

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

// 将不同的组件组合在一起是我们构建
// 用户界面的方式。在这里,我们将定义一个可重用的 <ProgressBar/>。
// 您将看到如何使用文档注释来记录组件
// 及其属性。

/// 显示朝向目标的进度。
#[component]
fn ProgressBar(
    // 将此标记为可选 prop。它将默认为其类型的默认值,即 0。
    #[prop(default = 100)]
    /// 进度条的最大值。
    max: u16,
    // 将在传递给 prop 的值上运行 `.into()`。
    #[prop(into)]
    // `Signal<T>` 是几种响应式类型的包装器。
    // 它在像这样的组件 API 中很有用,我们
    // 可能想要接受任何类型的响应式值
    /// 应该显示多少进度。
    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 的编辑器中打开此文件,
        // 尝试悬停在 `ProgressBar`、`max` 或 `progress` 上
        // 以查看我们上面定义的文档
        <ProgressBar max=50 progress=count/>
        // 让我们在这个上使用默认的最大值
        // 默认值是 100,所以它应该移动得慢一半
        <ProgressBar progress=count/>
        // Signal::derive 从我们的派生 signal 创建 Signal 包装器
        // 使用 double_count 意味着它应该移动得快两倍
        <ProgressBar max=50 progress=Signal::derive(double_count)/>
    }
}

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

迭代

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

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

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

使用 Vec<_> 的静态视图

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

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

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

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

_列表_是静态的这一事实并不意味着界面需要是静态的。您可以将动态项目作为静态列表的一部分渲染。

// 创建 5 个 signals 的列表
let length = 5;
let counters = (1..=length).map(|idx| RwSignal::new(idx));

注意这里我们没有调用 signal() 来获得带有 reader 和 writer 的元组,而是使用 RwSignal::new() 来获得单个读写 signal。这对于我们否则会传递元组的情况更方便。

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

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

您_也可以_响应式地渲染 Fn() -> Vec<_>。但请注意,这是一个无键列表更新:它将重用现有的 DOM 元素,并根据它们在新 Vec<_> 中的顺序用新值更新它们。如果您只是在列表末尾添加和删除项目,这工作得很好,但如果您移动项目或将项目插入列表中间,这将导致浏览器做比需要更多的工作,并且可能对输入状态和 CSS 动画等产生令人惊讶的影响。(有关"键控"与"无键"区别的更多信息,以及一些实际示例,您可以阅读这篇文章。)

幸运的是,也有一种高效的键控列表迭代方法。

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

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

  • each:返回要迭代的项目 T 的响应式函数
  • key:接受 &T 并返回稳定、唯一键或 ID 的键函数
  • children:将每个 T 渲染为视图

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

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

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

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

Live example

点击打开 CodeSandbox。

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

// 迭代是大多数应用程序中非常常见的任务。
// 那么如何获取数据列表并在 DOM 中渲染它?
// 这个示例将向您展示两种方法:
// 1) 对于大部分静态的列表,使用 Rust 迭代器
// 2) 对于增长、缩小或移动项目的列表,使用 <For/>

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

/// 计数器列表,没有添加或删除任何计数器的能力。
#[component]
fn StaticList(
    /// 此列表中包含多少个计数器。
    length: usize,
) -> impl IntoView {
    // 创建从递增数字开始的计数器 signals
    let counters = (1..=length).map(|idx| RwSignal::new(idx));

    // 当您有一个不变的列表时,您可以
    // 使用普通的 Rust 迭代器操作它
    // 并将其收集到 Vec<_> 中以插入到 DOM 中
    let counter_buttons = counters
        .map(|count| {
            view! {
                <li>
                    <button
                        on:click=move |_| *count.write() += 1
                    >
                        {count}
                    </button>
                </li>
            }
        })
        .collect::<Vec<_>>();

    // 注意如果 `counter_buttons` 是响应式列表
    // 并且其值发生变化,这将非常低效:
    // 每次列表变化时它都会重新渲染每一行。
    view! {
        <ul>{counter_buttons}</ul>
    }
}

/// 允许您添加或删除计数器的计数器列表。
#[component]
fn DynamicList(
    /// 开始时的计数器数量。
    initial_length: usize,
) -> impl IntoView {
    // 这个动态列表将使用 <For/> 组件。
    // <For/> 是一个键控列表。这意味着每一行
    // 都有一个定义的键。如果键不变,行
    // 将不会重新渲染。当列表变化时,只有
    // 最少数量的变化会应用到 DOM。

    // `next_counter_id` 将让我们生成唯一 ID
    // 我们通过简单地每次创建计数器时将 ID 增加一来做到这一点
    let mut next_counter_id = initial_length;

    // 我们生成一个初始列表,如在 <StaticList/> 中
    // 但这次我们包含 ID 和 signal
    // 请参阅下面 add_counter 中关于 ArcRwSignal 的注释
    let initial_counters = (0..initial_length)
        .map(|id| (id, ArcRwSignal::new(id + 1)))
        .collect::<Vec<_>>();

    // 现在我们将初始列表存储在 signal 中
    // 这样,我们就能够随时间修改列表,
    // 添加和删除计数器,它将响应式地变化
    let (counters, set_counters) = signal(initial_counters);

    let add_counter = move |_| {
        // 为新计数器创建 signal
        // 我们在这里使用 ArcRwSignal,而不是 RwSignal
        // ArcRwSignal 是引用计数类型,而不是我们到目前为止一直使用的
        // arena 分配的 signal 类型。
        // 当我们创建这样的 signals 集合时,使用 ArcRwSignal
        // 允许每个 signal 在其行被删除时被释放。
        let sig = ArcRwSignal::new(next_counter_id + 1);
        // 将此计数器添加到计数器列表
        set_counters.update(move |counters| {
            // 由于 `.update()` 给我们 `&mut T`
            // 我们可以只使用普通的 Vec 方法如 `push`
            counters.push((next_counter_id, sig))
        });
        // 增加 ID 使其始终唯一
        next_counter_id += 1;
    };

    view! {
        <div>
            <button on:click=add_counter>
                "Add Counter"
            </button>
            <ul>
                // <For/> 组件在这里是核心
                // 这允许高效的键控列表渲染
                <For
                    // `each` 接受任何返回迭代器的函数
                    // 这通常应该是 signal 或派生 signal
                    // 如果它不是响应式的,只需渲染 Vec<_> 而不是 <For/>
                    each=move || counters.get()
                    // 键应该对每行是唯一且稳定的
                    // 使用索引通常是一个坏主意,除非您的列表
                    // 只能增长,因为在列表内移动项目
                    // 意味着它们的索引会改变,它们都会重新渲染
                    key=|counter| counter.0
                    // `children` 从您的 `each` 迭代器接收每个项目
                    // 并返回一个视图
                    children=move |(id, count)| {
                        // 我们可以将 ArcRwSignal 转换为可复制的 RwSignal
                        // 以便在将其移动到视图中时获得更好的 DX
                        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/> 在迭代时访问索引

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

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

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

<ForEnumerate
    each=move || counters.get() // 与 <For/> 相同
    key=|counter| counter.id    // 与 <For/> 相同
    // 提供索引作为 signal 和子 T
    children={move |index: ReadSignal<usize>, counter: Counter| {
        view! {
            <button>{move || index.get()} ". Value: " {move || counter.count.get()}</button>
        }
    }}
/>

或者也可以使用更方便的 let 语法:

<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/>迭代更复杂的数据

本章更深入地讨论了对嵌套数据结构的迭代。它属于这里与其他迭代章节一起,但如果您想坚持更简单的主题,请随时跳过它并稍后回来。

问题

我刚刚说过,框架不会重新渲染行中的任何项目,除非键已更改。这在一开始可能有道理,但很容易让您困惑。

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

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

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

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

注意这里的let(child)语法。在上一章中,我们介绍了带有children prop的<For/>。我们实际上可以直接在<For/>组件的子元素中创建这个值,而不需要跳出view宏:上面的let(child)结合<p>{child.value}</p>等价于

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

为了方便,您也可以选择解构数据的模式:

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

当您点击Update Values按钮时...什么都没有发生。或者说:signal被更新了,新值被记录了,但每行的{child.value}没有更新。

让我们看看:这是因为我们忘记添加闭包使其响应式吗?让我们试试{move || child.value}

...不行。仍然什么都没有。

问题是:正如我所说,每行只有在键更改时才重新渲染。我们更新了每行的值,但没有更新任何行的键,所以什么都没有重新渲染。如果您查看child.value的类型,它是一个普通的i32,而不是响应式的ReadSignal<i32>或类似的东西。这意味着即使我们在其周围包装一个闭包,这一行中的值也永远不会更新。

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

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

选项1:更改键

每行只有在键更改时才重新渲染。我们上面的行没有重新渲染,因为键没有更改。那么:为什么不强制键更改呢?

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

现在我们在key中包含键和值。这意味着每当行的值更改时,<For/>将把它视为完全新的行,并替换前一个。

优点

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

缺点

**这是三个选项中效率最低的。**每次行的值更改时,它都会丢弃前一个<p>元素并用全新的元素替换它。换句话说,它不是对文本节点进行细粒度更新,而是真正在每次更改时重新渲染整行,这与行的UI复杂程度成正比地昂贵。

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

选项2:嵌套Signal

如果我们确实想要值的细粒度响应性,一个选项是将每行的value包装在signal中。

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

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

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

这个版本有效!如果您在浏览器的DOM检查器中查看,您会看到与前一个版本不同,在这个版本中只有单个文本节点被更新。将signal直接传递到{child.value}中有效,因为如果您将signal传递到视图中,它们确实保持其响应性。

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

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

优点

这是最有效的选项,直接符合框架的其余心理模型:随时间变化的值被包装在signal中,以便界面可以响应它们。

缺点

如果您从API或您不控制的其他数据源接收数据,并且您不想创建一个将每个字段包装在signal中的不同结构,嵌套响应性可能很麻烦。

选项3:记忆化切片

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

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

应用程序的大部分可以与初始(损坏的)版本保持相同,但<For/>将更新为:

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

您会注意到这里有几个不同之处:

  • 我们使用ForEnumerate而不是For,所以我们可以访问index signal
  • 我们明确使用children prop,以便更容易运行一些非view代码
  • 我们定义一个value memo并在视图中使用它。这个value字段实际上不使用传递到每行的child。相反,它使用索引并回到原始data中获取值。

现在每次data更改时,每个memo都会重新计算。如果其值已更改,它将更新其文本节点,而不重新渲染整行。

注意:对于此示例的早期版本中的枚举迭代器,使用For是不安全的:

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

在这种情况下,对data中值的更改会有反应,但对排序的更改不会,因为Memo将始终使用它最初创建时的index。如果任何项目被移动,这将导致渲染输出中的重复条目。

优点

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

缺点

<ForEnumerate/>循环内设置这个每行memo比使用嵌套signal稍微复杂一些。例如,您会注意到我们必须防止data[index.get()]会panic的可能性,通过使用data.get(index.get()),因为这个memo可能在行被删除后被触发重新运行一次。(这是因为每行的memo和整个<ForEnumerate/>都依赖于相同的data signal,并且依赖于相同signal的多个响应式值的执行顺序不能保证。)

还要注意,虽然memo记忆化它们的响应式更改,但每次都需要重新运行相同的计算来检查值,所以嵌套响应式signal对于这里的精确更新仍然更有效。

选项4:Store

这些内容中的一些在这里的全局状态管理与store部分中重复。两个部分都是中级/可选内容,所以我认为一些重复不会有害。

Leptos 0.7引入了一个称为"store"的新响应式原语。Store旨在解决本章到目前为止描述的问题。它们有点实验性,所以它们需要在您的Cargo.toml中添加一个名为reactive_stores的额外依赖项。

Store为您提供对结构的各个字段以及集合(如Vec<_>)中各个项目的细粒度响应式访问,而无需像上面给出的选项那样手动创建嵌套signal或memo。

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

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

store的顶层总是需要是一个结构,所以我们将创建一个带有单个rows字段的Data包装器。

#[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的字段进行键控访问,这在下面的<For/>组件中很有用。我们可以简单地使用key,与我们在<For/>中使用的相同键。

<For/>组件非常直接:

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

因为rows是一个键控字段,它实现了IntoIterator,我们可以简单地使用move || data.rows()作为each prop。这将对rows列表的任何更改做出反应,就像我们嵌套signal版本中的move || data.get()一样。

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

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

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

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

优点

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

就个人而言,我认为store版本是这里最好的。毫不奇怪,因为它是最新的API。我们有几年时间来思考这些事情,store包含了我们学到的一些经验教训!

缺点

另一方面,它是最新的API。在写这句话时(2024年12月),store只发布了几周;我确信仍有一些错误或边缘情况需要解决。

完整示例

这是完整的store示例。您可以在这里找到另一个更完整的示例,在书中这里有更多讨论。

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 {
    // 而不是带有行的signal,我们为Data创建一个store
    let data = Store::new(Data {
        rows: vec![
            DatabaseEntry {
                key: "foo".to_string(),
                value: 10,
            },
            DatabaseEntry {
                key: "bar".to_string(),
                value: 20,
            },
            DatabaseEntry {
                key: "baz".to_string(),
                value: 15,
            },
        ],
    });

    view! {
        // 当我们点击时,更新每一行,
        // 将其值加倍
        <button on:click=move |_| {
            // 允许迭代可迭代store字段中的条目
            use reactive_stores::StoreFieldIterator;

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

表单和输入

表单和表单输入是交互式应用程序的重要组成部分。在 Leptos 中与输入交互有两种基本模式,如果您熟悉 React、SolidJS 或类似框架,您可能会认识:使用受控非受控输入。

受控输入

在"受控输入"中,框架控制输入元素的状态。在每个 input 事件上,它更新一个保存当前状态的本地 signal,这反过来更新输入的 value prop。

有两件重要的事情要记住:

  1. input 事件在元素的(几乎)每次更改时触发,而 change 事件在您取消焦点输入时触发(或多或少)。您可能想要 on:input,但我们给您选择的自由。
  2. value _属性_只设置输入的初始值,即它只在您开始输入之前更新输入。value _属性_在那之后继续更新输入。出于这个原因,您通常想要设置 prop:value。(对于 <input type="checkbox"> 上的 checkedprop:checked 也是如此。)
let (name, set_name) = signal("Controlled".to_string());

view! {
    <input type="text"
        // 添加 :target 给我们对触发事件的元素的类型化访问
        on:input:target=move |ev| {
            // .value() 返回 HTML 输入元素的当前值
            set_name.set(ev.target().value());
        }

        // `prop:` 语法让您更新 DOM 属性,
        // 而不是属性。
        prop:value=name
    />
    <p>"Name is: " {name}</p>
}

为什么需要 prop:value

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

一个奇怪的怪癖是 HTML 属性和 DOM 元素属性之间有区别,即从 HTML 解析并可以使用 .setAttribute() 在 DOM 元素上设置的"属性"和作为该解析 HTML 元素的 JavaScript 类表示的字段的"属性"之间的区别。

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

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

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

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

el.setAttribute("value", "one more time?");
// 什么都不应该改变。设置"初始值"现在什么都不做

// 但是...
el.value = "But this works";

许多其他前端框架混淆属性和属性,或为正确设置值的输入创建特殊情况。也许 Leptos 也应该这样做,但现在,我更喜欢给用户最大程度的控制,让他们决定是设置属性还是属性,并尽我所能教育人们实际的底层浏览器行为,而不是掩盖它。

使用 bind: 简化受控输入

遵守 Web 标准和"从 signal 读取"和"写入 signal"之间的明确划分是好的,但以这种方式创建受控输入有时可能看起来比真正必要的样板代码更多。

Leptos 还包括一个特殊的 bind: 语法,用于允许您自动将 signals 绑定到输入的输入。它们做的事情与上面的"受控输入"模式完全相同:创建一个更新 signal 的事件监听器,以及一个从 signal 读取的动态属性。您可以对文本输入使用 bind:value,对复选框使用 bind:checked,对单选按钮组使用 bind:group

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

非受控输入

在"非受控输入"中,浏览器控制输入元素的状态。我们不是持续更新 signal 来保存其值,而是使用 NodeRef 在我们想要获取其值时访问输入。

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

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

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

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

视图现在应该是相当不言自明的。注意两件事:

  1. 与受控输入示例不同,我们使用 value(不是 prop:value)。这是因为我们只是设置输入的初始值,让浏览器控制其状态。(我们也可以使用 prop:value。)
  2. 我们使用 node_ref=... 来填充 NodeRef。(较旧的示例有时使用 _ref。它们是同一件事,但 node_ref 有更好的 rust-analyzer 支持。)

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

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

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

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

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

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

特殊情况:<textarea><select>

两个表单元素往往以不同的方式引起一些混乱。

<textarea>

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

所以如果您想要服务器渲染初始值,并且值也在浏览器中响应,您可以既将初始文本节点作为子元素传递给它,又使用 prop: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>

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

受控与非受控表单 CodeSandbox

点击打开 CodeSandbox。

CodeSandbox 源码
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 {
    // 创建一个 signal 来保存值
    let (name, set_name) = signal("Controlled".to_string());

    view! {
        <input type="text"
            // 每当输入更改时触发事件
            // 在事件后添加 :target 给我们访问
            // ev.target() 处正确类型的元素
            on:input:target=move |ev| {
                set_name.set(ev.target().value());
            }

            // `prop:` 语法让您更新 DOM 属性,
            // 而不是属性。
            //
            // 重要:`value` *属性*只设置
            // 初始值,直到您进行更改。
            // `value` *属性*设置当前值。
            // 这是 DOM 的怪癖;我没有发明它。
            // 其他框架掩盖了这一点;我认为
            // 更重要的是让您访问浏览器
            // 的真实工作方式。
            //
            // 简而言之:对表单输入使用 prop:value
            prop:value=name
        />
        <p>"Name is: " {name}</p>
    }
}

#[component]
fn UncontrolledComponent() -> impl IntoView {
    // 导入 <input> 的类型
    use leptos::html::Input;

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

    // 我们将使用 NodeRef 来存储对输入元素的引用
    // 这将在元素创建时被填充
    let input_element: NodeRef<Input> = NodeRef::new();

    // 当表单 `submit` 事件发生时触发
    // 这将在我们的 signal 中存储 <input> 的值
    let on_submit = move |ev: SubmitEvent| {
        // 阻止页面重新加载!
        ev.prevent_default();

        // 在这里,我们将从输入中提取值
        let value = input_element.get()
            // 事件处理程序只能在视图挂载到 DOM 后触发,
            // 所以 `NodeRef` 将是 `Some`
            .expect("<input> to exist")
            // `NodeRef` 为 DOM 元素类型实现 `Deref`
            // 这意味着我们可以调用 `HtmlInputElement::value()`
            // 来获取输入的当前值
            .value();
        set_name.set(value);
    };

    view! {
        <form on:submit=on_submit>
            <input type="text"
                // 在这里,我们使用 `value` *属性*只设置
                // 初始值,让浏览器在那之后维护状态
                value=name

                // 在 `input_element` 中存储对此输入的引用
                node_ref=input_element
            />
            <input type="submit" value="Submit"/>
        </form>
        <p>"Name is: " {name}</p>
    }
}

// 这个 `main` 函数是应用程序的入口点
// 它只是将我们的组件挂载到 <body>
// 因为我们将其定义为 `fn App`,我们现在可以在
// 模板中使用它作为 <App/>
fn main() {
    leptos::mount::mount_to_body(App)
}

控制流

在大多数应用程序中,您有时需要做出决定:我应该渲染视图的这一部分吗?我应该渲染 <ButtonA/> 还是 <WidgetB/>?这就是控制流

一些提示

在考虑如何使用 Leptos 做到这一点时,重要的是要记住几件事:

  1. Rust 是一种面向表达式的语言:像 if x() { y } else { z }match x() { ... } 这样的控制流表达式返回它们的值。这使它们对声明式用户界面非常有用。
  2. 对于任何实现 IntoViewT——换句话说,对于 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 || 闭包中。这是为了确保当它们依赖的 signal 更改时它们实际重新运行,保持 UI 响应式。

那又怎样?

为了稍微连接一下点:这意味着您实际上可以使用原生 Rust 代码实现大部分控制流,而无需任何控制流组件或特殊知识。

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

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

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

if 语句

假设我想在数字为奇数时渲染一些文本,在偶数时渲染其他文本。好吧,这样如何?

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

if 表达式返回其值,&str 实现 IntoView,所以 Fn() -> &str 实现 IntoView,所以这...就是工作的!

Option<T>

假设我们想在奇数时渲染一些文本,在偶数时什么都不渲染。

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

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

这工作得很好。如果我们愿意,我们可以使用 bool::then() 使其更短一些。

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

如果您愿意,您甚至可以内联这个,尽管我个人有时喜欢通过将事物从 view 中拉出来获得更好的 cargo fmtrust-analyzer 支持。

match 语句

我们仍然只是在编写普通的 Rust 代码,对吧?所以您拥有 Rust 模式匹配的所有能力。

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

为什么不呢?YOLO,对吧?

防止过度渲染

不那么 YOLO。

我们刚才做的一切基本上都很好。但有一件事您应该记住并尽量小心。我们到目前为止创建的每个控制流函数基本上都是一个派生 signal:它将在值每次更改时重新运行。在上面的示例中,值在每次更改时从偶数切换到奇数,这很好。

但考虑以下示例:

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

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

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

这_工作_,当然。但如果您添加了日志,您可能会感到惊讶

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

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

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 语句和重新渲染不是什么大问题。但想象一下如果是这样的:

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

这重新渲染 <Small/> 五次,然后无限重新渲染 <Big/>。如果它们正在加载资源、创建 signals,或者甚至只是创建 DOM 节点,这是不必要的工作。

<Show/>

<Show/> 组件是答案。您向它传递一个 when 条件函数,一个在 when 函数返回 false 时显示的 fallback,以及在 whentrue 时要渲染的子元素。

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

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

<Show/> 记忆化 when 条件,所以它只渲染其 <Small/> 一次,继续显示相同的组件,直到 value 大于五;然后它渲染 <Big/> 一次,继续无限显示它,或者直到 value 低于五,然后再次渲染 <Small/>

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

注意:类型转换

在本节中有一件最后的重要事情要说。

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

这不会编译,因为不同的 HTML 元素是不同的类型。

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

这种强类型非常强大,因为它启用了各种编译时优化。但在像这样的条件逻辑中可能有点烦人,因为您不能在 Rust 中从条件的不同分支返回不同类型。有两种方法可以让自己摆脱这种情况:

  1. 使用枚举 Either(和 EitherOf3EitherOf4 等)将不同类型转换为相同类型。
  2. 使用 .into_any() 将多种类型转换为一个类型擦除的 AnyView

这是同样的示例,添加了转换:

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

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
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
        <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>` 也是如此

        <p>{odd_text}</p>
        // 这意味着您可以在其上使用 `Option` 方法
        <p>{move || odd_text().map(|text| text.len())}</p>

        <h2>"Conditional Logic"</h2>
        // 您可以以几种方式进行动态条件 if-then-else 逻辑
        //
        // a. 函数中的 "if" 表达式
        //    这将在值每次更改时简单地重新渲染,
        //    这使其适用于轻量级 UI
        <p>
            {move || if is_odd() {
                "Odd"
            } else {
                "Even"
            }}
        </p>

        // b. 切换某种类
        //    这对于经常切换的元素很聪明,
        //    因为它不会在状态之间销毁它
        //    (您可以在 `index.html` 中找到 `hidden` 类)
        <p class:hidden=is_odd>"Appears if even."</p>

        // c. <Show/> 组件
        //    这只渲染 fallback 和子元素一次,惰性地,
        //    并在需要时在它们之间切换。
        //    这在许多情况下比 {move || if ...} 块更高效
        <Show when=is_odd
            fallback=|| view! { <p>"Even steven"</p> }
        >
            <p>"Oddment"</p>
        </Show>

        // d. 因为 `bool::then()` 将 `bool` 转换为 `Option`,
        //    您可以使用它来创建显示/隐藏切换
        {move || is_odd().then(|| view! { <p>"Oddity!"</p> })}

        <h2>"Converting between Types"</h2>
        // e. 注意:如果分支返回不同类型,
        //    您可以使用 `.into_any()` 或使用 `Either` 枚举
        //    (`Either`、`EitherOf3`、`EitherOf4` 等)在它们之间转换
        {move || match is_odd() {
            true if value.get() == 1 => {
                // <pre> 返回 HtmlElement<Pre>
                view! { <pre>"One"</pre> }.into_any()
            },
            false if value.get() == 2 => {
                // <p> 返回 HtmlElement<P>
                // 所以我们转换为更通用的类型
                view! { <p>"Two"</p> }.into_any()
            }
            _ => view! { <textarea>{value.get()}</textarea> }.into_any()
        }}
    }
}

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

错误处理

在上一章中,我们看到您可以渲染 Option<T>:在 None 情况下,它将不渲染任何内容,在 Some(T) 情况下,它将渲染 T(即,如果 T 实现 IntoView)。您实际上可以对 Result<T, E> 做非常类似的事情。在 Err(_) 情况下,它将不渲染任何内容。在 Ok(T) 情况下,它将渲染 T

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

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

    view! {
        <label>
            "Type an integer (or not!)"
            <input type="number" on:input:target=move |ev| {
              // 当输入更改时,尝试从输入解析数字
              set_value.set(ev.target().value().parse::<i32>())
            }/>
            <p>
                "You entered "
                <strong>{value}</strong>
            </p>
        </label>
    }
}

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

You entered 42

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

You entered

这不太好。它省去了我们使用 .unwrap_or_default() 或其他东西,但如果我们能捕获错误并对其做些什么会好得多。

您可以使用 <ErrorBoundary/> 组件来做到这一点。

Note

人们经常试图指出 <input type="number"> 阻止您输入像 foo 这样的字符串,或任何其他不是数字的东西。这在某些浏览器中是正确的,但不是在所有浏览器中!此外,有各种可以输入到普通数字输入中但不是 i32 的东西:浮点数、大于 32 位的数字、字母 e 等等。浏览器可以被告知维护其中一些不变量,但浏览器行为仍然不同:自己解析很重要!

<ErrorBoundary/>

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

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

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

    view! {
        <h1>"Error Handling"</h1>
        <label>
            "Type a number (or something that's not a number!)"
            <input type="number" on:input:target=move |ev| {
                // 当输入更改时,尝试从输入解析数字
                set_value.set(ev.target().value().parse::<i32>())
            }/>
            // 如果在 <ErrorBoundary/> 内渲染了 `Err(_)`,
            // 将显示 fallback。否则,将显示 <ErrorBoundary/> 的子元素。
            <ErrorBoundary
                // fallback 接收包含当前错误的 signal
                fallback=|errors| view! {
                    <div class="error">
                        <p>"Not a number! Errors: "</p>
                        // 如果我们愿意,我们可以将错误列表渲染为字符串
                        <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`,它将不渲染任何内容并触发错误边界。
                    // 它是一个 signal,所以当 `value` 更改时这将动态更新
                    <strong>{value}</strong>
                </p>
            </ErrorBoundary>
        </label>
    }
}

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

You entered 42

如果您输入 foo,value 是 Err(_)fallback 将渲染。我们选择将错误列表渲染为 String,所以您将看到类似

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

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

Live example

点击打开 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| {
                // 当输入更改时,尝试从输入解析数字
                set_value.set(ev.target().value().parse::<i32>())
            }/>
            // 如果在 <ErrorBoundary/> 内渲染了 `Err(_)`,
            // 将显示 fallback。否则,将显示 <ErrorBoundary/> 的子元素。
            <ErrorBoundary
                // fallback 接收包含当前错误的 signal
                fallback=|errors| view! {
                    <div class="error">
                        <p>"Not a number! Errors: "</p>
                        // 如果我们愿意,我们可以将错误列表渲染为字符串
                        <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`,它将不渲染任何内容并触发错误边界。
                    // 它是一个 signal,所以当 `value` 更改时这将动态更新
                    <strong>{value}</strong>
                </p>
            </ErrorBoundary>
        </label>
    }
}

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

父子组件通信

您可以将应用程序视为组件的嵌套树。每个组件处理自己的本地状态并管理用户界面的一个部分,因此组件往往相对自包含。

但有时,您会想要在父组件和其子组件之间进行通信。例如,想象您已经定义了一个 <FancyButton/> 组件,它为 <button/> 添加了一些样式、日志记录或其他功能。您想在 <App/> 组件中使用 <FancyButton/>。但您如何在两者之间进行通信呢?

从父组件向子组件传递状态很容易。我们在组件和 props 的材料中涵盖了其中一些内容。基本上,如果您希望父组件与子组件通信,您可以将 ReadSignalSignal 作为 prop 传递。

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

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

1. 传递 WriteSignal

一种方法是简单地将 WriteSignal 从父组件传递到子组件,并在子组件中更新它。这让您可以从子组件操作父组件的状态。

#[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,您应该真正考虑这是否使编写意大利面条代码变得太容易了。

2. 使用回调

另一种方法是将回调传递给子组件:比如 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/> 中。这有保持本地状态本地化的优势,防止了意大利面条式改变的问题。但这也意味着改变该 signal 的逻辑需要存在于 <App/> 中,而不是在 <ButtonB/> 中。这些是真正的权衡,不是简单的对错选择。

3. 使用事件监听器

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

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

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

这让您在 <ButtonC/> 中编写的代码比在 <ButtonB/> 中少得多,并且仍然为监听器提供正确类型的事件。这通过向 <ButtonC/> 返回的每个元素添加 on: 事件监听器来工作:在这种情况下,只是一个 <button>

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

4. 提供 Context

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

#[component]
pub fn App() -> impl IntoView {
    let (toggled, set_toggled) = signal(false);
    view! {
        <p>"Toggled? " {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 drilling"的事情,向两者之间的每一层添加 prop:

#[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。这不仅烦人而且难以维护:想象我们添加一个"半切换"选项,set_toggled 的类型需要更改为 enum。我们必须在三个地方更改它!

难道没有某种方法可以跳过层级吗?

有的!

4.1 Context API

您可以使用 provide_contextuse_context 提供跳过层级的数据。Contexts 由您提供的数据类型标识(在这个示例中是 WriteSignal<bool>),它们存在于遵循 UI 树轮廓的自顶向下树中。在这个示例中,我们可以使用 context 来跳过不必要的 prop drilling。

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

    // 与此组件的所有子组件共享 `set_toggled`
    provide_context(set_toggled);

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

// <Layout/> 和 <Content/> 省略
// 要在此版本中工作,请删除每个上的 `set_toggled` 参数

#[component]
pub fn ButtonD() -> impl IntoView {
    // use_context 在 context 树中向上搜索,希望找到 `WriteSignal<bool>`
    // 在这种情况下,我使用 .expect() 因为我知道我提供了它
    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 中全局状态管理最有效的技术之一:简单地在您需要它的最高级别提供状态,并在您需要它的较低级别使用它。

注意这种方法没有性能缺点。因为您传递的是细粒度响应式 signal,当您更新它时,中间组件(<Layout/><Content/>)中_什么都不会发生_。您直接在 <ButtonD/><App/> 之间通信。实际上——这就是细粒度响应性的力量——您直接在 <ButtonD/> 中的按钮点击和 <App/> 中的单个文本节点之间通信。就好像组件本身根本不存在一样。嗯,实际上...在运行时,它们不存在。一直都是 signals 和 effects。

注意这种方法做出了重要的权衡:您在 provide_contextuse_context 之间不再有类型安全。在子组件中接收正确的 context 是运行时检查(参见 use_context.expect(...))。编译器不会在重构期间指导您,就像早期方法那样。

Live example

点击打开 CodeSandbox。

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

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

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

#[component]
pub fn App() -> impl IntoView {
    // 只是一些 signals 来切换我们 <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>` contexts 的混淆
    // 并使在 ButtonD 中引用它变得更容易
    provide_context(SmallcapsContext(set_smallcaps));

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

            // Button A:传递 signal setter
            <ButtonA setter=set_red/>

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

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

            // Button D 从 context 而不是 props 获取其 setter
            <ButtonD/>
        </main>
    }
}

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

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

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

/// Button D 与 Button A 非常相似,但不是将 setter 作为 prop 传递
/// 我们从 context 获取它
#[component]
pub fn ButtonD() -> impl IntoView {
    let setter = use_context::<SmallcapsContext>().unwrap().0;

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

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

组件子元素

将子元素传递到组件中是很常见的,就像您可以将子元素传递到 HTML 元素中一样。例如,想象我有一个增强 HTML <form><FancyForm/> 组件。我需要某种方法来传递所有的输入。

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

在 Leptos 中如何做到这一点?基本上有两种方法将组件传递给其他组件:

  1. render props:返回视图的函数属性
  2. children prop:一个特殊的组件属性,包括您作为子元素传递给组件的任何内容。

实际上,您已经在 <Show/> 组件中看到了这两种方法的实际应用:

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

让我们定义一个接受一些子元素和 render prop 的组件。

/// 在标记内显示 `render_prop` 和一些子元素。
#[component]
pub fn TakesChildren<F, IV>(
    /// 接受一个函数(类型 F),返回任何可以转换为 View 的内容(类型 IV)
    render_prop: F,
    /// `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()}
    }
}

render_propchildren 都是函数,所以我们可以调用它们来生成适当的视图。特别是 childrenBox<dyn FnOnce() -> AnyView> 的别名。(您不是很高兴我们将其命名为 Children 而不是这个吗?)这里返回的 AnyView 是一个不透明的、类型擦除的视图:您无法检查它。还有各种其他子元素类型:例如,ChildrenFragment 将返回一个 Fragment,这是一个可以迭代其子元素的集合。

如果您在这里需要 FnFnMut,因为您需要多次调用 children,我们还提供了 ChildrenFnChildrenMut 别名。

我们可以这样使用组件:

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

类型化子元素:Slots

到目前为止,我们讨论了具有单个 children prop 的组件,但有时创建具有不同类型的多个子元素的组件很有用。例如:

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

#[slot] 宏将普通的 Rust 结构体注释为组件 slot:

// 用 `#[slot]` 注释的简单结构体,
// 期望子元素
#[slot]
struct Then {
    children: ChildrenFn,
}

这个 slot 可以用作组件中的 prop:

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

现在,If 组件期望一个 Then 类型的子元素。您需要用 slot:<prop_name> 注释使用的 slot:

view! {
    <If condition=a_is_true>
        // `If` 组件总是期望 `then_slot` 的 `Then` 子元素
        <Then slot:then_slot>"Show content when a is true"</Then>
    </If>
}

指定不带名称的 slot 将默认选择的 slot 为结构体名称的蛇形命名版本。所以在这种情况下 <Then slot> 等同于 <Then slot:then>

有关完整示例,请参阅 slots 示例

Slots 上的事件处理程序

事件处理程序不能直接在 slots 上指定,如下所示:

<ComponentWithSlot>
    // ⚠️ 直接在 slot 上的事件处理程序 `on:click` 是不允许的
    <SlotWithChildren slot:slot on:click=move |_| {}> 
        <h1>"Hello, World!"</h1>
    </SlotWithChildren>
</ComponentWithSlot>

相反,将 slot 内容包装在常规元素中并在那里附加事件处理程序:

<ComponentWithSlot>
    <SlotWithChildren slot:slot>
        // ✅ 事件处理程序不是直接在 slot 上定义的
        <div on:click=move |_| {}>
            <h1>"Hello, World!"</h1>
        </div>
    </SlotWithChildren>
</ComponentWithSlot>

操作子元素

Fragment 类型基本上是包装 Vec<AnyView> 的方法。您可以将其插入到视图中的任何地方。

但您也可以直接访问这些内部视图来操作它们。例如,这是一个接受其子元素并将它们转换为无序列表的组件。

/// 将每个子元素包装在 `<li>` 中并将它们嵌入到 `<ul>` 中。
#[component]
pub fn WrapsChildren(children: ChildrenFragment) -> impl IntoView {
    // children() 返回一个 `Fragment`,它有一个
    // `nodes` 字段,包含 Vec<View>
    // 这意味着我们可以迭代子元素
    // 来创建新的东西!
    let children = children()
        .nodes
        .into_iter()
        .map(|child| view! { <li>{child}</li> })
        .collect::<Vec<_>>();

    view! {
        <h1><code>"<WrapsChildren/>"</code></h1>
        // 将我们包装的子元素包装在 UL 中
        <ul>{children}</ul>
    }
}

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

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

Live example

点击打开 CodeSandbox。

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

// 通常,您想要将某种子视图传递给另一个组件。
// 有两种基本模式来做到这一点:
// - "render props":创建一个接受创建视图的函数的组件 prop
// - `children` prop:一个特殊属性,包含作为组件子元素传递的内容
//   在您的视图中,而不是作为属性

#[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
            // 对于组件 props,您可以简写
            // `render_prop=render_prop` => `render_prop`
            // (这对 HTML 元素属性不起作用)
            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)
}

无宏:View Builder 语法

如果您对到目前为止描述的 view! 宏语法完全满意,欢迎跳过本章。本节中描述的 builder 语法始终可用,但从不是必需的。

出于某种原因,许多开发人员更愿意避免宏。也许您不喜欢有限的 rustfmt 支持。(不过,您应该查看 leptosfmt,这是一个优秀的工具!)也许您担心宏对编译时间的影响。也许您更喜欢纯 Rust 语法的美学,或者您在类似 HTML 的语法和 Rust 代码之间的上下文切换有困难。或者也许您想要比 view 宏提供的更多灵活性来创建和操作 HTML 元素。

如果您属于这些阵营中的任何一个,builder 语法可能适合您。

view 宏将类似 HTML 的语法展开为一系列 Rust 函数和方法调用。如果您不想使用 view 宏,您可以简单地自己使用那个展开的语法。而且它实际上相当不错!

首先,如果您愿意,您甚至可以放弃 #[component] 宏:组件只是创建视图的设置函数,所以您可以将组件定义为简单的函数调用:

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

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

p()

自定义元素/Web 组件可以通过使用带有其名称的 custom() 函数创建:

custom("my-custom-element")

您可以使用 .child() 向元素添加子元素,它接受单个子元素或实现 IntoView 的类型的元组或数组。

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

属性使用 .attr() 添加。这可以接受您可以作为属性传递到视图宏中的任何相同类型(实现 Attribute 的类型)。

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

它们也可以使用属性方法添加,这些方法对任何内置 HTML 属性名称都可用:

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

类似地,class:prop:style: 语法直接映射到 .class().prop().style() 方法。

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

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

所有这些加起来形成了一个非常 Rusty 的语法来构建功能齐全的视图,如果您喜欢这种风格的话。

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

使用 Builder 语法的组件

要使用 builder 语法创建自己的组件,您可以简单地使用普通函数(见上文)。要使用其他组件(例如,内置的 ForShow 控制流组件),您可以利用每个组件都是一个组件 props 参数的函数这一事实,组件 props 有自己的 builder。

您可以使用组件 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(),
)

或者您可以直接构建 props 结构体:

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")),
})

使用组件 builder 正确应用各种修饰符如 #[prop(into)];使用结构体语法,我们通过自己调用 .into() 手动应用了这个。

展开宏

这里并没有详细描述 view 宏或 component 宏语法的每个功能。但是,Rust 为您提供了理解任何宏正在发生什么所需的工具。具体来说,rust-analyzer 的"递归展开宏"功能允许您展开任何宏以显示它生成的代码,cargo-expand 将项目中的所有宏展开为常规 Rust 代码。本书的其余部分将继续使用 view 宏语法,但如果您不确定如何将其转换为 builder 语法,您可以使用这些工具来探索生成的代码。

响应式系统

Leptos 建立在细粒度响应式系统之上,旨在响应响应式值的变化,尽可能少地运行昂贵的副作用(如在浏览器中渲染某些内容或发出网络请求)。

到目前为止,我们已经看到了 signals 的实际应用。这些章节将更深入一些,并查看 effects,它们是故事的另一半。

使用 Signals

到目前为止,我们使用了一些简单的 signal 示例,它返回一个 ReadSignal getter 和一个 WriteSignal setter。

获取和设置

有几个基本的 signal 操作:

获取

  1. .read() 返回一个读取守卫,它解引用到 signal 的值,并响应式地跟踪 signal 值的任何未来更改。注意,在此守卫被丢弃之前,您无法更新 signal 的值,否则会导致运行时错误。
  2. .with() 接受一个函数,该函数通过引用(&T)接收 signal 的当前值,并跟踪 signal。
  3. .get() 克隆 signal 的当前值并跟踪值的进一步更改。

.get() 是访问 signal 最常见的方法。.read() 对于接受不可变引用的方法很有用,无需克隆值(my_vec_signal.read().len())。.with() 在您需要对该引用做更多事情但想确保不会比需要的时间更长地持有锁时很有用。

设置

  1. .write() 返回一个写入守卫,它是对 signal 值的可变引用,并通知任何订阅者它们需要更新。注意,在此守卫被丢弃之前,您无法从 signal 的值读取,否则会导致运行时错误。
  2. .update() 接受一个函数,该函数接收对 signal 当前值的可变引用(&mut T),并通知订阅者。(.update() 不返回闭包返回的值,但如果需要,您可以使用 .try_update();例如,如果您从 Vec<_> 中删除项目并想要被删除的项目。)
  3. .set() 替换 signal 的当前值并通知订阅者。

.set() 最常用于设置新值;.write() 对于就地更新值非常有用。就像 .read().with() 的情况一样,.update() 在您想避免比预期更长时间持有写锁的可能性时很有用。

Note

这些 traits 基于 trait 组合并由毯子实现提供。例如,Read 为任何实现 TrackReadUntracked 的类型实现。With 为任何实现 Read 的类型实现。Get 为任何实现 WithClone 的类型实现。等等。

WriteUpdateSet 存在类似的关系。

在阅读文档时值得注意这一点:如果您只看到 ReadUntrackedTrack 作为已实现的 traits,您仍然能够使用 .with().get()(如果 T: Clone)等等。

使用 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 来实现。

但当然,.get().set() 是更好的语法。

但是,其他方法有一些非常好的用例。

例如,考虑一个持有 Vec<String> 的 signal。

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

在逻辑方面,这足够简单,但它隐藏了一些重大的低效率。记住 names.get().is_empty() 克隆值。这意味着我们克隆整个 Vec<String>,运行 is_empty(),然后立即丢弃克隆。

同样,set_names 用全新的 Vec<_> 替换值。这很好,但我们不妨就地改变原始 Vec<_>

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

现在我们的函数简单地通过引用获取 names 来运行 is_empty(),避免了那个克隆,然后就地改变 Vec<_>

线程安全和线程本地值

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

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

因此,我们为每个 signal 原语提供"本地"替代方案,可用于存储 !Send 数据。只有当您有需要存储在 signal 中的 !Send 浏览器类型时,您才应该使用这些。

Nightly 语法

当使用 nightly 功能和 nightly 语法时,将 ReadSignal 作为函数调用是 .get() 的语法糖。将 WriteSignal 作为函数调用是 .set() 的语法糖。所以

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

与以下相同

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

这不仅仅是语法糖,而是通过使 signals 在语义上与函数相同来提供更一致的 API:请参阅插曲

使 signals 相互依赖

人们经常询问某个 signal 需要根据其他 signal 的值而改变的情况。有三种好方法来做到这一点,还有一种在受控情况下不太理想但可以的方法。

好的选择

1) B 是 A 的函数。 为 A 创建一个 signal,为 B 创建一个派生 signal 或 memo。

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

有关是否使用派生 signal 或 memo 的指导,请参阅 Memo 的文档

2) C 是 A 和其他东西 B 的函数。 为 A 和 B 创建 signals,为 C 创建派生 signal 或 memo。

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

3) A 和 B 是独立的 signals,但有时同时更新。 当您调用更新 A 时,单独调用更新 B。

// A
let (age, set_age) = signal(32);
// B
let (favorite_number, set_favorite_number) = signal(42);
// 使用这个来处理 `Clear` 按钮的点击
let clear_handler = move |_| {
  // 更新 A 和 B
  set_age.set(0);
  set_favorite_number.set(0);
};

如果您真的必须...

4) 创建一个 effect 在 A 更改时写入 B。 这是官方不鼓励的,有几个原因: a) 它总是效率较低,因为这意味着每次 A 更新时,您都要完整地通过响应式过程两次。(您设置 A,这导致 effect 运行,以及依赖于 A 的任何其他 effects。然后您设置 B,这导致依赖于 B 的任何 effects 运行。) b) 它增加了意外创建无限循环或过度重新运行 effects 等情况的机会。这是 2010 年代初常见的那种乒乓球式响应式意大利面条代码,我们试图通过读写分离和不鼓励从 effects 写入 signals 等方式来避免。

在大多数情况下,最好重写事物,使基于派生 signals 或 memos 有清晰的自顶向下数据流。但这不是世界末日。

我故意不在这里提供示例。阅读 Effect 文档来弄清楚这如何工作。

使用 Effects 响应变化

我们已经走了这么远而没有提到响应式系统的一半:effects。

响应性分为两半:更新单个响应式值("signals")通知依赖于它们的代码片段("effects")它们需要再次运行。响应式系统的这两半是相互依赖的。没有 effects,signals 可以在响应式系统内改变,但永远不会以与外部世界交互的方式被观察到。没有 signals,effects 运行一次但永远不会再次运行,因为没有可观察的值可以订阅。Effects 实际上是响应式系统的"副作用":它们存在是为了将响应式系统与其外部的非响应式世界同步。

渲染器使用 effects 来响应 signals 的变化更新 DOM 的部分。您可以创建自己的 effects 来以其他方式将响应式系统与外部世界同步。

Effect::new 接受一个函数作为其参数。它在响应式系统的下一个"tick"上运行此函数。(例如,如果您在组件中使用它,它将在该组件被渲染_之后_运行。)如果您在该函数内访问任何响应式 signal,它会注册 effect 依赖于该 signal 的事实。每当 effect 依赖的 signals 之一发生变化时,effect 就会再次运行。

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

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

effect 函数使用包含它上次运行时返回的任何值的参数调用。在初始运行时,这是 None

默认情况下,effects 不在服务器上运行。这意味着您可以在 effect 函数内调用特定于浏览器的 API 而不会引起问题。如果您需要 effect 在服务器上运行,请使用 Effect::new_isomorphic

自动跟踪和动态依赖

如果您熟悉像 React 这样的框架,您可能会注意到一个关键区别。React 和类似框架通常要求您传递"依赖数组",这是一个明确的变量集合,用于确定 effect 何时应该重新运行。

因为 Leptos 来自同步响应式编程的传统,我们不需要这个明确的依赖列表。相反,我们根据在 effect 内访问哪些 signals 自动跟踪依赖。

这有两个效果(没有双关语的意思)。依赖是:

  1. 自动的:您不需要维护依赖列表,或担心应该或不应该包含什么。框架简单地跟踪哪些 signals 可能导致 effect 重新运行,并为您处理它。
  2. 动态的:依赖列表在每次 effect 运行时被清除和更新。如果您的 effect 包含条件(例如),只有在当前分支中使用的 signals 被跟踪。这意味着 effects 重新运行的次数绝对最少。

如果这听起来像魔法,如果您想深入了解自动依赖跟踪如何工作,查看这个视频。(为低音量道歉!)

Effects 作为零成本抽象

虽然它们在最技术意义上不是"零成本抽象"——它们需要一些额外的内存使用,在运行时存在等等——在更高层次上,从您在其中进行的任何昂贵 API 调用或其他工作的角度来看,effects 是零成本抽象。考虑到您如何描述它们,它们重新运行绝对必要的最少次数。

想象我正在创建某种聊天软件,我希望人们能够显示他们的全名或只是他们的名字,并在他们的名字更改时通知服务器:

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

// 这将在任何源 signals 更改时将名称添加到日志中
Effect::new(move |_| {
    logging::log!(
        "{}", if use_last.get() {
            format!("{} {}", first.get(), last.get())
        } else {
            first.get()
        },
    )
});

如果 use_lasttrue,effect 应该在 firstlastuse_last 更改时重新运行。但如果我将 use_last 切换为 falselast 的更改永远不会导致全名更改。实际上,last 将从依赖列表中删除,直到 use_last 再次切换。这节省了我们在 use_last 仍然是 false 时多次更改 last 时向 API 发送多个不必要请求的情况。

创建 effect,还是不创建 effect?

Effects 旨在将响应式系统与外部的非响应式世界同步,而不是在不同响应式值之间同步。换句话说:使用 effect 从一个 signal 读取值并在另一个 signal 中设置它总是次优的。

如果您需要定义一个依赖于其他 signals 值的 signal,请使用派生 signal 或 Memo。在 effect 内写入 signal 不是世界末日,它不会导致您的计算机着火,但派生 signal 或 memo 总是更好——不仅因为数据流清晰,而且因为性能更好。

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

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

// ✅ 太棒了!
let b = move || a.get() * 2;

如果您需要将某些响应式值与外部的非响应式世界同步——如 Web API、控制台、文件系统或 DOM——在 effect 中写入 signal 是一种很好的方法。但在许多情况下,您会发现您实际上是在事件监听器或其他东西内部写入 signal,而不是在 effect 内部。在这些情况下,您应该查看 leptos-use 看看它是否已经提供了响应式包装原语来做到这一点!

如果您想了解更多关于何时应该和不应该使用 create_effect 的信息,查看这个视频 进行更深入的考虑!

Effects 和渲染

我们已经走了这么远而没有提到 effects,因为它们内置在 Leptos DOM 渲染器中。我们已经看到您可以创建一个 signal 并将其传递到 view 宏中,它将在 signal 更改时更新相关的 DOM 节点:

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

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

这之所以有效,是因为框架本质上创建了一个包装此更新的 effect。您可以想象 Leptos 将此视图转换为类似这样的东西:

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

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

// 创建 effect 来响应式更新文本
Effect::new(move |prev_value| {
    // 首先,访问 signal 的值并将其转换为字符串
    let text = count.get().to_string();

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

    // 返回此值,以便我们可以记忆化下一次更新
    text
});

每次 count 更新时,此 effect 将重新运行。这就是允许对 DOM 进行响应式、细粒度更新的原因。

使用 Effect::watch() 显式跟踪

除了 Effect::new() 之外,Leptos 还提供了一个 Effect::watch() 函数,可用于通过显式传入一组要跟踪的值来分离跟踪和响应变化。

watch 接受三个参数。dependency_fn 参数被响应式跟踪,而 handlerimmediate 不被跟踪。每当 dependency_fn 更改时,handler 就会运行。如果 immediate 是 false,handler 只会在检测到在 dependency_fn 中访问的任何 signal 的第一次更改后运行。watch 返回一个 Effect,可以用 .stop() 调用来停止跟踪依赖。

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

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

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

effect.stop(); // 停止监视

set_num.set(2); // (什么都不发生)

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
use leptos::html::Input;
use leptos::prelude::*;

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

#[component]
fn App() -> impl IntoView {
    // 只是在这里制作一个可见的日志
    // 您可以忽略这个...
    let log = RwSignal::<Vec<String>>::new(vec![]);
    let logged = move || log.get().join("\n");

    // newtype 模式在这里不是*必需的*,但是一个好的实践
    // 它避免了与其他可能的未来 `RwSignal<Vec<String>>` contexts 的混淆
    // 并使引用它变得更容易
    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);

    // 这将在任何源 signals 更改时将名称添加到日志中
    Effect::new(move |_| {
        log(if use_last.get() {
            let first = first.read();
            let last = last.read();
            format!("{first} {last}")
        } else {
            first.get()
        })
    });

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

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

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

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

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

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

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

插曲:响应式和函数

我们的一位核心贡献者最近对我说:"在开始使用Leptos之前,我从来没有这么频繁地使用闭包。"这是真的。闭包是任何Leptos应用程序的核心。有时看起来有点愚蠢:

// signal保存一个值,可以被更新
let (count, set_count) = signal(0);

// 派生signal是一个访问其他signal的函数
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自动跟踪它依赖的signal
// 并在它们改变时重新运行
Effect::new(move |_| {
    logging::log!("text = {}", text());
});

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

到处都是闭包!

但为什么?

函数和UI框架

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

  1. 初始渲染
  2. 更新

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

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

  1. 虚拟DOM(VDOM)框架如React、Yew或Dioxus一遍又一遍地重新运行组件或渲染函数,以生成可以与先前结果协调以修补DOM的虚拟DOM树
  2. 编译框架如Angular和Svelte将您的组件模板分为"创建"和"更新"函数,当它们检测到组件状态的变化时重新运行更新函数
  3. 在细粒度响应式框架如SolidJS、Sycamore或Leptos中,_您_定义重新运行的函数

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

以最简单形式的典型<SimpleCounter/>示例为例:

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

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

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

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

函数是响应式的关键。它们为框架提供了响应变化重新运行应用程序最小可能单元的能力。

所以记住两件事:

  1. 您的组件函数是设置函数,不是渲染函数:它只运行一次。
  2. 为了让视图模板中的值是响应式的,它们必须是响应式函数:要么是signal,要么是捕获并从signal读取的闭包。

Note

这实际上是Leptos稳定版和nightly版之间的主要区别。如您所知,使用nightly编译器和nightly功能允许您直接调用signal作为函数:所以,value()而不是value.get()

但这不仅仅是语法糖。它允许一个极其一致的语义模型:响应式的东西是函数。Signal通过调用函数来访问。要说"给我一个signal作为参数",您可以接受任何impl Fn() -> T。这个基于函数的接口在signal、memo和派生signal之间没有区别:任何一个都可以通过将它们作为函数调用来访问。

不幸的是,在像signal这样的任意结构上实现Fn trait需要nightly Rust,尽管这个特定功能大多只是停滞不前,不太可能很快改变(或稳定化)。许多人出于这样或那样的原因避免nightly。所以,随着时间的推移,我们已经将文档等内容的默认值转向稳定版。不幸的是,这使得"signal是函数"的简单心理模型不那么直接。

测试您的组件

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

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

在许多情况下,将逻辑从组件中提取出来并单独测试是有意义的。对于一些简单的组件,没有特别的逻辑需要测试,但对于许多组件来说,值得使用可测试的包装类型并在普通的 Rust impl 块中实现逻辑。

例如,不要像这样直接在组件中嵌入逻辑:

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

您可以将该逻辑提取到单独的数据结构中并进行测试:

pub struct Todos(Vec<Todo>);

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

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

#[component]
pub fn TodoApp() -> impl IntoView {
    let (todos, set_todos) = signal(Todos(vec![Todo { /* ... */ }]));
    // ✅ 这有一个与之关联的测试
    let num_remaining = move || todos.read().num_remaining();
}

一般来说,包装在组件本身中的逻辑越少,您的代码就会感觉越符合惯用法,也越容易测试。

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

我们的 examples 目录有几个使用不同测试工具进行广泛端到端测试的示例。

了解如何使用这些工具的最简单方法是查看测试示例本身:

使用 counterwasm-bindgen-test

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

示例测试

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

    // 首先渲染我们的计数器并将其挂载到 DOM
    // 注意我们从初始值 10 开始
    let _dispose = mount_to(
        test_wrapper.clone().unchecked_into(),
        || view! { <SimpleCounter initial_value=10 step=1/> },
    );

    // 现在我们通过遍历 DOM 来提取按钮
    // 如果它们有 ID 会更容易
    let div = test_wrapper.query_selector("div").unwrap().unwrap();
    let clear = test_wrapper
        .query_selector("button")
        .unwrap()
        .unwrap()
        .unchecked_into::<web_sys::HtmlElement>();

    // 现在让我们点击 `clear` 按钮
    clear.click();

    // 响应式系统建立在异步系统之上,所以更改不会同步反映在 DOM 中
    // 为了在这里检测更改,我们只需在每次更改后短暂让出,
    // 允许更新视图的 effects 运行
    tick().await;

    // 现在让我们根据预期值测试 <div>
    // 我们可以通过测试其 `outerHTML` 来做到这一点
    assert_eq!(div.outer_html(), {
        // 就像我们用值 0 创建它一样,对吧?
        let (value, _set_value) = signal(0);

        // 我们可以移除事件监听器,因为它们不会渲染到 HTML
        view! {
            <div>
                <button>"Clear"</button>
                <button>"-1"</button>
                <span>"Value: " {value} "!"</span>
                <button>"+1"</button>
            </div>
        }
        // Leptos 支持 HTML 元素的多个后端渲染器
        // 这里的 .into_view() 只是指定"使用常规 DOM 渲染器"的便捷方式
        .into_view()
        // views 是惰性的——它们描述 DOM 树但还没有创建它
        // 调用 .build() 将实际构建 DOM 元素
        .build()
        // .build() 返回了一个 ElementState,它是 DOM 元素的智能指针
        // 所以我们仍然可以调用 .outer_html(),它访问实际 DOM 元素上的 outerHTML
        .outer_html()
    });

    // 实际上有一个更简单的方法来做这件事...
    // 我们可以只是针对初始值为 0 的 <SimpleCounter/> 进行测试
    assert_eq!(test_wrapper.inner_html(), {
        let comparison_wrapper = document.create_element("section").unwrap();
        let _dispose = mount_to(
            comparison_wrapper.clone().unchecked_into(),
            || view! { <SimpleCounter initial_value=0 step=1/>},
        );
        comparison_wrapper.inner_html()
    });
}

使用 counters 的 Playwright

这些测试使用常见的 JavaScript 测试工具 Playwright 在同一个示例上运行端到端测试,使用许多前端开发人员熟悉的库和测试方法。

示例测试

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

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

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

使用 todo_app_sqlite 的 Gherkin/Cucumber 测试

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

@add_todo
Feature: Add Todo

    Background:
        Given I see the app

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

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

这些操作的定义在 Rust 代码中定义。

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

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

    Ok(())
}

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

    Ok(())
}

// 等等...

了解更多

请随时查看 Leptos 仓库中的 CI 设置,了解如何在您自己的应用程序中使用这些工具。所有这些测试方法都定期针对实际的 Leptos 示例应用程序运行。

使用 async

到目前为止,我们只使用同步用户界面:您提供一些输入,应用程序立即处理它并更新界面。这很好,但这只是 Web 应用程序功能的一小部分。特别是,大多数 Web 应用程序必须处理某种异步数据加载,通常是从 API 加载某些内容。

异步数据因为"函数着色"问题而难以与代码的同步部分集成。

在接下来的章节中,我们将看到一些用于处理异步数据的响应式原语。但在一开始就要注意这一点很重要:如果您只想做一些异步工作,Leptos 提供了一个跨平台的 spawn_local 函数,使运行 Future 变得容易。如果本节其余部分讨论的原语似乎不能满足您的需求,请考虑将 spawn_local 与设置 signal 结合使用。

虽然即将介绍的原语非常有用,在某些情况下甚至是必要的,但人们有时会遇到这样的情况:他们真的只需要生成一个任务并等待它完成,然后再做其他事情。在这些情况下使用 spawn_local

使用 Resources 加载数据

Resources 是异步任务的响应式包装器,允许您将异步 Future 集成到同步响应式系统中。

它们有效地允许您加载一些异步数据,然后同步或异步地响应式访问它。您可以像普通 Future 一样 .await 一个 resource,这将跟踪它。但您也可以使用 .get() 和其他 signal 访问方法访问 resource,就好像 resource 是一个在已解析时返回 Some(T)、仍在等待时返回 None 的 signal。

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

Local Resources

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

Future 可以是 async 块、async fn 调用的结果或任何其他 Rust Future。该函数将像派生 signal 或我们迄今为止看到的其他响应式闭包一样工作:您可以在其中读取 signals,每当 signal 更改时,函数将再次运行,创建一个新的 Future 来运行。

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

// 跟踪 `count`,并通过调用 `load_data` 重新加载
// 每当它改变时
let async_data = LocalResource::new(move || load_data(count.get()));

创建 resource 会立即调用其 fetcher 并开始轮询 Future。从 resource 读取将返回 None,直到异步任务完成,此时它将通知其订阅者,现在有 Some(value)

您也可以 .await 一个 resource。这可能看起来毫无意义——为什么要创建一个 Future 的包装器,然后再 .await 它?我们将在下一章中看到原因。

Resources

如果您使用 SSR,在大多数情况下您应该使用 Resource 而不是 LocalResource

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

  1. 一个 source 函数,包含"输入"。这个输入被记忆化,每当其值更改时,fetcher 将被调用。
  2. 一个 fetcher 函数,接受来自 source 函数的数据并返回一个 Future

LocalResource 不同,Resource 将其值从服务器序列化到客户端。然后,在客户端,当首次加载页面时,初始值将被反序列化而不是异步任务再次运行。这非常重要且非常有用:这意味着不是等待客户端 WASM 包加载并开始运行应用程序,数据加载在服务器上开始。(在后面的章节中会有更多关于这一点的内容。)

这也是为什么 API 分为两部分的原因:source 函数中的 signals 被跟踪,但 fetcher 中的 signals 不被跟踪,因为这允许 resource 保持响应性而无需在客户端初始 hydration 期间再次运行 fetcher。

这是同样的示例,使用 Resource 而不是 LocalResource

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

// 我们的 resource
let async_data = Resource::new(
    move || count.get(),
    // 每次 `count` 更改时,这将运行
    |count| load_data(count) 
);

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

要创建一个只运行一次的 resource,您可以使用 OnceResource,它只接受一个 Future,并添加一些来自知道它只会加载一次的优化。

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

访问 Resources

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

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
use gloo_timers::future::TimeoutFuture;
use leptos::prelude::*;

// 在这里我们定义一个异步函数
// 这可以是任何东西:网络请求、数据库读取等
// 在这里,我们只是将一个数字乘以 10
async fn load_data(value: i32) -> i32 {
    // 模拟一秒延迟
    TimeoutFuture::new(1_000).await;
    value * 10
}

#[component]
pub fn App() -> impl IntoView {
    // 这个 count 是我们的同步本地状态
    let (count, set_count) = signal(0);

    // 跟踪 `count`,并通过调用 `load_data` 重新加载
    // 每当它改变时
    let async_data = LocalResource::new(move || load_data(count.get()));

    // 如果 resource 不读取任何响应式数据,它只会加载一次
    let stable = LocalResource::new(|| load_data(1));

    // 我们可以使用 .get() 访问 resource 值
    // 这将在 Future 解析之前响应式返回 None
    // 并在解析时更新为 Some(T)
    let async_result = move || {
        async_data
            .get()
            .map(|value| format!("Server returned {value:?}"))
            // 这个加载状态只会在第一次加载之前显示
            .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/>

在上一章中,我们展示了如何创建一个简单的加载屏幕,在 resource 加载时显示一些后备内容。

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

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

但是如果我们有两个 resources,并且想要等待它们两个呢?

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(),
        _ => view! { <p>"Loading..."</p> }.into_any()
    }}
}

这并不_那么_糟糕,但有点烦人。如果我们可以反转控制流会怎么样?

<Suspense/> 组件让我们可以做到这一点。您给它一个 fallback prop 和子元素,其中一个或多个通常涉及从 resource 读取。在 <Suspense/> "下"(即在其子元素之一中)读取 resource 会将该 resource 注册到 <Suspense/>。如果它仍在等待 resources 加载,它会显示 fallback。当它们全部加载完成时,它会显示子元素。

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

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

每次其中一个 resources 重新加载时,"Loading..." 后备内容将再次显示。

这种控制流的反转使得添加或删除单个 resources 变得更容易,因为您不需要自己处理匹配。它还在服务端渲染期间解锁了一些巨大的性能改进,我们将在后面的章节中讨论。

使用 <Suspense/> 还为我们提供了一种直接 .await resources 的有用方法,允许我们减少上面的嵌套层级。Suspend 类型让我们创建一个可渲染的 Future,可以在视图中使用:

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

Suspend 允许我们避免对每个 resource 进行空值检查,并减少代码的额外复杂性。

<Await/>

如果您只是想等待某个 Future 解析后再渲染,您可能会发现 <Await/> 组件有助于减少样板代码。<Await/> 本质上将 OnceResource 与没有 fallback 的 <Suspense/> 结合起来。

换句话说:

  1. 它只轮询 Future 一次,不响应任何响应式更改。
  2. Future 解析之前它不渲染任何内容。
  3. Future 解析后,它将数据绑定到您选择的任何变量名,然后在该变量的作用域内渲染其子元素。
async fn fetch_monkeys(monkey: i32) -> i32 {
    // 也许这不需要是异步的
    monkey * 2
}
view! {
    <Await
        // `future` 提供要解析的 `Future`
        future=fetch_monkeys(3)
        // 数据绑定到您提供的任何变量名
        let:data
    >
        // 您通过引用接收数据,可以在这里的视图中使用它
        <p>{*data} " little monkeys, jumping on the bed."</p>
    </Await>
}

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
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` 更改时重新加载
    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 "下"读取的 resource
            // 正在加载时,fallback 将显示
            fallback=move || view! { <p>"Loading..."</p> }
        >
            // Suspend 允许您在视图中使用异步块
            <p>
                "Your shouting name is "
                {move || Suspend::new(async move {
                    async_data.await
                })}
            </p>
        </Suspense>
        <Suspense
            // 每当在 suspense "下"读取的 resource
            // 正在加载时,fallback 将显示
            fallback=move || view! { <p>"Loading..."</p> }
        >
            // 子元素将最初渲染一次,
            // 然后在任何 resources 解析时再次渲染
            <p>
                "Which should be the same as... "
                {move || async_data.get().as_deref().map(ToString::to_string)}
            </p>
        </Suspense>
    }
}

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

<Transition/>

您会注意到在 <Suspense/> 示例中,如果您继续重新加载数据,它会一直闪烁回到 "Loading..."。有时这很好。对于其他时候,有 <Transition/>

<Transition/> 的行为与 <Suspense/> 完全相同,但不是每次都回退,它只在第一次显示 fallback。在所有后续加载中,它继续显示旧数据,直到新数据准备好。这对于防止闪烁效果和允许用户继续与您的应用程序交互非常有用。

这个示例展示了如何使用 <Transition/> 创建一个简单的选项卡式联系人列表。当您选择新选项卡时,它继续显示当前联系人,直到新数据加载。这可能比不断回退到加载消息提供更好的用户体验。

Live example

点击打开 CodeSandbox。

CodeSandbox 源码
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` 更改时重新加载
    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 将最初显示
            // 在后续重新加载时,当前子元素将
            // 继续显示
            fallback=move || view! { <p>"Loading initial data..."</p> }
            // 每当转换正在进行时,这将设置为 `true`
            set_pending
        >
            <p>
                {move || user_data.read().as_deref().map(ToString::to_string)}
            </p>
        </Transition>
    }
}

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

使用 Actions 修改数据

我们已经讨论了如何使用 resources 加载 async 数据。Resources 立即加载数据并与 <Suspense/><Transition/> 组件密切配合,以显示您的应用程序中数据是否正在加载。但是如果您只想调用一些任意的 async 函数并跟踪它在做什么呢?

好吧,您总是可以使用 spawn_local。这允许您通过将 Future 交给浏览器(或在服务器上,Tokio 或您正在使用的任何其他运行时)在同步环境中生成 async 任务。但是您如何知道它是否仍在等待?好吧,您可以设置一个 signal 来显示它是否正在加载,另一个来显示结果...

所有这些都是正确的。或者您可以使用最终的 async 原语:Action

Actions 和 resources 看起来相似,但它们代表根本不同的东西。如果您试图通过运行 async 函数来加载数据,无论是一次还是当其他值更改时,您可能想要使用 resource。如果您试图偶尔运行 async 函数以响应用户点击按钮之类的事情,您可能想要使用 Action

假设我们有一些想要运行的 async 函数。

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

Action::new() 接受一个 async 函数,该函数接受对单个参数的引用,您可以将其视为"输入类型"。

输入始终是单一类型。如果您想传入多个参数,可以使用结构体或元组来完成。

// 如果有单个参数,只需使用它
let action1 = Action::new(|input: &String| {
   let input = input.clone();
   async move { todo!() }
});

// 如果没有参数,使用单元类型 `()`
let action2 = Action::new(|input: &()| async { todo!() });

// 如果有多个参数,使用元组
let action3 = Action::new(
  |input: &(usize, String)| async { todo!() }
);

因为 action 函数接受引用但 Future 需要有 'static 生命周期,您通常需要克隆值以将其传递到 Future 中。这确实有点笨拙,但它解锁了一些强大的功能,如乐观 UI。我们将在未来的章节中看到更多相关内容。

所以在这种情况下,我们创建 action 所需要做的就是

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

我们不会直接调用 add_todo_action,而是使用 .dispatch() 调用它,如

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

您可以从事件监听器、超时或任何地方执行此操作;因为 .dispatch() 不是 async 函数,它可以从同步上下文中调用。

Actions 提供对几个 signals 的访问,这些 signals 在您调用的异步 action 和同步响应式系统之间同步:

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

这使得跟踪请求的当前状态、显示加载指示器或基于提交将成功的假设进行"乐观 UI"变得容易。

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

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

现在,这一切可能看起来有点过于复杂,或者可能过于受限。我想在这里包含 actions,与 resources 一起,作为拼图的缺失部分。在真正的 Leptos 应用程序中,您实际上最常将 actions 与 server functions、ServerAction<ActionForm/> 组件一起使用,以创建真正强大的渐进增强表单。所以如果这个原语对您来说似乎没用...不要担心!也许稍后会有意义。(或者现在查看我们的 todo_app_sqlite 示例。)

Live example

点击打开 CodeSandbox。

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

// 在这里我们定义一个异步函数
// 这可以是任何东西:网络请求、数据库读取等
// 将其视为变更:您运行的一些命令式异步操作,
// 而 resource 将是您加载的一些异步数据
async fn add_todo(text: &str) -> Uuid {
    _ = text;
    // 模拟一秒延迟
    // SendWrapper 允许我们使用这个 !Send 浏览器 API;不用担心
    send_wrapper::SendWrapper::new(TimeoutFuture::new(1_000)).await;
    // 假装这是一个帖子 ID 或其他什么
    Uuid::new_v4()
}

#[component]
pub fn App() -> impl IntoView {
    // action 接受一个带有单个参数的异步函数
    // 它可以是简单类型、结构体或 ()
    let add_todo = Action::new(|input: &String| {
        // 输入是引用,但我们需要 Future 拥有它
        // 这很重要:我们需要克隆并移动到 Future 中
        // 这样它就有了 'static 生命周期
        let input = input.to_owned();
        async move { add_todo(&input).await }
    });

    // actions 提供一堆同步的响应式变量
    // 告诉我们 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(); // 不要重新加载页面...
                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)
}

投影子元素

在构建组件时,您可能偶尔会发现自己想要通过多层组件"投影"子元素。

问题

考虑以下情况:

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

这非常直接:如果内部条件为 true,我们想要显示 children。如果不是,我们想要显示 fallback。如果外部条件为 false,我们只渲染 (),即什么都不渲染。

换句话说,我们想要将 <NestedShow/> 的子元素_通过_外部 <Show/> 组件传递,成为内部 <Show/> 的子元素。这就是我所说的"投影"。

这不会编译。

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

每个 <Show/> 需要能够多次构造其 children。第一次构造外部 <Show/> 的子元素时,它获取 fallbackchildren 将它们移动到内部 <Show/> 的调用中,但然后它们就不可用于未来的外部 <Show/> 子元素构造了。

详细信息

随时跳到解决方案。

如果您想真正理解这里的问题,查看展开的 view 宏可能会有帮助。这是一个清理过的版本:

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

所有组件都拥有它们的 props;所以在这种情况下 <Show/> 不能被调用,因为它只捕获了对 fallbackchildren 的引用。

解决方案

但是,<Suspense/><Show/> 都接受 ChildrenFn,即它们的 children 应该实现 Fn 类型,这样它们可以只用不可变引用被多次调用。这意味着我们不需要拥有 childrenfallback;我们只需要能够传递对它们的 'static 引用。

我们可以通过使用 StoredValue 原语来解决这个问题。这本质上将值存储在响应式系统中,将所有权交给框架,换取一个像 signals 一样是 Copy'static 的引用,我们可以通过某些方法访问或修改它。

在这种情况下,这真的很简单:

pub fn NestedShow<F, IV>(fallback: F, children: ChildrenFn) -> impl IntoView
where
    F: Fn() -> IV + Send + Sync + 'static,
    IV: IntoView + 'static,
{
    let fallback = StoredValue::new(fallback);
    let children = StoredValue::new(children);

    view! {
        <Show
            when=|| todo!()
            fallback=|| ()
        >
            <Show
                // 通过从 resource 读取来检查用户是否已验证
                when=move || todo!()
                fallback=move || fallback.read_value()()
            >
                {children.read_value()()}
            </Show>
        </Show>
    }
}

在顶层,我们将 fallbackchildren 都存储在 NestedShow 拥有的响应式作用域中。现在我们可以简单地将这些引用向下移动通过其他层到 <Show/> 组件中并在那里调用它们。

最后说明

注意这之所以有效,是因为 <Show/> 只需要对其子元素的不可变引用(.read_value 可以提供),而不是所有权。

在其他情况下,您可能需要通过一个接受 ChildrenFn 的函数投影拥有的 props,因此需要被多次调用。在这种情况下,您可能会发现 view 宏中的 clone: 助手很有用。

考虑这个示例

#[component]
pub fn App() -> impl IntoView {
    let name = "Alice".to_string();
    view! {
        <Outer>
            <Inner>
                <Inmost name=name.clone()/>
            </Inner>
        </Outer>
    }
}

#[component]
pub fn Outer(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inner(children: ChildrenFn) -> impl IntoView {
    children()
}

#[component]
pub fn Inmost(name: String) -> impl IntoView {
    view! {
        <p>{name}</p>
    }
}

即使使用 name=name.clone(),这也会给出错误

cannot move out of `name`, a captured variable in an `Fn` closure

它通过需要运行多次的多层子元素被捕获,并且没有明显的方法将其克隆_到_子元素中。

在这种情况下,clone: 语法就派上用场了。调用 clone:name 将在将 name 移动到 <Inner/> 的子元素之前克隆它,这解决了我们的所有权问题。

view! {
	<Outer>
		<Inner clone:name>
			<Inmost name=name.clone()/>
		</Inner>
	</Outer>
}

由于 view 宏的不透明性,这些问题可能有点难以理解或调试。但一般来说,它们总是可以解决的。

全局状态管理

到目前为止,我们只在组件中使用本地状态,并且已经了解了如何在父子组件之间协调状态。有时,人们会寻找一个更通用的全局状态管理解决方案,可以在整个应用程序中使用。

一般来说,您不需要这一章。典型的模式是将应用程序组合成组件,每个组件管理自己的本地状态,而不是将所有状态存储在全局结构中。但是,在某些情况下(如主题设置、保存用户设置或在 UI 不同部分的组件之间共享数据),您可能希望使用某种全局状态管理。

全局状态的三种最佳方法是:

  1. 使用路由器通过 URL 驱动全局状态
  2. 通过 context 传递 signals
  3. 使用 stores 创建全局状态结构

选项 #1:URL 作为全局状态

在许多方面,URL 实际上是存储全局状态的最佳方式。它可以从树中任何地方的任何组件访问。有像 <form><a> 这样的原生 HTML 元素专门用于更新 URL。它在页面重新加载和设备之间持久存在;您可以与朋友分享 URL 或从手机发送到笔记本电脑,存储在其中的任何状态都会被复制。

教程的接下来几个部分将介绍路由器,我们将更深入地讨论这些主题。

但现在,我们只看选项 #2 和 #3。

选项 #2:通过 Context 传递 Signals

父子通信部分,我们看到您可以使用 provide_context 将 signal 从父组件传递给子组件,并使用 use_context 在子组件中读取它。但 provide_context 可以跨任何距离工作。如果您想创建一个保存某些状态的全局 signal,您可以提供它并通过 context 在提供它的组件的任何后代中访问它。

通过 context 提供的 signal 只在读取它的地方引起响应式更新,而不是在中间的任何组件中,因此即使在远距离也保持了细粒度响应式更新的能力。

我们首先在应用程序的根部创建一个 signal,并使用 provide_context 将其提供给所有子组件和后代。

#[component]
fn App() -> impl IntoView {
    // 在这里我们在根部创建一个可以在应用程序任何地方
    // 消费的 signal
    let (count, set_count) = signal(0);
    // 我们将 setter 传递给特定组件,
    // 但通过 context 将 count 本身提供给整个应用程序
    provide_context(count);

    view! {
        // SetterButton 被允许修改 count
        <SetterButton set_count/>
        // 这些消费者只能从中读取
        // 但如果我们想要,我们可以通过传递 `set_count` 给它们写访问权限
        <FancyMath/>
        <ListItems/>
    }
}

<SetterButton/> 是我们已经写过几次的那种计数器。

<FancyMath/><ListItems/> 都通过 use_context 消费我们提供的 signal 并对其进行某些操作。

/// 一个对全局 count signal 进行一些"花哨"数学运算的组件
#[component]
fn FancyMath() -> impl IntoView {
    // 在这里我们使用 `use_context` 消费全局 count signal
    let count = use_context::<ReadSignal<u32>>()
        // 我们知道我们刚刚在父组件中提供了这个
        .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

这部分内容与这里关于使用 stores 进行复杂迭代的部分有重复。两个部分都是中级/可选内容,所以我认为一些重复不会有害。

Stores 是一个新的响应式原语,在 Leptos 0.7 中通过附带的 reactive_stores crate 提供。(这个 crate 现在单独发布,这样我们可以继续开发它而不需要对整个框架进行版本更改。)

Stores 允许您包装整个结构体,并响应式地读取和更新单个字段,而不跟踪对其他字段的更改。

它们通过在结构体上添加 #[derive(Store)] 来使用。(您可以 use reactive_stores::Store; 来导入宏。)这会创建一个扩展 trait,当结构体被包装在 Store<_> 中时,为结构体的每个字段提供一个 getter。

#[derive(Clone, Debug, Default, Store)]
struct GlobalState {
    count: i32,
    name: String,
}

这创建了一个名为 GlobalStateStoreFields 的 trait,它为 Store<GlobalState> 添加了 countname 方法。每个方法返回一个响应式 store 字段

#[component]
fn App() -> impl IntoView {
    provide_context(Store::new(GlobalState::default()));

    // 等等...
}

/// 更新全局状态中 count 的组件
#[component]
fn GlobalStateCounter() -> impl IntoView {
    let state = expect_context::<Store<GlobalState>>();

    // 这只给我们对 `count` 字段的响应式访问
    let count = state.count();

    view! {
        <div class="consumer blue">
            <button
                on:click=move |_| {
                    *count.write() += 1;
                }
            >
                "Increment Global Count"
            </button>
            <br/>
            <span>"Count is: " {move || count.get()}</span>
        </div>
    }
}

点击这个按钮只更新 state.count。如果我们在其他地方读取 state.name,点击按钮不会通知它。这允许您结合自顶向下数据流和细粒度响应式更新的好处。

查看仓库中的 stores 示例 以获得更广泛的示例。

路由

基础知识

路由驱动着大多数网站。路由器是这个问题的答案:"给定这个URL,页面上应该显示什么?"

URL由许多部分组成。例如,URL https://my-cool-blog.com/blog/search?q=Search#results 包含

  • 协议https
  • 域名my-cool-blog.com
  • 路径/blog/search
  • 查询(或搜索):?q=Search
  • 哈希#results

Leptos Router与路径和查询(/blog/search?q=Search)一起工作。给定URL的这一部分,应用程序应该在页面上渲染什么?

理念

在大多数情况下,路径应该驱动页面上显示的内容。从用户的角度来看,对于大多数应用程序,应用程序状态的大多数主要变化都应该反映在URL中。如果您复制并粘贴URL并在另一个选项卡中打开它,您应该发现自己或多或少在同一个地方。

从这个意义上说,路由器实际上是应用程序全局状态管理的核心。比任何其他东西都更重要的是,它驱动页面上显示的内容。

路由器通过将当前位置映射到特定组件来为您处理大部分工作。

定义路由

开始使用

使用路由器很容易上手。

首先,确保您已将leptos_router包添加到您的依赖项中。与leptos不同,这没有单独的csrhydrate功能;它确实有一个ssr功能,仅用于服务器端,因此为您的服务器端构建激活它。

路由器是与leptos本身分离的包,这很重要。这意味着路由器中的所有内容都可以在用户代码中定义。如果您想创建自己的路由器,或不使用路由器,您完全可以自由地这样做!

并从路由器导入相关类型,例如

use leptos_router::components::{Router, Route, Routes};

提供<Router/>

路由行为由<Router/>组件提供。这通常应该在应用程序的根部附近,包装应用程序的其余部分。

您不应该尝试在应用程序中使用多个<Router/>。记住路由器驱动全局状态:如果您有多个路由器,当URL更改时哪一个决定要做什么?

让我们从使用路由器的简单<App/>组件开始:

use leptos::prelude::*;
use leptos_router::components::Router;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          /* ... */
        </main>
      </Router>
    }
}

定义<Routes/>

<Routes/>组件是您定义用户可以在应用程序中导航到的所有路由的地方。每个可能的路由都由<Route/>组件定义。

您应该将<Routes/>组件放置在应用程序中希望渲染路由的位置。<Routes/>之外的所有内容都将出现在每个页面上,因此您可以将导航栏或菜单等内容留在<Routes/>之外。

use leptos::prelude::*;
use leptos_router::components::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
      <Router>
        <nav>
          /* ... */
        </nav>
        <main>
          // 我们所有的路由都将出现在<main>内
          <Routes fallback=|| "Not found.">
            /* ... */
          </Routes>
        </main>
      </Router>
    }
}

<Routes/>还应该有一个fallback,一个定义如果没有路由匹配时应该显示什么的函数。

单个路由通过使用<Route/>组件为<Routes/>提供子元素来定义。<Route/>接受pathview。当当前位置匹配path时,将创建并显示view

path最容易使用path宏定义,可以包括

  • 静态路径(/users),
  • 以冒号开头的动态命名参数(/:id),
  • 和/或以星号开头的通配符(/user/*any

view是返回视图的函数。任何没有props的组件都可以在这里工作,返回某些视图的闭包也可以。

<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/> }的简写。

现在,如果您导航到//users,您将获得主页或<Users/>。如果您转到/users/3/blahblah,您将获得用户配置文件或您的404页面(<NotFound/>)。在每次导航时,路由器确定应该匹配哪个<Route/>,因此应该在定义<Routes/>组件的位置显示什么内容。

够简单吧?

嵌套路由

我们刚刚定义了以下路由集合:

<Routes fallback=|| "Not found.">
  <Route path=path!("/") view=Home/>
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
  <Route path=path!("/*any") view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>

这里有一定程度的重复:/users/users/:id。对于小应用程序来说这很好,但您可能已经可以看出它不会很好地扩展。如果我们可以嵌套这些路由不是很好吗?

嗯...您可以!

<Routes fallback=|| "Not found.">
  <Route path=path!("/") view=Home/>
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
  </ParentRoute>
  <Route path=path!("/*any") view=|| view! { <h1>"Not Found"</h1> }/>
</Routes>

您可以在<ParentRoute/>内嵌套<Route/>。看起来很直接。

但是等等。我们刚刚巧妙地改变了我们的应用程序所做的事情。

下一节是本指南整个路由部分中最重要的部分之一。仔细阅读,如果有任何您不理解的地方,请随时提问。

嵌套路由作为布局

嵌套路由是一种布局形式,而不是路由定义的方法。

让我换一种说法:定义嵌套路由的目标主要不是为了避免在路由定义中键入路径时重复自己。它实际上是告诉路由器同时在页面上并排显示多个<Route/>

让我们回顾一下我们的实际例子。

<Routes fallback=|| "Not found.">
  <Route path=path!("/users") view=Users/>
  <Route path=path!("/users/:id") view=UserProfile/>
</Routes>

这意味着:

  • 如果我转到/users,我得到<Users/>组件。
  • 如果我转到/users/3,我得到<UserProfile/>组件(参数id设置为3;稍后会详细介绍)

假设我使用嵌套路由:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
  </ParentRoute>
</Routes>

这意味着:

  • 如果我转到/users/3,路径匹配两个<Route/><Users/><UserProfile/>
  • 如果我转到/users,路径不匹配。

我实际上需要添加一个fallback路由

<Routes>
  <ParentRoute path=path!("/users") view=Users>
    <Route path=path!(":id") view=UserProfile/>
    <Route path=path!("") view=NoUser/>
  </ParentRoute>
</Routes>

现在:

  • 如果我转到/users/3,路径匹配<Users/><UserProfile/>
  • 如果我转到/users,路径匹配<Users/><NoUser/>

换句话说,当我使用嵌套路由时,每个路径可以匹配多个路由:每个URL可以同时在同一页面上渲染多个<Route/>组件提供的视图。

这可能是反直觉的,但它非常强大,原因您希望在几分钟内看到。

为什么要嵌套路由?

为什么要费心这样做?

大多数Web应用程序包含对应于布局不同部分的导航级别。例如,在电子邮件应用程序中,您可能有一个像/contacts/greg这样的URL,它在屏幕左侧显示联系人列表,在屏幕右侧显示Greg的联系人详细信息。联系人列表和联系人详细信息应该始终同时出现在屏幕上。如果没有选择联系人,也许您想显示一些说明文本。

您可以轻松地用嵌套路由定义这个

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/contacts") view=ContactList>
    <Route path=path!(":id") view=ContactInfo/>
    <Route path=path!("") view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </ParentRoute>
</Routes>

您可以走得更深。假设您想为每个联系人的地址、电子邮件/电话和您与他们的对话设置选项卡。您可以在:id内添加_另一组_嵌套路由:

<Routes fallback=|| "Not found.">
  <ParentRoute path=path!("/contacts") view=ContactList>
    <ParentRoute path=path!(":id") view=ContactInfo>
      <Route path=path!("") view=EmailAndPhone/>
      <Route path=path!("address") view=Address/>
      <Route path=path!("messages") view=Messages/>
    </ParentRoute>
    <Route path=path!("") view=|| view! {
      <p>"Select a contact to view more info."</p>
    }/>
  </ParentRoute>
</Routes>

Remix网站的主页(React Router创建者的React框架)如果您向下滚动,有一个很好的视觉示例,有三个级别的嵌套路由:Sales > Invoices > an invoice。

<Outlet/>

父路由不会自动渲染其嵌套路由。毕竟,它们只是组件;它们不知道应该在哪里渲染其子组件,"只是把它粘在父组件的末尾"不是一个好答案。

相反,您使用<Outlet/>组件告诉父组件在哪里渲染任何嵌套组件。<Outlet/>简单地渲染两种情况之一:

  • 如果没有匹配的嵌套路由,它什么也不显示
  • 如果有匹配的嵌套路由,它显示其view

就是这样!但重要的是要知道和记住,因为这是"为什么这不工作?"挫折的常见来源。如果您不提供<Outlet/>,嵌套路由将不会显示。

#[component]
pub fn ContactList() -> impl IntoView {
  let contacts = todo!();

  view! {
    <div style="display: flex">
      // 联系人列表
      <For each=contacts
        key=|contact| contact.id
        children=|contact| todo!()
      />
      // 嵌套子项,如果有的话
      // 不要忘记这个!
      <Outlet/>
    </div>
  }
}

重构路由定义

如果您不想,您不需要在一个地方定义所有路由。您可以将任何<Route/>及其子项重构为单独的组件。

例如,您可以重构上面的示例以使用两个单独的组件:

#[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()
}

第二个组件是#[component(transparent)],意味着它只返回其数据,而不是视图;同样,它使用.into_inner()来删除view宏添加的一些调试信息,只返回<ParentRoute/>创建的路由定义。

嵌套路由和性能

从概念上讲,所有这些都很好,但再次——有什么大不了的?

性能。

在像Leptos这样的细粒度响应式库中,做尽可能少的渲染工作总是很重要的。因为我们使用真实的DOM节点而不是diff虚拟DOM,我们希望尽可能少地"重新渲染"组件。嵌套路由使这变得极其容易。

想象我的联系人列表示例。如果我从Greg导航到Alice到Bob再回到Greg,联系人信息需要在每次导航时更改。但<ContactList/>永远不应该重新渲染。这不仅节省了渲染性能,还维护了UI中的状态。例如,如果我在<ContactList/>顶部有一个搜索栏,从Greg导航到Alice到Bob不会清除搜索。

实际上,在这种情况下,我们甚至不需要在联系人之间移动时重新渲染<Contact/>组件。路由器只会在我们导航时响应式地更新:id参数,允许我们进行细粒度更新。当我们在联系人之间导航时,我们将更新单个文本节点以更改联系人的姓名、地址等,而不进行_任何_额外的重新渲染。

这个沙盒包括本节和上一节中讨论的几个功能(如嵌套路由),以及我们将在本章其余部分中涵盖的几个功能。路由器是一个如此集成的系统,提供一个单一的示例是有意义的,所以如果有任何您不理解的地方,请不要感到惊讶。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // 这个<nav>将在每个路由上显示,
            // 因为它在<Routes/>之外
            // 注意:我们可以只使用普通的<a>标签
            // 路由器将使用客户端导航
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / 只有一个非嵌套的"Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果没有指定id,回退
                        <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,回退
                        <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">
            // 这里是我们的联系人列表组件本身
            <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/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // 我们可以使用`use_params_map`响应式地访问:id参数
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // 想象我们在这里从API加载数据
    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/>
        </div>
    }
}

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

参数和查询

静态路径对于区分不同页面很有用,但几乎每个应用程序都希望在某个时候通过URL传递数据。

您可以通过两种方式做到这一点:

  1. 命名路由参数,如/users/:id中的id
  2. 命名路由查询,如/search?q=Foo中的q

由于URL的构建方式,您可以从_任何_<Route/>视图访问查询。您可以从定义它们的<Route/>或其任何嵌套子项访问路由参数。

使用几个hook访问参数和查询非常简单:

每个都有一个类型化选项(use_queryuse_params)和一个非类型化选项(use_query_mapuse_params_map)。

非类型化版本保存一个简单的键值映射。要使用类型化版本,在结构体上派生Params trait。

Params是一个非常轻量级的trait,通过对每个字段应用FromStr将字符串的扁平键值映射转换为结构体。由于路由参数和URL查询的扁平结构,它比serde等东西灵活性要低得多;它也为您的二进制文件增加的重量要少得多。

use leptos::Params;
use leptos_router::params::Params;

#[derive(Params, PartialEq)]
struct ContactParams {
    id: Option<usize>,
}

#[derive(Params, PartialEq)]
struct ContactSearch {
    q: Option<String>,
}

注意:Params派生宏位于leptos_router::params::Params

使用stable,您只能在参数中使用Option<T>。如果您使用nightly功能, 您可以使用TOption<T>

现在我们可以在组件中使用它们。想象一个既有参数又有查询的URL,如/contacts/:id?q=Search

类型化版本返回Memo<Result<T, _>>。它是一个Memo,所以它对URL的变化做出反应。它是一个Result,因为参数或查询需要从URL解析,可能有效也可能无效。

use leptos_router::hooks::{use_params, use_query};

let params = use_params::<ContactParams>();
let query = use_query::<ContactSearch>();

// id: || -> usize
let id = move || {
    params
        .read()
        .as_ref()
        .ok()
        .and_then(|params| params.id)
        .unwrap_or_default()
};

非类型化版本返回Memo<ParamsMap>。再次,它是一个Memo来对URL的变化做出反应。ParamsMap的行为很像任何其他映射类型,有一个返回Option<String>.get()方法。

use leptos_router::hooks::{use_params_map, use_query_map};

let params = use_params_map();
let query = use_query_map();

// id: || -> Option<String>
let id = move || params.read().get("id");

这可能会变得有点混乱:派生一个包装Option<_>Result<_>的signal可能涉及几个步骤。但这样做是值得的,原因有两个:

  1. 它是正确的,即,它强制您考虑这些情况,"如果用户没有为这个查询字段传递值怎么办?如果他们传递无效值怎么办?"
  2. 它是高性能的。具体来说,当您在匹配相同<Route/>的不同路径之间导航,只有参数或查询发生变化时,您可以对应用程序的不同部分进行细粒度更新而无需重新渲染。例如,在我们的联系人列表示例中在不同联系人之间导航会对名称字段(以及最终的联系人信息)进行有针对性的更新,而无需替换或重新渲染包装的<Contact/>。这就是细粒度响应式的用途。

这是上一节的相同示例。路由器是一个如此集成的系统,提供一个突出多个功能的单一示例是有意义的,即使我们还没有解释所有功能。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // 这个<nav>将在每个路由上显示,
            // 因为它在<Routes/>之外
            // 注意:我们可以只使用普通的<a>标签
            // 路由器将使用客户端导航
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / 只有一个非嵌套的"Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果没有指定id,回退
                        <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,回退
                        <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">
            // 这里是我们的联系人列表组件本身
            <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/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // 我们可以使用`use_params_map`响应式地访问:id参数
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // 想象我们在这里从API加载数据
    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/>
        </div>
    }
}

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

<A/>组件

客户端导航与普通HTML <a>元素配合得非常好。路由器添加了一个监听器,处理对<a>元素的每次点击,并尝试在客户端处理它,即,不需要再次往返服务器请求HTML。这就是您可能从大多数现代Web应用程序中熟悉的快速"单页应用程序"导航的原因。

路由器在以下情况下会放弃处理<a>点击

  • 点击事件已调用prevent_default()
  • 点击时按住了MetaAltCtrlShift
  • <a>targetdownload属性,或rel="external"
  • 链接与当前位置有不同的来源

换句话说,路由器只有在相当确定可以处理时才会尝试进行客户端导航,它会升级每个<a>元素以获得这种特殊行为。

这也意味着如果您需要选择退出客户端路由,您可以轻松做到。例如,如果您有一个指向同一域上另一个页面的链接,但它不是您的Leptos应用程序的一部分,您可以只使用<a rel="external">来告诉路由器这不是它可以处理的东西。

路由器还提供了一个<A>组件,它做了两件额外的事情:

  1. 正确解析相对嵌套路由。使用普通<a>标签的相对路由可能很棘手。例如,如果您有一个像/post/:id这样的路由,<A href="1">将生成正确的相对路由,但<a href="1"可能不会(取决于它在您的视图中出现的位置)。<A/>相对于它出现的嵌套路由的路径解析路由。
  2. 如果此链接是活动链接(即,它是指向您所在页面的链接),则将aria-current属性设置为page。这对可访问性和样式很有帮助。例如,如果您想在链接指向您当前所在页面时设置不同的颜色,您可以使用CSS选择器匹配此属性。

程序化导航

您在页面之间导航的最常用方法应该是使用<a><form>元素或增强的<A/><Form/>组件。使用链接和表单进行导航是可访问性和优雅降级的最佳解决方案。

不过,有时您会想要程序化导航,即调用可以导航到新页面的函数。在这种情况下,您应该使用use_navigate函数。

let navigate = leptos_router::hooks::use_navigate();
navigate("/somewhere", Default::default());

您几乎永远不应该做像<button on:click=move |_| navigate(/* ... */)>这样的事情。任何导航的on:click都应该是<a>,出于可访问性的原因。

这里的第二个参数是一组NavigateOptions,包括相对于当前路由解析导航的选项(如<A/>组件所做的),在导航堆栈中替换它,包含一些导航状态,以及在导航时维护当前滚动状态。

再次,这是相同的示例。查看相对<A/>组件,并查看index.html中的CSS以查看基于ARIA的样式。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Outlet, ParentRoute, Route, Router, Routes, A};
use leptos_router::hooks::use_params_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1>"Contact App"</h1>
            // 这个<nav>将在每个路由上显示,
            // 因为它在<Routes/>之外
            // 注意:我们可以只使用普通的<a>标签
            // 路由器将使用客户端导航
            <nav>
                <a href="/">"Home"</a>
                <a href="/contacts">"Contacts"</a>
            </nav>
            <main>
                <Routes fallback=|| "Not found.">
                    // / 只有一个非嵌套的"Home"
                    <Route path=path!("/") view=|| view! {
                        <h3>"Home"</h3>
                    }/>
                    // /contacts 有嵌套路由
                    <ParentRoute
                        path=path!("/contacts")
                        view=ContactList
                      >
                        // 如果没有指定id,回退
                        <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,回退
                        <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">
            // 这里是我们的联系人列表组件本身
            <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/>
        </div>
    }
}

#[component]
fn ContactInfo() -> impl IntoView {
    // 我们可以使用`use_params_map`响应式地访问:id参数
    let params = use_params_map();
    let id = move || params.read().get("id").unwrap_or_default();

    // 想象我们在这里从API加载数据
    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/>
        </div>
    }
}

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

<Form/>组件

链接和表单有时看起来完全不相关。但实际上,它们的工作方式非常相似。

在纯HTML中,有三种导航到另一个页面的方法:

  1. 链接到另一个页面的<a>元素:使用GET HTTP方法导航到其href属性中的URL。
  2. <form method="GET">:使用GET HTTP方法导航到其action属性中的URL,并将其输入的表单数据编码在URL查询字符串中。
  3. <form method="POST">:使用POST HTTP方法导航到其action属性中的URL,并将其输入的表单数据编码在请求正文中。

由于我们有客户端路由器,我们可以进行客户端链接导航而不重新加载页面,即,不需要完整的往返服务器。以同样的方式进行客户端表单导航是有意义的。

路由器提供了一个<Form>组件,它像HTML <form>元素一样工作,但使用客户端导航而不是完整的页面重新加载。<Form/>GETPOST请求都兼容。使用method="GET",它将导航到表单数据中编码的URL。使用method="POST",它将发出POST请求并处理服务器的响应。

<Form/>为我们将在后面章节中看到的一些组件(如<ActionForm/><MultiActionForm/>)提供了基础。但它也启用了一些强大的模式。

例如,想象您想创建一个搜索字段,在用户搜索时实时更新搜索结果,无需页面重新加载,但也将搜索存储在URL中,以便用户可以复制并粘贴它与其他人分享结果。

事实证明,我们到目前为止学到的模式使这很容易实现。

async fn fetch_results() {
    // 一些异步函数来获取我们的搜索结果
}

#[component]
pub fn FormExample() -> impl IntoView {
    // 对URL查询字符串的响应式访问
    let query = use_query_map();
    // 搜索存储为?q=
    let search = move || query.read().get("q").unwrap_or_default();
    // 由搜索字符串驱动的资源
    let search_results = Resource::new(search, |_| fetch_results());

    view! {
        <Form method="GET" action="">
            <input type="search" name="q" value=search/>
            <input type="submit"/>
        </Form>
        <Transition fallback=move || ()>
            /* 渲染搜索结果 */
            {todo!()}
        </Transition>
    }
}

每当您点击Submit时,<Form/>将"导航"到?q={search}。但因为这种导航是在客户端完成的,所以没有页面闪烁或重新加载。URL查询字符串更改,这触发search更新。因为searchsearch_results资源的源signal,这触发search_results重新加载其资源。<Transition/>继续显示当前搜索结果,直到新结果加载完成。当它们完成时,它切换到显示新结果。

这是一个很好的模式。数据流极其清晰:所有数据从URL流向资源再流向UI。应用程序的当前状态存储在URL中,这意味着您可以刷新页面或将链接发送给朋友,它将显示您期望的内容。一旦我们引入服务器渲染,这种模式也将被证明是真正容错的:因为它在底层使用<form>元素和URL,即使不在客户端加载您的WASM,它实际上也能很好地工作。

我们实际上可以更进一步,做一些聪明的事情:

view! {
	<Form method="GET" action="">
		<input type="search" name="q" value=search
			oninput="this.form.requestSubmit()"
		/>
	</Form>
}

您会注意到这个版本删除了Submit按钮。相反,我们向输入添加了一个oninput属性。注意这_不是_on:input,它会监听input事件并运行一些Rust代码。没有冒号,oninput是纯HTML属性。所以字符串实际上是JavaScript字符串。this.form给我们输入附加到的表单。requestSubmit()<form>上触发submit事件,它被<Form/>捕获,就像我们点击了Submit按钮一样。现在表单将在每次按键或输入时"导航",以保持URL(因此搜索)与用户输入在他们键入时完全同步。

Live example

Click to open CodeSandbox.

CodeSandbox Source
use leptos::prelude::*;
use leptos_router::components::{Form, Route, Router, Routes};
use leptos_router::hooks::use_query_map;
use leptos_router::path;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <Router>
            <h1><code>"<Form/>"</code></h1>
            <main>
                <Routes fallback=|| "Not found.">
                    <Route path=path!("") view=FormExample/>
                </Routes>
            </main>
        </Router>
    }
}

#[component]
pub fn FormExample() -> impl IntoView {
    // 对URL查询的响应式访问
    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查询字符串
        <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/>将在提交时导航
        <h2>"Manual Submission"</h2>
        <Form method="GET" action="">
            // 输入名称确定查询字符串键
            <input type="text" name="name" value=name/>
            <input type="number" name="number" value=number/>
            <select name="select">
                // `selected`将设置哪个开始被选中
                <option selected=move || select() == "A">
                    "A"
                </option>
                <option selected=move || select() == "B">
                    "B"
                </option>
                <option selected=move || select() == "C">
                    "C"
                </option>
            </select>
            // 提交应该导致客户端导航,
            // 而不是完整重新加载
            <input type="submit"/>
        </Form>
        // 这个<Form/>使用一些JavaScript在
        // 每次输入时提交
        <h2>"Automatic Submission"</h2>
        <Form method="GET" action="">
            <input
                type="text"
                name="name"
                value=name
                // 这个oninput属性将导致表单
                // 在每次输入字段时提交
                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>
            // 提交应该导致客户端导航,
            // 而不是完整重新加载
            <input type="submit"/>
        </Form>
    }
}

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

插曲:样式

任何创建网站或应用程序的人很快就会遇到样式问题。对于小型应用程序,单个 CSS 文件可能足以为您的用户界面设置样式。但随着应用程序的增长,许多开发人员发现普通 CSS 变得越来越难以管理。

一些前端框架(如 Angular、Vue 和 Svelte)提供了内置的方法来将 CSS 限定到特定组件,使得在整个应用程序中管理样式变得更容易,而不会让用于修改一个小组件的样式产生全局影响。其他框架(如 React 或 Solid)不提供内置的 CSS 作用域,而是依赖生态系统中的库来为它们做这件事。Leptos 属于后一阵营:框架本身对 CSS 没有任何意见,但提供了一些工具和原语,允许其他人构建样式库。

以下是为您的 Leptos 应用程序设置样式的几种不同方法,从普通 CSS 开始。

普通 CSS

使用 Trunk 的客户端渲染

trunk 可用于将 CSS 文件和图像与您的站点捆绑在一起。为此,您可以通过在 <head> 中的 index.html 中定义它们来将它们添加为 Trunk 资产。例如,要添加位于 style.css 的 CSS 文件,您可以添加标签 <link data-trunk rel="css" href="./style.css"/>

您可以在 Trunk 文档的资产部分找到更多信息。

使用 cargo-leptos 的服务端渲染

cargo-leptos 模板默认配置为使用 SASS 来捆绑 CSS 文件并在 /pkg/{project_name}.css 输出它们。如果您想加载额外的 CSS 文件,您可以通过将它们导入到该 style.scss 文件中,或者将它们添加到 public 目录来做到这一点。(例如,public/foo.css 的文件在 /foo.css 提供服务。)

要在组件中加载样式表,您可以使用 Stylesheet 组件。

TailwindCSS:实用优先的 CSS

TailwindCSS 是一个流行的实用优先 CSS 库。它允许您通过使用内联实用类来为应用程序设置样式,使用自定义 CLI 工具扫描您的文件以查找 Tailwind 类名并捆绑必要的 CSS。

这允许您编写这样的组件:

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

    view! {
        <main class="my-0 mx-auto max-w-3xl text-center">
            <h2 class="p-6 text-4xl">"Welcome to Leptos with Tailwind"</h2>
            <p class="px-10 pb-10 text-left">"Tailwind will scan your Rust files for Tailwind class names and compile them into a CSS file."</p>
            <button
                class="bg-sky-600 hover:bg-sky-700 px-5 py-3 text-white rounded-lg"
                on:click=move |_| *set_count.write() += 1
            >
                {move || if count.get() == 0 {
                    "Click me!".to_string()
                } else {
                    count.get().to_string()
                }}
            </button>
        </main>
    }
}

一开始设置 Tailwind 集成可能有点复杂,但您可以查看我们的两个示例,了解如何将 Tailwind 与客户端渲染的 trunk 应用程序服务端渲染的 cargo-leptos 应用程序一起使用。cargo-leptos 还有一些内置的 Tailwind 支持,您可以将其用作 Tailwind CLI 的替代方案。

Stylers:编译时 CSS 提取

Stylers 是一个编译时作用域 CSS 库,让您在组件主体中声明作用域 CSS。Stylers 将在编译时将此 CSS 提取到 CSS 文件中,然后您可以将其导入到您的应用程序中,这意味着它不会向您的应用程序的 WASM 二进制大小添加任何内容。

这允许您编写这样的组件:

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

Stylers 让您在 Rust 代码中内联编写 CSS,在编译时提取它,并限定其作用域。Stylance 允许您在组件旁边的 CSS 文件中编写 CSS,将这些文件导入到您的组件中,并将 CSS 类限定到您的组件。

这与 trunkcargo-leptos 的实时重新加载功能配合得很好,因为编辑的 CSS 文件可以立即在浏览器中更新。

import_style!(style, "app.module.scss");

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <div class=style::jumbotron/>
    }
}

您可以直接编辑 CSS 而不会导致 Rust 重新编译。

.jumbotron {
  background: blue;
}

Styled:运行时 CSS 作用域

Styled 是一个与 Leptos 很好集成的运行时作用域 CSS 库。它让您在组件函数主体中声明作用域 CSS,然后在运行时应用这些样式。

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

欢迎贡献

Leptos 对您如何为网站或应用程序设置样式没有意见,但我们很乐意为您试图创建的任何工具提供支持,以使其更容易。如果您正在开发想要添加到此列表中的 CSS 或样式方法,请告诉我们!

元数据

到目前为止,我们渲染的所有内容都在 HTML 文档的 <body> 内。这是有道理的。毕竟,您在网页上看到的所有内容都存在于 <body> 内。

但是,有很多场合您可能想要使用与 UI 相同的响应式原语和组件模式来更新文档 <head> 内的某些内容。

这就是 leptos_meta 包的用武之地。

元数据组件

leptos_meta 提供特殊组件,让您从应用程序中任何地方的组件内部将数据注入到 <head> 中:

<Title/> 允许您从任何组件设置文档的标题。它还接受一个 formatter 函数,可用于对其他页面设置的标题应用相同的格式。例如,如果您在 <App/> 组件中放置 <Title formatter=|text| format!("{text} — My Awesome Site")/>,然后在您的路由上放置 <Title text="Page 1"/><Title text="Page 2"/>,您将得到 Page 1 — My Awesome SitePage 2 — My Awesome Site

<Link/><link> 元素注入到 <head> 中。

<Stylesheet/> 使用您提供的 href 创建 <link rel="stylesheet">

<Style/> 使用您传入的子元素(通常是字符串)创建 <style>。您可以使用它在编译时从另一个文件导入一些自定义 CSS <Style>{include_str!("my_route.css")}</Style>

<Meta/> 让您设置带有描述和其他元数据的 <meta> 标签。

Warning

注意:这些组件应该在应用程序的主体中使用,在组件的某个地方。它们不应该在 <head> 中使用(例如,如果您使用服务端渲染)。与其将 leptos_meta 组件放入 <head> 中,您可以并且应该简单地使用相应的 HTML 元素。

<Script/><script>

leptos_meta 还提供了一个 <Script/> 组件,这里值得暂停一下。我们考虑的所有其他组件都在 <head> 中注入仅限 <head> 的元素。但 <script> 也可以包含在 body 中。

有一个非常简单的方法来确定您应该使用大写 S 的 <Script/> 组件还是小写 s 的 <script> 元素:<Script/> 组件将在 <head> 中渲染,而 <script> 元素将在您将其放置在用户界面 <body> 中的任何地方渲染,与其他普通 HTML 元素一起。这些会导致 JavaScript 在不同时间加载和运行,所以使用适合您需求的那个。

<Body/><Html/>

甚至还有几个元素旨在使语义 HTML 和样式更容易。<Body/><Html/> 旨在允许您向页面上的 <html><body> 标签添加任意属性。您可以在展开运算符({..})之后使用通常的 Leptos 语法添加任意数量的属性,这些属性将直接添加到适当的元素中。

<Html
    {..}
    lang="he"
    dir="rtl"
    data-theme="dark"
/>

元数据和服务端渲染

现在,其中一些在任何场景中都很有用,但其中一些对于搜索引擎优化(SEO)特别重要。确保您有适当的 <title><meta> 标签等内容是至关重要的。现代搜索引擎爬虫确实处理客户端渲染,即作为空 index.html 发送并完全在 JS/WASM 中渲染的应用程序。但它们更喜欢接收您的应用程序已渲染为实际 HTML 的页面,在 <head> 中有元数据。

这正是 leptos_meta 的用途。实际上,在服务端渲染期间,这正是它所做的:收集您通过在整个应用程序中使用其组件声明的所有 <head> 内容,然后将其注入到实际的 <head> 中。

但我有点超前了。我们实际上还没有谈论服务端渲染。下一章将讨论与 JavaScript 库的集成。然后我们将结束客户端的讨论,转向服务端渲染。

与 JavaScript 集成:wasm-bindgenweb_sysHtmlElement

Leptos 提供了各种工具,让您可以构建声明式 Web 应用程序而无需离开框架的世界。响应式系统、componentview 宏以及路由器等功能让您可以构建用户界面而无需直接与浏览器提供的 Web API 交互。它们让您可以直接在 Rust 中完成所有这些工作,这很棒——假设您喜欢 Rust。(如果您已经读到了本书的这一部分,我们假设您喜欢 Rust。)

生态系统 crates 如 leptos-use 提供的出色实用工具集可以让您走得更远,通过为许多 Web API 提供 Leptos 特定的响应式包装器。

尽管如此,在许多情况下您需要直接访问 JavaScript 库或 Web API。本章可以提供帮助。

使用 wasm-bindgen 使用 JS 库

您的 Rust 代码可以编译为 WebAssembly (WASM) 模块并加载到浏览器中运行。但是,WASM 无法直接访问浏览器 API。相反,Rust/WASM 生态系统依赖于从您的 Rust 代码生成绑定到托管它的 JavaScript 浏览器环境。

wasm-bindgen crate 是该生态系统的核心。它提供了一个接口,用于标记 Rust 代码的部分,并用注释告诉它如何调用 JS,以及一个用于生成必要的 JS 胶水代码的 CLI 工具。您一直在不知不觉中使用它:trunkcargo-leptos 都在底层依赖 wasm-bindgen

如果有一个您想从 Rust 调用的 JavaScript 库,您应该参考 wasm-bindgen 文档中关于从 JS 导入函数的内容。从 JavaScript 导入单个函数、类或值以在您的 Rust 应用程序中使用相对容易。

将 JS 库直接集成到您的应用程序中并不总是容易的。特别是,任何依赖于特定 JS 框架(如 React)的库可能很难集成。以某种方式操作 DOM 状态的库(例如,富文本编辑器)也应该谨慎使用:Leptos 和 JS 库可能都会假设它们是应用程序状态的最终真相来源,因此您应该小心分离它们的职责。

使用 web-sys 访问 Web API

如果您只需要访问一些浏览器 API 而不引入单独的 JS 库,您可以使用 web_sys crate 来做到这一点。这为浏览器提供的所有 Web API 提供绑定,从浏览器类型和函数到 Rust 结构体和方法的 1:1 映射。

一般来说,如果您问"如何用 Leptos 做 X?"其中 做 X 是访问某些 Web API,查找原生 JavaScript 解决方案并使用 web-sys 文档将其翻译为 Rust 是一个好方法。

在本节之后,您可能会发现wasm-bindgen 指南中关于 web-sys 的章节对于额外阅读很有用。

启用功能

web_sys 大量使用功能门控以保持编译时间较低。如果您想使用其众多 API 之一,您可能需要启用一个功能来使用它。

使用项目所需的功能总是在其文档中列出。例如,要使用 Element::get_bounding_rect_client,您需要启用 DomRectElement 功能。

Leptos 已经启用了一大堆功能 - 如果所需的功能已经在这里启用,您就不必在自己的应用程序中启用它。否则,将其添加到您的 Cargo.toml 中,您就可以开始了!

[dependencies.web-sys]
version = "0.3"
features = ["DomRect"]

但是,随着 JavaScript 标准的发展和 API 的编写,您可能想要使用技术上还不完全稳定的浏览器功能,例如 WebGPUweb_sys 将遵循(可能经常变化的)标准,这意味着不提供稳定性保证。

为了使用这个,您需要添加 RUSTFLAGS=--cfg=web_sys_unstable_apis 作为环境变量。这可以通过将其添加到每个命令或添加到您仓库中的 .cargo/config.toml 来完成。

作为命令的一部分:

RUSTFLAGS=--cfg=web_sys_unstable_apis cargo # ...

.cargo/config.toml 中:

[env]
RUSTFLAGS = "--cfg=web_sys_unstable_apis"

从您的 view 访问原始 HtmlElement

框架的声明式风格意味着您不需要直接操作 DOM 节点来构建用户界面。但是,在某些情况下,您希望直接访问代表视图一部分的底层 DOM 元素。本书关于"非受控输入"的部分展示了如何使用 NodeRef 类型来做到这一点。

NodeRef::get 返回一个正确类型的 web-sys 元素,可以直接操作。

例如,考虑以下内容:

#[component]
pub fn App() -> impl IntoView {
    let node_ref = NodeRef::<Input>::new();

    Effect::new(move |_| {
        if let Some(node) = node_ref.get() {
            leptos::logging::log!("value = {}", node.value());
        }
    });

    view! {
        <input node_ref=node_ref/>
    }
}

在这里的 effect 内部,node 只是一个 web_sys::HtmlInputElement。这允许我们调用任何适当的方法。

(注意这里 .get() 返回一个 Option,因为 NodeRef 在实际创建 DOM 元素时被填充之前是空的。Effects 在组件运行后一个 tick 运行,所以在大多数情况下,当 effect 运行时 <input> 已经被创建了。)

总结第一部分:客户端渲染

到目前为止,我们编写的所有内容几乎完全在浏览器中渲染。当我们使用Trunk创建应用程序时,它使用本地开发服务器提供服务。如果您为生产构建并部署它,它由您使用的任何服务器或CDN提供服务。在任何一种情况下,提供的都是一个HTML页面,其中包含

  1. 您的Leptos应用程序的URL,它已编译为WebAssembly(WASM)
  2. 用于初始化此WASM blob的JavaScript的URL
  3. 一个空的<body>元素

当JS和WASM加载完成后,Leptos将把您的应用程序渲染到<body>中。这意味着在JS/WASM加载并运行之前,屏幕上不会出现任何内容。这有一些缺点:

  1. 它增加了加载时间,因为用户的屏幕在下载额外资源之前是空白的。
  2. 对SEO不利,因为加载时间更长,您提供的HTML没有有意义的内容。
  3. 对于由于某种原因JS/WASM无法加载的用户来说是有问题的(例如,他们在火车上,在WASM完成加载之前刚好进入隧道;他们使用不支持WASM的较旧设备;他们由于某种原因关闭了JavaScript或WASM等)。

这些缺点适用于整个Web生态系统,但特别适用于WASM应用程序。

但是,根据您项目的要求,您可能对这些限制没有问题。

如果您只想部署客户端渲染网站,请跳到"部署"章节 - 在那里,您将找到如何最好地部署Leptos CSR站点的说明。

但是,如果您想在index.html页面中返回的不仅仅是空的<body>标签,该怎么办?使用"服务器端渲染"!

关于这个主题可以(并且可能已经)写整本书,但在其核心,它真的很简单:与其返回空的<body>标签,使用SSR,您将返回一个反映应用程序或站点实际起始状态的初始HTML页面,以便在JS/WASM加载期间,直到它们加载完成,用户可以访问纯HTML版本。

本书的第2部分,关于Leptos SSR,将详细介绍这个主题!

第二部分:服务器端渲染

正如您在上一章中读到的,使用客户端渲染的Web应用程序有一些限制。本书的第二部分将讨论如何使用服务器端渲染来克服这些限制,并从您的Leptos应用程序中获得最佳性能和SEO。

Info

在服务器端使用Leptos时,您可以自由选择官方支持的Actix或Axum集成,或我们社区支持的选择之一。Leptos的完整功能集可用于官方选择,社区选择可能支持较少。查看它们的文档了解详情。

我们有各种社区支持的选择,包括与WinterCG兼容的运行时(如Deno或Cloudflare)和服务器端WASM运行时(如Spin)。Viz和Pavex的社区支持集成提供了更传统的服务器选择。不建议初学者自己编写集成,但中级/高级Rust用户可能希望这样做。如果您对此有疑问,请随时在我们的Discord或Github上联系。

我建议初学者使用Axum或Actix。两者都功能齐全,在它们之间选择是个人偏好问题。没有错误的选择,但如果您正在寻找建议,Leptos团队目前默认为新项目使用Axum。

介绍cargo-leptos

到目前为止,我们只是在浏览器中运行代码,并使用Trunk来协调构建过程和运行本地开发过程。如果我们要添加服务器端渲染,我们还需要在服务器上运行应用程序代码。这意味着我们需要构建两个单独的二进制文件,一个编译为本机代码并运行服务器,另一个编译为WebAssembly(WASM)并在用户的浏览器中运行。此外,服务器需要知道如何将此WASM版本(以及初始化它所需的JavaScript)提供给浏览器。

这不是一个不可克服的任务,但它增加了一些复杂性。为了方便和更好的开发者体验,我们构建了cargo-leptos构建工具。cargo-leptos基本上存在于协调应用程序的构建过程,处理在您进行更改时重新编译服务器和客户端部分,并为Tailwind、SASS和测试等内容添加一些内置支持。

开始使用非常简单。只需运行

cargo install --locked cargo-leptos

然后要创建新项目,您可以运行

# 对于Actix模板
cargo leptos new --git https://github.com/leptos-rs/start-actix

# 对于Axum模板
cargo leptos new --git https://github.com/leptos-rs/start-axum

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

rustup target add wasm32-unknown-unknown

现在cd进入您创建的目录并运行

cargo leptos watch

一旦您的应用程序编译完成,您可以打开浏览器到http://localhost:3000查看它。

cargo-leptos有很多额外的功能和内置工具。您可以在其README了解更多。

但是当您打开浏览器到localhost:3000时到底发生了什么?好吧,继续阅读找出答案。

页面加载的生命周期

在我们深入细节之前,有一个更高层次的概述可能会有所帮助。从您输入服务器渲染的Leptos应用程序的URL的那一刻,到您点击按钮并且计数器增加的那一刻,到底发生了什么?

我在这里假设您对互联网如何工作有一些基本知识,不会深入HTTP或其他细节。相反,我将尝试展示Leptos API的不同部分如何映射到过程的每个部分。

这个描述也从您的应用程序正在为两个单独的目标编译的前提开始:

  1. 服务器版本,通常在Actix或Axum上运行,使用Leptos ssr功能编译
  2. 浏览器版本,使用Leptos hydrate功能编译为WebAssembly(WASM)

cargo-leptos构建工具存在于协调为这两个不同目标编译应用程序的过程。

在服务器上

  • 您的浏览器向您的服务器发出该URL的GET请求。此时,浏览器对即将渲染的页面几乎一无所知。("浏览器如何知道在哪里请求页面?"这是一个有趣的问题,但超出了本教程的范围!)
  • 服务器接收该请求,并检查它是否有处理该路径上GET请求的方法。这就是leptos_axumleptos_actix中的.leptos_routes()方法的用途。当服务器启动时,这些方法遍历您在<Routes/>中提供的路由结构,生成应用程序可以处理的所有可能路由的列表,并告诉服务器的路由器"对于这些路由中的每一个,如果您收到请求...将其交给Leptos。"
  • 服务器看到这个路由可以由Leptos处理。所以它渲染您的根组件(通常称为<App/>之类的东西),为其提供正在请求的URL和一些其他数据,如HTTP标头和请求元数据。
  • 您的应用程序在服务器上运行一次,构建将在该路由渲染的组件树的HTML版本。(在下一章中,关于资源和<Suspense/>还有更多要说的。)
  • 服务器返回此HTML页面,还注入有关如何加载已编译为WASM的应用程序版本的信息,以便它可以在浏览器中运行。

返回的HTML页面本质上是您的应用程序,"脱水"或"冻干":它是没有您添加的任何响应性或事件监听器的HTML。浏览器将通过添加响应式系统并将事件监听器附加到该服务器渲染的HTML来"重新水合"此HTML页面。因此,适用于此过程两半的两个功能标志:服务器上的ssr用于"服务器端渲染",浏览器中的hydrate用于重新水合过程。

在浏览器中

  • 浏览器从服务器接收此HTML页面。它立即回到服务器开始加载运行应用程序的交互式客户端版本所需的JS和WASM。
  • 与此同时,它渲染HTML版本。
  • 当WASM版本重新加载时,它执行与服务器相同的路由匹配过程。因为<Routes/>组件在服务器和客户端中是相同的,浏览器版本将读取URL并渲染与服务器已经返回的相同页面。
  • 在这个初始"水合"阶段,应用程序的WASM版本不会重新创建构成应用程序的DOM节点。相反,它遍历现有的HTML树,"拾取"现有元素并添加必要的交互性。

请注意,这里有一些权衡。在此水合过程完成之前,页面将_看起来_是交互式的,但实际上不会响应交互。例如,如果您有一个计数器按钮并在WASM加载之前点击它,计数不会增加,因为必要的事件监听器和响应性尚未添加。我们将在未来的章节中查看一些构建"优雅降级"的方法。

客户端导航

下一步非常重要。想象用户现在点击链接导航到应用程序中的另一个页面。

浏览器_不会_再次往返服务器,重新加载整个页面,就像在普通HTML页面之间导航或使用服务器渲染(例如使用PHP)但没有客户端部分的应用程序一样。

相反,应用程序的WASM版本将在浏览器中加载新页面,而不从服务器请求另一个页面。本质上,您的应用程序将自己从服务器加载的"多页应用程序"升级为浏览器渲染的"单页应用程序"。这产生了两全其美的效果:由于服务器渲染的HTML而快速的初始加载时间,以及由于客户端路由而快速的二次导航。

以下章节中将描述的一些内容——如服务器函数、资源和<Suspense/>之间的交互——可能看起来过于复杂。您可能会发现自己在问,"如果我的页面在服务器上渲染为HTML,为什么我不能在服务器上.await这个?如果我可以在服务器函数中调用库X,为什么我不能在组件中调用它?"原因很简单:为了启用从服务器渲染到客户端渲染的升级,应用程序中的所有内容都必须能够在客户端和服务器上运行。

当然,这不是创建网站或Web框架的唯一方法。但这是最常见的方法,我们恰好认为这是为用户创造最流畅体验的相当好的方法。

异步渲染和SSR"模式"

服务器渲染仅使用同步数据的页面非常简单:您只需遍历组件树,将每个元素渲染为HTML字符串。但这是一个相当大的警告:它没有回答我们应该如何处理包含异步数据的页面的问题,即在客户端上会在<Suspense/>节点下渲染的那种内容。

当页面加载需要渲染的异步数据时,我们应该怎么做?我们应该等待所有异步数据加载,然后一次渲染所有内容吗?(让我们称之为"异步"渲染)我们应该走向完全相反的方向,只是立即将我们拥有的HTML发送到客户端,让客户端加载资源并填充它们吗?(让我们称之为"同步"渲染)或者有一些中间解决方案以某种方式击败它们两个?(提示:有的。)

如果您曾经听过流媒体音乐或在线观看视频,我相信您意识到HTTP支持流式传输,允许单个连接一个接一个地发送数据块,而无需等待完整内容加载。您可能没有意识到浏览器也非常擅长渲染部分HTML页面。综合起来,这意味着您实际上可以通过流式HTML来增强用户体验:这是Leptos开箱即用支持的,无需任何配置。实际上有不止一种流式HTML的方法:您可以按顺序流式传输构成页面的HTML块,就像视频帧一样,或者您可以...嗯,无序地流式传输它们。

让我多说一点我的意思。

Leptos支持所有包含异步数据的HTML渲染的主要方式:

  1. 同步渲染
  2. 异步渲染
  3. 按序流式传输
  4. 无序流式传输(和部分阻塞变体)

同步渲染

  1. 同步:提供包含任何<Suspense/>fallback的HTML外壳。使用create_local_resource在客户端加载数据,一旦资源加载就替换fallback
  • 优点:应用外壳出现得非常快:很好的TTFB(首字节时间)。
  • 缺点
    • 资源加载相对较慢;您需要等待JS + WASM加载后才能发出请求。
    • 无法在<title>或其他<meta>标签中包含来自异步资源的数据,损害SEO和社交媒体链接预览等功能。

如果您使用服务器端渲染,从性能角度来看,同步模式几乎从来不是您真正想要的。这是因为它错过了一个重要的优化。如果您在服务器渲染期间加载异步资源,您实际上可以开始在服务器上加载数据。而不是等待客户端接收HTML响应,然后加载其JS + WASM,_然后_意识到它需要资源并开始加载它们,服务器渲染实际上可以在客户端首次发出响应时开始加载资源。从这个意义上说,在服务器渲染期间,异步资源就像一个在服务器上开始加载并在客户端解析的Future。只要资源实际上是可序列化的,这总是会导致更快的总加载时间。

这就是为什么Resource需要其数据是可序列化的,以及为什么您应该对任何不可序列化的异步数据使用LocalResource,因此应该只在浏览器本身中加载。当您可以创建可序列化资源时创建本地资源总是一种去优化。

异步渲染

  1. async:在服务器上加载所有资源。等到所有数据加载完毕,然后一次性渲染HTML。
  • 优点:更好地处理meta标签(因为您甚至在渲染<head>之前就知道异步数据)。比同步更快的完整加载,因为异步资源在服务器上开始加载。
  • 缺点:较慢的加载时间/TTFB:您需要等待所有异步资源加载后才能在客户端显示任何内容。页面完全空白,直到所有内容加载完毕。

按序流式传输

  1. 按序流式传输:遍历组件树,渲染HTML直到遇到<Suspense/>。将到目前为止获得的所有HTML作为流中的块发送下去,等待在<Suspense/>下访问的所有资源加载,然后将其渲染为HTML并继续遍历,直到遇到另一个<Suspense/>或页面结束。
  • 优点:而不是空白屏幕,在数据准备好之前至少显示_某些内容_。
  • 缺点
    • 比同步渲染(或无序流式传输)加载外壳更慢,因为它需要在每个<Suspense/>处暂停。
    • 无法显示<Suspense/>的fallback状态。
    • 在整个页面加载完毕之前无法开始水合,因此页面的早期部分在挂起的块加载之前不会是交互式的。

无序流式传输

  1. 无序流式传输:像同步渲染一样,提供包含任何<Suspense/>fallback的HTML外壳。但在服务器上加载数据,在解析时将其流式传输到客户端,并为<Suspense/>节点流式传输HTML,这被交换以替换fallback。
  • 优点:结合了同步和**async**的最佳特性。
    • 快速的初始响应/TTFB,因为它立即发送整个同步外壳
    • 快速的总时间,因为资源在服务器上开始加载。
    • 能够显示fallback加载状态并动态替换它,而不是为未加载的数据显示空白部分。
  • 缺点:需要启用JavaScript才能使挂起的片段以正确顺序出现。(这个小的JS块与包含渲染的<Suspense/>片段的<template>标签一起在<script>标签中流式传输,因此它不需要加载任何额外的JS文件。)
  1. 部分阻塞流式传输:当您在页面上有多个单独的<Suspense/>组件时,"部分阻塞"流式传输很有用。它通过在路由上设置ssr=SsrMode::PartiallyBlocked并依赖视图内的阻塞资源来触发。如果其中一个<Suspense/>组件从一个或多个"阻塞资源"(见下文)读取,fallback将不会被发送;相反,服务器将等待直到该<Suspense/>解析,然后在服务器上用解析的片段替换fallback,这意味着它包含在初始HTML响应中,即使JavaScript被禁用或不支持也会出现。其他<Suspense/>无序流入,类似于SsrMode::OutOfOrder默认值。

当您在页面上有多个<Suspense/>,其中一个比另一个更重要时,这很有用:想想博客文章和评论,或产品信息和评论。如果只有一个<Suspense/>,或者每个<Suspense/>都从阻塞资源读取,这_不_有用。在这些情况下,它是async渲染的较慢形式。

  • 优点:如果JavaScript在用户设备上被禁用或不支持,仍然有效。
  • 缺点
    • 比无序更慢的初始响应时间。
    • 由于服务器上的额外工作,总体响应稍慢。
    • 不显示fallback状态。

使用SSR模式

因为它提供了最佳的性能特征组合,Leptos默认为无序流式传输。但选择这些不同模式真的很简单。您通过在一个或多个<Route/>组件上添加ssr属性来做到这一点,就像在ssr_modes示例中一样。

<Routes fallback=|| "Not found.">
	// 我们将使用无序流式传输和<Suspense/>加载主页
	<Route path=path!("") view=HomePage/>

	// 我们将使用异步渲染加载帖子,这样它们可以在
	// 加载数据*之后*设置标题和元数据
	<Route
		path=path!("/post/:id")
		view=BlogPost
		ssr=SsrMode::Async
	/>
</Routes>

对于包含多个嵌套路由的路径,将使用最严格的模式:即,如果甚至单个嵌套路由要求async渲染,整个初始请求将被async渲染。async是最严格的要求,其次是按序,然后是无序。(如果您考虑几分钟,这可能是有道理的。)

阻塞资源

阻塞资源可以用Resource::new_blocking创建。阻塞资源仍然像Rust中的任何其他async/.await一样异步加载。它不会阻塞服务器线程或类似的东西。相反,在<Suspense/>下从阻塞资源读取会阻塞HTML_流_返回任何内容,包括其初始同步外壳,直到该<Suspense/>解析。

从性能角度来看,这不是理想的。您页面的同步外壳都不会加载,直到该资源准备好。但是,不渲染任何内容意味着您可以在实际HTML中设置<head>中的<title><meta>标签等内容。这听起来很像async渲染,但有一个大区别:如果您有多个<Suspense/>部分,您可以阻塞_其中一个_,但仍然渲染占位符,然后流入另一个。

例如,想想博客文章。对于SEO和社交分享,我绝对希望我的博客文章标题和元数据在初始HTML <head>中。但我真的不关心评论是否已经加载;我想尽可能懒惰地加载它们。

使用阻塞资源,我可以做这样的事情:

#[component]
pub fn BlogPost() -> impl IntoView {
    let post_data = Resource::new_blocking(/* 加载博客文章 */);
    let comments_data = Resource::new(/* 加载博客评论 */);
    view! {
        <Suspense fallback=|| ()>
            {move || Suspend::new(async move {
                let data = post_data.await;
                view! {
                    <Title text=data.title/>
                    <Meta name="description" content=data.excerpt/>
                    <article>
                        /* 渲染文章内容 */
                    </article>
                }
            })}
        </Suspense>
        <Suspense fallback=|| "Loading comments...">
            {move || Suspend::new(async move {
                let comments = comments_data.await;
                todo!()
            })}
        </Suspense>
    }
}

第一个<Suspense/>,包含博客文章的正文,将阻塞我的HTML流,因为它从阻塞资源读取。等待阻塞资源的Meta标签和其他head元素将在流发送之前渲染。

结合以下路由定义,使用SsrMode::PartiallyBlocked,阻塞资源将在服务器端完全渲染,使禁用WebAssembly或JavaScript的用户可以访问它。

<Routes fallback=|| "Not found.">
	// 我们将使用无序流式传输和<Suspense/>加载主页
	<Route path=path!("") view=HomePage/>

	// 我们将使用异步渲染加载帖子,这样它们可以在
	// 加载数据*之后*设置标题和元数据
	<Route
		path=path!("/post/:id")
		view=BlogPost
		ssr=SsrMode::PartiallyBlocked
	/>
</Routes>

第二个<Suspense/>,包含评论,不会阻塞流。阻塞资源给了我优化页面SEO和用户体验所需的确切功能和粒度。

水合错误 (以及如何避免它们)

思想实验

让我们尝试一个实验来测试您的直觉。打开一个您正在使用cargo-leptos进行服务器渲染的应用程序。(如果您到目前为止只是使用trunk来玩示例,请克隆一个cargo-leptos模板,仅为了这个练习。)

在您的根组件中放置一个日志。(我通常称我的为<App/>,但任何都可以。)

#[component]
pub fn App() -> impl IntoView {
	logging::log!("where do I run?");
	// ... 其他内容
}

让我们启动它

cargo leptos watch

您期望where do I run?在哪里记录?

  • 在您运行服务器的命令行中?
  • 在您加载页面时的浏览器控制台中?
  • 都不是?
  • 两者都是?

试试看。

...

...

...

好的,考虑剧透警告。

您当然会注意到它在两个地方都记录,假设一切按计划进行。实际上在服务器上它记录两次——首先在初始服务器启动期间,当Leptos渲染您的应用程序一次以提取路由树时,然后在您发出请求时第二次。每次您重新加载页面时,where do I run?应该在服务器上记录一次,在客户端记录一次。

如果您考虑最后几节中的描述,希望这是有道理的。您的应用程序在服务器上运行一次,在那里它构建一个发送到客户端的HTML树。在这个初始渲染期间,where do I run?在服务器上记录。

一旦WASM二进制文件在浏览器中加载,您的应用程序运行第二次,遍历相同的用户界面树并添加交互性。

这听起来像浪费吗?从某种意义上说,是的。但减少这种浪费是一个真正困难的问题。这是一些JS框架(如Qwik)旨在解决的问题,尽管现在判断它是否比其他方法带来净性能增益可能还为时过早。

错误的可能性

好的,希望所有这些都有意义。但这与本章标题"水合错误(以及如何避免它们)"有什么关系?

记住应用程序需要在服务器和客户端上运行。这产生了几组不同的潜在问题,您需要知道如何避免。

服务器和客户端代码之间的不匹配

创建错误的一种方法是在服务器发送的HTML和客户端渲染的内容之间创建不匹配。我认为无意中做到这一点实际上相当困难(至少根据我从人们那里得到的错误报告来判断)。但想象我做这样的事情

#[component]
pub fn App() -> impl IntoView {
    let data = if cfg!(target_arch = "wasm32") {
        vec![0, 1, 2]
    } else {
        vec![]
    };
    data.into_iter()
        .map(|value| view! { <span>{value}</span> })
        .collect_view()
}

换句话说,如果这被编译为WASM,它有三个项目;否则它是空的。

当我在浏览器中加载页面时,我什么也看不到。如果我打开控制台,我看到一个panic:

ssr_modes.js:423 panicked at /.../tachys/src/html/element/mod.rs:352:14:
called `Option::unwrap()` on a `None` value

在浏览器中运行的应用程序的WASM版本期望找到一个元素(实际上,它期望三个元素!)但从服务器发送的HTML没有。

解决方案

您很少故意这样做,但这可能发生在服务器和浏览器上运行不同逻辑的情况下。如果您看到这样的警告并且您认为这不是您的错,更可能是<Suspense/>或其他东西的错误。请随时在GitHub上开启问题讨论寻求帮助。

无效/边缘情况HTML,以及HTML和DOM之间的不匹配

服务器用HTML响应请求。然后浏览器将该HTML解析为称为文档对象模型(DOM)的树。在水合期间,Leptos遍历应用程序的视图树,水合一个元素,然后移动到其子元素,水合第一个子元素,然后移动到其兄弟元素,依此类推。这假设您的应用程序在服务器上产生的HTML树直接映射到浏览器解析该HTML的DOM树。

有几种情况需要注意,其中您的view创建的HTML树和DOM树可能不完全对应:这些可能导致水合错误。

无效HTML

这是一个导致水合错误的非常简单的应用程序:

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <p>
            <div class:blue=move || count.get() == 2>
                 "First"
            </div>
        </p>
    }
}

这将给出类似这样的错误消息

A hydration error occurred while trying to hydrate an element defined at src/app.rs:6:14.

The framework expected a text node, but found this instead:  <p></p>

The hydration mismatch may have occurred slightly earlier, but this is the first time the framework found a node of an unexpected type.

(在大多数浏览器开发工具中,您可以右键单击该<p></p>以显示它在DOM中出现的位置,这很方便。)

如果您在DOM检查器中查看,您会看到它不是<p>内的<div>,而是显示:

<p></p>
<div>First</div>
<p></p>

这是因为这是无效的HTML!<div>不能放在<p>内。当浏览器解析该<div>时,它实际上关闭了前面的<p>,然后打开<div>;然后,当它看到(现在不匹配的)关闭</p>时,它将其视为新的空<p>

结果,我们的DOM树不再匹配预期的视图树,水合错误随之而来。

不幸的是,使用我们当前的模型在编译时确保视图中HTML的有效性是困难的,并且不会对整体编译时间产生影响。现在,如果您遇到这样的问题,请考虑通过验证器运行HTML输出。(在上面的情况下,W3C HTML验证器确实显示了错误!)

Info

您可能会注意到从0.6迁移到0.7时出现一些这样的错误。这是由于水合工作方式的变化。

Leptos 0.1-0.6使用了一种水合方法,其中每个HTML元素都被赋予一个唯一的ID,然后用于通过ID在DOM中找到它。Leptos 0.7开始直接遍历DOM,在遇到每个元素时进行水合。这具有更好的性能特征(更短、更清洁的HTML输出和更快的水合时间),但对上述无效或边缘情况HTML示例的弹性较差。也许更重要的是,这种方法还修复了水合中的许多其他边缘情况和错误,使框架总体上更具弹性。

没有<tbody><table>

我知道还有一个额外的边缘情况,其中有效HTML产生与视图树不同的DOM树,那就是<table>。当(大多数)浏览器解析HTML <table>时,它们会在DOM中插入<tbody>,无论您是否包含它。

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <table>
            <tr>
                <td class:blue=move || count.get() == 0>"First"</td>
            </tr>
        </table>
    }
}

再次,这产生了水合错误,因为浏览器在DOM树中插入了一个额外的<tbody>,而这不在您的视图中。

这里,修复很简单:添加<tbody>

#[component]
pub fn App() -> impl IntoView {
    let count = RwSignal::new(0);

    view! {
        <table>
            <tbody>
                <tr>
                    <td class:blue=move || count.get() == 0>"First"</td>
                </tr>
            </tbody>
        </table>
    }
}

(将来值得探索我们是否可以比检查有效HTML更容易地检查这个特定的怪癖。)

一般建议

这种不匹配可能很棘手。一般来说,我的调试建议:

  1. 右键单击消息中的元素,查看框架首次注意到问题的位置。
  2. 比较该点及其上方的DOM,检查与您的视图树的不匹配。是否有额外的元素?缺少元素?

不是所有客户端代码都能在服务器上运行

想象您愉快地导入一个像gloo-net这样的依赖项,您习惯于在浏览器中使用它来发出请求,并在服务器渲染应用程序的create_resource中使用它。

您可能会立即看到可怕的消息

panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets'

哦不。

但当然这是有道理的。我们刚刚说过您的应用程序需要在客户端和服务器上运行。

解决方案

有几种方法可以避免这种情况:

  1. 只使用可以在服务器和客户端上运行的库。例如,reqwest适用于在两种设置中发出HTTP请求。
  2. 在服务器和客户端上使用不同的库,并使用#[cfg]宏对它们进行门控。(点击这里查看示例。)
  3. 将仅客户端代码包装在Effect::new中。因为effect只在客户端运行,这可以是访问初始渲染不需要的浏览器API的有效方法。

例如,假设我想在signal更改时在浏览器的localStorage中存储某些内容。

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
	let storage = gloo_storage::LocalStorage::raw();
	logging::log!("{storage:?}");
}

这会panic,因为我无法在服务器渲染期间访问LocalStorage

但如果我将其包装在effect中...

#[component]
pub fn App() -> impl IntoView {
    use gloo_storage::Storage;
    Effect::new(move |_| {
        let storage = gloo_storage::LocalStorage::raw();
		log!("{storage:?}");
    });
}

没问题!这将在服务器上适当渲染,忽略仅客户端代码,然后在浏览器上访问存储并记录消息。

不是所有服务器代码都能在客户端上运行

在浏览器中运行的WebAssembly是一个相当有限的环境。您无法访问文件系统或标准库可能习惯拥有的许多其他东西。不是每个crate都可以编译为WASM,更不用说在WASM环境中运行了。

特别是,您有时会看到关于crate miocore中缺少内容的错误。这通常表明您正在尝试将无法编译为WASM的内容编译为WASM。如果您要添加仅服务器依赖项,您需要在Cargo.toml中将它们标记为optional = true,然后在ssr功能定义中启用它们。(查看模板Cargo.toml文件之一以查看更多详细信息。)

您可以创建一个Effect来指定某些内容应该只在客户端运行,而不在服务器上运行。有没有办法指定某些内容应该只在服务器上运行,而不在客户端上运行?

实际上,有的。下一章将详细介绍服务器函数的主题。(与此同时,您可以在这里查看它们的文档。)

与服务器协作

上一节描述了服务器端渲染的过程,使用服务器生成页面的HTML版本,该版本将在浏览器中变为交互式。到目前为止,一切都是"同构的";换句话说,您的应用程序在客户端和服务器上具有"相同(iso)形状(morphe)"。

但服务器可以做的远不止渲染HTML!实际上,服务器可以做很多您的浏览器_无法_做的事情,比如从SQL数据库读取和写入。

如果您习惯于构建JavaScript前端应用程序,您可能习惯于调用某种REST API来完成这种服务器工作。如果您习惯于使用PHP或Python或Ruby(或Java或C#或...)构建站点,这种服务器端工作是您的基本功,而客户端交互性往往是事后考虑的。

使用Leptos,您可以两者兼得:不仅使用相同的语言,不仅共享相同的类型,甚至在相同的文件中!

本节将讨论如何构建应用程序中独特的服务器端部分。

服务器函数

如果您正在创建任何超出玩具应用程序的东西,您将需要一直在服务器上运行代码:从只在服务器上运行的数据库读取或写入,使用您不想发送到客户端的库运行昂贵的计算,访问由于CORS原因需要从服务器而不是客户端调用的API,或者因为您需要存储在服务器上的秘密API密钥,绝对不应该发送到用户的浏览器。

传统上,这是通过分离服务器和客户端代码,并设置REST API或GraphQL API之类的东西来允许客户端获取和变更服务器上的数据来完成的。这很好,但它要求您在多个单独的地方编写和维护代码(用于获取的客户端代码,要运行的服务器端函数),以及创建第三个要管理的东西,即两者之间的API契约。

Leptos是引入服务器函数概念的许多现代框架之一。服务器函数有两个关键特征:

  1. 服务器函数与您的组件代码共同定位,以便您可以按功能而不是按技术组织工作。例如,您可能有一个"暗模式"功能,应该在会话之间持久化用户的暗/亮模式偏好,并在服务器渲染期间应用,这样就没有闪烁。这需要一个需要在客户端交互的组件,以及一些要在服务器上完成的工作(设置cookie,甚至可能在数据库中存储用户)。传统上,这个功能可能最终被分割在代码的两个不同位置,一个在您的"前端",一个在您的"后端"。使用服务器函数,您可能只需在一个dark_mode.rs中编写它们,然后忘记它。

  2. 服务器函数是同构的,即,它们可以从服务器或浏览器调用。这是通过为两个平台生成不同的代码来完成的。在服务器上,服务器函数简单地运行。在浏览器中,服务器函数的主体被替换为实际向服务器发出fetch请求的存根,将参数序列化到请求中,并从响应中反序列化返回值。但在任一端,函数都可以简单地调用:您可以创建一个写入数据库的add_todo函数,并简单地从浏览器中按钮的点击处理程序调用它!

使用服务器函数

实际上,我有点喜欢那个例子。它会是什么样子?实际上很简单。

// todo.rs

#[server]
pub async fn add_todo(title: String) -> Result<(), ServerFnError> {
    let mut conn = db().await?;

    match sqlx::query("INSERT INTO todos (title, completed) VALUES ($1, false)")
        .bind(title)
        .execute(&mut conn)
        .await
    {
        Ok(_row) => Ok(()),
        Err(e) => Err(ServerFnError::ServerError(e.to_string())),
    }
}

#[component]
pub fn BusyButton() -> impl IntoView {
	view! {
        <button on:click=move |_| {
            spawn_local(async {
                add_todo("So much to do!".to_string()).await;
            });
        }>
            "Add Todo"
        </button>
	}
}

您会立即注意到几件事:

  • 服务器函数可以使用仅服务器依赖项,如sqlx,并可以访问仅服务器资源,如我们的数据库。
  • 服务器函数是async的。即使它们只在服务器上进行同步工作,函数签名仍然需要是async,因为从浏览器调用它们_必须_是异步的。
  • 服务器函数返回Result<T, ServerFnError>。再次,即使它们只在服务器上进行不会失败的工作,这也是真的,因为ServerFnError的变体包括在发出网络请求过程中可能出错的各种事情。
  • 服务器函数可以从客户端调用。看看我们的点击处理程序。这是_只会_在客户端运行的代码。但它可以调用函数add_todo(使用spawn_local运行Future),就像它是一个普通的异步函数一样:
move |_| {
	spawn_local(async {
		add_todo("So much to do!".to_string()).await;
	});
}
  • 服务器函数是用fn定义的顶级函数。与事件监听器、派生signal和Leptos中的大多数其他东西不同,它们不是闭包!作为fn调用,它们无法访问应用程序的响应式状态或任何其他未作为参数传入的内容。再次,这完全有道理:当您向服务器发出请求时,服务器无法访问客户端状态,除非您明确发送它。(否则我们必须序列化整个响应式系统并在每个请求中通过网络发送它。这不是一个好主意。)
  • 服务器函数参数和返回值都需要是可序列化的。再次,希望这有道理:虽然函数参数通常不需要序列化,但从浏览器调用服务器函数意味着序列化参数并通过HTTP发送它们。

关于定义服务器函数的方式,也有一些需要注意的事情。

  • 服务器函数是通过使用#[server]注释顶级函数来创建的,该函数可以在任何地方定义。

服务器函数通过使用条件编译工作。在服务器上,服务器函数创建一个HTTP端点,该端点接收其参数作为HTTP请求,并将其结果作为HTTP响应返回。对于客户端/浏览器构建,服务器函数的主体被HTTP请求存根替换。

Warning

关于安全性的重要说明

服务器函数是一项很酷的技术,但记住这一点非常重要。**服务器函数不是魔法;它们是定义公共API的语法糖。**服务器函数的_主体_永远不会公开;它只是服务器二进制文件的一部分。但服务器函数是一个可公开访问的API端点,其返回值只是JSON或类似的blob。除非信息是公开的,或者您已经实施了适当的安全程序,否则不要从服务器函数返回信息。这些程序可能包括验证传入请求、确保适当的加密、限制访问速率等。

自定义服务器函数

默认情况下,服务器函数将其参数编码为HTTP POST请求(使用serde_qs),将其返回值编码为JSON(使用serde_json)。此默认值旨在促进与<form>元素的兼容性,该元素对发出POST请求有本机支持,即使在WASM被禁用、不支持或尚未加载时也是如此。它们将其端点挂载在旨在防止名称冲突的哈希URL上。

但是,有许多方法可以自定义服务器函数,支持各种输入和输出编码,设置特定端点的能力等等。

查看#[server]server_fn crate的文档,以及repo中广泛的server_fns_axum示例,以获取更多信息和示例。

使用自定义错误

服务器函数可以返回任何实现FromServerFnError trait的错误类型。 这使错误处理更加符合人体工程学,并允许您向客户端提供特定于域的错误信息:

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> {
    // 尝试在数据库中创建用户
    match insert_user_into_db(&name, &email).await {
        Ok(user) => Ok(user),
        Err(e) => Err(AppError::DbError(e.to_string())),
    }
}

需要注意的怪癖

服务器函数有一些值得注意的怪癖:

  • 使用指针大小的整数类型(如isizeusize)在32位WASM架构和64位服务器架构之间进行调用时可能导致错误;如果服务器响应的值不适合32位,这将导致反序列化错误。使用固定大小类型(如i32i64)来缓解此问题。
  • 默认情况下,发送到服务器的参数使用serde_qs进行URL编码。这允许它们与<form>元素很好地配合使用,但可能有一些怪癖:例如,当前版本的serde_qs并不总是与可选类型很好地配合使用(参见这里这里)或与具有元组变体的枚举配合使用(参见这里)。您可以使用这些问题中描述的解决方法,或切换到替代输入编码

将服务器函数与Leptos集成

到目前为止,我所说的一切实际上都是框架无关的。(实际上,Leptos服务器函数crate也已经集成到Dioxus中!)服务器函数只是定义类似函数的RPC调用的一种方式,它依赖于HTTP请求和URL编码等Web标准。

但在某种程度上,它们也提供了我们到目前为止故事中最后缺失的原语。因为服务器函数只是一个普通的Rust异步函数,它与我们之前讨论的异步Leptos原语完美集成。所以您可以轻松地将服务器函数与应用程序的其余部分集成:

  • 创建调用服务器函数从服务器加载数据的资源
  • <Suspense/><Transition/>下读取这些资源,以启用流式SSR和数据加载时的回退状态。
  • 创建调用服务器函数在服务器上变更数据的操作

本书的最后一节将通过引入使用渐进增强HTML表单运行这些服务器操作的模式,使这一点更加具体。

但在接下来的几章中,我们实际上将看看您可能想要对服务器函数做的一些细节,包括与Actix和Axum服务器框架提供的强大提取器集成的最佳方法。

提取器

我们在上一章中看到的服务器函数展示了如何在服务器上运行代码,并将其与您在浏览器中渲染的用户界面集成。但它们没有向您展示如何真正充分利用服务器的潜力。

服务器框架

我们称Leptos为"全栈"框架,但"全栈"总是用词不当(毕竟,它从来不意味着从浏览器到您的电力公司的一切)。对我们来说,"全栈"意味着您的Leptos应用程序可以在浏览器中运行,可以在服务器上运行,并且可以集成两者,汇集每个中可用的独特功能;正如我们在本书中到目前为止所看到的,浏览器上的按钮点击可以驱动服务器上的数据库读取,两者都写在同一个Rust模块中。但Leptos本身不提供服务器(或数据库,或操作系统,或固件,或电缆...)

相反,Leptos为两个最流行的Rust Web服务器框架提供集成,Actix Web(leptos_actix)和Axum(leptos_axum)。我们已经与每个服务器的路由器构建了集成,以便您可以简单地使用.leptos_routes()将Leptos应用程序插入现有服务器,并轻松处理服务器函数调用。

如果您还没有看过我们的ActixAxum模板,现在是查看它们的好时机。

使用提取器

Actix和Axum处理程序都建立在提取器的相同强大理念上。提取器从HTTP请求中"提取"类型化数据,允许您轻松访问特定于服务器的数据。

Leptos提供extract辅助函数,让您直接在服务器函数中使用这些提取器,语法非常方便,与每个框架的处理程序非常相似。

Actix提取器

leptos_actix中的extract函数将处理程序函数作为其参数。处理程序遵循与Actix处理程序类似的规则:它是一个异步函数,接收将从请求中提取的参数并返回某个值。处理程序函数接收提取的数据作为其参数,并可以在async move块的主体内对它们进行进一步的async工作。它返回您返回到服务器函数中的任何值。

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct MyQuery {
    foo: String,
}

#[server]
pub async fn actix_extract() -> Result<String, ServerFnError> {
    use actix_web::dev::ConnectionInfo;
    use actix_web::web::Query;
    use leptos_actix::extract;

    let (Query(search), connection): (Query<MyQuery>, ConnectionInfo) = extract().await?;
    Ok(format!("search = {search:?}\nconnection = {connection:?}",))
}

Axum提取器

leptos_axum::extract函数的语法非常相似。

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct MyQuery {
    foo: String,
}

#[server]
pub async fn axum_extract() -> Result<String, ServerFnError> {
    use axum::{extract::Query, http::Method};
    use leptos_axum::extract;

    let (method, query): (Method, Query<MyQuery>) = extract().await?;

    Ok(format!("{method:?} and {query:?}"))
}

这些是访问服务器基本数据的相对简单的示例。但您可以使用提取器访问标头、cookie、数据库连接池等内容,使用完全相同的extract()模式。

Axum extract函数仅支持状态为()的提取器。如果您需要使用State的提取器,您应该使用extract_with_state。这要求您提供状态。您可以通过使用Axum FromRef模式扩展现有的LeptosOptions状态来做到这一点,该模式在渲染和具有自定义处理程序的服务器函数期间将状态作为上下文提供。

use axum::extract::FromRef;

/// 派生FromRef以允许状态中的多个项目,使用Axum的
/// SubStates模式。
#[derive(FromRef, Debug, Clone)]
pub struct AppState{
    pub leptos_options: LeptosOptions,
    pub pool: SqlitePool
}

点击这里查看在自定义处理程序中提供上下文的示例

Axum状态

Axum的典型依赖注入模式是提供State,然后可以在路由处理程序中提取。Leptos通过上下文提供自己的依赖注入方法。上下文通常可以用来代替State来提供共享服务器数据(例如,数据库连接池)。

let connection_pool = /* 这里是一些共享状态 */;

let app = Router::new()
    .leptos_routes_with_context(
        &leptos_options,
        routes,
        move || provide_context(connection_pool.clone()),
        {
            let leptos_options = leptos_options.clone();
            move || shell(leptos_options.clone())
        },
    )
    // 等等。

然后可以在服务器函数内使用简单的use_context::<T>()访问此上下文。

如果您_需要_在服务器函数中使用State——例如,如果您有一个需要State的现有Axum提取器——这也可以使用Axum的FromRef模式和extract_with_state。基本上,您需要通过上下文和Axum路由器状态提供状态:

#[derive(FromRef, Debug, Clone)]
pub struct MyData {
    pub value: usize,
    pub leptos_options: LeptosOptions,
}

let app_state = MyData {
    value: 42,
    leptos_options,
};

// 构建我们的应用程序与路由
let app = Router::new()
    .leptos_routes_with_context(
        &app_state,
        routes,
        {
            let app_state = app_state.clone();
            move || provide_context(app_state.clone())
        },
        App,
    )
    .fallback(file_and_error_handler)
    .with_state(app_state);

// ...
#[server]
pub async fn uses_state() -> Result<(), ServerFnError> {
    let state = expect_context::<AppState>();
    let SomeStateExtractor(data) = extract_with_state(&state).await?;
    // todo
}

关于数据加载模式的说明

因为Actix和(特别是)Axum建立在单次往返HTTP请求和响应的理念上,您通常在应用程序的"顶部"(即,在开始渲染之前)运行提取器,并使用提取的数据来确定应该如何渲染。在渲染<button>之前,您加载应用程序可能需要的所有数据。任何给定的路由处理程序都需要知道该路由需要提取的所有数据。

但Leptos集成了客户端和服务器,重要的是能够使用来自服务器的新数据刷新UI的小部分,而不强制完全重新加载所有数据。所以Leptos喜欢将数据加载"向下"推到您的应用程序中,尽可能接近用户界面的叶子。当您点击<button>时,它可以只刷新它需要的数据。这正是服务器函数的用途:它们为您提供对要加载和重新加载的数据的细粒度访问。

extract()函数让您通过在服务器函数中使用提取器来结合两种模型。您可以访问路由提取器的全部功能,同时将需要提取的内容的知识分散到各个组件中。这使得重构和重新组织路由变得更容易:您不需要预先指定路由需要的所有数据。

响应和重定向

提取器提供了在服务器函数内访问请求数据的简单方法。Leptos还提供了修改HTTP响应的方法,使用ResponseOptions类型(参见ActixAxum类型的文档)和redirect辅助函数(参见ActixAxum的文档)。

ResponseOptions

ResponseOptions在初始服务器渲染响应期间和任何后续服务器函数调用期间通过上下文提供。它允许您轻松设置HTTP响应的状态码,或向HTTP响应添加标头,例如,设置cookie。

#[server]
pub async fn tea_and_cookies() -> Result<(), ServerFnError> {
    use actix_web::{
        cookie::Cookie,
        http::header::HeaderValue,
        http::{header, StatusCode},
    };
    use leptos_actix::ResponseOptions;

    // 从上下文中拉取ResponseOptions
    let response = expect_context::<ResponseOptions>();

    // 设置HTTP状态码
    response.set_status(StatusCode::IM_A_TEAPOT);

    // 在HTTP响应中设置cookie
    let cookie = Cookie::build("biscuits", "yes").finish();
    if let Ok(cookie) = HeaderValue::from_str(&cookie.to_string()) {
        response.insert_header(header::SET_COOKIE, cookie);
    }
    Ok(())
}

redirect

对HTTP响应的一个常见修改是重定向到另一个页面。Actix和Axum集成提供了redirect函数来使这变得容易。

#[server]
pub async fn login(
    username: String,
    password: String,
    remember: Option<String>,
) -> Result<(), ServerFnError> {
    // 从上下文中拉取DB池和认证提供者
    let pool = pool()?;
    let auth = auth()?;

    // 检查用户是否存在
    let user: User = User::get_from_username(username, &pool)
        .await
        .ok_or_else(|| {
            ServerFnError::ServerError("User does not exist.".into())
        })?;

    // 检查用户是否提供了正确的密码
    match verify(password, &user.password)? {
        // 如果密码正确...
        true => {
            // 登录用户
            auth.login_user(user.id);
            auth.remember_user(remember.is_some());

            // 并重定向到主页
            leptos_axum::redirect("/");
            Ok(())
        }
        // 如果不正确,返回错误
        false => Err(ServerFnError::ServerError(
            "Password does not match.".to_string(),
        )),
    }
}

然后可以从您的应用程序使用此服务器函数。这个redirect与渐进增强的<ActionForm/>组件配合得很好:没有JS/WASM时,服务器响应将因为状态码和标头而重定向。有JS/WASM时,<ActionForm/>将检测服务器函数响应中的重定向,并使用客户端导航重定向到新页面。

Progressive Enhancement (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, 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 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:

  • 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?

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.

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. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

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/> 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. Define a server function using the #[server] macro (see Server Functions.)
  2. Create an action using ServerAction::new(), specifying the type of the server function you’ve defined.
  3. Create an <ActionForm/>, providing the server action in the action prop.
  4. Pass the named arguments to the server function as form fields with the same names.

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` matches the `title` argument to `add_todo`
                <input type="text" name="title"/>
            </label>
            <input type="submit" value="Add"/>
        </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

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.

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);
	// silly example of validation: if the todo is "nope!", nope it
	if data.is_err() || data.unwrap().title == "nope!" {
		// ev.prevent_default() will prevent form submission
		ev.prevent_default();
	}
}

// ... add the `submit` handler to an `ActionForm`

<ActionForm on:submit:capture=on_submit /* ... */>

Note

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

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");
    aseert_eq!(hefty_arg.settings.display_name.as_str(), "my alias");
    Ok(())
}

Deployment

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. Remember: Always deploy Rust apps built in --release mode, not debug mode. This has a huge effect on both performance and binary size.
  2. 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.)
  3. 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

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

We've created several example repositories which show how to set up and deploy a Leptos CSR app to various hosting services.

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

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

Example

name: Release to Github Pages

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: write # for committing to gh-pages branch.
  pages: write
  id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
  group: "pages"
  cancel-in-progress: false

jobs:
  Github-Pages-Release:

    timeout-minutes: 10

    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}

    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4 # repo checkout

      # Install Rust Nightly Toolchain, with Clippy & Rustfmt
      - name: Install nightly Rust
        uses: dtolnay/rust-toolchain@nightly
        with:
          components: clippy, rustfmt

      - name: Add WASM target
        run: rustup target add wasm32-unknown-unknown

      - name: lint
        run: cargo clippy & cargo fmt


      # If using tailwind...
      # - name: Download and install tailwindcss binary
      #   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


      - name: Download and install Trunk binary
        run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.4/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-

      - name: Build with Trunk
        # "${GITHUB_REPOSITORY#*/}" evaluates into the name of the repository
        # using --public-url something will allow trunk to modify all the href paths like from favicon.ico to repo_name/favicon.ico .
        # this is necessary for github pages where the site is deployed to username.github.io/repo_name and all files must be requested
        # relatively as favicon.ico. if we skip public-url option, the href paths will instead request username.github.io/favicon.ico which
        # will obviously return error 404 not found.
        run: ./trunk build --release --public-url "${GITHUB_REPOSITORY#*/}"

      # 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

      # Deploy to gh-pages branch
      # - name: Deploy 🚀
      #   uses: JamesIves/github-pages-deploy-action@v4
      #   with:
      #     folder: dist


      # Deploy with Github Static Pages

      - name: Setup Pages
        uses: actions/configure-pages@v4
        with:
          enablement: true
          # token:

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v2
        with:
          # Upload dist dir
          path: './dist'

      - name: Deploy to GitHub Pages 🚀
        id: deployment
        uses: actions/deploy-pages@v3

For more on deploying to Github Pages see the example repo here

Vercel

Step 1: Set Up Vercel

In the Vercel Web UI...

  1. Create a new project
  2. Ensure
    • The "Build Command" is left empty with Override on
    • The "Output Directory" is changed to dist (which is the default output directory for Trunk builds) and the Override is on

Step 2: Add Vercel Credentials for GitHub Actions

Note: Both the preview and deploy actions will need your Vercel credentials setup in GitHub secrets

  1. 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.

  2. Install the Vercel CLI using the npm i -g vercel command, then run vercel login to login to your acccount.

  3. 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.

  4. Inside the generated .vercel folder, open the the project.json file and save the "projectId" and "orgId" for the next step.

  5. Inside GitHub, go the repo's "Settings" > "Secrets and Variables" > "Actions" and add the following as Repository secrets:

    • save your Vercel Access Token (from sub-step 1) as the VERCEL_TOKEN secret
    • from the .vercel/project.json add "projectID" as VERCEL_PROJECT_ID
    • from the .vercel/project.json add "orgId" as VERCEL_ORG_ID

For full instructions see "How can I use Github Actions with Vercel"

Step 3: Add Github Action Scripts

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.

Production deployment script: vercel_deploy.yml

Example

name: Release to Vercel

on:
push:
	branches:
	- main
env:
CARGO_TERM_COLOR: always
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
Vercel-Production-Deployment:
	runs-on: ubuntu-latest
	environment: production
	steps:
	- name: git-checkout
		uses: actions/checkout@v3

	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt
	- uses: Swatinem/rust-cache@v2
	- name: Setup Rust
		run: |
		rustup target add wasm32-unknown-unknown
		cargo clippy
		cargo fmt --check

	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release

	- name: Install Vercel CLI
		run: npm install --global vercel@latest

	- name: Pull Vercel Environment Information
		run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}

	- name: Deploy to Vercel & Display URL
		id: deployment
		working-directory: ./dist
		run: |
		vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }} >> $GITHUB_STEP_SUMMARY
		echo $GITHUB_STEP_SUMMARY

Preview deployments script: vercel_preview.yml

Example

# For more info re: vercel action see:
# https://github.com/amondnet/vercel-action

name: Leptos CSR Vercel Preview

on:
pull_request:
	branches: [ "main" ]

workflow_dispatch:

env:
CARGO_TERM_COLOR: always
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
fmt:
	name: Rustfmt
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: rustfmt
	- name: Enforce formatting
		run: cargo fmt --check

clippy:
	name: Clippy
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy
	- uses: Swatinem/rust-cache@v2
	- name: Linting
		run: cargo clippy -- -D warnings

test:
	name: Test
	runs-on: ubuntu-latest
	needs: [fmt, clippy]
	steps:
	- uses: actions/checkout@v4
	- uses: dtolnay/rust-toolchain@nightly
	- uses: Swatinem/rust-cache@v2
	- name: Run tests
		run: cargo test

build-and-preview-deploy:
	runs-on: ubuntu-latest
	name: Build and Preview

	needs: [test, clippy, fmt]

	permissions:
	pull-requests: write

	environment:
	name: preview
	url: ${{ steps.preview.outputs.preview-url }}

	steps:
	- name: git-checkout
		uses: actions/checkout@v4

	- uses: dtolnay/rust-toolchain@nightly
	- uses: Swatinem/rust-cache@v2
	- name: Build
		run: rustup target add wasm32-unknown-unknown

	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release

	- name: Preview Deploy
		id: preview
		uses: amondnet/vercel-action@v25.1.1
		with:
		vercel-token: ${{ secrets.VERCEL_TOKEN }}
		github-token: ${{ secrets.GITHUB_TOKEN }}
		vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
		vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
		github-comment: true
		working-directory: ./dist

	- name: Display Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.preview.outputs.preview-url }}" >> $GITHUB_STEP_SUMMARY

See the example repo here for more.

Spin - Serverless WebAssembly

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.

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. Open "Fermyon Cloud" > "User Settings". If you’re not logged in, choose the Login With GitHub button.

  2. In the “Personal Access Tokens”, choose “Add a Token”. Enter the name “gh_actions” and click “Create Token”.

  3. Fermyon Cloud displays the token; click the copy button to copy it to your clipboard.

  4. 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"

  5. Copy and paste the following Github Actions scripts (below) into your .github/workflows/<SCRIPT_NAME>.yml files

  6. With the 'preview' and 'deploy' scripts active, Github Actions will now generate previews on pull requests & deploy automatically on updates to your 'main' branch.

Production deployment script: spin_deploy.yml

Example

# For setup instructions needed for Fermyon Cloud, see:
# https://developer.fermyon.com/cloud/github-actions

# For reference, see:
# https://developer.fermyon.com/cloud/changelog/gh-actions-spin-deploy

# For the Fermyon gh actions themselves, see:
# https://github.com/fermyon/actions

name: Release to Spin Cloud

on:
push:
	branches: [main]
workflow_dispatch:

permissions:
contents: read
id-token: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "spin"
cancel-in-progress: false

jobs:
Spin-Release:

	timeout-minutes: 10

	environment:
	name: production
	url: ${{ steps.deployment.outputs.app-url }}

	runs-on: ubuntu-latest

	steps:
	- uses: actions/checkout@v4 # repo checkout

	# Install Rust Nightly Toolchain, with Clippy & Rustfmt
	- name: Install nightly Rust
		uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt

	- name: Add WASM & WASI targets
		run: rustup target add wasm32-unknown-unknown && rustup target add wasm32-wasi

	- name: lint
		run: cargo clippy & cargo fmt


	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release


	# Install Spin CLI & Deploy

	- name: Setup Spin
		uses: fermyon/actions/spin/setup@v1
		# with:
		# plugins:


	- name: Build and deploy
		id: deployment
		uses: fermyon/actions/spin/deploy@v1
		with:
		fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
		# key_values: |-
			# abc=xyz
			# foo=bar
		# variables: |-
			# password=${{ secrets.SECURE_PASSWORD }}
			# apikey=${{ secrets.API_KEY }}

	# Create an explicit message to display the URL of the deployed app, as well as in the job graph
	- name: Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.deployment.outputs.app-url }}" >> $GITHUB_STEP_SUMMARY

Preview deployment script: spin_preview.yml

Example

# For setup instructions needed for Fermyon Cloud, see:
# https://developer.fermyon.com/cloud/github-actions


# For the Fermyon gh actions themselves, see:
# https://github.com/fermyon/actions

# Specifically:
# https://github.com/fermyon/actions?tab=readme-ov-file#deploy-preview-of-spin-app-to-fermyon-cloud---fermyonactionsspinpreviewv1

name: Preview on Spin Cloud

on:
pull_request:
	branches: ["main", "v*"]
	types: ['opened', 'synchronize', 'reopened', 'closed']
workflow_dispatch:

permissions:
contents: read
pull-requests: write

# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "spin"
cancel-in-progress: false

jobs:
Spin-Preview:

	timeout-minutes: 10

	environment:
	name: preview
	url: ${{ steps.preview.outputs.app-url }}

	runs-on: ubuntu-latest

	steps:
	- uses: actions/checkout@v4 # repo checkout

	# Install Rust Nightly Toolchain, with Clippy & Rustfmt
	- name: Install nightly Rust
		uses: dtolnay/rust-toolchain@nightly
		with:
		components: clippy, rustfmt

	- name: Add WASM & WASI targets
		run: rustup target add wasm32-unknown-unknown && rustup target add wasm32-wasi

	- name: lint
		run: cargo clippy & cargo fmt


	# If using tailwind...
	# - name: Download and install tailwindcss binary
	#   run: npm install -D tailwindcss && npx tailwindcss -i <INPUT/PATH.css> -o <OUTPUT/PATH.css>  # run tailwind


	- name: Download and install Trunk binary
		run: wget -qO- https://github.com/trunk-rs/trunk/releases/download/v0.18.2/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xzf-


	- name: Build with Trunk
		run: ./trunk build --release


	# Install Spin CLI & Deploy

	- name: Setup Spin
		uses: fermyon/actions/spin/setup@v1
		# with:
		# plugins:


	- name: Build and preview
		id: preview
		uses: fermyon/actions/spin/preview@v1
		with:
		fermyon_token: ${{ secrets.FERMYON_CLOUD_TOKEN }}
		github_token: ${{ secrets.GITHUB_TOKEN }}
		undeploy: ${{ github.event.pull_request && github.event.action == 'closed' }}
		# key_values: |-
			# abc=xyz
			# foo=bar
		# variables: |-
			# password=${{ secrets.SECURE_PASSWORD }}
			# apikey=${{ secrets.API_KEY }}


	- name: Display Deployed URL
		run: |
		echo "Deployed app URL: ${{ steps.preview.outputs.app-url }}" >> $GITHUB_STEP_SUMMARY

See the example repo here.

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

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

Create a rust-toolchain.toml file in your project root with the following content:

[toolchain]
channel = "stable"
targets = ["wasm32-unknown-unknown"]

Deployment

  1. Add your project to Netlify by connecting your Git repository
  2. Netlify will automatically detect your netlify.toml configuration
  3. If you need additional environment variables, configure them in Netlify's environment variables settings

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.

Deploying a Full-Stack SSR App

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.

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:

Note: Leptos does not endorse the use of any particular method of deployment or hosting service.

Creating a Containerfile

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

# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-bookworm as builder

# If you’re using stable, use this instead
# FROM rust:1.88-bookworm as builder

# Install cargo-binstall, which makes it easier to install other
# cargo extensions like cargo-leptos
RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
RUN cp cargo-binstall /usr/local/cargo/bin

# Install required tools
RUN apt-get update -y \
  && apt-get install -y --no-install-recommends clang

# Install cargo-leptos
RUN cargo binstall cargo-leptos -y

# Add the WASM target
RUN rustup target add wasm32-unknown-unknown

# Make an /app dir, which everything will eventually live in
RUN mkdir -p /app
WORKDIR /app
COPY . .

# Build the app
RUN cargo leptos build --release -vv

FROM debian:bookworm-slim as runtime
WORKDIR /app
RUN apt-get update -y \
  && apt-get install -y --no-install-recommends openssl ca-certificates \
  && apt-get autoremove -y \
  && apt-get clean -y \
  && rm -rf /var/lib/apt/lists/*

# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# Copy the server binary to the /app directory
COPY --from=builder /app/target/release/leptos_start /app/

# /target/site contains our JS/WASM/CSS, etc.
COPY --from=builder /app/target/site /app/site

# Copy Cargo.toml if it’s needed at runtime
COPY --from=builder /app/Cargo.toml /app/

# Set any required env variables and
ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT="site"
EXPOSE 8080

# -- NB: update binary name from "leptos_start" to match your app name in Cargo.toml --
# Run the server
CMD ["/app/leptos_start"]

Alpine

# Get started with a build env with Rust nightly
FROM rustlang/rust:nightly-alpine as builder

RUN apk update && \
    apk add --no-cache bash curl npm libc-dev binaryen

RUN npm install -g sass

RUN curl --proto '=https' --tlsv1.3 -LsSf https://github.com/leptos-rs/cargo-leptos/releases/latest/download/cargo-leptos-installer.sh | sh

# Add the WASM target
RUN rustup target add wasm32-unknown-unknown

WORKDIR /work
COPY . .

RUN cargo leptos build --release -vv

FROM rustlang/rust:nightly-alpine as runner

WORKDIR /app

COPY --from=builder /work/target/release/leptos_start /app/
COPY --from=builder /work/target/site /app/site
COPY --from=builder /work/Cargo.toml /app/

ENV RUST_LOG="info"
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
ENV LEPTOS_SITE_ROOT=./site
EXPOSE 8080

CMD ["/app/leptos_start"]

Read more: gnu and musl build files for Leptos apps.

Cloud Deployments

Deploy to 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.

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.

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

Then login to Fly.io

fly auth login

and manually launch your app using the command

fly launch

The flyctl CLI tool will walk you through the process of deploying your app to 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.

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.

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".

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

to create a new Fly app and register it with the service. Git commit your new fly.toml file.

To set up the Github Actions deployment workflow, copy the following into a .github/workflows/fly_deploy.yml file:

Example

# For more details, see: https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/

name: Deploy to Fly.io
on:
push:
	branches:
	- main
jobs:
deploy:
	name: Deploy app
	runs-on: ubuntu-latest
	steps:
	- uses: actions/checkout@v4
	- uses: superfly/flyctl-actions/setup-flyctl@master
	- name: Deploy to fly
		id: deployment
		run: |
		  flyctl deploy --remote-only | tail -n 1 >> $GITHUB_STEP_SUMMARY
		env:
		  FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

On the next commit to your Github main branch, your project will automatically deploy to Fly.io.

See the example repo here.

Railway

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:

Deploy on Railway

The template has renovate setup to keep dependencies up to date and supports GitHub Actions to test your code before a deploy happens.

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

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.

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."

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

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.

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.

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.

Platforms Working on Leptos Support

Deploy to Spin Serverless WASI (with Leptos 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.

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.

Deploy to Shuttle.rs

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.

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.

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.

Optimizing WASM Binary Size

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. Make sure you’re looking at a release build. (Debug builds are much, much larger.)
  2. Add a release profile for WASM that optimizes for size, not speed.

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"

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

  2. 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.

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"]

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

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"]

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

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.)

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 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.)

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]

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 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 */]
            })
        }
    }

    // this function will be lazy-loaded, concurrently with data()
    fn view(this: Self) -> AnyView {
        let BlogListingRoute { titles } = this;

        // ... now you can use the `posts` resource with Suspense, etc.,
        // and return AnyView by calling .into_any() on a view
    }
}

Examples and More Information

You can find more in-depth discussion in this YouTube video, and a full lazy_routes example in the repo.

指南:Islands

Leptos 0.5引入了新的islands功能。本指南将介绍islands功能和核心概念,同时使用islands架构实现一个演示应用程序。

Islands架构

主流的JavaScript前端框架(React、Vue、Svelte、Solid、Angular)都起源于构建客户端渲染单页应用程序(SPA)的框架。初始页面加载被渲染为HTML,然后进行水合,后续导航直接在客户端处理。(因此称为"单页":一切都从服务器的单次页面加载开始,即使后来有客户端路由。)这些框架中的每一个后来都添加了服务器端渲染以改善初始加载时间、SEO和用户体验。

这意味着默认情况下,整个应用程序都是交互式的。这也意味着整个应用程序必须作为JavaScript发送到客户端才能进行水合。Leptos遵循了相同的模式。

您可以在服务器端渲染章节中阅读更多内容。

但也可以朝相反的方向工作。与其采用完全交互式的应用程序,在服务器上将其渲染为HTML,然后在浏览器中进行水合,您可以从普通HTML页面开始,添加小的交互区域。这是2010年代之前任何网站或应用程序的传统格式:您的浏览器向服务器发出一系列请求,并返回每个新页面的HTML作为响应。在"单页应用程序"(SPA)兴起之后,这种方法有时被称为"多页应用程序"(MPA)作为对比。

"islands架构"这个短语最近出现,用来描述从服务器渲染HTML页面的"海洋"开始,并在整个页面中添加交互性"岛屿"的方法。

额外阅读

本指南的其余部分将介绍如何在Leptos中使用islands。有关该方法的更多背景信息,请查看以下文章:

激活Islands模式

让我们从一个新的cargo-leptos应用程序开始:

cargo leptos new --git leptos-rs/start-axum

在这个例子中,Actix和Axum之间应该没有真正的区别。

我只是要运行

cargo leptos build

在后台,同时启动我的编辑器并继续写作。

我要做的第一件事是在我的Cargo.toml中添加islands功能。我只需要将其添加到leptos crate中。

leptos = { version = "0.7", features = ["islands"] }

接下来,我将修改从src/lib.rs导出的hydrate函数。我将删除调用leptos::mount::hydrate_body(App)的行,并将其替换为

leptos::mount::hydrate_islands();

与其运行整个应用程序并水合它创建的视图,这将按顺序水合每个单独的island。

app.rs中,在shell函数中,我们还需要在HydrationScripts组件中添加islands=true

<HydrationScripts options islands=true/>

好的,现在启动您的cargo leptos watch并转到http://localhost:3000(或其他地方)。

点击按钮,然后...

什么都没有发生!

完美。

Note

起始模板在其hydrate()函数定义中包含use app::*;。一旦您切换到islands模式,您就不再使用导入的主App函数,所以您可能认为可以删除它。(实际上,Rust lint工具可能会发出警告,如果您不这样做!)

但是,如果您使用工作区设置,这可能会导致问题。我们使用wasm-bindgen为每个函数独立导出入口点。根据我的经验,如果您使用工作区设置,并且您的frontend crate中没有任何东西实际使用app crate,那些绑定将不会正确生成。有关更多信息,请参见此讨论

使用Islands

什么都没有发生,因为我们刚刚完全颠倒了应用程序的心理模型。与其默认交互并水合所有内容,应用程序现在默认是普通HTML,我们需要选择加入交互性。

这对WASM二进制大小有很大影响:如果我在发布模式下编译,这个应用程序只有24kb的WASM(未压缩),而在非islands模式下是274kb。(274kb对于"Hello, world!"来说相当大。这实际上只是与客户端路由相关的所有代码,在演示中没有使用。)

当我们点击按钮时,什么都没有发生,因为我们的整个页面都是静态的。

那么我们如何让某些事情发生呢?

让我们将HomePage组件变成一个island!

这是非交互式版本:

#[component]
fn HomePage() -> impl IntoView {
    // 创建一个响应式值来更新按钮
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

这是交互式版本:

#[island]
fn HomePage() -> impl IntoView {
    // 创建一个响应式值来更新按钮
    let count = RwSignal::new(0);
    let on_click = move |_| *count.write() += 1;

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在当我点击按钮时,它工作了!

#[island]宏的工作方式与#[component]宏完全相同,只是在islands模式下,它将此指定为交互式island。如果我们再次检查二进制大小,这在发布模式下是166kb未压缩;比24kb完全静态版本大得多,但比355kb完全水合版本小得多。

如果您现在打开页面的源代码,您会看到您的HomePage island已被渲染为特殊的<leptos-island> HTML元素,该元素指定应该使用哪个组件来水合它:

<leptos-island data-component="HomePage_7432294943247405892">
  <h1>Welcome to Leptos!</h1>
  <button>
    Click Me:
    <!>0
  </button>
</leptos-island>

只有这个<leptos-island>内部的代码被编译为WASM,只有该代码在水合时运行。

有效使用Islands

记住_只有_#[island]内的代码需要编译为WASM并发送到浏览器。这意味着islands应该尽可能小和具体。例如,我的HomePage最好分解为常规组件和island:

#[component]
fn HomePage() -> impl IntoView {
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <Counter/>
    }
}

#[island]
fn Counter() -> impl IntoView {
    // 创建一个响应式值来更新按钮
    let (count, set_count) = signal(0);
    let on_click = move |_| *set_count.write() += 1;

    view! {
        <button on:click=on_click>"Click Me: " {count}</button>
    }
}

现在<h1>不需要包含在客户端包中,或进行水合。这现在看起来像是一个愚蠢的区别;但请注意,您现在可以向HomePage本身添加任意多的惰性HTML内容,WASM二进制大小将保持完全相同。

在常规水合模式下,您的WASM二进制大小随着应用程序的大小/复杂性而增长。在islands模式下,您的WASM二进制随着应用程序中交互性的数量而增长。您可以在islands之外添加任意多的非交互式内容,它不会增加二进制大小。

解锁超能力

所以,这50%的WASM二进制大小减少很好。但真的,重点是什么?

重点在于结合两个关键事实:

  1. #[component]函数内的代码现在_只_在服务器上运行,除非您在island中使用它。*
  2. 子元素和props可以从服务器传递到islands,而无需包含在WASM二进制中。

这意味着您可以直接在组件主体中运行仅服务器代码,并将其直接传递到子元素中。在完全水合应用程序中需要复杂的服务器函数和Suspense混合的某些任务可以在islands中内联完成。

* 这个"除非您在island中使用它"很重要。#[component]组件_不是_只在服务器上运行。相反,它们是"共享组件",只有在#[island]主体中使用时才会编译到WASM二进制中。但如果您不在island中使用它们,它们就不会在浏览器中运行。

我们将在本演示的其余部分依赖第三个事实:

  1. Context可以在其他独立的islands之间传递。

所以,与其我们的计数器演示,让我们做一些更有趣的事情:一个从服务器上的文件读取数据的选项卡界面。

将服务器子元素传递给Islands

关于islands最强大的事情之一是您可以将服务器渲染的子元素传递到island中,而island不需要了解它们的任何信息。Islands水合它们自己的内容,但不水合传递给它们的子元素。

正如React的Dan Abramov所说(在RSC的非常相似的上下文中),islands实际上不是islands:它们是甜甜圈。您可以将仅服务器内容直接传递到"甜甜圈洞"中,可以说,允许您创建微小的交互性环礁,被惰性服务器HTML的海洋_两边_包围。

在下面包含的演示代码中,我添加了一些样式,将所有服务器内容显示为浅蓝色"海洋",所有islands显示为浅绿色"陆地"。希望这有助于描绘我在说什么!

继续演示:我将创建一个Tabs组件。在选项卡之间切换需要一些交互性,所以当然这将是一个island。让我们现在开始简单:

#[island]
fn Tabs(labels: Vec<String>) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-between;">
            {buttons}
        </div>
    }
}

哎呀。这给了我一个错误

error[E0463]: can't find crate for `serde`
  --> src/app.rs:43:1
   |
43 | #[island]
   | ^^^^^^^^^ can't find crate

简单修复:让我们cargo add serde --features=derive#[island]宏想要引入serde,因为它需要序列化和反序列化labels prop。

现在让我们更新HomePage以使用Tabs

#[component]
fn HomePage() -> impl IntoView {
	// 这些是我们要读取的文件
    let files = ["a.txt", "b.txt", "c.txt"];
	// 选项卡标签将只是文件名
	let labels = files.iter().copied().map(Into::into).collect();
    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels/>
    }
}

如果您在DOM检查器中查看,您会看到island现在类似于

<leptos-island
  data-component="Tabs_1030591929019274801"
  data-props='{"labels":["a.txt","b.txt","c.txt"]}'
>
  <div style="display: flex; width: 100%; justify-content: space-between;;">
    <button>a.txt</button>
    <button>b.txt</button>
    <button>c.txt</button>
    <!---->
  </div>
</leptos-island>

我们的labels prop被序列化为JSON并存储在HTML属性中,以便可以用于水合island。

现在让我们添加一些选项卡。目前,Tab island将非常简单:

#[island]
fn Tab(index: usize, children: Children) -> impl IntoView {
    view! {
        <div>{children()}</div>
    }
}

目前,每个选项卡只是一个包装其子元素的<div>

我们的Tabs组件也将获得一些子元素:现在,让我们只显示它们全部。

#[island]
fn Tabs(labels: Vec<String>, children: Children) -> impl IntoView {
    let buttons = labels
        .into_iter()
        .map(|label| view! { <button>{label}</button> })
        .collect_view();
    view! {
        <div style="display: flex; width: 100%; justify-content: space-around;">
            {buttons}
        </div>
        {children()}
    }
}

好的,现在让我们回到HomePage。我们将创建要放入选项卡框中的选项卡列表。

#[component]
fn HomePage() -> impl IntoView {
    let files = ["a.txt", "b.txt", "c.txt"];
    let labels = files.iter().copied().map(Into::into).collect();
	let tabs = move || {
        files
            .into_iter()
            .enumerate()
            .map(|(index, filename)| {
                let content = std::fs::read_to_string(filename).unwrap();
                view! {
                    <Tab index>
                        <h2>{filename.to_string()}</h2>
                        <p>{content}</p>
                    </Tab>
                }
            })
            .collect_view()
    };

    view! {
        <h1>"Welcome to Leptos!"</h1>
        <p>"Click any of the tabs below to read a recipe."</p>
        <Tabs labels>
            <div>{tabs()}</div>
        </Tabs>
    }
}

呃...什么?

如果您习惯使用Leptos,您知道您就是不能这样做。组件主体中的所有代码都必须在服务器上运行(以渲染为HTML)和在浏览器中运行(以水合),所以您不能只调用std::fs;它会panic,因为在浏览器中没有访问本地文件系统(当然也没有访问服务器文件系统!)的权限。这将是一个安全噩梦!

除了...等等。我们在islands模式下。这个HomePage组件_真的_只在服务器上运行。所以我们实际上可以使用这样的普通服务器代码。

**这是一个愚蠢的例子吗?**是的!在.map()中同步从三个不同的本地文件读取在现实生活中不是一个好选择。这里的重点只是演示这确实是仅服务器内容。

继续在项目根目录中创建三个名为a.txtb.txtc.txt的文件,并用您喜欢的任何内容填充它们。

刷新页面,您应该在浏览器中看到内容。编辑文件并再次刷新;它将被更新。

您可以将仅服务器内容从#[component]传递到#[island]的子元素中,而island不需要了解如何访问该数据或渲染该内容的任何信息。

**这真的很重要。**将服务器children传递给islands意味着您可以保持islands小。理想情况下,您不希望在页面的整个块周围放置#[island]。您希望将该块分解为交互式部分(可以是#[island])和大量可以传递给该island作为children的额外服务器内容,以便页面交互部分的非交互子部分可以保持在WASM二进制之外。

在Islands之间传递Context

这些还不是真正的"选项卡":它们只是一直显示每个选项卡。所以让我们为我们的TabsTab组件添加一些简单的逻辑。

我们将修改Tabs以创建一个简单的selected signal。我们通过context提供读取部分,并在有人点击我们的按钮之一时设置signal的值。

#[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以使用该context来显示或隐藏自己:

#[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通过context将signal传递给每个TabTab使用它来确定是否应该打开。

这就是为什么在HomePage中,我让let tabs = move ||成为一个函数,并像{tabs()}这样调用它:以这种懒惰的方式创建选项卡意味着Tabs island已经在每个Tab寻找selected context时提供了它。

我们完整的选项卡演示大约是200kb未压缩:不是世界上最小的演示,但仍然比我们开始的使用客户端路由的"Hello, world"小得多!只是为了好玩,我使用#[server]函数和Suspense构建了相同的演示,没有islands模式,它超过400kb。所以再次,这大约节省了50%的二进制大小。这个应用程序包含相当少的仅服务器内容:记住,当我们添加额外的仅服务器组件和页面时,这200kb不会增长。

概述

这个演示可能看起来很基础。确实如此。但有许多直接的收获:

  • 50% WASM二进制大小减少,这意味着客户端的交互时间和初始加载时间有可测量的改善。
  • **减少数据序列化成本。**创建资源并在客户端读取它意味着您需要序列化数据,以便可以用于水合。如果您还读取了该数据以在Suspense中创建HTML,您最终会得到"双重数据",即相同的确切数据既渲染为HTML又序列化为JSON,增加响应的大小,因此减慢它们。
  • 轻松在#[component]内使用仅服务器API,就像它是在服务器上运行的普通本机Rust函数一样——在islands模式下,它就是!
  • 减少加载服务器数据的#[server]/create_resource/Suspense样板代码

未来探索

islands功能反映了前端Web框架目前正在探索的前沿工作。就目前而言,我们的islands方法与Astro非常相似(在其最近的View Transitions支持之前):它允许您构建传统的服务器渲染、多页应用程序,并非常无缝地集成交互性islands。

有一些小的改进很容易添加。例如,我们可以做一些非常类似于Astro的View Transitions方法的事情:

  • 通过从服务器获取后续导航并用新的HTML文档替换HTML文档,为islands应用程序添加客户端路由
  • 使用View Transitions API在旧文档和新文档之间添加动画过渡
  • 支持显式持久islands,即您可以用唯一ID标记的islands(类似于视图中组件上的persist:searchbar),可以从旧文档复制到新文档而不丢失其当前状态

还有其他更大的架构变化,我还没有被说服

额外信息

查看islands示例路线图Hackernews演示以获得额外讨论。

演示代码

use leptos::prelude::*;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <main style="background-color: lightblue; padding: 10px">
            <HomePage/>
        </main>
    }
}

/// 渲染应用程序的主页。
#[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>
    }
}

附录:响应式系统是如何工作的?

为了成功使用库,您不需要了解响应式系统实际如何工作的太多细节。但是,一旦您开始在高级水平上使用框架,了解幕后发生的事情总是有用的。

您使用的响应式原语分为三组:

  • SignalReadSignal/WriteSignalRwSignalResourceTrigger)您可以主动更改以触发响应式更新的值。
  • 计算Memo)依赖于signal(或其他计算)并通过一些纯计算派生新响应式值的值。
  • Effect 监听某些signal或计算中的变化并运行函数,产生一些副作用的观察者。

派生signal是一种非原语计算:作为普通闭包,它们只是允许您将一些重复的基于signal的计算重构为可重用的函数,可以在多个地方调用,但它们在响应式系统本身中不被表示。

所有其他原语实际上作为响应式图中的节点存在于响应式系统中。

响应式系统的大部分工作包括将变化从signal传播到effect,可能通过一些中间的memo。

响应式系统的假设是effect(如渲染到DOM或发出网络请求)比应用程序内部更新Rust数据结构等事情昂贵几个数量级。

因此,响应式系统的主要目标尽可能少地运行effect

Leptos通过构建响应式图来做到这一点。

Leptos当前的响应式系统很大程度上基于JavaScript的Reactively库。您可以阅读Milo的文章"Super-Charging Fine-Grained Reactivity",了解其算法的出色描述,以及细粒度响应式的一般情况——包括一些漂亮的图表!

响应式图

Signal、memo和effect都共享三个特征:

  • 它们有一个当前值:要么是signal的值,要么是(对于memo和effect)前一次运行返回的值(如果有的话)。
  • 它们依赖的任何其他响应式原语。(对于signal,这是一个空集。)
  • 订阅者 依赖于它们的任何其他响应式原语。(对于effect,这是一个空集。)

实际上,signal、memo和effect只是响应式图中"节点"这一通用概念的常规名称。Signal总是"根节点",没有源/父节点。Effect总是"叶节点",没有订阅者。Memo通常既有源又有订阅者。

在以下示例中,我将使用nightly语法,只是为了减少这个供您阅读而不是复制粘贴的文档中的冗长!

简单依赖

想象以下代码:

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
Effect::new(move |_| {
	log!("{}", name_upper());
});

set_name("Bob");

您可以轻松想象这里的响应式图:name是唯一的signal/起源节点,Effect::new是唯一的effect/终端节点,中间有一个memo。

A   (name)
|
B   (name_upper)
|
C   (the effect)

分支拆分

让我们让它稍微复杂一些。

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("len = {}", name_len());
});

// E
Effect::new(move |_| {
	log!("name = {}", name_upper());
});

这也很直接:一个signal源signal(name/A)分为两个并行轨道:name_upper/Bname_len/C,每个都有一个依赖于它的effect。

 __A__
|     |
B     C
|     |
E     D

现在让我们更新signal。

set_name("Bob");

我们立即记录

len = 3
name = BOB

让我们再做一次。

set_name("Tim");

日志应该显示

name = TIM

len = 3不会再次记录。

记住:响应式系统的目标是尽可能少地运行effect。将name"Bob"更改为"Tim"将导致每个memo重新运行。但它们只有在值实际发生变化时才会通知其订阅者。"BOB""TIM"是不同的,所以该effect再次运行。但两个名称的长度都是3,所以它们不会再次运行。

重新合并分支

再举一个例子,有时称为钻石问题

// A
let (name, set_name) = signal("Alice");

// B
let name_upper = Memo::new(move |_| name.with(|n| n.to_uppercase()));

// C
let name_len = Memo::new(move |_| name.len());

// D
Effect::new(move |_| {
	log!("{} is {} characters long", name_upper(), name_len());
});

这个图看起来是什么样的?

 __A__
|     |
B     C
|     |
|__D__|

您可以看到为什么它被称为"钻石问题"。如果我用直线而不是糟糕的ASCII艺术连接节点,它会形成一个钻石:两个memo,每个都依赖于一个signal,它们都输入到同一个effect中。

一个天真的、基于推送的响应式实现会导致这个effect运行两次,这会很糟糕。(记住,我们的目标是尽可能少地运行effect。)例如,您可以实现一个响应式系统,使得signal和memo立即将其变化一直传播到图的下方,通过每个依赖项,本质上是深度优先遍历图。换句话说,更新A会通知B,然后B会通知D;然后A会通知C,然后C会再次通知D。这既低效(D运行两次)又有故障(D实际上在第一次运行期间使用第二个memo的不正确值运行)。

解决钻石问题

任何值得称道的响应式实现都致力于解决这个问题。有许多不同的方法(再次,参见Milo的文章以获得出色的概述)。

以下是我们的工作方式,简而言之。

响应式节点总是处于三种状态之一:

  • Clean:已知没有改变
  • Check:可能已经改变
  • Dirty:肯定已经改变

更新signal Dirty将该signal标记为Dirty,并递归地将其所有后代标记为Check。其任何作为effect的后代都会被添加到队列中以重新运行。

    ____A (DIRTY)___
   |               |
B (CHECK)    C (CHECK)
   |               |
   |____D (CHECK)__|

现在运行这些effect。(此时所有effect都将被标记为Check。)在重新运行其计算之前,effect检查其父节点是否脏。

  • 所以DB并检查它是否是Dirty
  • B也被标记为Check。所以B做同样的事情:
    • BA,发现它是Dirty
    • 这意味着B需要重新运行,因为它的一个源已经改变。
    • B重新运行,生成一个新值,并将自己标记为Clean
    • 因为B是一个memo,它然后检查其先前值与新值。
    • 如果它们相同,B返回"没有变化"。否则,它返回"是的,我改变了"。
  • 如果B返回"是的,我改变了",D知道它肯定需要运行,并在检查任何其他源之前立即重新运行。
  • 如果B返回"不,我没有改变",D继续检查C(参见上面B的过程)。
  • 如果BC都没有改变,effect不需要重新运行。
  • 如果BC中的任何一个确实改变了,effect现在重新运行。

因为effect只被标记为Check一次,只排队一次,所以它只运行一次。

如果天真版本是"基于推送"的响应式系统,简单地将响应式变化一直推送到图的下方,因此运行effect两次,这个版本可以称为"推拉"。它将Check状态一直推送到图的下方,但然后"拉"回上方。实际上,对于大图,它可能最终在图上来回弹跳,左右弹跳,试图确定哪些节点需要重新运行。

注意这个重要的权衡:基于推送的响应式更快地传播signal变化,但代价是过度重新运行memo和effect。记住:响应式系统旨在最小化您重新运行effect的频率,基于(准确的)假设,即副作用比完全在库的Rust代码内部发生的这种缓存友好的图遍历昂贵几个数量级。一个好的响应式系统的衡量标准不是它传播变化的速度,而是它传播变化的速度_而不过度通知_。

Memo vs. Signal

注意signal总是通知其子节点;即,signal在更新时总是被标记为Dirty,即使其新值与旧值相同。否则,我们必须在signal上要求PartialEq,这在某些类型上实际上是相当昂贵的检查。(例如,当很明显它确实已经改变时,向some_vec_signal.update(|n| n.pop())这样的东西添加不必要的相等检查。)

另一方面,Memo在通知其子节点之前检查它们是否改变。无论您.get()结果多少次,它们只运行一次计算,但每当其signal源改变时它们就会运行。这意味着如果memo的计算_非常_昂贵,您可能实际上也想要记忆化其输入,以便memo只有在确定其输入已改变时才重新计算。

Memo vs. 派生Signal

所有这些都很酷,memo非常棒。但大多数实际应用程序的响应式图相当浅且相当宽:您可能有100个源signal和500个effect,但没有memo,或者在罕见情况下,signal和effect之间有三或四个memo。Memo在它们所做的事情上极其出色:限制它们通知订阅者它们已经改变的频率。但正如对响应式系统的这种描述应该显示的那样,它们带来两种形式的开销:

  1. PartialEq检查,可能昂贵也可能不昂贵。
  2. 在响应式系统中存储另一个节点的额外内存成本。
  3. 响应式图遍历的额外计算成本。

在计算本身比这种响应式工作更便宜的情况下,您应该避免用memo"过度包装",而只是使用派生signal。这是一个您永远不应该使用memo的很好例子:

let (a, set_a) = signal(1);
// 这些都不适合作为memo
let b = move || a() + 2;
let c = move || b() % 2 == 0;
let d = move || if c() { "even" } else { "odd" };

set_a(2);
set_a(3);
set_a(5);

即使记忆化在技术上会在将a设置为35之间节省d的额外计算,这些计算本身比响应式算法更便宜。

最多,您可能考虑在运行一些昂贵的副作用之前记忆化最终节点:

let text = Memo::new(move |_| {
    d()
});
Effect::new(move |_| {
    engrave_text_into_bar_of_gold(&text());
});

附录:Signal的生命周期

在使用Leptos的中级阶段,通常会出现三个问题:

  1. 如何连接到组件生命周期,在组件挂载或卸载时运行一些代码?
  2. 如何知道signal何时被释放,为什么在尝试访问已释放的signal时偶尔会出现panic?
  3. signal如何能够是Copy的,并且可以移动到闭包和其他结构中而无需显式克隆?

这三个问题的答案密切相关,每个都有些复杂。本附录将尝试为您提供理解答案的背景,以便您能够正确推理应用程序的代码及其运行方式。

组件树 vs. 决策树

考虑以下简单的Leptos应用:

use leptos::logging::log;
use leptos::prelude::*;

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

    view! {
        <button on:click=move |_| *set_count.write() += 1>"+1"</button>
        {move || if count.get() % 2 == 0 {
            view! { <p>"Even numbers are fine."</p> }.into_any()
        } else {
            view! { <InnerComponent count/> }.into_any()
        }}
    }
}

#[component]
pub fn InnerComponent(count: ReadSignal<usize>) -> impl IntoView {
    Effect::new(move |_| {
        log!("count is odd and is {}", count.get());
    });

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

#[component]
pub fn OddDuck() -> impl IntoView {
    view! {
        <p>"You're an odd duck."</p>
    }
}

它所做的就是显示一个计数器按钮,如果是偶数则显示一条消息,如果是奇数则显示不同的消息。如果是奇数,它还会在控制台中记录值。

映射这个简单应用程序的一种方法是绘制嵌套组件树:

App 
|_ InnerComponent
   |_ OddDuck

另一种方法是绘制决策点树:

root
|_ is count even?
   |_ yes
   |_ no

如果将两者结合起来,您会注意到它们并不完全映射。决策树将我们在InnerComponent中创建的视图切分为三个部分,并将InnerComponent的一部分与OddDuck组件结合:

DECISION            COMPONENT           DATA    SIDE EFFECTS
root                <App/>              (count) render <button>
|_ is count even?   <InnerComponent/>
   |_ yes                                       render even <p>
   |_ no                                        start logging the count 
                    <OddDuck/>                  render odd <p> 
                                                render odd <p> (in <InnerComponent/>!)

查看这个表格,我注意到以下几点:

  1. 组件树和决策树彼此不匹配:"count是否为偶数?"决策将<InnerComponent/>分为三个部分(一个永不改变的部分,一个偶数部分,一个奇数部分),并将其中一个与<OddDuck/>组件合并。
  2. 决策树和副作用列表完全对应:每个副作用都在特定的决策点创建。
  3. 决策树和数据树也对齐。虽然表格中只有一个signal很难看出,但与组件不同(组件是一个可以包含多个决策或不包含决策的函数),signal总是在决策树的特定行创建。

关键是:数据结构和副作用结构影响应用程序的实际功能。组件结构只是编写的便利。您不关心,也不应该关心哪个组件渲染了哪个<p>标签,或哪个组件创建了记录值的effect。重要的是它们在正确的时间发生。

在Leptos中,组件不存在。也就是说:您可以将应用程序编写为组件树,因为这很方便,我们提供了一些围绕组件构建的调试工具和日志记录,因为这也很方便。但您的组件在运行时不存在:组件不是变更检测或渲染的单位。它们只是函数调用。您可以在一个大组件中编写整个应用程序,或将其拆分为一百个组件,这不会影响运行时行为,因为组件并不真正存在。

另一方面,决策树确实存在。而且它非常重要!

决策树、渲染和所有权

每个决策点都是某种响应式语句:一个可以随时间变化的signal或函数。当您将signal或函数传递给渲染器时,它会自动将其包装在一个effect中,该effect订阅它包含的任何signal,并相应地随时间更新视图。

这意味着当您的应用程序被渲染时,它会创建一个完全镜像决策树的嵌套effect树。在伪代码中:

// root
let button = /* render the <button> once */;

// the renderer wraps an effect around the `move || if count() ...`
Effect::new(|_| {
    if count.get() % 2 == 0 {
        let p = /* render the even <p> */;
    } else {
        // the user created an effect to log the count
        Effect::new(|_| {
            log!("count is odd and is {}", count.get());
        });

        let p1 = /* render the <p> from OddDuck */;
        let p2 = /* render the second <p> */ 

        // the renderer creates an effect to update the second <p>
        Effect::new(|_| {
            // update the content of the <p> with the signal
            p2.set_text_content(count.get());
        });
    }
})

每个响应式值都被包装在自己的effect中以更新DOM,或运行signal变化的任何其他副作用。但您不需要这些effect永远运行。例如,当count从奇数切换回偶数时,第二个<p>不再存在,因此继续更新它的effect不再有用。effect不会永远运行,而是在创建它们的决策发生变化时被取消。换句话说,更准确地说:当创建effect时正在运行的effect重新运行时,effect会被取消。如果它们是在条件分支中创建的,并且重新运行effect通过相同的分支,effect将再次创建:如果不是,则不会。

从响应式系统本身的角度来看,您的应用程序的"决策树"实际上是一个响应式"所有权树"。简单地说,响应式"所有者"是当前正在运行的effect或memo。它拥有在其内部创建的effect,它们拥有自己的子项,依此类推。当effect即将重新运行时,它首先"清理"其子项,然后再次运行。

到目前为止,这个模型与JavaScript框架(如S.js或Solid)中存在的响应式系统共享,其中所有权概念的存在是为了自动取消effect。

Leptos添加的是我们为所有权添加了第二个类似的含义:响应式所有者不仅拥有其子effect,以便它可以取消它们;它还拥有其signal(memo等),以便它可以释放它们。

所有权和Copy Arena

这是使Leptos能够作为Rust UI框架使用的创新。传统上,在Rust中管理UI状态一直很困难,因为UI都是关于共享可变性的。(一个简单的计数器按钮就足以看出问题:您需要不可变访问来设置显示计数器值的文本节点,以及在点击处理程序中的可变访问,每个Rust UI框架都是围绕Rust旨在防止这种情况而设计的!)在Rust中使用事件处理程序等传统上依赖于通过具有内部可变性的共享内存(Rc<RefCell<_>>Arc<Mutex<_>>)或通过通道进行共享内存通信的原语,其中任何一种通常都需要显式.clone()才能移动到事件监听器中。这还算可以,但也是一个巨大的不便。

Leptos一直使用signal的arena分配形式。signal本身本质上是保存在其他地方的数据结构的索引。它是一个廉价复制的整数类型,不会自己进行引用计数,因此可以被复制、移动到事件监听器等,而无需显式克隆。

这些signal的生命周期由所有权树确定,而不是Rust生命周期或引用计数。

正如所有effect都属于拥有的父effect,并且当所有者重新运行时子项被取消一样,所有signal也都属于所有者,并且当父项重新运行时被释放。

在大多数情况下,这完全没问题。想象一下,在我们上面的例子中,<OddDuck/>创建了一些其他signal,用于更新其UI的一部分。在大多数情况下,该signal将用于该组件中的本地状态,或者可能作为prop传递给另一个组件。它很少被提升到决策树之外并在应用程序的其他地方使用。当count切换回偶数时,它不再需要并且可以被释放。

但是,这意味着可能出现两个问题。

Signal可能在被释放后使用

您持有的ReadSignalWriteSignal只是一个整数:比如说,如果它是应用程序中的第3个signal,就是3。(一如既往,现实稍微复杂一些,但不多。)您可以将该数字复制到各处,并用它来说"嘿,给我signal 3。"当所有者清理时,signal 3的将被无效化;但您复制到各处的数字3无法被无效化。(没有整个垃圾收集器是不行的!)这意味着如果您将signal向"上"推回决策树,并将它们存储在概念上比创建它们的地方"更高"的应用程序中的某个地方,它们可能在被释放后被访问。

如果您尝试在signal被释放后更新它,实际上不会发生什么坏事。框架只会警告您尝试更新一个不再存在的signal。但如果您尝试访问一个,除了panic之外没有连贯的答案:没有可以返回的值。(.get().with()方法有try_等价物,如果signal已被释放,它们将简单地返回None)。

如果您在更高的作用域中创建signal并且从不释放它们,signal可能会泄漏

相反的情况也是如此,特别是在处理signal集合时会出现,比如RwSignal<Vec<RwSignal<_>>>。如果您在更高级别创建signal,并将其传递给较低级别的组件,它不会被释放,直到更高级别的所有者被清理。

例如,如果您有一个todo应用程序,为每个todo创建一个新的RwSignal<Todo>,将其存储在RwSignal<Vec<RwSignal<Todo>>>中,然后将其传递给<Todo/>,当您从列表中删除todo时,该signal不会自动释放,而必须手动释放,否则它将在其所有者仍然存活的时间内"泄漏"。(有关更多讨论,请参见TodoMVC示例。)

这只有在您创建signal、将它们存储在集合中,并从集合中删除它们而不同时手动释放它们时才是问题。

使用引用计数Signal解决这些问题

0.7引入了每个arena分配原语的引用计数等价物:对于每个RwSignal,都有一个ArcRwSignalArcReadSignalArcWriteSignalArcMemo等)。

这些通过引用计数而不是所有权树来管理它们的内存和释放。

这意味着它们可以安全地用于arena分配等价物会被泄漏或在被释放后使用的情况。

这在创建signal集合时特别有用:例如,您可能创建ArcRwSignal<_>而不是RwSignal<_>,然后在表格的每一行中将其转换为RwSignal<_>

有关更具体的示例,请参见counters示例ArcRwSignal<i32>的使用。

连接各个要点

我们开始时提出的问题的答案现在应该有一些意义了。

组件生命周期

没有组件生命周期,因为组件并不真正存在。但有一个所有权生命周期,您可以使用它来完成相同的事情:

  • 挂载前:简单地在组件主体中运行代码将在"组件挂载前"运行它
  • 挂载时create_effect在组件的其余部分之后运行一个tick,因此它对于需要等待视图挂载到DOM的effect很有用。
  • 卸载时:您可以使用on_cleanup为响应式系统提供应该在当前所有者清理时运行的代码,在再次运行之前。因为所有者围绕"决策",这意味着on_cleanup将在您的组件卸载时运行:如果某些东西可以卸载,渲染器必须创建一个正在卸载它的effect!

已释放Signal的问题

一般来说,只有当您在所有权树的较低位置创建signal并将其存储在较高位置时,才会出现问题。如果您在这里遇到问题,您应该将signal创建"提升"到父级中,然后将创建的signal传递下去——确保在需要时在删除时释放它们!

Copy signal

整个Copyable包装器类型系统(signal、StoredValue等)使用所有权树作为UI不同部分生命周期的近似。实际上,它将Rust语言基于代码块的生命周期系统与基于UI部分的生命周期系统并行。这不能总是在编译时完美检查,但总的来说我们认为这是一个净正面。