Temporal 上来就先问你:你手上的是哪一类时间

JavaScript 的 Date 内部只存一个毫秒时间戳,却通过一堆 UTC 方法和宿主本地方法混杂着暴露出来。这个设计处理简单的瞬间还行,一碰到日历日期、具名时区和夏令时切换就出了名地难用。

Temporal 把这些概念拆成了各自独立的不可变类型。于是第一个问题不再是“我该调哪个方法”,而是“这个值到底是什么意思”。

需求 Temporal 类型
时间轴上的确切一点 Temporal.Instant
某个 IANA 时区里的日期和时间 Temporal.ZonedDateTime
不带时区的日历日期加钟面时间 Temporal.PlainDateTime
生日这类日历日期 Temporal.PlainDate
开门时间这类钟面时间 Temporal.PlainTime
一段日历或钟面意义上的时长 Temporal.Duration

TC39 的规范是一份 2026 年 7 月的 Stage 4 草案。Firefox 和 Chrome 已经发布了这个 API,Node.js 26 也默认启用,但 MDN 仍把它标为「有限可用」。所以特性检测依然是稳妥上线的一部分。

Instant 就是那个 Unix 时间戳类型

当这个值回答的是这件事什么时候发生的,就用 Temporal.Instant。它表示 UTC 时间轴上的一个点,分辨率到纳秒。

const fromMilliseconds = Temporal.Instant.fromEpochMilliseconds(
  1_700_000_000_000,
);

const fromNanoseconds = Temporal.Instant.fromEpochNanoseconds(
  1_700_000_000_000_000_000n,
);

console.log(fromMilliseconds.toString());
// "2023-11-14T22:13:20Z"

末尾那个 n 把纳秒值变成了 BigInt。当下这个年代的纪元纳秒值,JavaScript 的 Number 精确表示不了。

最终定稿的构造接口提供的是毫秒和纳秒两种方法:

Temporal.Instant.fromEpochMilliseconds(epochMilliseconds);
Temporal.Instant.fromEpochNanoseconds(epochNanoseconds);

Unix 秒请显式换算:

const epochSeconds = 1_700_000_000;
const instant = Temporal.Instant.fromEpochMilliseconds(epochSeconds * 1_000);

在边界上给单位命名,这件事依然重要。Temporal 无从知道一个没标注的整数究竟是秒、是毫秒,还是某个非 Unix 纪元的计数。

把一个瞬间转成钟面视图

瞬间本身不含任何城市信息,也不含民用时间规则。需要本地视图时,套上一个 IANA 时区:

const instant = Temporal.Instant.from("2026-11-01T05:30:00Z");
const newYork = instant.toZonedDateTimeISO("America/New_York");

console.log(newYork.toString());
// "2026-11-01T01:30:00-04:00[America/New_York]"

Temporal.ZonedDateTime 把瞬间、IANA 时区和日历组合在一起。它的字符串里同时包含当前偏移和时区标注。正是这份额外信息,让跨越时区切换的日历运算成为可能。

方括号那种写法来自 RFC 9557 的互联网扩展日期时间格式。偏移量描述的是某一特定瞬间与 UTC 的关系,而具名时区提供的是其他瞬间所需的规则。

把夏令时歧义变成一条策略,而不是一次意外

有些本地时间不存在,有些会出现两次。Temporal.ZonedDateTime.from() 为这个反向映射提供了一个消歧选项:

const repeatedTime = {
  timeZone: "America/New_York",
  year: 2026,
  month: 11,
  day: 1,
  hour: 1,
  minute: 30,
};

const earlier = Temporal.ZonedDateTime.from(repeatedTime, {
  disambiguation: "earlier",
});

const later = Temporal.ZonedDateTime.from(repeatedTime, {
  disambiguation: "later",
});

console.log(earlier.toString());
console.log(later.toString());

几个选项的行为如下:

