时间的老账:Date 三十年,和 Temporal 能不能还清

前阵子线上出了个很典型的 bug:运营配了一个活动,"9 月 30 日 23:59 结束",页面上倒计时却比预期早了一个小时。排查了半天,最后发现原因特别朴素——有人在配置里写了本地时间,有人写了 UTC,还有一段代码用 +86400000 来"加一天"。

这三种写法在一年里的三百多天都是对的。只有夏令时切换的那两天会错。

暗色桌面上散落着几个指向不同时刻的指针时钟,空中漂浮着日历页和时区标签,暖色灯光

这类 bug 我一直归咎于自己不够小心。但 Temporal 提案文档里有一句话让我改了想法:Date 不是被用错了,它是设计错了。 它从 1995 年被复制出来那天起,就带着一套不成立的模型。

# 一、Date 的出身问题

1995 年,Brendan Eich 用十天写出了 JavaScript 的第一版。日期部分没什么时间设计,直接照抄了当时 Java 的 java.util.Date——连 getYear() 返回 1900 偏移、月份从 0 开始这些毛病都一起抄了过来。后来 Java 自己在 1.1 版本把 java.util.Date 的大部分方法标记为废弃,换上了 Calendar,再后来 Java 8 又换成了 java.time。

Java 换了三代,JavaScript 这一抄,抄了三十年。

复古 CRT 显示器上显示着出错的日历界面,周围散落着写满日期又被划掉的便签,暖黄灯光

具体的问题我按"坑到我过"的程度排个序:

1. 它是可变的。 const a = new Date(); const b = a; b.setMonth(b.getMonth() + 1); —— a 跟着变了。const 挡不住这个,因为它保护的是引用。任何把 Date 当参数传进函数的地方,都得赌对方不会改它。

2. 月份从 0 开始,年份从 1900 开始。 new Date(2026, 8, 26) 是 9 月 26 日。这个我到现在写的时候还要停顿半秒。

3. 它只有一个"绝对时刻"模型。 Date 内部就是一个毫秒数,里面没有任何时区信息。所谓"本地时间"是读取的那一刻用运行环境时区现算出来的。这意味着同一个 Date 对象,在北京和纽约打印出来不一样,而且你没法让一个 Date 对象"固定"在东京时区。

4. 字符串解析是玄学。 new Date('2026-09-26') 按 UTC 解析,new Date('2026/09/26') 按本地解析,new Date('2026-09-26T00:00')(没有时区后缀)在 ES2016 之后又变成按本地解析。规范里写明了:不在 ISO 8601 格式内的字符串,解析行为由实现自己决定。

5. 算术在夏令时那天是错的。 这是最要命的一条,也是开头那个 bug 的根源:

// 美国 2026-03-08 凌晨 2 点跳到 3 点,这一天只有 23 小时
const d = new Date('2026-03-07T12:00:00-05:00')
d.setTime(d.getTime() + 24 * 60 * 60 * 1000)
// → 2026-03-08T13:00:00-04:00,墙上时间变成了下午 1 点,不是中午 12 点

d.setDate(d.getDate() + 1)
// → 2026-03-08T12:00:00-04:00,这个才是对的

setTime(+24h) 和 setDate(+1) 平时等价,切换日那天不等价。而这两行代码在代码库里长得一样无辜。

# 二、Temporal 的核心思路:先分清楚你在说哪种时间

Temporal 最漂亮的地方不是 API 更好用,而是它逼你先回答一个问题:你手里这个值,是"人类钟表上的读数",还是"时间轴上的一个点"?

这两件事在 Date 里被混成了一个东西,在 Temporal 里是两套类型:

  • Instant —— 时间轴上的一个点,精确到纳秒,没有时区、没有日历,等价于"绝对时刻"。对应旧世界里的 Date.getTime()。
  • PlainDate / PlainTime / PlainDateTime —— 墙上时间,就是日历上写的那个"2026 年 9 月 26 日 20:00",不带任何时区。它不需要时区,也不允许你问它"这是 UTC 几点"。
  • ZonedDateTime —— 墙上时间 + IANA 时区(Asia/Shanghai)+ 偏移量。它既是绝对时刻也是墙上时间,两边都能读。
  • Duration —— 一段时间长度,不是"两个点之间的差"的近似,而是年/月/周/日/时/分/秒的独立量。

左右分屏构图:左边是暖色的挂钟显示本地时间,右边是冷色的 UTC 原子钟读数,冷暖光线对比强烈

这个区分一旦做出来,前面那几个坑就自己消失了:

  • 生日、节假日、"每周三下午三点开会" —— 这是 PlainDate / PlainTime,永远不该带时区。你不会希望一个出生日期因为用户换了时区就变成前一天。
  • 日志时间戳、订单创建时间、expires_at —— 这是 Instant,永远不该被当成墙上时间去做 "加一天"。
  • 用户可见的日程、航班起飞时间 —— 这是 ZonedDateTime,必须存 IANA 时区(不是偏移量),因为偏移量会变,时区规则也会变。

# 三、几个高频场景的对照

# 加一天

// 墙上时间加一天:永远是日历上的下一天
Temporal.PlainDate.from('2026-03-07').add({ days: 1 })
// → 2026-03-08

// 带时区加一天:墙上读数保持 12 点,但偏移量跟着变
const zdt = Temporal.ZonedDateTime.from('2026-03-07T12:00:00-05:00[America/New_York]')
zdt.add({ days: 1 })
// → 2026-03-08T12:00:00-04:00[America/New_York]   ← 仍是中午 12 点

zdt.add({ hours: 24 })
// → 2026-03-08T13:00:00-04:00[America/New_York]   ← 绝对时间过了 24 小时,墙上变成 13 点

