Web 平台 API / 拆解 JS 的新一代日期时间 API 待审核
Instant · ZonedDateTime · Plain* · Duration

拆解 JS 的新一代日期时间 API

Date 把「时刻、墙上时间、日历日期、一段时长」全揉进一个可变对象里,还会隐式地替使用者换算时区。Temporal 把它们拆成一组各司其职、不可变的类型:要精确时刻用 Instant,要某地的墙上时间用 ZonedDateTime,要纯日期或纯时间用 Plain*,要一段时长用 Duration。本页四节依次讲 ISO 8601、类型全景、Temporal.NowDuration,每个 demo 都跑真实的 temporal-polyfill

注 · 选型可以一句话记住:知道「是地球上哪一瞬」用 Instant;要带某时区与历法地显示给人看用 ZonedDateTime;只关心几号、几点而不挂时区用 PlainDatePlainTimePlainDateTime;只记年月或月日用 PlainYearMonthPlainMonthDay;描述「多久」用 Duration

1 · ISO 8601:Temporal 的母语

每个 Temporal 类型都用 ISO 8601 字符串来 toString() 序列化、也用它来 from() 解析。先把一条「信息最全」的字符串逐段拆开,认全每一块的含义。

1.1 · 一条完整字符串的分段

图 1-1 · 一个 ZonedDateTime 的 ISO 形式。可点图例高亮对应段并看解释。

T 是日期和时间之间的分隔符;+08:00 是相对 UTC 的偏移 (offset);方括号里的 [Asia/Shanghai] 是命名时区,[u-ca=iso8601] 是历法。越往右,信息越具体到某地某历法。

1.2 · 同一段文字解析成不同类型

把一段 ISO 文本喂给不同的 Temporal.X.from(...),每个类型只认领它关心的部分。

图 1-2 · 可改输入或切换类型,看真实解析结果(from 之后再 toString() 转回来)。

1.3 · 时长的另一套写法

时长不写成日期,而是以 P(Period,期)起头的串:P 后面是日期部分,年月周日依次用 Y、M、W、D 标记,再用一个 T 分隔出时间部分,时分秒依次用 H、M、S 标记。所以 P30D 是 30 天、PT1H 是 1 小时、P1Y2M10DT2H30M 是 1 年 2 月 10 天 2 时 30 分。

图 1-3 · 时长串的逐段拆解。可改写各字段,看解析出的 Duration 如何变化。

警示 · 字母 M 在 T 之前是月 (month)、在 T 之后是分 (minute)。这正是必须有 T 的原因:PT1M 是 1 分钟,P1M 是 1 个月。

2 · 类型全景

Temporal 不是单个通用类,而是一组各装不同信息的不可变类型。关键直觉是从「纯时刻」到「带时区加历法」,信息逐级变多,按需取用。

图 2-1 · 可点任意一张卡,查看它带有哪几个字段,并配一个真实场景运行一遍。

建议 · 有一条规律可以省去记忆:只要字段条里点亮了年、月、日中任意一个,这个类型就一定带 calendar(绿色块),默认是 iso8601(公历)。因为「这是几月几号」本身就依赖用哪套历法去数——同一个绝对日期,在公历、农历、希伯来历里是不同的年月日。Instant(纯时刻)和 PlainTime(纯时间)不碰日期,所以没有历法。

3 · Temporal.Now:读「现在」的工具集

Temporal 的类型本身不可变、也不知道「现在」是几点——这是故意的,纯值才可测试、可缓存。要拿此刻,得显式问 Temporal.Now 这组函数。它返回的也都是普通 Temporal 值,拿到后该是哪种类型就当哪种类型用。

图 3-1 · 一个实时刷新的时钟,底层每帧调一次 Temporal.Now。

3.1 · Temporal.Now 的各个方法

图 3-2 · 同一刻问不同的 Now 方法,拿到不同类型的值,实时刷新。

