译:危险的注水

原文: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!