译:如何避免 SSR 渲染本地日期时的 FOUC 问题

原文:https://blog.6nok.org/server-side-rendering-local-dates-without-fouc/
作者:Fatih
译者:ChatGPT 4 Turbo

编者注:SSR 注水不匹配会导致 FOUC 等问题,其原因很多,比如时区、亮黑主题等,同时解法也很多,比如 suppressHydrationWarning、client hints、额外注入客户端脚本修改等。本文介绍的方法就是最后一种,虽然有点 Hack,但效果不错,避免了 FOUC。期待 React 有针对这个问题的官方解吧。

如果你在使用 React 对用户的本地时区进行服务器端渲染日期时,可能会遇到水合作用不匹配的问题。服务器无法知道用户的时区;这将不可避免地导致不匹配,并在控制台生成错误日志。

如果这就是你遇到的问题,你可以在 <time> 元素上加上一个 suppressHydrationWarning ,然后就大功告成了。这是官方推荐的针对这个特定用例的方法。

但如果你对那种服务器渲染的日期显示出来,直到 hydration 完成的过程中出现的不协调感到困扰,那么你来对地方了。

在开始之前,让我告诉你这是一个长期存在的问题,有不同的解决方案和权衡。我将分享一些解决方案和我想出的一个新颖方法。


首先,让我们明确一些概念。如果你对服务器端渲染、注水或不匹配感到困惑,请查看 Josh Comeau 关于这个话题的优秀文章

  • React 传统上是一个客户端渲染库,它接收一个空白页面,并在客户端生成 HTML。
  • 服务器端渲染会在服务器上将应用程序转换为 HTML,因此您的用户不会最初看到一个空白页面。
  • Hydration 是 React 在概念上将其将要生成的 HTML 与服务器已经生成的 HTML 对齐,并附加自身以启用交互性的步骤。
  • 如果 React 生成的 HTML 与服务器生成的 HTML 不匹配,我们称之为 Hydration 不匹配,React 会抛出一个错误。
  • Hydration 不匹配可能由许多原因造成,但最常见的原因是服务器和客户端在根本上不同。它们有不同的 API 同时理解的是不同的事物。
  • 用户时区是已知差异之一,在渲染页面之前,服务器无法知道用户的时区。(注:你可以尝试从 IP 猜测时区,但在某些场景下还是不够的,比如用户用了 VPN。Vercel 的 Edge 里有提供相应的请求头。)

最后一点值得更多阐述。假设你需要用户的地区设置。在这种情况下,你可以从请求头中猜测它。Donavon West 关于这个话题有一篇很好的文章,我强烈推荐。

另一方面,如果你愿意多做一次 round trip,你就可以从客户端那里获取到信息。Jacob Paris 发现了一种快速通知服务器的好方法。Kent C. Dodds 将这个想法发展成了一个精心挑选了默认设置的,如果你对它的权衡满意,你可以使用它。

然而,也许你对未经水合的内容闪烁(FOUC)感到舒适。如果是这样,你可以遵循我最初的建议,添加一个 suppressHydrationWarning 属性,或者使用众多 useIsSSR 钩子之一,它只在水合完成后才返回 false ,以避免不匹配。

但 FOUC 是一个重要问题,可能会导致累积布局偏移(CLS)或者假设性地发送错误的警报。如果你可以接受一次 round trip 往返,并且想要一个强大的解决方案,可以考虑使用 client-hints 。否则,请继续阅读。


我的方法借鉴了 Josh 的另一篇关于类似问题的精彩文章,那是可怕的浅色模式闪烁问题。(注:关于这个问题,Jenna 的渐进增强修复和 Oliver 的条件渲染工作也无可否认地具有影响力)本质上是相同的技术:注入一个同步脚本,在内容被绘制(或 React 开始注水)之前先行执行。这里有几个步骤。

  1. 需要一个原生 JavaScript 函数来格式化日期。它必须是无依赖和自包含的,因为我们必须将其转换为字符串。
  2. 将其添加到客户端入口点的 window 对象中。
  3. 在浏览器绘制之前,向你的日期组件渲染一个额外的 <script> 标签,以用客户端格式化的数据替换服务器渲染的 HTML。

以下是代码:

// format-date.js
export function formatDate(date) {
  return new Intl.DateTimeFormat("en-US", {
    dateStyle: "medium",
    timeStyle: "medium",
  }).format(date);
}
// root.js
import { formatDate } from "./format-date.js"
function Document() {
  return (
    <html>
      <head>
        <script
          suppressHydrationWarning
          dangerouslySetInnerHTML={{
            __html: `window.formatDate = ${formatDate.toString()}`,
          }}
        />
      </head>
      <body>{/* Your app here! */}</body>
    </html>
  )
}
// DateTime.js
import { formatDate } from "./format-date.js"
export function DateTime({ date }) {
  const isoString = date.toISOString()
  const formattedDate = formatDate(date)
  const id = useId()

  return (
    <>
      <time dateTime={isoString} id={id}>
        {formattedDate}
      </time>
      <script
        dangerouslySetInnerHTML={{
          __html: `
            document.getElementById("${id}").textContent = window.formatDate(new Date("${isoString}"));
          `,
        }}
      />
    </>
  )
}

查看示例以查看它的实际操作并进行尝试。即使示例使用的是 Remix,它也应该能够与 Next.js 或任何其他支持 SSR 的框架一起工作。

这是它的工作原理和原因:

  1. 当浏览器解析并执行 <head> 时,我们的 formatDate 函数被添加到全局作用域并准备好被调用。它不能是一个模块,因为它必须同步执行。
  2. 当浏览器获取到由 DateTime 组件在服务器上生成的 HTML 时,它会立即执行我们添加的脚本,客户端格式化的日期将替换 <time> 元素的文本内容。
  3. 浏览器可以渲染客户端格式化的日期,用户只看到本地日期。
  4. <body> 结束时,React 被执行并对文档进行了 hydration。由于它使用相同的函数来格式化日期,输出是一致的,因此没有 hydration 不匹配的问题。危机解除了!
  5. React 已完成 hydrating,页面现在可交互。

我必须承认这个解决方案是临时应急的,且容易出现错误。但我相信有可能对其进行改进。对可能感兴趣的人一些建议:

  • 没有硬性要求使 formatDate 函数无依赖且自包含。你可以使用打包工具来序列化它。
  • 入口点中的 <script> 标签有一个 suppressHydrationWarning ,因为函数在构建过程中可能经过了打包器,可能与字符串化的版本不完全匹配。
  • 这种方法需要一些手动布线,但可以抽象出部分内容,使其更难弄错。甚至可以将其泛化,以便用于不仅仅是日期格式化的其他方面!

嗯,我希望你会觉得它有用。我也希望 React 将来能为这类问题提供一个官方解决方案,这样就不需要这种变通方法了。

如果你使用了它,请告诉我,我很想听听你的使用体验!

感谢 Dogan 审阅了本文的草稿。