拆解 JS 的新一代日期时间 API
老 Date 把「时刻、墙上时间、日历日期、一段时长」全揉进一个可变对象里,还会隐式地替你换算时区。Temporal 把它们拆成一组各司其职、不可变的类型:要精确时刻用 Instant,要某地的墙上时间用
ZonedDateTime,要纯日期 / 纯时间用 Plain*,要一段时长用 Duration。每个 demo 都能改输入、看真实运行结果(底层跑 temporal-polyfill)。全文四步:ISO 8601、类型全景、Temporal.Now、Duration。
一句话选型: 知道「是地球上哪一瞬」→ Instant;要带「某时区 + 历法」地显示给人看 → ZonedDateTime;只关心「几号 / 几点」而不挂时区 → PlainDate / PlainTime / PlainDateTime;只记「年-月 / 月-日」→ PlainYearMonth /
PlainMonthDay;描述「多久」→ Duration。
1 · ISO 8601:Temporal 的「母语」
每个 Temporal 类型都用 ISO 8601 字符串来 toString() 序列化、也用它来 from() 解析。先把一条「信息最全」的字符串逐段拆开,认全每一块的含义。
1.1 · 拆解一条完整字符串
下面是一个 ZonedDateTime 的 ISO 形式。点下面的图例,高亮对应段并看解释:
记忆要点: T 是日期和时间之间的分隔符(2026-06-06T09:30);+08:00 是相对 UTC 的偏移(offset);方括号里的 [Asia/Shanghai] 是命名时区、[u-ca=iso8601]
是历法。越往右,信息越「具体到某地某历法」。
1.2 · 反过来:同一段文字,解析成不同类型
把一段 ISO 文本喂给不同的 Temporal.X.from(...),每个类型只「认领」它关心的部分。改下面的输入或切换类型,看真实解析结果(from 后再 toString() 转回来):
1.3 · 时长(Duration):另一套 ISO 写法
时长不写成「日期」,而是 P(Period,期)起头的串:P 后面是日期部分(年 Y / 月 M / 周 W / 日 D),再用一个 T 分隔出时间部分(时 H / 分
M / 秒 S)。所以 P30D = 30 天、PT1H = 1 小时、P1Y2M10DT2H30M = 1 年 2 月 10 天 2 时 30 分。
易混点: M 在 T 前是月(month)、在 T 后是分(minute)。这也是为什么必须有 T:PT1M 是 1 分钟,P1M 是 1 个月。
2 · 类型全景:谁带哪些信息
Temporal 不是单个通用类,而是一组各装不同信息的不可变类型。点下面任意一张卡,查看它带有哪几个字段、并配一个真实场景运行一遍。关键直觉:从「纯时刻」到「带时区+历法」信息逐级变多,按需取用。
带日期就带历法。 上面字段条里只要点亮了 年/月/日 中任意一个,这个类型就一定带 calendar(绿色块),默认是
iso8601(公历)。因为「这是几月几号」本身就依赖用哪套历法去数——同一个绝对日期,在公历、农历、希伯来历里是不同的「年-月-日」。Instant(纯时刻)和 PlainTime(纯时间)不碰日期,所以没有历法。
3 · Temporal.Now:读「现在」的工具集
Temporal 的类型本身不可变、也不知道「现在」是几点——这是故意的(纯值,可测试、可缓存)。要拿「此刻」,得显式问 Temporal.Now 这组函数。它返回的也都是普通 Temporal 值,拿到后该是哪种类型就当哪种类型用。下面是一个实时刷新的时钟。
3.1 · Temporal.Now.* 工具箱(实时)
同一刻,问不同的 Now 方法,拿到不同类型的值:
为什么分这么多? 倒计时只要 instant()(绝对时刻);只显示日期用 plainDateISO();判断营业用 plainTimeISO();要完整带时区显示用 zonedDateTimeISO()。各方法都能传一个时区参数(不传 = 系统本地)。instant()
等概念见「类型全景」那节。另外,取「当前时区 id」,Temporal 之前的传统写法是 Intl.DateTimeFormat().resolvedOptions().timeZone;它与 Temporal.Now.timeZoneId() 返回相同的 IANA 时区名,在不支持 Temporal 的环境里仍可用。
3.2 · 同一刻,不同时区的「墙上时间」
下面几行是同一个 Instant(此刻),用 zonedDateTimeISO(zone) 投影到不同时区。时刻相同,钟面不同——这正是 Instant 与 ZonedDateTime 的分工(见「类型全景」那节)。
4 · Duration:「一个月」不是固定天数
最反直觉的一点:Duration 描述的是日历意义上的「多久」,不是一个毫秒数。P1M(1 个月)到底多少天?不知道——要看你从哪一天起算:可能 28、29、30 或 31 天。所以日历单位的时长,必须配一个起点才能落地。
4.1 · 合同结束日 = 合同开始日 + 合同期限
这是 Duration 最自然的用法:一个日期 .add() 一段时长,得到新日期。改改看:
反过来,「两个日期之间是多久」用 start.until(end) 得到一个 Duration;它会默认给出 P6M 这种日历单位结果,而不是「183 天」这种纯数字(除非你指定 largestUnit: 'day')。
4.2 · 同样是 P1M,落在不同起点 = 不同天数
下面每一行都是「起点 + 同一个时长」,但实际跨过的天数各不相同。注意 1 月 31 日 + P1M 会被钳到 2 月底(没有 2 月 31 日)。改时长试试:
所以不要这样算: 开始时间戳 + 30*24*60*60*1000 不等于「一个月后」。那只是「30 天后」,而且还会在夏令时的「那天只有 23 小时」上算错。要「一个月后」,就用 date.add(Temporal.Duration.from('P1M')),让历法替你处理月末、闰年、夏令时。
4.3 · 验证:脱离起点,Duration 答不出「多少天」
为什么要拆这么细? 老 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实现。