译:避免使用 useSyncExternalStore 导致的 Hydration 不匹配

Photo by Chanhee Lee

Hydration 不匹配是 React 开发者可能面临的最令人畏惧的错误之一:

Uncaught Error: Text content does not match server-rendered HTML.

我们怎么会这样?我们被承诺了同构渲染——先在服务器上,然后在客户端。编写一次代码,执行两次。

服务器不是客户端。它可能在另一个时区运行,或者使用不同的区域设置,因此渲染的信息与客户端不同,例如涉及日期时。它也无法访问只有在浏览器中才可用的 API,比如 window

// 日期渲染
function LastUpdated() {
  const date = getLastUpdated()
  return <span>Last updated at: {date.toLocaleDateString()}</span>
}

如果服务器的区域设置与客户端不同,日期可能在一端显示为 “21/02/2024”,而在另一端显示为 “2/21/2024”。当发生这种不匹配时,React 会向我们报错,因为它希望服务器渲染的输出与客户端上的完全匹配,以提供最佳的用户体验

但正如我们所见,有些情况下的不匹配是不可避免的。那么我们该如何“修复”它呢?

suppressHydrationWarning

这感觉有点像 eslint-ignore@ts-expect-error ,如果你知道自己在做什么,这大概是没问题的。只需在相关元素上加上 suppressHydrationWarning ,然后就大功告成了:

function LastUpdated() {
  const date = getLastUpdated()
  return (
    <span suppressHydrationWarning>
      Last updated at: {date.toLocaleDateString()}
    </span>
  )
}

根据文档,这是一个紧急出口,不应该被过度使用。那么我们还能做什么呢?

双重渲染通道

另一个流行的解决方案是在客户端渲染两次。我们在服务器上用我们拥有的信息进行渲染,这将产生静态标记。然后,在客户端,我们会尝试在第一次渲染周期产生与服务器上相同的输出。这确保了 hydration 不会出错。之后,我们将触发另一个渲染周期,使用“真实”的客户端信息。

当然,这里的缺点是内容短暂的闪现,正如我们在这个动画中看到的(注意时间):

由于时区信息只有客户端知道,服务器渲染不可能知道要显示的正确时间,因为这对每个用户来说都是不同的,这取决于他们的位置。

另一种变体是仅在服务器上渲染 null ,并让正确的内容仅在客户端“出现”。

无论你为服务器渲染选择什么值,代码通常看起来会像这样:

function LastUpdated() {
  const [isClient, setIsClient] = React.useState(false)

  React.useEffect(() => {
    setIsClient(true)
  }, [])

  if (!isClient) {
    return null
  }

  const date = getLastUpdated()
  return <span>Last updated at: {date.toLocaleDateString()}</span>
}

由于效果不会在服务器上运行, null 将首先被返回。然后,在客户端,第一次渲染周期也会产生 null 。在效果生效后,我们的日期将被正确显示。

这段代码有点像样板代码,但如果我们愿意的话可以将其抽象出来(我敢肯定也有现成的包 😂),而且这是一个相当常见的模式。

那么问题出在哪里?

客户端转场

在组件是服务器渲染的情况下,双重渲染是有问题的。但在传统的 SSR 应用程序中,并不是每个页面都是服务器渲染的。通常,只有你首次访问的页面需要生成静态标记。之后,每次导航都是客户端转换,类似于 SPA。它们会发出异步请求来获取数据(想象在 nextJs 中的 getServerSideProps ),然后只在客户端渲染下一个页面。

在这些情况下,使用 useEffect 的双重渲染通道解决方法会不必要地拖慢我们的速度。我们已经在客户端了,但我们的代码并不知道这一点。它会无论如何渲染 null ,然后触发 effect,然后渲染内容。而且如果我们在客户端,我们也不能添加额外的检查,因为 SSR 后的第一次客户端渲染也需要渲染 null 。😭

我们正在寻找的解决方案需要了解服务器渲染,更重要的是,要知道首次客户端渲染何时发生。而令人惊讶的是,对此最佳的钩子似乎是 useSyncExternalStore

useSyncExternalStore

即使 useSyncExternalStore 的主要用途是订阅外部存储,它还有一个有趣的第二特性:它允许我们区分 serverSnapshotclientSnapshot 。让我们看看文档getServerSnapshot 的说明:

getServerSnapshot

返回存储中数据初始快照的函数。它将仅在服务器渲染期间以及在客户端对服务器渲染内容进行水合作用时使用。服务器快照在客户端和服务器之间必须相同,通常会序列化后从服务器传递到客户端。

这正是我们需要避免水合错误的内容,而且,如果我们在客户端转到一个包含 useSyncExternalStore 的页面, clientSnapshot 将会立即被采用。

只有一个问题:我们应该订阅哪个“store”?这看起来可能有点奇怪,但答案是:我们将使用一个空的 store 订阅,它永远不会更新。 clientSnapshot 在每次渲染时都会被评估,而且没有必要从 react 外部推送更新到这个组件。

由于 subscribe 参数是必须的,我们的代码可能看起来像这样:

const emptySubscribe = () => () => {}

function LastUpdated() {
  const date = React.useSyncExternalStore(
    emptySubscribe,
    () => lastUpdated.toLocaleDateString(),
    () => null
  )

  return date ? <span>Last updated at: {date}</span> : null
}

subscribe 需要是一个稳定的函数,所以我们必须在 React 组件外部声明它。我发现这种模式有点取巧,所以在发布这篇文章之前,我必须从 React 团队那里得到确认,这是一个好主意。

我希望有更符合人体工学的方法,但我还没有找到。也许我应该把这个做成一个包…… 🤔


这种模式也使得创建一个 ClientGate 变得非常容易——一个只会在客户端渲染的组件,在那里你可以安全地访问仅限浏览器的 API:

function ClientGate({ children }) {
  const isServer = React.useSyncExternalStore(
    emptySubscribe,
    () => false,
    () => true
  )

  return isServer ? null : children()
}

function App() {
  return (
    <main>
      Hello Server
      <ClientGate>{() => `Hello Client ${window.title}`}</ClientGate>
    </main>
  )
}

最小化布局偏移

布局偏移并不理想,仅因为一个细节依赖于客户端信息就不完全渲染一个组件,可能会导致比必要的更大的布局偏移。如果 JS 永远不加载——我们将无法显示屏幕那些部分的任何信息。

所以如果我们能在服务器上产生一个稳定的日期输出,我们就可以将内容的变动限制在日期上,类似于之前展示的 GIF 中页面所做的那样:

const emptySubscribe = () => () => {}

function LastUpdated() {
  const date = React.useSyncExternalStore(
    emptySubscribe,
    () => lastUpdated.toLocaleDateString(),
    () => lastUpdated.toLocaleDateString('en-US')
  )

  return <span>Last updated at: {date}</span>
}

请注意,传递一个静态的 locale 是很重要的,因为服务器快照将在服务器和客户端上进行评估。如果我们让运行时推断 locale(通过不传递任何东西),我们也会遇到整个 hydration 错误场景。

Client hints

未来,HTTP Client hints 希望能改善整个情况,因为它将为服务器提供一种在渲染任何内容之前了解仅在客户端可用的信息的方法。有了这个,我们可以在不需要诉诸变通方法的情况下进行正确的服务器端渲染(SSR)。在那之前,我认为这已经是目前能做到的最好的了,而且我更倾向于 useSyncExternalStore 而不是效果解决方案。


今天就到这里。如果你有任何问题,欢迎在推特上联系我。