Temporal API 实战:Date 那条坑了二十年的路,终于有人铺平了

去年年底做财务对账系统的时候,一个 bug 让我印象特别深。要算出「上个月的最后一个工作日」,我同事写了这么一段:

const today = new Date()
const lastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1)

let lastDay = new Date(lastMonth.getFullYear(), lastMonth.getMonth() + 1, 0)
// 然后往前推,遇到周末继续减

while (lastDay.getDay() === 0 || lastDay.getDay() === 6) {
  lastDay.setDate(lastDay.getDate() - 1)
}

看着挺顺。但他没考虑到一件事:服务器跑在 UTC 时区,而用户在中国。所以 new Date() 拿到的「今天」可能是昨天。跨月的头一天或者月末最后一天会出问题——具体的现象是有些月份算出来的”上个月最后一天”是当月第一天。查了一个多小时,才发现是时区混淆。

这不是同事的水平问题。Date 这个 API 本身的设计就是反直觉的。月份从 0 数起、对象可变、时区语义模糊、没有”仅日期”的概念——任何一个具体点都能当钉子踩。二十多年来大家用 moment、dayjs、date-fns 各种库往上糊,无非是想盖住这些钉子。

现在终于有了官方的补丁,叫 Temporal API。2025 年已经进入 Stage 4 阶段,跟 ECMAScript 的下一个版本一起落地。Firefox 139 已经默认开启,Chrome 和 Safari 还在陆续跟上。这篇文章把我在几个项目里实际用它的经验整理一下。

Date 到底坑在哪

先说清问题,才能理解 Temporal 的设计取舍。

第一个坑:月份从 0 开始。new Date(2024, 0, 1) 是 1 月 1 日,不是 2 月。这个规则每年都能坑到新人,也坑到过老手。

第二个坑:对象是可变的。date.setMonth(5) 会修改原对象。函数传参的时候,如果收到一个 Date 想改一下用,结果把外部传进来的对象改了。要避免要么手动 clone,要么写纯函数很别扭。而且这也让 Date 不能安心地做 Map 的 key 或者 Set 的成员。

第三个坑:没有”纯日期”概念。一个”2024 年 1 月 1 日”这个信息,用 Date 表达的时候必须带上一个时区或者一个瞬间。但你只想表示”1 月 1 日”这个日子本身,不需要决定它是哪个时区的。Date 没有这样的类型。

第四个坑:自动溢出。new Date(2024, 1, 30) 是 3 月 1 日,不是报错。因为 2 月没有 30 号,Date 会”聪明”地帮你进位。听起来方便,实际是坑——如果用户输入 2 月 30 号的生日,系统会默默改成 3 月 2 号,不报错。等你发现数据全错了,已经过了几个月。

第五个坑:时区语义模糊。Date 内部存的是一个 UTC 时间戳,但 getMonth() 这类方法是按本地时区解读的。同一段代码,在用户电脑上和在服务器上跑出来的结果可能不一样。这就是我同事那次踩的坑。

这五个坑单独看都不致命,但叠加在一段真实业务代码里,就能制造出各种神奇的 bug。

Temporal 是什么

Temporal 不是一个新方法,而是一整套新的日期时间 API,包含七种核心类型。每个类型解决一类场景。

类型 表示什么 典型用途
Temporal.Instant 纳秒精度的绝对时刻 时间戳、事件记录
Temporal.PlainDate 年月日,无时间无时区 生日、结算日、工作日
Temporal.PlainTime 时分秒,无日期 开门时间、营业时段
Temporal.PlainDateTime 年月日时分秒,无时区 本地日程、会议时间
Temporal.ZonedDateTime 带时区的完整时刻 跨时区的会议、航班
Temporal.Duration 时长或差值 倒计时、订阅周期
Temporal.Now 获取当前时间 所有场景的入口

这七种类型跟 Date 最大的不同点:全部是不可变的。所有看起来像”修改”的方法,其实都返回一个新对象。这让纯函数写法变得自然。

另一个大的不同点:月份从 1 开始,跟人类直觉一致。Temporal.PlainDate.from({ year: 2024, month: 3, day: 15 }) 就是 3 月 15 日。不用再想那个 -1。