days 和 hours 在这里终于不再是同一个东西了。语义是被迫显式化的,这在我看来是 Temporal 最大的价值。

# 不存在的那个小时

纽约 2026-03-08 的凌晨 2:30 是不存在的(2 点直接跳到 3 点)。Date 会安静地给你返回一个 3:30,Temporal 会直接抛错:

Temporal.ZonedDateTime.from('2026-03-08T02:30[America/New_York]')
// RangeError: ZonedDateTime does not exist

// 你可以显式告诉它你想怎么办
Temporal.ZonedDateTime.from('2026-03-08T02:30[America/New_York]', { disambiguation: 'later' })
// → 2026-03-08T03:30:00-04:00[America/New_York]

Temporal.ZonedDateTime.from('2026-03-08T02:30[America/New_York]', { disambiguation: 'reject' })
// → 抛错,适合"配置错了就该报错"的场景

disambiguation 有四个值:compatible(默认,等价于 Date 的行为)、earlier、later、reject。同理,秋天"重复的那个小时"用 offset 选项来消歧。以前这些全靠运气。

世界地图以发光的垂直时区条带呈现,其中一条被橙色高亮标出夏令时切换,深蓝背景

# 时区和偏移量不是一回事

这是我以前一直混淆的点。-05:00 只是一个偏移量,美国东部冬天是 -05:00,夏天是 -04:00。America/New_York 是一个时区,它包含了"什么时候用哪个偏移量"的全部历史规则和未来规则。

所以存数据库的时候,别存偏移量,存 IANA 时区名。Temporal 的 ZonedDateTime 把 IANA 时区放进方括号里就是在提醒你这件事:

Temporal.ZonedDateTime.from({ timeZone: 'Asia/Shanghai', year: 2026, month: 9, day: 26, hour: 20 })
// → 2026-09-26T20:00:00+08:00[Asia/Shanghai]

Temporal.Now.zonedDateTimeISO('Asia/Shanghai')
// → 当前时刻,固定渲染在东八区

# 时间差和排序

const a = Temporal.PlainDate.from('2026-01-01')
const b = Temporal.PlainDate.from('2026-09-26')

a.until(b)                            // → P268D
a.until(b, { largestUnit: 'months' }) // → P8M25D
b.since(a)                            // → P268D

// 排序不用再写 getTime() 相减了
list.sort(Temporal.Instant.compare)

until / since 返回的是 Duration,可以再 round、total('hours')、with。注意"一个月是多少天"这个问题没有唯一答案,所以 Duration 里的月和日不会自动互转——除非你给一个 relativeTo。

# 和 Date 互转

迁移期免不了打交道,转换很直接:

const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime())
const back = new Date(instant.epochMilliseconds)

// 或者走字符串
Temporal.Instant.from(date.toISOString())

# 四、现在能直接用吗

这里要说实话:看你的运行环境。

按目前公开的信息(写这篇的时候),Temporal 在 2026 年 3 月的 TC39 会议上进入 Stage 4,正式成为 ES2026 的一部分;Chrome / Edge 从 144 版本(2026 年 1 月)起默认开启;Firefox 也已经跟上;Safari 的进度相对靠后。Node.js 26(2026 年 5 月发布,同年 10 月进 LTS)默认启用了 Temporal。

也就是说:服务端(新版 Node)现在基本可以直接写,浏览器端要看你的用户分布。

整洁的开发者工作台,屏幕上显示着现代 JavaScript 日期与时长代码,旁边漂浮着日历控件和时间轴条,柔和自然光

要用的话,路径大概是这样的:

  1. 新代码优先用 Temporal,旧代码别急着动。 这两套东西可以并存,转换成本很低(就是上面那两行)。
  2. 需要兼容老浏览器就上 polyfill(@js-temporal/polyfill)。它把 Temporal 挂到全局,业务代码不用改。代价是体积——它要打包完整的时区与日历数据,gzip 后仍是几十 KB 量级,比 dayjs 大不少。如果只是为了"计算",可以考虑只在新代码里用、用构建时的能力检测做条件加载。
  3. moment.js 可以先退了。 moment 当年解决的正是 Date 的这些坑(不可变、链式、时区、格式化),现在这些能力原生都有了,它作为"补丁"的使命基本结束。dayjs / date-fns 也依然有用——它们更轻,而且在 Temporal 覆盖不到的地方(比如复杂的自定义格式化)仍然省事。
  4. 最重要的一条:先把"这个字段是墙上时间还是绝对时刻"这件事在代码里显式化。 就算暂时不上 Temporal,把 PlainDate 和 Instant 的区分用命名、注释或者类型标注先表达出来,也已经能挡掉大半的 bug。

# 五、一点感想

我一开始对 Temporal 是有抵触的:Date 我用了十年,闭着眼都能写,现在告诉我这套东西不对,还要多记七八个类型名。

但真把它用在那个倒计时 bug 上之后,我的感受变了。以前我要在脑子里一直维护一条隐含规则:"这里加的是 24 小时还是一天来着?这个时间是本地还是 UTC?" 现在这条规则被搬到了类型系统里——add({ days: 1 }) 和 add({ hours: 24 }) 写在代码里就是两行不一样的东西,读代码的人一眼能看出区别。

好的 API 不是让你少打字,是让你没办法不小心写错。Date 的所有问题本质上都是同一件事:它把两种不同的东西当成了一种。Temporal 花了八年(2018 年进 Stage 1,到 2026 年进 Stage 4)才把这笔三十年的老账理清楚,慢是慢了点,但这次是真的还清了。


(文中浏览器与 Node 的支持情况截至本文写作时,实际使用前请以当前版本的实测结果为准。)