Temporal API 落地:用不可变的日期时间对象替换 Date

我在给一个订阅制产品写账单周期计算的时候,先用现成的 Date 写了一版最朴素的实现:账单从某一天开始,每满一个自然月续费一次,续费日就是上个周期的开始日加一个月。逻辑简单到不需要多想:

1function nextBillingDate(start) {
2  const next = new Date(start)
3  next.setMonth(next.getMonth() + 1)
4  return next
5}
6
7const start = new Date('2026-01-31T00:00:00')
8console.log(nextBillingDate(start).toISOString())
9// 2026-03-03T00:00:00.000Z —— 不是我要的 2 月底

1 月 31 日加一个月,setMonth 把月份索引从 0 推到 1,但 2 月根本没有 31 号,Date 会自动把多出来的天数进位到 3 月。我要的是"下一个自然月的对应日期,如果那天不存在就退到月底",但 Date 给我的是一个溢出结果,日期直接跳到了 3 月 3 日。

我以为这是我自己漏处理了月末溢出,加了个判断月份是否跳变的补丁,结果在另一个用例上又栽了一次——跨夏令时那天的账单。用户所在时区在三月某个周日凌晨从 UTC-8 切到 UTC-7,我算"这个周期是不是整整 30 天"时,用两个 Date 相减再除以 86400000,结果得到 29.958333,不是整数:

1const before = new Date('2026-03-08T00:00:00-08:00')
2const after = new Date('2026-04-07T00:00:00-07:00')
3console.log((after - before) / (24 * 60 * 60 * 1000))
4// 29.958333333333332,不是 30

两个日期在挂钟意义上都是"当地 0 点",间隔应该是整整 30 天,但 Date 底层存的是自纪元以来的毫秒数,跨夏令时那一天实际只有 23 小时,减法算出来的是真实经过的时间量,不是日历意义上的天数。这两个坑加起来,我才想起去年看到的 Temporal 提案早就把这类问题写进了设计初衷里,干脆把整套日期时间处理换成 Temporal 重写。

Date 的老问题不是一天两天了

Date 这几个毛病其实业内早有共识,只是平时躲得开。

第一个是可变。一个 Date 实例创建后还能被 setMonthsetDatesetHours 之类的方法原地修改,传给别的函数时如果对方顺手改了一下,调用者这边的值也跟着变了,排查起来很难想到是被谁改的:

1function addDays(date, days) {
2  date.setDate(date.getDate() + days) // 直接改了传进来的对象
3  return date
4}
5
6const original = new Date('2026-05-01')
7const later = addDays(original, 10)
8console.log(original.toDateString()) // Thu May 11 2026,原对象也被改了

第二个是月份从 0 开始。这个决定当年抄的是 Java 的 java.util.Date,一月是 0,十二月是 11。我自己都写了快十年 JS,偶尔手写字面量还是会习惯性把月份填成实际月数,然后拿到错的月份。

第三个是时区处理挂在全局环境上。同一个 Date 实例,toString() 在不同机器、不同 TZ 环境变量下打印出的挂钟时间不一样,而这个实例本身没有"它属于哪个时区"的概念——它只存了一个 UTC 时间戳,本地时间是渲染的时候现算的。想存"东京时间 2026-06-01 09:00"这个具体的挂钟时刻,Date 做不到,只能自己额外记一个时区字符串,然后手写转换。

第四个是解析不一致。new Date('2026-05-26')new Date('2026/05/26') 表现不一样——前者按 ISO 8601 解析成 UTC 零点,后者按本地时间解析,两个字符串看起来只差一个分隔符,得到的却是两个不同时刻。非 ISO 格式的解析规则在规范里长期没有强制约束,不同引擎实现细节还有出入。

这几条单独看都能靠写规范约束团队绕过去,但摞在一起,日期时间相关的 bug 在我经手的项目里从来没断过根。

Temporal 把一个模糊的东西拆成了五个精确的

Temporal 提案最核心的设计决定,是不再用一个 Date 对象囊括所有场景,而是按"你到底知道多少信息"拆成好几个不同的类型。

先说最基础的三个纯日历类型,它们完全不涉及时区:

