Skip to content
博客在 Next.js 中可靠地格式化日期

在 Next.js 中可靠地格式化日期

2024年9月25日(2025年3月28日更新) · 作者 Jan Amann

让我们来看一下下面的组件:

import {formatDistance} from 'date-fns';
 
type Props = {
  published: Date;
};
 
export default function BlogPostPublishedDate({published}: Props) {
  const now = new Date();
 
  // ... 这样写可以吗?🤔
  return <p>{formatDistance(published, now, {addSuffix: true})}</p>;
}

对这个组件进行快速的本地测试,会渲染出预期的结果:

1 hour ago

那么,我们应该将这段代码推向生产环境吗?

嗯,等等——感觉有点不对劲。让我们一起仔细看看。

环境差异

由于该组件既没有使用 React 的任何交互特性(如 useState),也没有读取服务器端数据,因此可以视为一个共享组件。这意味着根据组件被导入的位置不同,它可能渲染为服务器组件或客户端组件。

让我们来看看在不同情况下该组件的渲染环境:

类型服务器客户端
服务器组件
客户端组件

好消息是,如果我们把该组件作为服务器组件渲染,只需考虑一个环境:服务器。最终的标记就是在那里生成的,这也是用户所看到的内容。

对于客户端组件,情况则复杂一些。因为服务器和客户端在组件渲染时的本地时间很可能不同,我们可以预料到该组件在不同环境下的渲染结果可能不一致。

这里还有一个有趣的细节:由于我们的组件被归类为共享组件,它可以在任一环境中运行。即使开发者最初期望它作为服务器组件运行,如果将来该组件被导入到客户端组件中,它可能会无声地切换成客户端组件——从而增加了需要考虑的渲染环境。

水合不匹配

让我们仔细看看当 BlogPostPublishedDate 被当作客户端组件渲染时会发生什么。

在这种情况下,now 变量在服务器和客户端之间总是会不同,因为这两个环境之间存在延迟。根据缓存等因素,这种差异可能还会非常显著。

// 服务器端: "1 hour ago"
formatDistance(published, now, {addSuffix: true})}
 
// 客户端: "8 days ago"
formatDistance(published, now, {addSuffix: true})}

当 React 遇到这种情况时,通常会报错:

文本内容与服务器渲染的 HTML 不匹配

有趣的是,社区里也在讨论未来 React 可能会修补 Date 对象,这或许能帮助缓解此类问题。

不过目前情况并非如此,还有更多细节需要考虑——我们先继续往下说。

纯净性

这个组件的关键部分是:

const now = new Date();

如果你使用 React 有一段时间了,可能熟悉组件必须是_纯净的_这个概念。

引用 React 官方文档,我们可以通过两点保持组件纯净

专注自身,不影响外部: 不修改调用它之前存在的任何对象或变量。 相同输入,输出相同: 给定相同的输入,一个纯函数应该始终返回相同的结果。

由于组件在渲染中读取了持续变化的 new Date(),它违反了“相同输入,输出相同”的原则。React 组件需要函数式纯净性,确保在重新渲染时(可能随时发生,且往往用户没有显式请求更新时)输出一致。

但这对所有组件都适用吗?事实上,随着服务器组件的引入,出现了一种新的组件类型,它不受“相同输入,输出相同”这一限制。服务器组件可以获取数据,输出依赖外部系统的状态。这也没关系,因为服务器组件只会生成一次输出——在服务器端

那么这对我们的组件意味着什么?

利用服务器组件

没错,你可能已经猜到了:我们可以将 now 变量的创建移到服务器组件里,并作为 prop 传递下去。

page.tsx
import BlogPostPublishedDate from './BlogPostPublishedDate';
 
export default function BlogPostPage() {
  // ✅ 仅在服务器调用
  const now = new Date();
 
  const published = ...;
 
  return <BlogPostPublishedDate now={now} published={published} />;
}

Next.js 中页面默认作为服务器组件渲染,因此如果此文件没有 'use client' 指令,我们可以确定 now 变量只在服务器端创建。