怎么装

本地开发的时候,如果 Node 版本不够新,需要装 polyfill:

npm install @js-temporal/polyfill

然后代码里面:

import { Temporal } from '@js-temporal/polyfill'

const date = Temporal.PlainDate.from('2024-03-15')
console.log(date.toString())  // 2024-03-15

项目发布的时候,如果目标浏览器里 Temporal 已经是原生支持,可以做一个条件判断:

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

polyfill 的体积大概 30KB 左右(压缩后),比 moment 小,但比 dayjs 大一点。如果只是用几个 API,可以考虑用 Rollup 手动裁剪,但时间成本不划算,直接用完整版就行。

案例一:财务月底结账的日期计算

回到开头那个问题。用 Temporal 怎么解决?

第一步,拿到”今天”这件事,语义要明确。财务系统跑在服务器上,服务器是 UTC 时区,但业务以中国时区为准。所以:

const today = Temporal.Now.plainDateISO('Asia/Shanghai')
// 或者
const today = Temporal.Now.zonedDateTimeISO('Asia/Shanghai').toPlainDate()

这里 plainDateISO('Asia/Shanghai') 拿到的是”亚洲/上海时区下的今天”,而不是服务器本地时区的今天。就这一句,把同事之前的 bug 从根上堵住了。

第二步,拿到上个月:

const prevMonth = today.subtract({ months: 1 })

这一步也没问题,subtract 会正确处理跨年——1 月减 1 个月自动变成去年 12 月,3 月 31 号减 1 个月会按 overflow 规则处理。

第三步,拿到上个月的第一天和最后一天:

const firstOfPrevMonth = prevMonth.with({ day: 1 })
const lastOfPrevMonth = prevMonth.with({ day: prevMonth.daysInMonth })

daysInMonth 是 Temporal 提供的属性,直接告诉你这个月有多少天。2 月自动是 28 天或者 29 天,不用自己判断闰年。

第四步,从最后一天往前退到工作日:

let lastWorkday = lastOfPrevMonth

while (lastWorkday.dayOfWeek === 6 || lastWorkday.dayOfWeek === 7) {
  lastWorkday = lastWorkday.subtract({ days: 1 })
}

console.log(lastWorkday.toString())  // 2024-01-31 之类

dayOfWeek 里,1 是周一,7 是周日,这也比 Date 的 getDay() 好——后者 0 是周日,1 是周一,等于把周日放在了最开始,反直觉。

完整代码:

function lastWorkdayOfPrevMonth(timeZone = 'Asia/Shanghai') {
  const today = Temporal.Now.plainDateISO(timeZone)
  const prevMonth = today.subtract({ months: 1 })
  const lastDay = prevMonth.with({ day: prevMonth.daysInMonth })

  let candidate = lastDay
  while (candidate.dayOfWeek === 6 || candidate.dayOfWeek === 7) {
    candidate = candidate.subtract({ days: 1 })
  }
  return candidate
}

函数彻底不依赖运行环境的时区,不会因为服务器迁移出问题。这比之前的实现强太多。

案例二:跨时区的会议安排

第二个项目是给一个跨国团队做协调工具,要帮用户找出「上海、纽约、柏林三地都能开会的空档」。

这里必须用 ZonedDateTime,因为只算日期不够,还得知道具体到几点、几点的时区对应关系。

先看一个数据点。用户说”我们纽约时间下周一上午 10 点开会”,从字符串转 Temporal:

const meetingNY = Temporal.ZonedDateTime.from(
  '2024-03-18T10:00[America/New_York]'
)

console.log(meetingNY.toString())
// 2024-03-18T10:00:00-04:00[America/New_York]

中括号里的东西叫时区标识,它比小时偏移量重要得多。用偏移量(-04:00)是没问题的,但当夏令时切换的时候,同一个偏移量对应的规则会变。写时区标识则 Temporal 会自己在切换日期里挑正确的偏移量。

看它跟上海时间的换算:

const meetingShanghai = meetingNY.withTimeZone('Asia/Shanghai')
console.log(meetingShanghai.toString())
// 2024-03-18T22:00:00+08:00[Asia/Shanghai]

一个方法就搞定了。这比 Date 加库的写法干净太多。

如果要判断这个会议时间是不是中国的工作时间:

const hour = meetingShanghai.hour
const isWorkingHour = hour >= 9 && hour < 18
console.log('北京时间是否在工作时段:', isWorkingHour)  // false

再进一步,如果想让用户知道两个地点的具体日期会不会不一样(比如上海已经第二天了),可以:

const shDate = meetingShanghai.toPlainDate()
const nyDate = meetingNY.toPlainDate()

if (!shDate.equals(nyDate)) {
  console.log('注意:两地不在同一天')
}

注意这里用了 .equals() 而不是 ===。Temporal 对象不重载 ===,即使内容一样也是不同的对象。比较内容一律用 .equals()。这也是个习惯上的调整。

案例三:订阅周期计算

第三个案例是订阅系统。用户每月 15 号自动扣款,有时候会遇到一个问题:15 号在闰年 2 月里是有效的,但在某些月份里,如果用户当初选的是 31 号,2 月没有 31 号,该怎么办?

这是历史上一直纠结的问题。Date 用”自动溢出”的策略:2 月 31 号变成 3 月 3 号。但这不对——用户选的是 31 号,希望每个月都在月底扣款,而不是变成下个月 3 号。

Temporal 提供了三个策略,让你显式选择:

策略 含义 2 月遇到的 31 号
constrain(默认) 限制到当月最大值 2 月 29 号(或 28 号)
reject 数据无效,抛错 抛 RangeError
balance 溢出到下一周期 3 月 2 号左右

默认的 constrain 就是订阅系统需要的——每月 31 号扣款,遇到 2 月就在 28/29 号扣,这个行为对用户也合理。

function nextChargeDate(currentDate) {
  return currentDate.add({ months: 1 }, { overflow: 'constrain' })
}

let charge = Temporal.PlainDate.from('2024-01-31')
charge = nextChargeDate(charge)  // 2024-02-29
console.log(charge.toString())

charge = nextChargeDate(charge)  // 2024-03-29? 还是 31?
console.log(charge.toString())

这里有个坑。上面第一轮到了 29 号,第二轮再加 1 个月,得到的是 29 号,不是原来的 31 号。因为上一个 31 号已经被 constrain 到 29 号了。

解决思路:不要用「上次扣款日期 + 1 月」做迭代,而要用「首次扣款日」做基准:

function nextCharges(startDate, count) {
  const result = []
  for (let i = 0; i  d.toString()))
// [
//   "2024-01-31",
//   "2024-02-29",
//   "2024-03-31",
//   "2024-04-30",
//   "2024-05-31",
//   "2024-06-30"
// ]

这才是正确的行为——每月月底扣款,无论这个月有几天。这类问题在旧方案里会非常绕,因为 Date 的 overflow 是隐式的、无法控制的。Temporal 把它变成了显式的选择。

时长计算的另一面也用得上:

const start = Temporal.PlainDate.from('2024-01-31')
const end = Temporal.PlainDate.from('2024-06-30')
const duration = start.until(end)

console.log(duration.toString())  // P4M30D (4 个月 30 天)
console.log(duration.total({ unit: 'days' }))  // 151
console.log(duration.round({ largestUnit: 'days' }).toString())  // P151D

.until() 返回的是一个 Duration 对象,可以按需转成总天数或者保留「年月日」的结构。这在按订阅周期计费、按天算时长这些场景里非常好用。

跟 Date 怎么互操作

现实中不可能一次把老代码全换掉。Temporal 提供了转换方法:

// Date 转 Temporal.Instant
const d = new Date()
const instant = d.toTemporalInstant()
console.log(instant.toString())  // 2024-03-15T08:30:00Z

// 再往上转成 ZonedDateTime
const zdt = instant.toZonedDateTimeISO('Asia/Shanghai')

// Temporal 转回 Date(会有精度损失)
const backToDate = new Date(zdt.epochMilliseconds)