1const d = Temporal.PlainDate.from('2026-05-26')
2console.log(d.year, d.month, d.day) // 2026 5 26,月份就是实际月数
3
4const t = Temporal.PlainTime.from('14:30:00')
5console.log(t.hour, t.minute) // 14 30
6
7const dt = Temporal.PlainDateTime.from('2026-05-26T14:30:00')
8console.log(dt.toString()) // 2026-05-26T14:30:00

PlainDate 只表示"一个日历日期",没有时分秒,也没有时区——生日、纪念日、账单起算日这类"哪一天"比"哪一刻"更重要的场景正好对应它。PlainTime 反过来,只表示一天中的某个时刻,没有日期。PlainDateTime 把两者合起来,表示一个具体的挂钟时刻,但依然不知道自己在哪个时区,所以它不能直接换算成时间戳——同样的"5 月 26 日 14:30",在东京和在纽约对应的是两个完全不同的绝对时刻,PlainDateTime 只负责记录挂钟上写的数字。

真正携带时区信息、能精确定位到时间轴上某一点的是 ZonedDateTime

1const meeting = Temporal.ZonedDateTime.from({
2  year: 2026, month: 5, day: 26,
3  hour: 14, minute: 30,
4  timeZone: 'Asia/Shanghai',
5})
6
7console.log(meeting.toString())
8// 2026-05-26T14:30:00+08:00[Asia/Shanghai]
9
10console.log(meeting.epochMilliseconds) // 精确的绝对时间戳

它同时保留了挂钟时间、时区名字和由此推出的 UTC 偏移量,三样信息缺一不可,字符串表示里也把这三样都打印出来,不需要再靠额外注释说明"这个时间戳是哪个时区的"。

如果只关心绝对时刻本身、不关心它在哪个地方对应几点,就用 Instant

1const now = Temporal.Now.instant()
2console.log(now.epochMilliseconds)
3
4const someInstant = Temporal.Instant.from('2026-05-26T06:30:00Z')
5console.log(someInstant.toZonedDateTimeISO('Asia/Shanghai').toString())
6// 2026-05-26T14:30:00+08:00[Asia/Shanghai]

Instant 相当于旧 Date 里唯一靠谱的那部分——单纯的时间戳,日志时间、数据库存储、跨系统传递用它最合适,等要展示给某个具体时区的用户看时,再转成 ZonedDateTime

最后是 Duration,专门表示一段时间间隔,和"某个具体时刻"完全分开:

1const billingPeriod = Temporal.Duration.from({ months: 1 })
2const oneWeek = new Temporal.Duration(0, 0, 1) // 1 周
3console.log(oneWeek.total({ unit: 'days' })) // 7

拆成这五种类型看起来是多背了几个名字,但每种类型的方法集合都严格匹配它该有的语义——PlainDate 上没有时区相关的方法,Instant 上没有 yearmonth 这类挂钟字段,写代码时编译器和自动补全会直接告诉你"这个操作在这个类型上不存在",而不是像 Date 那样什么方法都挂在一个对象上,用错了也能跑,只是跑出错误结果。

时区计算重写一遍

回到我最初那个账单周期问题。用 PlainDate 重写月份推进,溢出行为是可以显式控制的:

1const start = Temporal.PlainDate.from('2026-01-31')
2const next = start.add({ months: 1 })
3console.log(next.toString()) // 2026-02-28,自动退到月末最后一天,不会跳到 3 月

add 默认的溢出策略是 constrain——超出目标月份天数上限时约束到月末,不进位到下个月。如果确实想要溢出到下个月,也可以显式指定:

1const jumped = start.add({ months: 1 }, { overflow: 'reject' })
2// 抛出 RangeError,因为 2 月没有 31 号,reject 策略下必须自己处理这种情况

reject 策略连"要不要自动约束"这个决定都不替你做,直接抛错,逼着调用方在写代码的时候就想清楚业务上该怎么处理月末溢出,而不是等到线上出现一个诡异日期才回头查。

跨夏令时那道题用 ZonedDateTime 算:

1const before = Temporal.ZonedDateTime.from({
2  year: 2026, month: 3, day: 8,
3  hour: 0, timeZone: 'America/Los_Angeles',
4})
5const after = Temporal.ZonedDateTime.from({
6  year: 2026, month: 4, day: 7,
7  hour: 0, timeZone: 'America/Los_Angeles',
8})
9
10const calendarDays = before.until(after, { largestUnit: 'days' })
11console.log(calendarDays.days) // 30,按日历天数算,不管中间夏令时怎么跳
12
13const realDuration = after.since(before, { largestUnit: 'hours' })
14console.log(realDuration.hours) // 719,因为其中一天只有 23 小时

untilsince 把"日历意义上过了几天"和"实际经过了多少小时"当成两个不同的问题分别回答,调用者自己选要哪一个,不用再纠结毫秒相减到底算的是什么。

不可变带来的实际好处

Temporal 的所有对象,包括 PlainDateZonedDateTimeDuration 在内,创建之后都不能被修改,任何"变更"操作返回的都是一个新实例:

1const original = Temporal.PlainDate.from('2026-05-01')
2const later = original.add({ days: 10 })
3
4console.log(original.toString()) // 2026-05-01,没有变
5console.log(later.toString())    // 2026-05-11,新对象

这不只是风格问题。前面那个 addDays 顺手改掉调用者对象的坑,在 Temporal 下结构性地不存在——没有 setDate 这类原地修改方法可用,唯一能做的就是拿到新对象。把 PlainDate 放进 React 状态、Vue 的响应式对象、或者当成对象的键(Temporal.PlainDate 实例支持通过 .toString()Temporal.PlainDate.compare 做值比较)都不用担心某处代码悄悄改了引用指向的内容却不触发更新——这类因为对象被意外改动而导致的排查工作量,在切换过去之后明显少了。

浏览器支持和 polyfill 该怎么选

Temporal 提案在 TC39 走到 Stage 3 已经有些年头,规范文本相对稳定,但真正落到浏览器里是最近这一年多的事。Firefox 走在前面,桌面版已经默认开启;Chrome 也在这两年陆续把实现合进主干,新版本默认可用;Safari 这边我在自己机器上试了最新的 Technology Preview,Temporal 全局对象还没出现,说明 WebKit 那边还在推进中,具体到正式版落地要等官方发布节奏。

也就是说现在直接依赖全局 Temporal 对象在生产环境还不现实,至少得兜一层 polyfill:

1npm install @js-temporal/polyfill
1import { Temporal } from '@js-temporal/polyfill'
2
3const date = Temporal.Now.plainDateISO()
4console.log(date.toString())

这个包是 TC39 提案组自己维护的参照实现,行为跟规范文本对齐得很紧,是目前最靠谱的选择。我在账单模块里的用法是判断运行环境:

1const TemporalImpl = globalThis.Temporal ?? (await import('@js-temporal/polyfill')).Temporal

如果宿主环境原生支持就直接用原生对象,享受引擎层面的性能优化;不支持就退回 polyfill。polyfill 本质是用现有 JS(内部大量依赖 Intl 的时区数据库)模拟整套行为,性能比不上原生实现,日历计算密集的场景(比如一次性批量算几万条订单的账单周期)我实测过差距是能感知到的,所以账单模块这种偏批处理的部分,我把计算放到了服务端用 Node 跑,Node 20 之后配合这个 polyfill 也是稳的,前端只做展示层的少量计算。

落地到账单周期计算的判断过程

具体到这次的账单模块,我最后没有把整个项目的日期处理一次性切过去,而是先圈定了一个明确的范围:账单周期计算这一块逻辑独立成一个模块,输入输出接口收窄成固定的几个函数,改动完全不影响其他还在用 Date 的地方。

1import { Temporal } from '@js-temporal/polyfill'
2
3interface BillingCycle {
4  periodStart: Temporal.PlainDate
5  periodEnd: Temporal.PlainDate
6}
7
8function computeNextCycle(anchor: Temporal.PlainDate, cycleMonths = 1): BillingCycle {
9  const periodStart = anchor
10  const periodEnd = anchor.add({ months: cycleMonths }).subtract({ days: 1 })
11  return { periodStart, periodEnd }
12}
13
14const anchor = Temporal.PlainDate.from('2026-01-31')
15const cycle = computeNextCycle(anchor)
16console.log(cycle.periodStart.toString(), '~', cycle.periodEnd.toString())
17// 2026-01-31 ~ 2026-02-27

