Temporal:为什么程序员最怕 2 月 29 日和夏令时

日期、时间点和时区不是同一种东西。本文以会议、生日和日志三个场景拆解 JavaScript Date 的边界问题,讲清 Temporal 的 Instant、PlainDate、ZonedDateTime 等类型,以及如何避免夏令时和跨时区计算陷阱。

Temporal:为什么程序员最怕 2 月 29 日和夏令时

有些 Bug 看起来像玄学:同一个会议在不同人的日历里差了一个小时,月底订阅在某些时区提前一天扣款,出生日期从数据库取出来后变成了前一天。它们通常不是“时间不听话”,而是程序把三种不同的东西混在了一起:绝对发生的时刻、某个地区的墙上时间,以及日历上的日期。

世界时区分布图

JavaScript 过去主要靠一个 Date 对象处理这些问题。它能工作,但 API 既承载时间点,又承载本地日期和时区转换,很多操作还会改变原对象。Temporal 的目标,就是把这些概念拆成一组有明确含义、不可变的类型。TC39 的规范草案列出了时区、夏令时安全运算、日期/时间分离和持续时间等核心能力。Temporal 规范

先别急着记 API:时间其实有三种语义

1. 绝对时刻:全世界只有一个答案

“服务器在 2026 年 8 月 6 日 02:00:00 收到请求”是一个绝对时刻。它通常用 UTC 或 Unix 时间戳表示。无论用户在上海、纽约还是伦敦,这个事件只发生过一次。

适合用绝对时刻的场景包括:日志、支付完成时间、消息发送时间、文件创建时间。它们的重点是“什么时候发生”,而不是“当地钟表显示什么”。Temporal 用 Temporal.Instant 表达这种值:它不带时区,只表示时间线上一个准确位置。

2. 墙上时间:同一个事件在不同地方看起来不同

“上海办公室每天 9:00 开会”不是一个绝对时刻。它首先是一个当地规则:在 Asia/Shanghai 这个时区,每天的墙上时间是 09:00。换算成纽约时间时,必须把时区规则、夏令时和历史变更都考虑进去。

这类值适合 Temporal.ZonedDateTime。它把日期、时间、时区和时间线联系在一起。时区不是简单的“加八小时”,而是一套会随地区政策变化的规则数据库。IANA 的时区数据库正是很多运行时进行转换时依赖的基础。IANA Time Zone Database

3. 日历日期:生日不是一个瞬间

“用户生日是 8 月 6 日”通常不应该被转换成 UTC。把它存成某个时间点后,用户在另一个时区打开页面,生日可能显示成 8 月 5 日。这是因为生日是日历概念,不是全球同步发生的事件。

这类值应该使用 Temporal.PlainDate。类似地,“店铺每天 09:00 开门”可以用 Temporal.PlainTime,而“2026 年 8 月 6 日 09:00”但暂时不知道在哪个时区,可以用 Temporal.PlainDateTime。它们故意不替你猜时区。

Temporal 解决的不是“日期格式丑”,而是边界不清

看一个常见的会议例子:

const meeting = Temporal.ZonedDateTime.from(
  "2026-08-06T09:00:00+08:00[Asia/Shanghai]"
)

const inNewYork = meeting.withTimeZone("America/New_York")
console.log(inNewYork.toString())

这里的含义很清楚:会议发生在上海时区的 9 点,然后把同一个瞬间显示成纽约时间。withTimeZone 改变的是“怎么看”,不是会议本身。

如果业务说的是“从会议开始后经过两小时”,应使用时间线上的加法;如果业务说的是“下个月同一天的 9 点”,应使用日历加法。两者在夏令时切换附近可能得到不同结果,这正是很多排班 Bug 的来源。

const start = Temporal.ZonedDateTime.from(
  "2026-03-08T01:30:00-08:00[America/Los_Angeles]"
)

const twoHoursLater = start.add({ hours: 2 })
const nextCalendarDay = start.add({ days: 1 })

“加两小时”强调经过了 7200 秒;“加一天”强调日历向后翻一页。遇到夏令时缺失或重复的本地时间,Temporal 还允许通过选项明确指定如何处理,而不是静默地替你做一个很难发现的决定。

不可变性:少一个隐形副作用

传统 Date 的很多方法会修改原对象。一个函数如果拿到 Date 后调用 setHours,调用者手里的值也可能被改变。Temporal 对象是不可变的:addwithwithTimeZone 都会返回新对象。这个设计让时间计算更接近普通值,更容易测试,也更适合在前端状态管理中传递。

但不可变不等于“自动正确”。你仍然要在数据模型里做选择:

  • 支付、日志和消息事件存 Instant
  • 生日、节假日和账单日存 PlainDate
  • 固定地点的营业时间存 PlainTime 加时区规则。
  • 远程会议存带时区的 ZonedDateTime,展示时再转换。
  • “三天后”要先问清楚是 72 小时,还是跨过三个日历日期。

Temporal 现在适合怎么用

第一步不是把项目里所有 Date 全部替换掉,而是盘点字段的语义。可以从新功能开始:订单事件使用 Instant,生日使用 PlainDate,会议使用 ZonedDateTime。老接口仍然需要和 ISO 字符串、Unix 时间戳互操作,因此要把转换集中在边界层,而不是让每个组件各自解析日期。

还要特别测试三类日期:时区切换前后、月份末尾、闰年 2 月 29 日。时间处理的难点从来不是把字符串格式化得漂亮,而是让每个值从一开始就有准确的含义。

一句话带走

时间 Bug 的根源经常不是算法错,而是“生日、会议和日志”被当成了同一种数据。Temporal 的价值,是让类型先替你问一句:你说的到底是一个瞬间,还是某个地方的钟表,还是日历上的一天?

延伸阅读

KEEP READING