选项 遇到重叠 遇到空档
compatible 取靠前那次 向后顺延
earlier 取靠前那次 向前跨过空档
later 取靠后那次 向后跨过空档
reject 抛错 抛错

在用户输入的边界上,如果“悄悄把预约挪走”会让人意外,就用 reject。只有当产品需求明确规定了别的行为时,才选其他策略。

日历运算和流逝时间,本来就是有意分开的

跨越夏令时切换时,“明天的同一个本地时间”和“24 小时之后”给出的答案可能不一样。

const start = Temporal.ZonedDateTime.from(
  "2026-03-07T12:00:00-05:00[America/New_York]",
);

const sameLocalTimeTomorrow = start.add({ days: 1 });

const exactlyTwentyFourHoursLater = start
  .toInstant()
  .add({ hours: 24 })
  .toZonedDateTimeISO("America/New_York");

console.log(sameLocalTimeTomorrow.toString());
// "2026-03-08T12:00:00-04:00[America/New_York]"

console.log(exactlyTwentyFourHoursLater.toString());
// "2026-03-08T13:00:00-04:00[America/New_York]"

加一个日历日,保住的是本地钟面,在这个例子里实际只跨了 23 小时。给瞬间加 24 小时,保住的是流逝时间,代价是显示出来的钟面变了。

这不是 Temporal 的怪癖。日历运算和时间轴运算本来就是两回事,Temporal 只是通过“你选了哪个类型、调了哪个操作”把这个区别摆到了明面上。

Plain 系列类型故意不带时区

Temporal.PlainDatePlainTimePlainDateTime 表示的是日历值或钟面值,它们并不声称自己对应某个瞬间。

const birthday = Temporal.PlainDate.from("1990-08-12");
const openingTime = Temporal.PlainTime.from("09:00");
const localAppointment = Temporal.PlainDateTime.from("2026-11-01T01:30");

生日、每天重复的营业时间、表单输入,用它们很合适。但如果这个值其实是一个真实事件的时间戳,用 Plain 类型就危险了。一个 plain 的日期时间,必须配上时区(在切换点附近还要配一条消歧策略)才能变成瞬间。

在边界上与老的 Date 互操作

两套 API 之间靠纪元毫秒来回转:

const legacyDate = new Date("2024-03-15T14:30:00Z");

const instant = Temporal.Instant.fromEpochMilliseconds(
  legacyDate.getTime(),
);

const backToDate = new Date(instant.epochMilliseconds);

因为 Date 只存毫秒,这次转换会丢掉所有亚毫秒精度。它同样不会传递 IANA 时区——一个 Date 里只有瞬间值。

至于 JSON,Temporal 对象通过各自的 toJSON() 序列化成字符串。请在 schema 里写明这个字段期望的是哪种 Temporal 类型。一个表示 Instant 的字符串,不会仅仅因为它和不带时区的 PlainDateTime 都用 ISO 风格语法,就可以互换。

上线时配合特性检测

目前已确认的原生里程碑包括 Firefox 139、Chrome 144 和 Node.js 26。浏览器支持仍未普及。

if (typeof globalThis.Temporal === "undefined") {
  throw new Error("Temporal is not available in this runtime");
}

生产上的取舍其实很直白:

  • 只有当你的运行时矩阵能保证支持时,才直接用原生 Temporal
  • 对不支持的目标环境,加载一个仍在维护的 polyfill
  • 简单的时间戳场景,如果 polyfill 的体积不值当,就继续用 Date
  • 把日期时间操作隔离在有测试覆盖的应用边界之后,这样以后换实现才方便

别想当然地认为浏览器里的 polyfill 和各家原生实现是逐字节一致的。请锁定 polyfill 版本,在你实际交付的运行时矩阵上做测试,并且在运行时或时区数据库更新之后,重跑那些对时区敏感的测试。

也要知道什么时候 Date 就够用了