模块对外的接口在需要和数据库、旧代码交互的地方,仍然接受和返回 ISO 字符串或者时间戳,内部计算全用 Temporal 对象,只在进出模块的那一刻做一次转换:

1function fromLegacyDate(d: Date): Temporal.PlainDate {
2  return Temporal.PlainDate.from({
3    year: d.getFullYear(),
4    month: d.getMonth() + 1, // 从 Date 转过来的时候,+1 这个动作只在这一处出现
5    day: d.getDate(),
6  })
7}

这样月份从 0 开始这个历史包袱只需要在"跟旧代码交界"的那一处显式处理一次,往里走的代码全是自然月份数字,不会散落成到处都是 +1/-1 的隐晦写法。这个判断过程其实很直接:新模块用新的、接口处转换、不强行推翻整个代码库,这类渐进式替换在处理跨越大量既有调用点的基础设施类型时,是相对稳妥的路子。

顺带解决的一个排期问题

账单模块换完之后,团队里另一个长期没根治的麻烦是跨时区会议排期——产品经理在北京约悉尼和旧金山的同事开会,经常算错到底该发几点的日历邀请,尤其赶上悉尼和旧金山两边夏令时切换日期还不一样的那几周。这个问题用 ZonedDateTime 处理起来是同一套思路:先确定一个锚点时刻,再各自转换成本地挂钟时间展示。

1const meetingUtc = Temporal.Instant.from('2026-06-10T02:00:00Z')
2
3const zones = ['Asia/Shanghai', 'Australia/Sydney', 'America/Los_Angeles']
4for (const tz of zones) {
5  const local = meetingUtc.toZonedDateTimeISO(tz)
6  console.log(tz, local.toPlainDateTime().toString(), local.offset)
7}
8// Asia/Shanghai        2026-06-10T10:00:00 +08:00
9// Australia/Sydney      2026-06-10T12:00:00 +10:00
10// America/Los_Angeles   2026-06-09T19:00:00 -07:00

同一个 Instant 转到三个时区,挂钟时间和偏移量都是准确的,甚至连"旧金山这边其实是前一天晚上"这种容易搞混的日期跨越都自动体现在结果里,不需要再手动加减时差表。之前那套排期逻辑是用几个写死的时差常量做加减,遇到某个地区临时调整夏令时规则(这种情况历史上真发生过,比如巴西一度取消过夏令时)就得手动改代码,现在换成查 IANA 时区数据库,规则更新交给运行时环境自己维护。

还不完善的地方

生态适配是目前最明显的滞后项。日期选择器组件、图表库里的时间轴刻度、表单校验库,大多数还是按 Date 或者时间戳字符串设计接口的,用了 Temporal 之后免不了要在组件接入的地方写一层转换。我试着找了几个主流的 UI 组件库,日期选择组件基本还是吃 Date 对象或者 dayjs/date-fns 那一套,接受原生 Temporal.PlainDate 的很少,大概率还得再等一两年生态才能跟上来。

Intl 相关的格式化 API 已经能配合 Temporal 对象使用,Intl.DateTimeFormat 可以直接传 ZonedDateTime

1const formatter = new Intl.DateTimeFormat('zh-CN', {
2  dateStyle: 'long',
3  timeStyle: 'short',
4})
5console.log(formatter.format(meeting)) // 2026年5月26日 14:30

但一些老的时间处理库(比如项目里存量还在用的 moment)完全不认识 Temporal 对象,混用的时候要格外小心哪块代码接的是哪种类型。

另外提案虽然到了 Stage 3,实现细节上浏览器之间偶尔还有出入,我在 Firefox 和 Chrome 上分别跑过同一段涉及历日历(比如伊斯兰历)转换的代码,输出格式有细微差异,这块如果项目涉及非公历日历系统,暂时得留意实测结果而不是完全信赖规范文本描述。日常的公历、时区、时长计算这些主流场景倒是两边表现一致,可以放心用。

这次账单模块换下来,之前那两个因为月末溢出和夏令时导致的计算错误确实消失了,代价是团队里几个还不熟悉 Temporal 类型划分的同事,一开始老是分不清什么时候该用 PlainDate 什么时候该用 ZonedDateTime,我索性把上面这几段对比代码整理成了内部文档,跑通几个真实的边界案例比讲抽象概念管用得多。