去年年底做财务对账系统的时候,一个 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 写了一段跨时区的逻辑,代码只有原来三分之一长度的时候,那种感觉就像用惯了破损的螺丝刀,突然拿到一把电动螺丝刀一样。不是新工具解决问题的方式变了,而是它本就应该被这么用。
就这样,该切就切,别犹豫。

