LWDW!

Learn the work from doing the work🍺

【译】The Perils of Hydration / 你所不了解的Hydration
Posted on 2023-06-12

译者:本人已经使用了相当一段时间的Next.js,且SSR、SSG一类的概念也从来没有困扰过我......

image.png

直到我为了进一步了解Next是如何在服务端返回HTML给客户端后,注入React的事件与状态,而搜索Hydration的时候,我发现了这篇博客:The Perils of Hydration —— 一看到是Josh W. Comeau大神的美丽博客,咱就知道不得不读了。

结果是,这篇文章不仅解答了我最初的问题,更多的是阐明了在使用 Gatsby/Next 这样的框架开发涉及SSR的应用时,Hydration是如何需要引起我们的警觉的。

下面开始正文,英文原文地址:The Perils of Hydration


最近我遇到了个很奇怪的问题——我的博客在开发环境下很完美,但到了生产环境,博客底部却乱了套:

乱套了的布局.png

于是我打开devtools,查看Elements,发现了问题所在——React组件渲染到了错误的位置!

<!-- 开发时,Newsletter表单在正确的位置 -->

<main>
<div class="ContentFooter">
  Last updated: <strong>Sometime</strong>
</div>
<div class="NewsletterSignup">
  <form>
    <!-- 注册表单 -->
  </form>
</div>
</main>
<!-- 到了生产环境,Newsletter表单被往里提了一层!? -->

<main>
<div class="ContentFooter">
  Last updated: <strong>Sometime</strong>
  <div class="NewsletterSignup">
    <form>
      <!-- 注册表单 -->
    </form>
  </div>
</div>
</main>

这又是为啥呢?难道是React的Bug?我看了看React Devtools的"⚛️ Components"选项卡,发现React组件的位置居然是正确的,那这不是离了谱了!

搞了半天,我最后意识到,我对于React如何处理**服务端渲染(SSR)**有根本性的错误理解,而且许多的React开发者可能也有这样的误判,这可能产生严重的错误!

一些问题代码

以下是一段可以导致渲染问题的代码示例,你能发现其中的问题么?

function Navigation() {
  if (typeof window === 'undefined') {
    return null;
  }
  // 假装getUser方法存在
  // getUserand要么返回一个user对象,要么返回`null`
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};

我过去一直觉得这段代码是ok的,直到我的博客开始一些“毕加索式布局”......

这篇文章会阐明:

  1. 服务端渲染是如何工作的?
  2. 以上的代码逻辑为何会有问题?
  3. 如何用更好的方式实现同样的效果?

服务端渲染的基本知识

为了理解问题所在,我们首先要理解Gatsby/Next这样的框架与传统的React搭建的客户端应用的区别。

当你使用create-react-app去搭建应用,所有的渲染都发生在客户端。不管你的应用有多么巨大,浏览器最初接收到的初始HTML大概都是这个样子:

<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- 这里有些meta信息之类的 -->
  </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>

除了几段脚本,这个页面基本是空的。一旦浏览器加载并解析了这些脚本,React就可以知道页面到底该长啥样子,创建DOM并注入到页面中,去实际完成页面渲染。而这,就是客户端渲染(client-side rendering,CSR),因为渲染步骤都是发生在客户端(用户的浏览器)。

以上这些步骤(加载解析脚本,执行渲染)都需要时间,所以当浏览器和React哼哧哼哧的处理页面的时候,用户只能盯着一片白屏,那这个体验就很一般了,在很多场景并非是最佳实践。

于是有些聪明人意识到,如果我们可以在服务端进行渲染,客户端首次就可以直接拿到完整的HTML,那用户就不用在等JS加载的时候看白屏了。这种渲染方式就是服务端渲染(server-side rendering,SSR)

服务端渲染可能会带来性能上的提升,但要注意,渲染是在用户请求时才进行。也就是说,当用户访问 your-website.com 的时候,React仍然需要去将React组件渲染为实际的HTML,而用户此时看到的也还是白屏。我们只是把这个步骤从用户的设备挪到了服务端而已。

于是呢,更聪明的人意识到,许多网页的大部分组成都是静态的,这些内容可以在编译时(而不是用户访问时)就确定下来。因此我们可以提前在编译阶段便将初始的HTML构建好,这样当用户请求时就可以直接返回了!这样一来,React应用的加载就和最基础的HTML站点一样快。

事实上,这就是Gatsby(以及特定配置下的Next.js)的工作原理。当运行yarn build的时候,它会为每个路由生成一份HTML文档。所有的子页面、博客之类的都会生成对应的HTML,于是它们就可以被随时拿去使用。

