原文: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 开始注水)之前先行执行。这里有几个步骤。
- 需要一个原生 JavaScript 函数来格式化日期。它必须是无依赖和自包含的,因为我们必须将其转换为字符串。
- 将其添加到客户端入口点的
window
对象中。 - 在浏览器绘制之前,向你的日期组件渲染一个额外的
<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 的框架一起工作。
这是它的工作原理和原因:
- 当浏览器解析并执行
<head>
时,我们的formatDate
函数被添加到全局作用域并准备好被调用。它不能是一个模块,因为它必须同步执行。 - 当浏览器获取到由
DateTime
组件在服务器上生成的 HTML 时,它会立即执行我们添加的脚本,客户端格式化的日期将替换<time>
元素的文本内容。 - 浏览器可以渲染客户端格式化的日期,用户只看到本地日期。
- 在
<body>
结束时,React 被执行并对文档进行了 hydration。由于它使用相同的函数来格式化日期,输出是一致的,因此没有 hydration 不匹配的问题。危机解除了! - React 已完成 hydrating,页面现在可交互。
我必须承认这个解决方案是临时应急的,且容易出现错误。但我相信有可能对其进行改进。对可能感兴趣的人一些建议:
- 没有硬性要求使
formatDate
函数无依赖且自包含。你可以使用打包工具来序列化它。 - 入口点中的
<script>
标签有一个suppressHydrationWarning
,因为函数在构建过程中可能经过了打包器,可能与字符串化的版本不完全匹配。 - 这种方法需要一些手动布线,但可以抽象出部分内容,使其更难弄错。甚至可以将其泛化,以便用于不仅仅是日期格式化的其他方面!
嗯,我希望你会觉得它有用。我也希望 React 将来能为这类问题提供一个官方解决方案,这样就不需要这种变通方法了。
如果你使用了它,请告诉我,我很想听听你的使用体验!
感谢 Dogan 审阅了本文的草稿。