PropelAuth Logo

回到博客

通过构建 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 应用通常与模板引擎(如 JinjaHandlebarsThymeleaf(对于所有我的 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,我们看到它工作了:

文章中的图片:通过构建 SSR React 项目来理解 Hydration Errors

但是,让我们看看当我们向我们的组件添加一个计数器时会发生什么:

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

它加载正确,但是单击按钮没有任何作用:

文章中的图片:通过构建 SSR React 项目来理解 Hydration Errors

这是因为 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>

为了不使这篇文章太长,我在这里跳过了一个重要的步骤,即捆绑我们的客户端入口点。 为此,你可以使用诸如 ViteRollup 之类的东西。

但是,一旦我们设置好它并使用 hydrateRoot 运行我们的新代码,我们的计数器现在就可以工作了:

文章中的图片:通过构建 SSR React 项目来理解 Hydration Errors

当客户端和服务器不一致时会发生什么?#

让我们以我们的示例为例,并犯一个明显的错误。 在服务器上,我们传入 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。

文章中的图片:通过构建 SSR React 项目来理解 Hydration Errors

最终,这就是 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,defaultSavedValueDefault,因此该组件将始终渲染 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

Andrew Israel

2025 年 4 月 3 日 • 10 分钟阅读

ReactNext.js

什么是服务端渲染? 构建一个示例 React SSR 应用 Hydrating 我们的 React 应用 当客户端和服务器不一致时会发生什么? 你为什么要关心 hydration error? 你如何在实践中获得 hydration error? 修复 hydration error 修复 hydration error 的完整示例 总结

相关帖子

Announcing ui.propelauth.com

ProductReact

发布 ui.propelauth.com 今天我们发布 ui.propelauth.com 的 Beta 版 - 一个关于如何在 Propel 上构建你自己的 UI 的完整指南...

Remix Authentication

RemixReact

Remix Authentication 我们很高兴正式发布 @propelauth/remix 库 - 一个可以非常轻松地添加...的库

Complete Guide to State Management in React

ReactCoding

React 中的状态管理完整指南如果你正在编写一个 React 应用程序,你几乎肯定在某个地方管理状态。 状态非常难...

PropelAuth logo

由内向者构建

PRODUCT

注册和登录组织管理企业准备就绪用户模拟用户洞察API 密钥身份验证定价

COMPARE

PropelAuth vs. Auth0PropelAuth vs. ClerkPropelAuth vs. Cognito

RESOURCES

博客文档示例应用 支持 安全与合规关于我们

状态|隐私政策|服务条款|Cookies | 版权所有 © PropelAuth 2025。保留所有权利。