传递给 BlogPostPublishedDatenow prop 是一个 Date 实例,React 可以自然地序列化 它。这意味着无论组件是在服务器还是客户端执行,都能正确使用这个值。

值得一提的是,发布时间现在会根据页面的缓存规则更新,因此如果页脚是静态渲染的,你可能想要引入revalidate 策略。

有人可能会争辩说,你甚至可能_想_在客户端渲染更新后的时间——但这种方法的权衡是最终渲染依赖客户端代码执行。如果这符合场景需求,你也可以使用 suppressHydrationWarning 来告诉 React 允许客户端更新该标记。

我们现在完成了吗?

几点时间?

如果我们有更多依赖当前时间的组件怎么办?我们可以在每个需要的组件中实例化 now,但考虑到单次渲染过程中也可能存在时间差异,若你使用需要精准时间的日期数据,可能会产生不一致。

一种确保所有组件共用单一 now 值的方法是使用 React 的 cache() 函数:

getNow.ts
import {cache} from 'react';
 
// 第一个调用 `getNow()` 的组件会触发创建 `Date` 实例。
const getNow = cache(() => new Date());
 
export default getNow;

… 并在页面组件中这样使用:

page.tsx
import getNow from './getNow';
 
export default function BlogPostPage() {
  // ✅ 当前请求中保持一致,
  // 无论调用时机如何
  const now = getNow();
  // ...
}

这样,第一个调用 getNow() 的组件会创建时间实例,并绑定到该请求,随后所有调用都会复用这个实例。

好,现在完成了吗?

现在到了哪一步?

我们已仔细避免水合不匹配,并在所有组件中建立了一致的时间处理。但如果有一天我们决定不显示相对时间(例如“2天前”),而是显示具体日期(如“2024年9月25日”)呢?

import {format} from 'date-fns';
 
type Props = {
  published: Date;
};
 
export default function BlogPostPublishedDate({published}: Props) {
  // 现在不再需要 `now` 了?🤔
  return <p>{format(published, 'MMM d, yyyy')}</p>;
}

本地快速测试一切正常,所以我们推向生产环境。

文本内容与服务器渲染的 HTML 不匹配

又回到原点了。

这是怎么回事?尽管本地测试正常,但在生产环境中却出现错误。

原因是:时区

处理时区

作为性能导向的开发者,我们可能希望将应用部署在接近用户的位置,但不能指望服务器和客户端共享同一时区。这意味着调用 format 时,服务器和客户端上的结果可能不同。

就我们而言,可能导致显示不同的日期。更复杂的是,这种情况只有在一天中特定时间,在两个环境的时区差异足够显著时才会出现。

这类 bug 可能需要大量排查调试。我深有体会,曾写过多个冗长的 PR 描述来修复这类问题。

修复方案与我们对 now 变量的处理类似:可以在服务器组件创建一个 timeZone 变量,将其作为单一真实来源。

page.tsx
export default function BlogPostPage() {
  // ...
 
  // 使用服务器的时区
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
 
  return <BlogPostPublishedDate timeZone={timeZone} published={published} />;
}

要将时区信息融入日期格式化中,可以使用 date-fns-tz 包,它是基于 date-fns 的拓展,支持根据指定时区格式化日期。

import {format} from 'date-fns-tz';
 
type Props = {
  published: Date;
  timeZone: string;
};
 
export default function BlogPostPublishedDate({published, timeZone}: Props) {
  return <p>{format(published, timeZone, 'MMM d, yyyy')}</p>;
}

对于你的应用来说,坚持使用单一时区无疑是最简单的解决方案。但如果你想按照用户的时区格式化日期,则可能需要在服务器端知道用户时区,以便在服务器端代码中使用。

因为浏览器不会在 HTTP 请求里携带用户时区信息,主要有两种方法在服务器端获取:

  1. 在客户端设置包含浏览器时区的 cookie,然后服务器端通过 cookies() 读取
  2. 利用用户 IP 的大致地理信息(如 x-vercel-ip-timezone),但需要注意这只是估算。

本地化日期格式

到目前为止,我们默认假设应用面向的是讲美式英文的用户,日期格式是:

Sep 25, 2024

情况会变得更有趣,如果考虑到日期格式并非一致的。例如英国可能写作 “19 Sept 2024”,日和月顺序调换。

如果想要本地化应用到其他语言,或支持多语言,就得考虑用户的_地区设置_(locale)。简单来说,locale 代表用户语言,且可包含额外信息,例如地区(如 en-GB 代表英国英语)。

面对这个新需求,你可能已经猜到下一步怎么做了。

为了确保服务器和客户端间日期格式一致,我们需要在服务器组件中创建一个 locale 变量并传递给相关组件,配合像 date-fns-tz 这样的库一起根据 locale 格式化日期。

import {format} from 'date-fns-tz';
 
type Props = {
  published: Date;
  timeZone: string;
  locale: string;
};
 
export default function BlogPostPublishedDate({
  published,
  timeZone,
  locale
}: Props) {
  return <p>{format(published, timeZone, 'MMM d, yyyy', {locale})}</p>;
}

现在重要的是,要将 locale 传递给所有格式化调用,因为它和之前的 timeZonenow 值一样,都可能因环境不同而异。

next-intl 能帮忙吗?

本文讨论的主要问题,是 Next.js 中在服务器和客户端之间格式化日期时出现的水合不匹配。为避免这些错误,我们需要确保三个关键环境属性在整个应用中保持共享:

  1. now:表示当前时间的单一共享时间戳
  2. timeZone:用户的地理时区,影响日期偏移
  3. locale:语言和区域设置,用于本地化

既然你正在阅读 next-intl 官方博客,可能已经猜到我们对此有自己的见解。需要说明的是,这并非在批评诸如 date-fns 之类的库,我反而非常推荐它们。

本帖讨论的挑战主要是如何在 Next.js 应用中集中管理并分发环境配置,涉及服务器和客户端间的混合渲染,确保日期格式化一致。即使仅支持单一语言,也需要细致思考。

next-intl 通过一个集中式的 i18n/request.ts 模块来提供请求特定的环境配置,如 nowtimeZone 及用户 locale

src/i18n/request.ts
import {getRequestConfig} from 'next-intl/server';
 
export default getRequestConfig(async () => ({
  // (可选择在应用中共享的值)
  now: new Date(),
 
  // (默认使用服务器时区)
  timeZone: 'Europe/Berlin',
 
  // (需要显式设置偏好)
  locale: 'en'
 
  // ...
}));

值得注意的是,共享 now 是可选的,因为有些应用可能希望在客户端渲染相对时间时显示更新的日期,或者为了更细粒度的缓存控制,允许稍有差异的日期显示,配合 dynamicIO 使用。

正如 getRequestConfig 名称所示,配置对象是按请求动态创建的,可以根据特定用户偏好来动态调整。

这套机制可用于任何服务器和客户端混合环境中的组件日期格式化:

import {useFormatter} from 'next-intl';
 
type Props = {
  published: Date;
};
 
export default function BlogPostPublishedDate({published}: Props) {
  // ✅ 在任意环境下均可使用
  const format = useFormatter();
 
  // "Sep 25, 2024"
  format.dateTime(published);
 
  // "8 days ago"
  format.relativeTime(published);
}

在背后,i18n/request.ts 由所有只运行于服务器的代码(通常是服务器组件,也包括服务器操作或路由处理器)引用。反过来,通常位于应用根布局的 NextIntlClientProvider 继承该配置并让所有客户端代码可用。

如此一来,诸如 format.dateTime(…) 这类格式化函数就能在任何环境中无缝访问所需配置。配置随后传递给 JavaScript 原生 API 如 Intl.DateTimeFormat,实现正确且一致的格式化。

(本文已针对 next-intl@4.0 进行了更新)


相关阅读

虽然本文主要聚焦于日期格式化,但如果你想深入了解这个主题,我推荐以下资源:

  1. API 和 JavaScript 日期陷阱 —— Jökull Solberg
  2. 时间与时区的问题 —— Computerphile
  3. date-fns —— Sasha Koss

Let’s keep in touch: