通过构建 SSR React 项目来理解 Hydration Errors
通过构建 SSR React 项目来理解 Hydration Errors
如果你在任何服务端渲染框架中编写过 React 代码,你几乎肯定遇到过 hydration error。 它们看起来像这样:
Text content does not match server-rendered HTML
或者
Error: Hydration failed because the initial UI does not match what was rendered on the server
并且在第一次看到这个错误后,你很快就会意识到你可以忽略它并继续前进...对于这样一个显眼的错误信息来说,这有点奇怪(稍后,我们将看到你可能不想完全忽略它们)。
那么,什么是 hydration error? 你应该在什么时候关注它们,什么时候忽略它们?
在这篇文章中,我们将通过构建一个非常简单的 React / Express 应用,该应用使用服务端渲染,来更多地了解它们。
但在我们回答这个问题之前,我们首先需要知道什么是服务端渲染。
什么是服务端渲染?#
服务端渲染 (SSR) 是一种服务器在将页面的 HTML 发送到客户端之前,先将其渲染的技术。
历史上,你会发现 SSR 应用通常与模板引擎(如 Jinja,Handlebars 或 Thymeleaf(对于所有我的 Java 朋友们)一起使用,这使得构建此类应用程序的过程变得简单。
我们可以将其与客户端渲染 (CSR) 进行对比,在客户端渲染中,服务器发送一个最小的 HTML 文件,并且渲染页面的大部分工作都在浏览器中的 JavaScript 中完成。
构建一个示例 React SSR 应用#
首先,我们将为我们的服务器安装 Express 和 React:
npminstall express react react-dom
然后,我们将创建一个带有 prop 的基本 React 组件:
importReactfrom'react';
interfaceAppProps{ message:string;}
functionApp({ message }:AppProps){return<div><h1>{message}</h1></div>}
exportdefaultApp;
最后,我们创建一个渲染此组件的 Express 服务器:
importexpressfrom'express';importReactfrom'react';import{ renderToString }from'react-dom/server';importAppfrom'./components/App';
const app =express();
consthtmlTemplate=(reactHtml:string)=>`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React SSR Example</title>
</head>
<body>
<div id="root">${reactHtml}</div>
</body>
</html>
`;
app.get('/',(req, res)=>{const message ='Hello from the server!';const appHtml =renderToString(React.createElement(App,{ message }));const fullPageHtml =htmlTemplate(appHtml) res.send(fullPageHtml);});
app.listen(3000,()=>{console.log(`Server running at http://localhost:3000`);});
我们运行我们的服务器,导航到 http://localhost:3000,我们看到它工作了:
但是,让我们看看当我们向我们的组件添加一个计数器时会发生什么:
functionApp({ message }:AppProps){const[count, setCount]=React.useState(0)return(<div><h1>{message}</h1><p>Counter: {count}</p><buttononClick={()=>setCount(c => c+1)}>Increment</button></div>);}
它加载正确,但是单击按钮没有任何作用:
这是因为 renderToString
生成静态 HTML,但没有任何用于处理事件(例如 onClick
)的 JavaScript。
我们需要的是一种让浏览器在服务器渲染的 HTML 之上附加事件处理程序并启用交互性的方法 - 这就是 hydration 所做的事情。
Hydrating 我们的 React 应用#
这里的关键函数是 hydrateRoot,其描述为:
hydrateRoot
允许你在浏览器 DOM 节点中显示 React 组件,该节点的 HTML 内容先前由react-dom/server
生成。
我们可以将其与 createRoot 进行对比,你会在 CSR 应用中找到它:
createRoot
允许你创建一个根来在浏览器 DOM 节点中显示 React 组件。
createRoot
假定它正在从头开始设置/显示所有 React 组件。 hydrateRoot
假定它正在在我们的服务器渲染的 HTML 之上设置/显示所有 React 组件。
如果我们回顾我们的 htmlTemplate
,你可以看到我们正在一个带有 ID 的 div 标签内渲染我们的服务器 HTML:
<divid="root">[server-rendered-html]</div>
因此,要“hydrate”我们的应用程序,我们只需要在客户端添加一些 JavaScript 代码,调用 hydrateRoot
并引用这个 div:
importReactfrom'react';import{ hydrateRoot }from'react-dom/client';importAppfrom'./components/App';
hydrateRoot(document.getElementById('root'),<Appmessage="Hello from the server!"/>);
// 请注意,对于此示例,我们正在硬编码 props
// 但在实际应用程序中,我们会从服务器传递它们下来
// 一种方法是添加一个 <script> 标签,该标签设置
// window.__INITIAL_PROPS__ = {"message": "Hello from the server!"}
// 然后在这里加载它。
为了确保这运行,我们还需要更新我们的模板以添加这个脚本。 我们可以将其添加到我们模板中的 <div id="root">${reactHtml}</div>
下面:
<body><divid="root">${reactHtml}</div><scriptsrc="/bundle.js"></script></body>
为了不使这篇文章太长,我在这里跳过了一个重要的步骤,即捆绑我们的客户端入口点。 为此,你可以使用诸如 Vite 或 Rollup 之类的东西。
但是,一旦我们设置好它并使用 hydrateRoot
运行我们的新代码,我们的计数器现在就可以工作了:
当客户端和服务器不一致时会发生什么?#
让我们以我们的示例为例,并犯一个明显的错误。 在服务器上,我们传入 Hello from the server!
作为 prop。 如果客户端改为传入 Hello from the client!
会怎样?
为了使这更明显,我们还延迟几秒钟调用 hydrateRoot
:
setTimeout(()=>{hydrateRoot(document.getElementById('root'),React.createElement(App,{message:'Hello from the client!'}))},5000);
当我们加载页面时,我们最初会看到 Hello from the server!
,然后在几秒钟后,我们会得到 Hello from the client!
以及一个 hydration error。
最终,这就是 hydration error 的全部 - 服务器为 React 组件返回了一些 HTML,当客户端尝试加载相同的组件时,它们不匹配。
你为什么要关心 hydration error?#
你 可能 关心 hydration error 的一个原因是,它是一种尴尬的用户体验。 在上面我们夸大的示例中,页面加载了一个消息,但在几秒钟后,该消息完全改变了。
对于更危险的 hydration error,值得考虑一下你可能如何自己实现 hydration。 组件的 HTML 已经加载,你所要做的就是将事件侦听器连接到正确的位置。
如果服务器返回以下内容会怎样:
<div><buttononClick={deleteMyAccount}>Delete my account</button></div>
客户端看到以下内容:
<div><button>Upgrade my account</button><button>Delete my account</button></div>
“Upgrade my account” 按钮现在是否会触发删除(希望在确认模态后面),因为它现在是 div 下的第一个按钮? 两个按钮都没有得到点击处理程序? 还是...两者都得到了?
在这里谨慎的原因是,不匹配的代码可能会导致一些不幸的模糊情况。
在实践中,对于此类不匹配,React 将拆除并重新创建不匹配的组件树以确保安全,从而将可能成为正确性问题的问题变成性能问题。
你如何在实践中获得 hydration error?#
显然,你在服务器与客户端上使用不同的 props 肯定会导致 hydration error。
React 文档 此处 突出显示了一个最直接,最现实的示例。 如果你需要渲染时间戳,则服务器和客户端可能会对确切时间存在分歧。
同样,大多数检查 window
或任何特定于浏览器的 API 的东西都可能导致这些错误,因为这些东西只存在于客户端而不存在于服务器上。
我一直觉得奇怪的一个是嵌套的 p
标签。 如果你执行以下操作:
<p><p>Text</p></p>
它也会导致 hydration error。 原因是实际上非常简单,因为这实际上不是有效的 HTML,并且浏览器会为你纠正它。 不幸的是,对于我们来说,纠正它会导致客户端和服务器之间的不匹配。
不幸的是,对于我来说,这只是突出了我不知道那是无效 HTML。
修复 hydration error#
在更高的层次上,修复 hydration error 只是意味着确保客户端和服务器匹配。
真的就是这样。 根据你的代码的样子,它会有所不同,但你将需要考虑服务器可以访问什么以及浏览器可以访问什么。
对于嵌套的 p
标签的情况,你需要确保返回有效的 HTML,以便浏览器不会纠正/修改它。
你会在 StackOverflow 帖子中找到的一种值得注意的模式是这种 isMounted
模式:
const[isMounted, setIsMounted]=useState(false);
useEffect(()=>{setIsMounted(true);},[]);
if(!isMounted){returnnull// alternatively return a placeholder}else{// do the thing you want to do}
为什么这有效?
由于 useEffect 块直到 hydration 完成后才会运行,因此 isMounted
变为 true 的唯一时间是在 hydration 完成之后,因此客户端和服务器都将看到此组件的 null
。
虽然这确实修复了 hydration error,但代价是......没有真正获得 SSR 的好处,因为最初在客户端上不呈现任何内容。 但是对于较小的组件或不可避免的不匹配的情况,这是一种消除错误的方法 - 你可能不想将其放在整个应用程序上。
修复 hydration error 的完整示例#
对于更具体的示例,假设我们创建一个像这样的 hook:
constuseSavedValue=()=>{const isBrowser =typeofwindow!=="undefined";
// We need to check if window is available, otherwise we'll get// an error when we reference localStorageconst defaultSavedValue = isBrowser
?localStorage.getItem("savedValue")||"Default":"Default"
returnuseState(defaultSavedValue)}
// Used in a component:constExample=()=>{const[savedValue, setSavedValue]=useSavedValue()return<pre>{savedValue}</pre>}
在什么条件下(如果有的话),此 Example
组件会导致 hydration error?
有三种情况需要考虑:
在服务器上: isBrowser
为 false,Example
组件将始终渲染 Default
在客户端上,localStorage 中没有值: isBrowser
为 true,defaultSavedValue
为 Default
,因此该组件将始终渲染 Default
在客户端上,localStorage 中有 "XYZ": isBrowser
为 true,defaultSavedValue
为 "XYZ",因此该组件将渲染 XYZ
这最终会导致一个 hydration error,该错误仅在 localStorage.getItem("savedValue") 中保存了 "Default" 以外的值时才会发生。 这是一个特别烦人的错误,因为不同的开发人员可能会也可能根本看不到它。
要解决此问题,我们可以重写我们的 hook,使其始终渲染 Default
,直到 hydration 完成:
constuseSavedValue=()=>{const[value, setValue]=useState("Default");
// useEffect blocks don't run until after hydration is completeuseEffect(()=>{const savedValue =localStorage.getItem("savedValue");if(savedValue)setValue(savedValue);},[]);
return[value, setValue];}
这还具有不需要再检查 isBrowser
的额外好处,因为 useEffect
块不在服务器上运行。
修复 hydration error 不幸的是会因你的代码而异,但要记住的最重要的事情是“服务器上呈现什么”与“**hydration 之前,**客户端上呈现什么”。
总结#
当你用许多现代 SSR 框架编写 React 代码时,hydration error 是一种不幸的常见经历。
当服务器最初渲染的 HTML 与 React 在客户端 hydration 期间期望的组件结构不匹配时,会发生 hydration error。
希望这篇文章能帮助你更多地了解什么是 hydration,这些不匹配是如何发生的,为什么它们最终是有问题的以及如何修复它们。
Andrew Israel
2025 年 4 月 3 日 • 10 分钟阅读
什么是服务端渲染? 构建一个示例 React SSR 应用 Hydrating 我们的 React 应用 当客户端和服务器不一致时会发生什么? 你为什么要关心 hydration error? 你如何在实践中获得 hydration error? 修复 hydration error 修复 hydration error 的完整示例 总结
相关帖子
发布 ui.propelauth.com 今天我们发布 ui.propelauth.com 的 Beta 版 - 一个关于如何在 Propel 上构建你自己的 UI 的完整指南...
Remix Authentication 我们很高兴正式发布 @propelauth/remix 库 - 一个可以非常轻松地添加...的库
React 中的状态管理完整指南如果你正在编写一个 React 应用程序,你几乎肯定在某个地方管理状态。 状态非常难...
由内向者构建
PRODUCT
注册和登录组织管理企业准备就绪用户模拟用户洞察API 密钥身份验证定价
COMPARE
PropelAuth vs. Auth0PropelAuth vs. ClerkPropelAuth vs. Cognito
RESOURCES