注水错误 (以及如何避免它们)
Hydration Bugs (and how to avoid them)
一个思想实验
A Thought Experiment
让我们尝试一个实验来测试你的直觉。打开一个你正在使用 cargo-leptos 进行服务器渲染的应用。(如果你到目前为止只是使用 trunk 来玩示例,为了这个练习,请去 克隆一个 cargo-leptos 模板。)
Let’s try an experiment to test your intuitions. Open up an app you’re server-rendering with cargo-leptos. (If you’ve just been using trunk so far to play with examples, go clone a cargo-leptos template just for the sake of this exercise.)
在你的根组件中放一个日志。(我通常把我的根组件称为 <App/>,但任何组件都可以。)
Put a log somewhere in your root component. (I usually call mine <App/>, but anything will do.)
#[component]
pub fn App() -> impl IntoView {
logging::log!("where do I run?");
// ... 随便什么内容
}
然后让我们启动它:
And let’s fire it up
cargo leptos watch
你期望 where do I run? 在哪里打印日志?
Where do you expect where do I run? to log?
-
在你运行服务器的命令行中?
-
In the command line where you’re running the server?
-
在你加载页面时的浏览器控制台中?
-
In the browser console when you load the page?
-
都没有?
-
Neither?
-
都有?
-
Both?
试一试。
Try it out.
...
...
...
好了,剧透警告。
Okay, consider the spoiler alerted.
你当然会注意到它在两个地方都打印了日志,假设一切都按计划进行。事实上,在服务器上它打印了两次——第一次是在初始服务器启动时,Leptos 渲染一次你的应用以提取路由树,第二次是在你发起请求时。每次你重新加载页面,where do I run? 应该在服务器上打印一次,在客户端打印一次。
You’ll notice of course that it logs in both places, assuming everything goes according to plan. In fact on the server it logs twice—first during the initial server startup, when Leptos renders your app once to extract the route tree, then a second time when you make a request. Each time you reload the page, where do I run? should log once on the server and once on the client.
如果你思考一下前几节的描述,希望这能讲得通。你的应用程序在服务器上运行一次,在那里它构建一个 HTML 树并发送到客户端。在这次初始渲染期间,where do I run? 在服务器上记录。
If you think about the description in the last couple sections, hopefully this makes sense. Your application runs once on the server, where it builds up a tree of HTML which is sent to the client. During this initial render, where do I run? logs on the server.
一旦 WASM 二进制文件在浏览器中加载,你的应用程序将第二次运行,遍历同一个用户界面树并添加交互性。
Once the WASM binary has loaded in the browser, your application runs a second time, walking over the same user interface tree and adding interactivity.
这听起来像是种浪费吗?在某种意义上是的。但减少这种浪费是一个真正困难的问题。这是一些像 Qwik 这样的 JS 框架旨在解决的问题,尽管现在判断它相对于其他方法是否具有净性能收益可能还为时过早。
Does that sound like a waste? It is, in a sense. But reducing that waste is a genuinely hard problem. It’s what some JS frameworks like Qwik are intended to solve, although it’s probably too early to tell whether it’s a net performance gain as opposed to other approaches.
潜在的 Bug
The Potential for Bugs
好了,希望所有这些都能理解。但这与本章的标题“注水错误(以及如何避免它们)”有什么关系呢?
Remember that the application needs to run on both the server and the client. This generates a few different sets of potential issues you need to know how to avoid.
请记住,应用程序需要在服务器和客户端上都运行。这会产生几组不同的潜在问题,你需要知道如何避免。
服务器和客户端代码之间的不匹配
Mismatches between server and client code
创建 Bug 的一种方法是让服务器发送的 HTML 与客户端渲染的内容之间产生不匹配。我认为非故意地做到这一点其实相当困难(至少根据我从人们那里收到的 bug 报告来看是这样)。但想象一下我这样做:
One way to create a bug is by creating a mismatch between the HTML that’s sent down by the server and what’s rendered on the client. It’s actually fairly hard to do this unintentionally, I think (at least judging by the bug reports I get from people.) But imagine I do something like this
#[component]
pub fn App() -> impl IntoView {
let data = if cfg!(target_arch = "wasm32") {
vec![0, 1, 2]
} else {
vec![]
};
data.into_iter()
.map(|value| view! { <span>{value}</span> })
.collect_view()
}
换句话说,如果它是为 WASM 编译的,它有三个项目;否则它是空的。
In other words, if this is being compiled to WASM, it has three items; otherwise it’s empty.
当我在浏览器中加载页面时,我什么也看不见。如果我打开控制台,我会看到一个 panic:
When I load the page in the browser, I see nothing. If I open the console I see a panic:
ssr_modes.js:423 panicked at /.../tachys/src/html/element/mod.rs:352:14:
called `Option::unwrap()` on a `None` value
你的 WASM 版本应用在浏览器中运行,它期望找到一个元素(实际上,它期望找到三个元素!)。但服务器发送的 HTML 中一个也没有。
The WASM version of your app, running in the browser, is expecting to find an element (in fact, it’s expecting three elements!) But the HTML sent from the server has none.
解决方案
Solution
你故意这样做的情况非常罕见,但它可能会由于在服务器和浏览器中运行不同的逻辑而发生。如果你看到这样的警告并且你认为这不是你的错,那么更有可能是 <Suspense/> 或其他东西的 bug。请随时在 GitHub 上打开一个 issue 或 discussion 以寻求帮助。
It’s pretty rare that you do this intentionally, but it could happen from somehow running different logic on the server and in the browser. If you’re seeing warnings like this and you don’t think it’s your fault, it’s much more likely that it’s a bug with <Suspense/> or something. Feel free to go ahead and open an issue or discussion on GitHub for help.
无效/边缘情况 HTML,以及 HTML 与 DOM 之间的不匹配
Invalid/edge-case HTML, and mismatches between HTML and the DOM
服务器以 HTML 响应请求。然后浏览器将该 HTML 解析为一个称为文档对象模型 (DOM) 的树。在注水期间,Leptos 遍历应用的视图树,注水一个元素,然后进入其子元素,注水第一个子元素,然后移动到其兄弟元素,依此类推。这假设你的应用在服务器上生成的 HTML 树直接映射到浏览器解析该 HTML 后的 DOM 树。
Servers respond to requests with HTML. The browser then parses that HTML into a tree called the Document Object Model (DOM). During hydration, Leptos walks over the view tree of your application, hydrating an element, then moving into its children, hydrating the first child, then moving to its siblings, and so on. This assumes that the tree of HTML produced by the your application on the server maps directly onto the DOM tree into which the browser parses that HTML.
在某些情况下,你的 view 创建的 HTML 树和 DOM 树可能不完全对应:这些情况会导致注水错误。
There are a few cases to be aware of in which the tree of HTML created by your view and the DOM tree might not correspond exactly: these can cause hydration errors.
无效 HTML
Invalid HTML
这是一个引起注水错误的非常简单的应用:
Here’s a very simple application that causes a hydration error:
#[component]
pub fn App() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<p>
<div class:blue=move || count.get() == 2>
"First"
</div>
</p>
}
}
这将给出一个类似这样的错误消息:
This will give an error message like
A hydration error occurred while trying to hydrate an element defined at src/app.rs:6:14.
The framework expected a text node, but found this instead: <p></p>
The hydration mismatch may have occurred slightly earlier, but this is the first time the framework found a node of an unexpected type.
(在大多数浏览器开发者工具中,你可以右键点击那个 <p></p> 来显示它在 DOM 中出现的位置,这很方便。)
(In most browser devtools, you can right-click on that <p></p> to show where it appears in the DOM, which is handy.)
如果你查看 DOM 查看器,你会看到它不是在 <p> 内部有一个 <div>,而是显示为:
If you look in the DOM inspector, you’ll see that it instead of a <div> inside a <p>, it shows:
<p></p>
<div>First</div>
<p></p>
那是因为这是无效的 HTML!<div> 不能放在 <p> 内部。当浏览器解析那个 <div> 时,它实际上关闭了前面的 <p>,然后打开了 <div>;然后,当它看到(现在不匹配的)闭合标签 </p> 时,它将其视为一个新的空 <p>。
That’s because this is invalid HTML! A <div> cannot go inside a <p>. When the browser parses that <div>, it actually closes the preceding <p>, then opens the <div>; then, when it sees the (now-unmatched) closing </p>, it treats it as a new, empty <p>.
结果,我们的 DOM 树不再匹配预期的视图树,注水错误随之发生。
As a result, our DOM tree no longer matches the expected view tree, and a hydration error ensues.
不幸的是,使用我们当前的模型,在不影响整体编译时间的情况下,很难在编译时确保视图中 HTML 的有效性。目前,如果你遇到此类问题,请考虑通过验证器运行 HTML 输出。(在上面的例子中,W3C HTML 验证器确实显示了一个错误!)
Unfortunately, it is difficult to ensure the validity of HTML in the view at compile time using our current model, and without an effect on compile times across the board. For now, if you run into issues like this, consider running the HTML output through a validator. (In the case above, the W3C HTML Validator does in fact show an error!)
:::admonish info
你可能会注意到,在从 0.6 迁移到 0.7 时会出现一些此类 bug。这是由于注水工作方式的改变。
Leptos 0.1-0.6 使用了一种注水方法,其中每个 HTML 元素都被赋予一个唯一的 ID,然后用于通过 ID 在 DOM 中查找它。Leptos 0.7 改为直接遍历 DOM,在遇到每个元素时对其进行注水。这具有更好的性能特征(更短、更干净的 HTML 输出和更快的注水时间),但对上述无效或边缘情况 HTML 示例的弹性较差。或许更重要的是,这种方法还修复了注水中的许多 其他 边缘情况和 bug,使框架总体上更具弹性。
:::
:::admonish info You may notice some bugs of this arise when migrating from 0.6 to 0.7. This is due to a change in how hydration works.
Leptos 0.1-0.6 used a method of hydration in which each HTML element was given a unique ID, which was then used to find it in the DOM by ID. Leptos 0.7 instead began walking over the DOM directly, hydrating each element as it came. This has much better performance characteristics (shorter, cleaner HTML output and faster hydration times) but is less resilient to the invalid or edge-case HTML examples above. Perhaps more importantly, this approach also fixes a number of other edge cases and bugs in hydration, making the framework more resilient on net. :::
没有 <tbody> 的 <table>
<table> without <tbody>
我所知道的还有一个额外的边缘情况,其中 有效 的 HTML 会产生与视图树不同的 DOM 树,那就是 <table>。当(大多数)浏览器解析 HTML <table> 时,无论你是否包含 <tbody>,它们都会在 DOM 中插入一个 <tbody>。
There’s one additional edge case I’aware of, in which valid HTML produces a DOM tree that differs from the view tree, and that’s <table>. When (most) browsers parse an HTML <table>, they insert a <tbody> into the DOM, whether you included one or not.
#[component]
pub fn App() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<table>
<tr>
<td class:blue=move || count.get() == 0>"First"</td>
</tr>
</table>
}
}
同样,这会产生注水错误,因为浏览器在 DOM 树中插入了一个视图中不存在的额外 <tbody>。
Again, this generates a hydration error, because the browser has inserted an additional <tbody> into the DOM tree that was not in your view.
在这里,修复很简单:添加 <tbody>:
Here, the fix is simple: adding <tbody>:
#[component]
pub fn App() -> impl IntoView {
let count = RwSignal::new(0);
view! {
<table>
<tbody>
<tr>
<td class:blue=move || count.get() == 0>"First"</td>
</tr>
</tbody>
</table>
}
}
(未来值得探讨的是,我们是否可以比验证有效 HTML 更容易地对这种特定的怪异行为进行 lint。)
(It would be worth exploring in the future whether we can lint for this particular quirk more easily than linting for valid HTML.)
一般建议
General Advice
这类不匹配可能很棘手。总的来说,我对调试的建议是:
In general, my recommendation for debugging:
-
右键点击消息中的元素,看看框架最初是在哪里 注意到 问题的。
-
Right-click on the element in the message to see where the framework first notices the problem.
-
比较该点及其上方的 DOM,检查与视图树的不匹配。是否有额外的元素?缺少的元素?
-
Compare the DOM at that point and above it, checking for mismatches with your view tree. Are there extra elements? Missing elements?
并非所有客户端代码都能在服务器上运行
Not all client code can run on the server
想象一下,你开心地导入了一个像 gloo-net 这样的依赖项,你以前一直用它在浏览器中发起请求,并将其用于服务器渲染应用中的 create_resource。
Imagine you happily import a dependency like gloo-net that you’ve been used to using to make requests in the browser, and use it in a create_resource in a server-rendered app.
你可能会立即看到那条可怕的消息:
You’ll probably instantly see the dreaded message
panicked at 'cannot call wasm-bindgen imported functions on non-wasm targets'
糟糕。
Uh-oh.
但这当然是有道理的。我们刚刚说过你的应用需要在客户端和服务器上都运行。
But of course this makes sense. We’ve just said that your app needs to run on the client and the server.
解决方案
Solution
有几种方法可以避免这种情况:
There are a few ways to avoid this:
-
只使用能在服务器和客户端上运行的库。例如,
reqwest在两种环境下都可以用于发起 HTTP 请求。 -
Only use libraries that can run on both the server and the client.
reqwest, for example, works for making HTTP requests in both settings. -
在服务器和客户端上使用不同的库,并使用
#[cfg]宏对它们进行门控。(点击此处查看示例。) -
Use different libraries on the server and the client, and gate them using the
#[cfg]macro. (Click here for an example.) -
将仅限客户端的代码包装在
Effect::new中。因为副作用(effects)只在客户端运行,这可以是访问初始渲染不需要的浏览器 API 的有效方式。 -
Wrap client-only code in
Effect::new. Because effects only run on the client, this can be an effective way to access browser APIs that are not needed for initial rendering.
例如,假设我想在每当信号改变时将某些东西存储在浏览器的 localStorage 中。
For example, say that I want to store something in the browser’s localStorage whenever a signal changes.
#[component]
pub fn App() -> impl IntoView {
use gloo_storage::Storage;
let storage = gloo_storage::LocalStorage::raw();
logging::log!("{storage:?}");
}
这会 panic,因为我在服务器渲染期间无法访问 LocalStorage。
This panics because I can’t access LocalStorage during server rendering.
但如果我把它包装在一个 effect 中……
But if I wrap it in an effect...
#[component]
pub fn App() -> impl IntoView {
use gloo_storage::Storage;
Effect::new(move |_| {
let storage = gloo_storage::LocalStorage::raw();
log!("{storage:?}");
});
}
没问题!这将在服务器上适当地渲染,忽略仅限客户端的代码,然后在浏览器上访问存储并记录消息。
It’s fine! This will render appropriately on the server, ignoring the client-only code, and then access the storage and log a message on the browser.
并非所有服务器代码都能在客户端运行
Not all server code can run on the client
在浏览器中运行的 WebAssembly 是一个非常受限的环境。你无法访问文件系统或标准库习惯于拥有的许多其他东西。并非每个 crate 都能被编译为 WASM,更不用说在 WASM 环境中运行了。
WebAssembly running in the browser is a pretty limited environment. You don’t have access to a file-system or to many of the other things the standard library may be used to having. Not every crate can even be compiled to WASM, let alone run in a WASM environment.
特别是,你有时会看到关于 crate mio 或缺少 core 里的东西的错误。这通常是一个信号,表明你正尝试将一些无法被编译为 WASM 的东西编译为 WASM。如果你添加了仅限服务器的依赖项,你需要将它们在 Cargo.toml 中标记为 optional = true,然后在 ssr 特性定义中启用它们。(查看一个模板 Cargo.toml 文件以了解更多细节。)
In particular, you’ll sometimes see errors about the crate mio or missing things from core. This is generally a sign that you are trying to compile something to WASM that can’t be compiled to WASM. If you’re adding server-only dependencies, you’ll want to mark them optional = true in your Cargo.toml and then enable them in the ssr feature definition. (Check out one of the template Cargo.toml files to see more details.)
你可以创建一个 Effect 来指定某些内容应该只在客户端运行,而不应在服务器上运行。有没有办法指定某些内容应该只在服务器上运行,而不在客户端上运行?
You can create an Effect to specify that something should only run on the client, and not on the server. Is there a way to specify that something should run only on the server, and not the client?
事实上,有的。下一章将详细介绍服务器函数(server functions)。(与此同时,你可以查看它们的文档 此处。)
In fact, there is. The next chapter will cover the topic of server functions in some detail. (In the meantime, you can check out their docs here.)