注意 epochMilliseconds 这个属性名,它把”毫秒时间戳”这件事说清楚了,不像 Date 的 getTime() 那样含糊。Temporal 还有 epochNanoseconds 给需要更高精度的场景。

如果是需要把旧的 Date 相关函数逐步迁移,可以先用一个适配层:

function toTemporalDate(d, timeZone = 'Asia/Shanghai') {
  return d.toTemporalInstant().toZonedDateTimeISO(timeZone).toPlainDate()
}

function fromTemporalDate(td, timeZone = 'Asia/Shanghai') {
  return new Date(td.toZonedDateTime({ timeZone, plainTime: '12:00' }).epochMilliseconds)
}

这么写,新的业务代码用 Temporal,旧的用 Date,中间通过这两个函数互相转换。项目大一点的话,迁移可以按模块来,不用一次性全改。

三个容易踩的坑

坑一:字符串解析格式要求严格。Date 那边 new Date('2024/03/15')、new Date('March 15, 2024') 各种格式都支持,浏览器实现各不相同。Temporal 一律用 ISO 8601 格式:

Temporal.PlainDate.from('2024-03-15')           // ✓
Temporal.PlainDate.from('2024/03/15')           // ✗ RangeError
Temporal.PlainDate.from('March 15, 2024')       // ✗ RangeError

这个限制看似麻烦,实际上是好事——避免了跨浏览器、跨语言的行为不一致。但如果你的用户要输入日期,得自己写解析器处理各种输入格式。

坑二:with() 修改日期时,默认 overflow 是 constrain。跟 add()/subtract() 一样,with() 也有 overflow 策略参数:

const d = Temporal.PlainDate.from('2024-01-31')
console.log(d.with({ month: 2 }).toString())  // 2024-02-29
console.log(d.with({ month: 2 }, { overflow: 'reject' }).toString())
// RangeError

如果你希望 1 月 31 号改成 2 月时报错而不是静默修改,记得传 reject。

坑三:.equals() 要显式调。很多人第一次写 Temporal 会这么写:

if (dateA === dateB) { /* ... */ }  // 永远不会执行

两个内容相同的 PlainDate 对象,用 === 比较结果永远是 false,因为它们是不同对象。必须用 equals()。这在做缓存 key 或者 Set 判重的时候非常容易漏。

什么时候该切,什么时候再等等

这几种场景我建议优先切:

  • 涉及跨时区的业务(国际化、跨国团队协作)
  • 财务、订阅、考勤这类跟”日”和”月”打交道的场景
  • 需要纯日期语义、不希望带上时刻时区的地方(生日、纪念日、记账日)
  • 新项目,没有兼容包袱

这几种场景可以再观察一阵:

  • 目标用户里有大量老旧浏览器,而 polyfill 的体积不可接受
  • 项目里在用 moment 或者 dayjs 的复杂功能(格式化、相对时间、国际化文案),迁移成本大收益小
  • 纯内部的、不涉及时间逻辑的小工具

就我自己的经验,2026 年新上的项目,只要目标浏览器允许,能上 Temporal 就上。Dayjs 那套东西已经成为历史包袱了,该换的时候不换,以后会越来越难改。

最后说一句

回头看看 Date 这个 API,它诞生于 1995 年,当时的设计是”模仿 Java 的 java.util.Date“。后来 Java 自己都把这套 API 淘汰了,改成了 java.time;但 JavaScript 因为兼容性没人敢动它,一直拖到今天才补上这一课。

Temporal 花了将近十年才走到 Stage 4,过程之曲折,背后的技术博弈和标准化讨论都很不容易。但它确实值得等。第一次用 Temporal 写了一段跨时区的逻辑,代码只有原来三分之一长度的时候,那种感觉就像用惯了破损的螺丝刀,突然拿到一把电动螺丝刀一样。不是新工具解决问题的方式变了,而是它本就应该被这么用。

就这样,该切就切,别犹豫。

Temporal API 实战:Date 那条坑了二十年的路,终于有人铺平了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript Temporal API 实战:Date 那条坑了二十年的路,终于有人铺平了 https://www.taomawang.com/web/javascript/2894.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务