以上都是服务端渲染(SSR)么?

技术上来讲,Gatsby就是“服务端渲染”,因为它使用了Node,使用了ReactDOMServer API去渲染React应用,传统的SSR也是这样的。但我个人更倾向的概念是,服务端渲染特指发生在“服务端接收到用户请求并响应”的这个过程,而像Gatsby这样在编译时渲染,是构建过程的一部分,发生的早得多。

因此,很多人也将其称为**静态页面生成(Static Site Generation,SSG)服务端生成(Server-Side Generated)**技术。

客户端的代码

如今的web应用都是动态的、响应式的,而这种体验是无法仅仅通过HTML和CSS实现的。所以我们仍然需要运行客户端的JS代码。

客户端同样包含了一份React的代码,它会试图描绘出页面应该是长什么样子,然后它拿自己想象出的样子去与客户端的HTML进行匹配。这个过程,就叫做Hydration

严格来说,Hydration并不等同于渲染(render)。在常规的渲染过程中,当props和state改变,React将会比较改动并更新DOM节点。在hydration过程中,React会假设DOM节点没有改变,并尝试去匹配已有的DOM。

动态渲染

好了,现在我们可以回头看看之前的代码片段:

const Navigation = () => {
  if (typeof window === 'undefined') {
    return null;
  }
  // 假装getUser方法存在
  // getUserand要么返回一个user对象,要么返回`null`
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};

Navigation组件有三种可能的返回:

  • 如果用户已经登录,渲染<AuthenticatedNav>组件。
  • 如果用户没有登录,渲染<UnauthenticatedNav>组件。
  • 如果不知道用户有没有登录,啥都不渲染。

薛定谔的用户

在一个可怕的思想实验中,奥地利物理学者埃尔温·薛定谔描述了这样一个场景:把一只猫放在一个盒子里,在一小时中,盒子内有50%的概率会放出毒气。那么在一小时后,这只猫是生是死的概率也为50%。但直到你打开盒子去确认这件事之前,这只猫可以被认为是处于生或死的叠加状态

在web应用中,我们也处于类似的困境:在用户刚刚打开我们的站点时,我们无法知道用户过去是否已经登录。

这是因为HTML文档是在编译时被构建的。因此不管有没有登录过,每个用户一开始得到的都是同样的一份HTML。只有当JS被解析执行之后,我们才能根据用户的状态去更新UI,那么这之间就有一个明显的时间差。说到底,我们之所以使用SSG,就是为了让慢网用户能快点看到页面,与此同时后台在努力的下载加载解析JS脚本,那这个过程就会相当漫长。

因此有许多的web应用都会默认展示“未登录”的状态,这就会导致一种很常见的闪屏现象:

guardian-with-effect.gif

airbnb.gif

我也构建了一个Gatsby demo去重现这个问题:

the-problem.gif

你可以自己试试,点击“Log in”去登录,再点击一次就可以登出。

一次不完美的尝试

在上面的代码片段中,我们尝试用下面这几行代码去解决这个问题:

const Navigation = () => {
  if (typeof window === 'undefined') {
    return null;
  }

这主意看起来挺靠谱:我们的编译发生在服务端运行时,Node环境。所以我们可以用window是否存在来判断代码是否运行在服务端。如果是的话,我们就早早放弃渲染。

问题在于,这破坏了规则。😬

Hydration ≠ render

在React应用hydrate的时候,它会假设DOM结构是匹配的。

当React第一次在客户端执行,它会在布局所有组件的时候描绘出DOM应该长啥样。然后它会试图将其匹配页面已有的DOM节点。要注意的是,此时它可不是在比较两者之间的差异(就像常规的状态更新时做的那样),而只是在试图将二者粘合在一起,以便进行未来的状态更新。

当我们通过判断代码运行环境而去渲染不同的组件,以上系统就被破坏了——我们在服务端渲染的是A,却让React在客户端得到了B:

<!-- 编译时生成的html -->

<header>
  <h1>Your Site</h1>
</header>
<!-- React在hydrate时期待的html -->

<header>
  <h1>Your Site</h1>
  <nav>
    <a href="/login">Login</a>
  </nav>
</header>

牛就牛在,React有时还能处理这种不匹配的情形。所以即使你干过这事儿,可能也没有产生严重的后果,但可这是在玩火——hydration过程是为了尽快的粘合DOM与React,它可不会帮你正确纠错。

Gatsby的特别行为

React团队意识到了Hydration不匹配可能会导致难以排查的问题,所以他们在console警告中特别指出了这种错误:

error

但问题在于,Gatsby只在生产环境使用SSR相关API。又由于React警告只在开发环境抛出,结果就是这些警告在Gatsby项目中从来不会出现😱。

这就是个取舍的事儿,由于在开发环境禁用了SSR,Gatsby可以让你快速得到代码改动的反馈,不得不说这也非常非常重要。Gatsby就是这样比起准确性更加重视速度的框架。

这里有一个相关的issue要求改进这个问题,所以我们也可能在将来看到hydration相关的警告。

目前呢,我们只能在使用Gatsby时格外小心了!

解决方案

要避免上述的问题,关键就在于hydration过程中,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。所以当hasMountedfalse的时候,我们就不用渲染实际的内容。

useEffect中,re-render立刻被触发,并将hasMounted设置为true。由于值为true,实际的内容就会被渲染了。

这和之前方案的区别在于:useEffect只在组件mount后触发。所以在hydration的时候,useEffect并没有被调用,这就符合React的期待了:

<!-- 编译时生成的html -->

<header>
  <h1>Your Site</h1>
</header>
<!-- React在hydrate时期待的html -->

<header>
  <h1>Your Site</h1>
</header>

在这次比较之后,我们就触发了re-render,这就让React可以后续进行正确的处理。它会注意到我们需要渲染一些新内容——登录后的菜单、登录按钮之类的,然后它会正确的更新DOM。

运用了解决方案后,看起来就像这样:

the-initial-solution.gif

二次渲染

你有没有注意到麦片盒上的保质日期并不是和盒子上的其他图案一起印刷的?它是后面印上去的:

image.png

其背后的逻辑是:麦片盒印刷分为两步。首先,将所有通用的东西印刷上去:logo、卡通文本、大幅麦片图案......这些东西都是固定的,所以它们可以在几个月前就大量印刷。

但保质日期就不能这么印刷了。因为在那个时候,麦片盒的生厂商也不知道保质日期是什么时候,那些被放进盒子里的麦片此时可能还没生产出来呢!所以厂商就在那个位置印了一个蓝方块。在几个月后,麦片被生产出来并放进盒子,厂商就可以将生产日期一起印刷上去并打包运输了。

二次渲染就是同样的道理。第一次渲染发生在编译时,所有的固定、非个人的内容都被生成,并留好动态内容的插槽。然后,当React在客户端mount后,第二次渲染会根据客户端的状态将动态内容塞进去。

性能影响

二次渲染的弊端在于它会延迟可交互时间。因为在mount后立即做一次强制渲染其实不是被推荐的行为。

虽然话这么说,但对于大多数应用来说,区别应该不大。因为动态内容一般是相对较少的,因此可以被较快的处理。如果你的应用有大片大片的动态内容,那它其实不太能利用提前渲染的优势。

总之,如果你很在意性能问题,还是自己做一些实验最好。

代码抽象

在这篇文章中,我需要写很多这种二次渲染的代码,重复这些逻辑显然令人疲惫。所以我抽象了一个<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>

我们甚至可以使用自定义hook

function useHasMounted() {
  const [hasMounted, setHasMounted] = React.useState(false);
  React.useEffect(() => {
    setHasMounted(true);
  }, []);
  return hasMounted;
}
function Navigation() {
  const hasMounted = useHasMounted();
  if (!hasMounted) {
    return null;
  }
  const user = getUser();
  if (user) {
    return (
      <AuthenticatedNav
        user={user}
      />
    );
  }
  return (
    <nav>
      <a href="/login">Login</a>
    </nav>
  );
};

最终通过这些方法,我解决了博客的渲染问题,可喜可贺可喜可贺!

背后的思考方式

代码的抽象方法其实不是本文的核心,更重要的还是背后的思考方式。

当开发Gatsby/Next应用的时候,我发现二次渲染的想法非常有用。第一次渲染发生在编译时,将所有通用的、固定的、用户无关的内容生成。之后在运行时,第二次渲染会将状态有关的、用户相关的内容填入。


译者:以上就是全部的正文内容,原作者最后还打了个他的原创课程广告:The Joy of React,感兴趣的话也请务必看看,因为Josh W. Comeau人家确实是很有水平!

另原文博客是用MDX写的,因此原博客地址阅读其实体验会更好,所以也推荐大家有条件的话去看看原文:The Perils of Hydration

那么下次再见,祝您生活愉快。👻