方法分这么多是因为用途不同:倒计时只要 instant()(绝对时刻);只显示日期用 plainDateISO();判断营业时间用 plainTimeISO();要完整带时区显示用 zonedDateTimeISO()。各方法都能传一个时区参数,不传即系统本地。另外,取当前时区 id,Temporal 之前的传统写法是 Intl.DateTimeFormat().resolvedOptions().timeZone;它与 Temporal.Now.timeZoneId() 返回相同的 IANA 时区名,在不支持 Temporal 的环境里仍可用。

3.2 · 同一刻在不同时区的墙上时间

图 3-3 · 同一个 Instant(此刻)用 zonedDateTimeISO(zone) 投影到不同时区。时刻相同,钟面不同——这正是 Instant 与 ZonedDateTime 的分工。

4 · Duration:「一个月」不是固定天数

最反直觉的一点:Duration 描述的是日历意义上的「多久」,不是一个毫秒数。P1M(1 个月)到底多少天?不知道,要看从哪一天起算,可能 28、29、30 或 31 天。所以日历单位的时长,必须配一个起点才能落地。

4.1 · 日期加时长

这是 Duration 最自然的用法:一个日期 .add() 一段时长,得到新日期。

图 4-1 · 合同结束日等于合同开始日加合同期限。可改起点与期限,看结果日期如何落定。

反过来,「两个日期之间是多久」用 start.until(end) 得到一个 Duration——但默认给的是天数而非日历单位。largestUnit 的默认值是 auto,对 PlainDate 即等同于 day,所以 2026-01-15 到 2026-07-15 返回的是 P181D;要拿到 P6M 这种日历单位结果,必须显式写 { largestUnit: 'month' }

4.2 · 同一个 P1M 落在不同起点

图 4-2 · 每一行都是「起点加同一个时长」,实际跨过的天数各不相同。注意 1 月 31 日加 P1M 会被钳到 2 月底,因为没有 2 月 31 日。可改时长再看。

警示 · 不要用 开始时间戳 + 30*24*60*60*1000 算「一个月后」。那只是 30 天后,而且还会在夏令时的「那天只有 23 小时」上算错。要一个月后,就用 date.add(Temporal.Duration.from('P1M')),让历法去处理月末、闰年与夏令时。

4.3 · 脱离起点的 Duration

图 4-3 · 验证一个 Duration 在没有起点时答不出「多少天」。可切换是否提供 relativeTo,看 total() 的行为差别。

建议 · 拆这么细的收益在可读性上。老 Date 用一个「时间戳加本地时区视图」承担全部职责,导致加一个月、跨时区显示、只比月日这些需求都得手算,还容易在夏令时上出错。Temporal 用类型把意图写清楚,让编译器和阅读代码的人能直接看出要的是哪种「时间」。

时区、DST、闰秒、历法都相当反直觉,推荐 Zach Holman 的演讲《UTC is enough for everyone … right?》,它说明了为什么 Temporal 要把这些概念拆成不同类型,促使使用者在写代码时先把意图想清楚。想交互式地对照时刻与时区的换算,可看 spacetime.how,在一条时间轴上拖动对照 instant、offset 与各地墙上时间。

规范 / 文档

  • Temporal Proposal · 文档与规范 tc39.es 提案本体的官方文档,逐类型说明 API 设计与 ISO / RFC 9557 字符串格式。
  • tc39/proposal-temporal GitHub TC39 提案仓库,现已进入 Stage 4(将并入 ECMA-262 / ECMA-402),含 cookbook 与 polyfill 链接。
  • Temporal MDN 各类型与 Temporal.Now 的逐方法参考,以及为何要取代旧 Date 的设计说明。
  • Can I use · Temporal caniuse.com 实时浏览器支持矩阵。Firefox / Chrome 已原生支持,其余环境仍需 polyfill。
  • temporal-polyfill npm 本页底层所用的轻量 polyfill,在尚未原生支持的环境里提供完整 Temporal 实现。