Temporal 最能派上用场的场景,是应用需要为日历日期、具名时区、周期性本地日程或夏令时歧义建模。但不是所有时间戳都属于这一类。

同时满足下面几条时,继续用 Date

  • 这个值本来就是一个瞬间,已经表示为 Unix 毫秒或带偏移的 ISO 字符串
  • 代码只是对这个瞬间做比较、存储或格式化
  • 毫秒精度足够
  • 部署矩阵不足以让引入 Temporal polyfill 变得划算

而当业务规则说的是“纽约时间上午 9 点”、“下个月的同一个本地时间”或者“重复出现的钟面时间要拒绝”时,就该选 Temporal。这些需求需要日历、具名时区或一条明确的消歧策略——也正是只用 Date 的代码最容易退化成一堆偏移量和隐含假设的地方。

划清这条界线,迁移才现实:在 InstantZonedDateTime 的语义确实能防住 bug 的地方用它们,但不要仅仅为了用上新 API,就去改写那些本来就稳定的纯时间戳代码。

有个很好用的自查问题:这段代码有没有从偏移量去反推本地时间?有没有用毫秒常量去做日历单位的加减?有没有在 Date 旁边另外用一个变量携带时区?出现这些模式,说明数据已经超出 Date 能承载的范围了。而一个只是存成瞬间、并在 UI 边界格式化一次的 createdAt,通常还没有。

一套能控制风险的迁移顺序

  1. 先在系统边界上,把现有整数的单位和字符串的格式标注清楚。
  2. 把有歧义的解析,换成明确的 Instant、plain 或 zoned 解析。
  3. 代码在给“事件”建模的地方,把 Date 值转成 Instant
  4. 只在确实需要具名时区规则的地方,才引入 ZonedDateTime
  5. 把毫秒算术换成语义明确的流逝时间运算或日历运算。
  6. 为夏令时空档、重叠、月末、闰年和精度损失补上测试。
  7. 在老 API 的边缘保留 Date 适配层,直到那些使用方也完成迁移。

Temporal 真正的收益不在纳秒,也不在方法名更好看。而在于:类型本身就告诉后来的读者,这个值究竟是一个瞬间、一个日历值,还是一个钟面值。它把那些难做的决定提前摆上桌面——而不是等生产环境替你做决定。

延伸阅读

Frequent questions:

Q: Temporal 是要取代 JavaScript 的 Date 吗?
A: Temporal 的定位是:新写的日期、时间、日历和时区代码都该用它。Date 仍然保留,用于兼容和简单的毫秒时间戳场景,两者可以通过纪元毫秒互相转换。
Q: 怎么把 Unix 秒转成 Temporal?
A: 把 Unix 秒乘以 1000,再传给 Temporal.Instant.fromEpochMilliseconds()。Stage 4 的 API 提供了 fromEpochMilliseconds() 和 fromEpochNanoseconds(),但没有 fromEpochSeconds()。
Q: Temporal 支持纳秒吗?
A: 支持。Temporal.Instant 表示的就是纪元纳秒,并通过 epochNanoseconds 以 BigInt 暴露出来。不过实际时钟精度和输入精度,可能比这个表示能力粗糙得多。
Q: Node.js 里能用 Temporal 吗?
A: Node.js 26 默认启用了 Temporal。更早的受支持版本,如果应用确实需要这个 API,请配合特性检测和一个仍在维护的 Temporal polyfill。
Q: 哪些浏览器支持 Temporal?
A: Firefox 从 139 版、Chrome 从 144 版开始支持。MDN 目前仍把 Temporal 标为「有限可用」,因为一些用户量很大的浏览器还不支持,所以生产站点应当做特性检测,并在需要时提供兜底方案。
Q: Temporal 怎么处理夏令时的空档和重叠?
A: ZonedDateTime 的转换接受一个 disambiguation 选项:compatible、earlier、later 或 reject。默认是 compatible;选 reject 时,遇到被跳过或重复的本地时间会直接抛错。