原文:https://www.joshwcomeau.com/react/the-perils-of-rehydration/
作者:Josh W Comeau
译者:ChatGPT 4 Turbo
编者注:几个收获,1)由于服务端和客户端的不一致,注水可能导致不匹配,此时直接用 typeof window 判断后做内容区分输出是不对的,这会让服务端和客户端的内容不一致,进而导致注水不匹配,作者的解法是用 useEffect 增加 isMount 状态,在 isMount 时再做客户端的动态内容输出,2)注水 != 渲染,注水是找不同。
最近我遇到了一个非常奇怪的问题。在开发环境中一切都很顺利,但是到了生产环境,我的博客底部出现了一些……非预期的行为:
在 devtools 的 Elements 标签里稍微挖掘了一下,揭示了罪魁祸首……我的 React 组件渲染在了错误的位置!
这怎么可能?我发现了 React 的一个 bug 吗?我检查了 React Devtools 的“⚛️ Components”标签页,它告诉了我一个不同的故事,一个一切都好,所有部件都在它们应该在的地方的故事。真是个骗子!
原来,我对 React 在服务器端渲染上下文中的工作方式有一个根本性的误解。而且我认为许多 React 开发者也有这种误解!这可能会有一些相当严重的后果。
一些有问题的代码
这是可能导致上述渲染问题的代码示例。你能发现问题所在吗?
function Navigation() {
if (typeof window === 'undefined') {
return null;
}
// Pretend that this function exists,
// and returns either a user object or `null`.
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
长时间以来,我一直认为这段代码是 OK 的。直到我的博客开始模仿毕加索的画作。
这个教程将揭开幕后,帮助我们理解服务器端渲染是如何工作的。我们将看到这里展示的逻辑为何可能会有问题,以及如何通过不同的方法达到相同的目标。
服务器端渲染 101
为了理解这个问题,我们首先需要深入了解一下像 Gatsby 和 Next.js 这样的框架与使用 React 构建的传统客户端应用程序有何不同。
当你使用 create-react-app 这样的工具与 React 一起使用时,所有的渲染都发生在浏览器中。无论你的应用程序有多大,浏览器仍然会接收到一个初始的 HTML 文档,看起来像这样:
<!DOCTYPE html>
<html lang="en">
<head>
<!-- Maybe some stuff here -->
</head>
<body>
<div id="root"></div>
<script
src="/static/bundle.js"
></script>
<script
src="/static/0.chunk.js"
></script>
<script
src="/static/main.chunk.js"
></script>
</body>
</html>
页面本质上是空的,但它包含了几个 JS 脚本。浏览器下载并解析这些脚本后,React 将构建出页面应有的样子,并注入一堆 DOM 节点来实现这一效果。这被称为客户端渲染,因为所有的渲染都发生在客户端(用户的浏览器)上。
所有这些东西都需要时间,而在浏览器和 React 展开它们的魔法时,用户正盯着一片空白的白屏。这并不是最佳体验。
聪明的人意识到,如果我们能在服务器上进行渲染,我们就可以向用户发送一个完整形成的 HTML 文档。这样,他们在浏览器下载、解析和执行 JS 的同时,就有东西可以看了。这被称为服务器端渲染(SSR)。
服务器端渲染可以提高性能,但问题是,这项工作仍然需要按需完成。当你请求 your-website.com 时,React 需要将你的 React 组件转换成 HTML,而你在等待的时候,屏幕上仍然是空白的。只不过这项工作是在服务器上完成的,而不是在用户的电脑上。
巨大的领悟是,许多网站和应用程序的大部分内容都是静态的,它们可以在编译时构建。我们可以在开发机器上提前生成初始 HTML,并在用户请求时立即分发。我们的 React 应用可以像纯 HTML 网站一样快速加载!
这正是 Gatsby 所做的事情(在某些配置下,Next.js 也是如此)。当你运行 yarn build
时,它会为你网站上的每一个路由生成一个 HTML 文档。每一个侧边页面、每一篇博客文章、每一个商店商品 —— 都会为它们各自创建一个 HTML 文件,随时准备提供服务。
客户端上的代码
我们如今构建的应用程序是互动性和动态性的——用户已经习惯了仅仅使用 HTML 和 CSS 无法实现的体验!所以我们仍然需要运行客户端 JS。
客户端 JS 包含了用于在编译时生成它的相同 React 代码。它在用户的设备上运行,并构建出世界应该呈现的样子。然后,它将其与嵌入文档中的 HTML 进行比较。这个过程被称为 hydration。
关键是,hydration 不同于渲染。在典型的渲染中,当 props 或 state 发生变化时,React 准备好协调任何差异并更新 DOM。在 hydration 中,React 假设 DOM 不会改变。它只是尝试采用现有的 DOM。
动态部分
这让我们回到了我们的代码片段。作为提醒:
const Navigation = () => {
if (typeof window === 'undefined') {
return null;
}
// Pretend that this function exists,
// and returns either a user object or `null`.
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
这个组件被设计为有三种可能的结果:
- 如果用户已登录,渲染
<AuthenticatedNav>
组件 - 如果用户未登录,请渲染
<UnauthenticatedNav>
组件。 - 如果我们不知道用户是否登录,什么都不渲染。
薛定谔的用户
在一个恐怖的思想实验中,奥地利物理学家埃尔温·薛定谔描述了这样一个情景:一只猫被放在一个盒子里,盒子中有一种毒素,它在一小时内有 50% 的几率被释放。一个小时后,猫活着或死了的概率是相等的。但在你打开盒子并查明结果之前,这只猫可以被认为既活着又死了。
在我们的 webapp 中,我们面临着类似的困境;在用户刚进入我们网站的最初时刻,我们不知道他们是否已经登录。
这是因为 HTML 文件是在编译时构建的。每个用户都会得到一个相同的 HTML 副本,不管他们是否登录。一旦 JS 包被解析和执行,我们就可以更新 UI 以反映用户的状态,但在此之前会有一个显著的时间间隔。记住,SSG 的全部意义在于,在我们下载、解析和填充应用程序的过程中,给用户提供一些可查看的内容,这在慢速网络/设备上可能是一个漫长的过程。
许多 web 应用默认显示“已登出”状态,这会导致你可能之前遇到过的一种闪烁现象:
《卫报》新闻网站显示了一个“登录”链接,后来将其替换为“您的账户”。
Airbnb 犯了同样的错误,默认使用了未登录状态的导航栏。
我自作主张构建了一个小型的 Gatsby 应用来复现这个问题:
在 3G 网速下,错误的状态显示了相当长的时间!如果你愿意,你可以自己试一试。点击“登录”链接进行假登录,然后再点击以登出。
有缺陷的尝试
在以下的代码片段中,我们尝试在前几行解决这个问题:
const Navigation = () => {
if (typeof window === 'undefined') {
return null;
}
这里的想法是可行的:我们的初始编译时构建发生在 Node.js,一个服务器运行时环境中。我们可以通过检查 window
是否存在来判断我们是否在服务器上渲染。如果不存在,我们可以提前中止渲染。
问题是这样做的话,我们就违反了规则。😬
Hydration ≠ 渲染
当 React 应用程序进行 hydrate 操作时,它假设 DOM 结构将会匹配。
当 React 应用第一次在客户端运行时,它通过挂载所有组件来构建一个关于 DOM 应该是什么样的心智图像。然后它对页面上已有的 DOM 节点眯眼审视,并尝试将两者拼合。它不是在进行典型更新时的“找出差异”游戏,它只是试图将两者对齐,以便将来的更新能够正确处理。
通过根据我们是否在服务器端渲染中渲染不同的内容,我们正在黑入系统。我们在服务器上渲染一件事,但然后告诉 React 在客户端期待另一件事:
有点令人惊讶的是,React 有时仍然可以处理这种情况。你可能自己就这么做过,并且侥幸成功了。但你这是在玩火。hydration 过程的优化是为了快速 ⚡️,而不是为了捕捉和修正不匹配。
关于 Gatsby 的具体情况
React 团队知道 hydration 不匹配可能会导致奇怪的问题,他们确保通过控制台消息来突出显示不匹配情况:
不幸的是,Gatsby 只在构建生产环境时使用服务器端渲染 API。而且因为 React 警告通常只在开发环境中触发,这意味着在使用 Gatsby 构建时这些警告从未显示 😱。
这是一种权衡。在开发中选择不使用服务器端渲染,Gatsby 优化了快速反馈循环。能够迅速看到你所做的更改是非常非常重要的。Gatsby 更重视速度而不是准确性。
这是一个相当严重的问题;在一个公开 issue 中,人们正在提倡进行更改,我们可能会开始看到 hydration 警告。
在此之前,开发 Gatsby 时尤其需要注意这一点!
解决方案
为了避免问题,我们需要确保 hydrated app 与原始 HTML 匹配。那么我们如何管理“动态”数据呢?
解决方案看起来是这样的:
function Navigation() {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
const user = getUser();
if (user) {
return (
<AuthenticatedNav
user={user}
/>
);
}
return (
<nav>
<a href="/login">Login</a>
</nav>
);
};
我们初始化一个状态,hasMounted
,为 false
。当它为假时,我们不会去渲染“真实”的内容。在 useEffect
调用中,我们立即触发重新渲染,将 hasMounted
设置为 true
。当这个值是 true
时,"真实"内容得到渲染。
我们早期解决方案的不同之处: useEffect
只在组件挂载后才触发。 当 React 应用在 hydration 期间采用 DOM 时, useEffect
还没有被调用,因此我们满足了 React 的期望:
在这次比较之后,我们立即触发了一次重新渲染,这使得 React 能够进行适当的协调。它会注意到这里有一些新内容需要渲染 —— 要么是一个已认证的菜单,要么是一个登录链接 —— 并相应地更新 DOM。
在我们的复现案例中,该解决方案看起来是这样的:
初始渲染时显示一个空白区域。挂载后,重新渲染会用真实状态更新它。
双遍渲染
你有没有注意到,谷物包装盒上的过期日期明显不是和盒子的其他部分同时印刷的?它是在事后盖上去的:
这里有一种逻辑:谷物盒印刷是一个两步骤的过程。首先,所有的“通用”内容被印刷:标志、卡通小矮人、放大以显示纹理的照片、智能手表的随机图片。因为这些东西是静态的,它们可以被大量生产,一次印刷数百万份,提前数月完成。
他们无法对过期日期做同样的处理。在那个时刻,制造商不知道过期日期应该是什么;装进那些盒子的谷物可能甚至还不存在!所以他们打印了一个空白的蓝色矩形。很久之后,当谷物生产出来并注入盒子后,他们可以在上面盖上白色的过期日期,然后打包发货。
两遍渲染的概念是一样的。第一遍,在编译时,会生成所有静态的非个人内容,并在动态内容将要放置的位置留下空白。然后,在 React 应用挂载到用户设备上之后,第二遍会填充所有依赖于客户端状态的动态内容。
性能影响
两遍渲染的缺点是它可能会延迟交互时间。通常不赞成在挂载后立即强制渲染。
话虽如此,对于大多数应用程序来说,这不应该造成太大的差异。通常情况下,动态内容的数量相对较少,可以迅速地进行协调。如果你的应用程序有大量动态内容,你将错过预渲染的许多好处,但这是不可避免的;根据定义,动态部分无法提前生成。
如果你对性能有所担忧,最好还是自己做一些实验。
抽象概念
在这个博客上,我最终需要将一些渲染决策推迟到第二遍处理,我已经厌倦了一遍又一遍地编写相同的逻辑。我创建了一个 <ClientOnly>
组件来抽象它:
function ClientOnly({ children, ...delegated }) {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
if (!hasMounted) {
return null;
}
return (
<div {...delegated}>
{children}
</div>
);
}
然后你可以将其包裹在你想要延迟的任何元素周围:
<ClientOnly>
<Navigation />
</ClientOnly>
我们也可以使用一个自定义钩子:
function useHasMounted() {
const [hasMounted, setHasMounted] = React.useState(false);
React.useEffect(() => {
setHasMounted(true);
}, []);
return hasMounted;
}
凭借这个小技巧,我解决了渲染问题。救星就是这一天!
心智模型
虽然整洁,但抽象并不是本教程最重要的部分。关键是心智模型。
在 Gatsby/Next 应用程序中工作时,我发现将渲染过程视为两遍渲染非常有帮助。第一遍在编译时发生,提前很长时间,为页面奠定基础,填充对所有用户都通用的内容。然后,很久以后,第二遍渲染会填充因人而异的状态部分。
我已经使用 React 构建项目超过 8 年了,期间我积累了大量有用的心智模型来理解 React 的工作原理以及如何有效地使用它。老实说,我真的非常喜欢 React。我几乎尝试了所有的前端框架,但没有什么能让我感觉像使用 React 一样高效。
在过去两年里,我一直在将所有这些知识整合到一个在线学习体验中。我称之为“React 的乐趣”。
这门课程的首要目标是帮助你建立对 React 的直觉,这样你就不会经常因为一些奇怪的小问题,比如 hydration bugs 而卡住,开始享受使用它进行开发!我希望你能像我一样热爱 React!