触发这次迁移的是一次真实的客诉。德国的一个用户在网站上预约了 3 月 31 日凌晨 2:30 的取件,到了门店发现没人——因为柏林那天凌晨 2 点直接跳到 3 点,2:30 根本不存在。系统当时用 dayjs + utc 插件处理,把用户的本地时间当成 UTC 处理,存进数据库之后又按服务器本地时区读出来,一来一回错了两小时。
这个 bug 我们跟了两周才定位到。修的时候我说了一句:时间处理这块,我们不能再靠 dayjs 拼插件了。同事提醒我之前老板提过要升级到 Temporal,我说那正好,趁这次彻底改。
下面就是这个改过来的过程。文章里会提到 Temporal 的几种类型、DST 的处理、月末加一月的语义,还有几个我一开始没想到的坑。
先把该用哪种类型想清楚
Temporal 跟 dayjs 最大的区别是它把”时间”拆成了好几种不同的概念。这一点刚上手会觉得繁琐,但用一段时间之后你会意识到这恰恰是它的价值。
简单列一下我们实际用到的几种:
PlainDate:只表示日期,比如”2024-03-31″,没有时间,没有时区。预约的”哪一天”用这个。
PlainTime:只表示时间,比如”14:30:00″,没有日期,没有时区。预约的”几点”用这个。
PlainDateTime:日期 + 时间,但没有时区。比如”2024-03-31T14:30″。这个只在”用户明确指定了本地时间”但还没确定时区的时候用。
ZonedDateTime:带时区的时刻。这是真正能对应”地球上的某一个瞬间”的类型,也是处理 DST 的主力。
Instant:绝对时刻,纳秒精度,等同于 Unix 时间戳。这是”跨系统、跨时区”传输时间最安全的类型。
Duration:时长,比如”2小时30分钟”或者”3天”。跟时间点是完全不同的概念。
TimeZone:时区标识,比如 Europe/Berlin。
回头看,dayjs 之所以在这类问题上容易出坑,就是因为它的 dayjs() 是一个”混合体”——既可以当 UTC 用,也可以当本地时间用,还可以当 PlainDate 用。语言的类型系统不帮你区分概念,你就得靠注释和命名来强调,而注释是会过时的。
预约系统改造:从存储到展示
我们的预约系统核心是这样:用户选一个日期、一个时间段(比如”09:00 – 11:00″)、一个服务门店。系统存下来,到时间了发通知。
原来的表结构是 reservation_start DATETIME,就一列,dayjs 的 format('YYYY-MM-DD HH:mm:ss') 直接写进去。看起来简单,但问题就在这——DATETIME 没有时区信息,谁看到就按自己所在时区理解。跨时区的时候必然出错。
我们改成了三列:
reservation_date DATE 存用户选的那一天。
reservation_start_time TIME 存开始时间。
reservation_timezone VARCHAR(64) 存用户预约时所在的时区。
对应到 PHP 后端是三个字段,返回到前端之后,前端把它组装成一个 ZonedDateTime:
const zone = Temporal.TimeZone.from(row.timezone);
const plainDT = Temporal.PlainDateTime.from(
`${row.date}T${row.startTime}`
);
const zdt = plainDT.toZonedDateTime({ timeZone: zone });
这里 toZonedDateTime 是我用起来觉得挺舒服的一个方法——它明确表达”把一个无时区的时间绑定到时区上,变成一个确定时刻”这个动作。原来 dayjs 里没有对应的概念,只能 dayjs.utc(str).tz(zone),语义上是”当成 UTC 再转码”,很容易理解错。
坑一:DST 切换那天的”不存在的时间”
回到我们最初的那个 bug。用 Temporal 之后,我们写了一段代码去验证行为:
const zone = Temporal.TimeZone.from('Europe/Berlin');
const plain = Temporal.PlainDateTime.from('2024-03-31T02:30');
const zdt = plain.toZonedDateTime({
timeZone: zone,
disambiguation: 'reject'
});
结果直接抛错了。
这就是 Temporal 对 DST 的处理态度:2024-03-31 02:30 在柏林这个时区里不存在,因为凌晨 2 点直接跳到了 3 点。Temporal 不撒谎,它不会偷偷把你挪到别的时间点,除非你告诉它”你可以挪”。
disambiguation 参数有四个值:
'compatible'(默认):向后找最近的有效时间。2:30 → 3:30。
'earlier':向前找。2:30 → 1:30。
'later':向后找。2:30 → 3:30(和 compatible 在这个场景一样,但在另一个方向的 DST 变化里不同)。
'reject':直接抛 RangeError。
我们最后选的是 'reject'。理由很简单:预约一个”不存在的时间”从来不是用户的意图,一定是哪里出了问题。与其悄悄挪到一个用户没选过的时间,不如让他重新选。前端在 TimeZone.getPossibleInstantsFor() 里能拿到合法的时间列表,直接在 UI 上把 2:00 到 2:59 那一段灰掉。
这段代码大概是这样:
const day = Temporal.PlainDate.from('2024-03-31');
const possible = [];
// 生成这一天的所有小时(每 30 分钟一格)
for (let h = 0; h < 24; h++) {
for (const m of [0, 30]) {
const dt = day.toPlainDateTime({ hour: h, minute: m });
try {
dt.toZonedDateTime({ timeZone: zone, disambiguation: 'reject' });
possible.push(dt);
} catch {
// 这个时间点不存在,跳过
}
}
}
三年前用 dayjs 写同样的逻辑,我都不敢想象要写多少行代码。而这段用 Temporal 也就是十几行。
坑二:月末加一月的语义差异
这个坑跟 DST 无关,但对我们这类”按月订阅”的业务来说同样致命。
我们的会员服务有”按月续费”,用户 1 月 31 日开通,下一次扣费日期应该是什么?
用 dayjs 试试:
import dayjs from 'dayjs';
dayjs('2024-01-31').add(1, 'month').format('YYYY-MM-DD');
// 输出:2024-03-02
为什么是 3 月 2 日?因为 dayjs 的内部实现是先算出”1 月 31 日往后推一个月”= “2 月 31 日”,然后因为 2 月没有 31 日,它按”溢出”规则算成 3 月 2 日。这个规则很符合”日期数学”,但完全不符合用户预期——用户会觉得”为什么我的会员多扣了两天的费用”。
再看 Temporal:
const d = Temporal.PlainDate.from('2024-01-31');
d.add({ months: 1 }).toString();
// 输出:2024-02-29
默认行为是”夹取”(clamp):如果目标月份没有这一天,就取该月最后一天。2024 年是闰年,2 月 29 日是月末,所以结果是 2 月 29 日。这更接近业务上的直觉。
但这里有个更微妙的问题:2 月 29 日再加一个月是几号?
Temporal.PlainDate.from('2024-02-29').add({ months: 1 }).toString();
// 输出:2024-03-29
注意是 29 不是 31。因为 add 是”从当前这个日期出发推算一个月”,2 月 29 日加一个月后会落到 3 月 29 日,而不是”3 月 31 日再往回夹取”。
这个行为让我们的账单周期出现了累积漂移:用户 1 月 31 日开通,2 月 29 日扣费,3 月 29 日扣费,4 月 29 日扣费。三个月下来扣费日期从月末漂到了月中。
解决的思路是存两个信息:原始的账期锚点(anchorDay = 31)和当前周期序号(billingCycle = 3)。每次续费的时候重新从锚点出发推算,而不是从上一次续费日期出发。
function getBillingDate(anchorDate, cycleCount) {
return anchorDate.add({ months: cycleCount });
}
这样 1 月 31 日 + 1 个月 = 2 月 29 日(夹取),1 月 31 日 + 2 个月 = 3 月 31 日,1 月 31 日 + 3 个月 = 4 月 30 日(夹取)。账期永远不会漂移。
顺便说一句,如果你希望严格禁止这种夹取行为(也就是月末用户只能选到每个月对应的最后一天),可以在 add 之后检查 result.month === 期望月份。如果不匹配说明发生了夹取,可以按业务规则处理。
坑三:ZonedDateTime 的序列化格式不是通用的
Temporal 的 toString() 在 ZonedDateTime 上会输出这样的字符串:
2024-03-31T12:00:00+02:00[Europe/Berlin]
这个格式看起来像 ISO 8601,但最后那个 [Europe/Berlin] 是 Temporal 独有的扩展语法,标准的 ISO 8601 里没有。所有不认这个格式的 JSON 反序列化器都会把整串当成普通字符串,或者干脆解析失败。
我们项目里有一个共享的 Java 服务,它接收前端传来的时间时用 OffsetDateTime.parse() 解析,直接抛 DateTimeParseException。因为我们前端的 mock 环境一直没接这个服务,所以直到联调阶段才发现。
处理办法有两个方向。
方案 A:传输用 Instant。把 ZonedDateTime 转成 Instant,输出格式是标准的 UTC 时间字符串(比如 2024-03-31T10:00:00Z),后端全平台都能解析。时区信息单独作为一个字段传。
const payload = {
startUtc: zdt.toInstant().toString(),
timezone: zdt.timeZoneId,
};
方案 B:让后端配合解析 Temporal 格式。用 Temporal polyfill 或者对应的后端库读取扩展语法。这个方法更”原生”,但需要每一端都升级。
我们选的是方案 A。原因很直接:Instant + timezone 的字符串格式是几十年 ISO 8601 传统,向后兼容性好。将来换语言、换框架,这套传输格式永远不需要变。而 Temporal 的扩展语法目前还没有被大多数库支持,作为传输格式太”新”了。
坑四:和数据库字段的直接映射
这个坑前面表格改结构的时候提到过,这里单独说。
很多团队迁移到 Temporal 之后,喜欢把 ZonedDateTime 的 toString() 直接塞进数据库的 TEXT 或 VARCHAR 字段。看起来”无损保存了所有信息”,实际上是在给后面的查询和索引挖坑。
数据库里存 2024-03-31T12:00:00+02:00[Europe/Berlin] 这样的字符串,你想按时间范围查询怎么办?所有记录都得反序列化再比较,索引全废。想按天聚合怎么办?DATE(column) 函数会返回 null,因为它不认识这个格式。
正确的映射方式是这样的:
Instant 对应数据库的 TIMESTAMP(9) 或 BIGINT(纳秒/毫秒)。这是真实时刻,用来做范围查询和排序。
PlainDate 对应 DATE 类型。日期就是日期,不需要时分秒。
PlainTime 对应 TIME 类型。
ZonedDateTime 不适合直接入库,因为它本质上是”一个 Instant 加上一个时区标识”两段信息。要么拆成两列(Instant + timezone),要么回到 Instant 存,时区在业务层处理。
我们最终的表结构是把预约拆成了三个字段:reservation_date DATE、start_time TIME、timezone VARCHAR(64),然后前端拼成 ZonedDateTime。这样做查询索引完全正常,也能正确处理跨时区的预约。
坑五:polyfill 的体积与性能权衡
Temporal 的原生支持其实已经不差了。Chrome 从 139 开始默认启用,Firefox 从 139 开始跟进,Safari 从 26 开始。但企业内网老浏览器、安卓 WebView、各种 Electron 客户端,还是会有老版本。
我们项目里给老环境打了 polyfill。用的是官方的 @js-temporal/polyfill。gzip 之前大概 300 多 KB,gzip 之后 40 KB 左右。对首屏有影响,但可用 import() 动态加载,在进入日期相关的页面时再载入,避免全站都背上这个体积。
性能上,原生 Temporal 比 dayjs 快不少——尤其是 Temporal.Now.instant() 和高频的时间算术操作,原生实现几十纳秒就能完成,dayjs 需要几十微秒。但如果是纯格式化(比如 'YYYY-MM-DD'),Intl.DateTimeFormat 其实更快,不需要任何 polyfill。
我们项目里对”格式化”这件事做了分类:只展示日期或时间而不做算术的地方,用 Intl.DateTimeFormat;需要做日期算术的地方,用 Temporal。这样能在体积和性能之间取得平衡。
什么时候不该迁到 Temporal
这篇文章写到这,感觉 Temporal 已经是万能的了。但我得说清楚——不是所有项目都值得迁。
如果你项目只是展示”什么时候创建了订单””用户注册多久了”这类只读的场景,需要的时间处理逻辑非常浅,那现有的 Intl.DateTimeFormat 加上 Date 已经够用了。Temporal 的类型系统、DST 处理、序列化差异都是额外的复杂度,在这个场景下收益很小。
如果你的项目已经有一套成熟的 dayjs 封装,控制得很稳,只是偶尔遇到时区问题,那迁移的成本可能高于冒 5 个新坑。可以先针对最痛的那几个模块做局部替换,等其他模块迭代的时候再跟进。
如果目标环境完全没有 Temporal(比如很久没有升级的混合 APP WebView),又不方便带 polyfill,那不建议硬上。等环境升级再说。
我们之所以下了决心改,是因为预约系统本身就是”时间 × 时区 × 日历”三件事交织,之前用 dayjs 加几个插件已经出过两次线上事故。在这种情况下,迁移不是成本问题,是”到底还要不要在生产上继续修 DST 类 bug”的问题。
迁移过程里几个小技巧
最后分享几个实施层面上的东西。
别一步到位,先替换读取层。我们一开始只在”当数据从 API 拿到后”这一层把字符串转成 Temporal 对象。写入层的代码暂时保留原来的 dayjs 逻辑。这样每个读接口都能拿到 Temporal 对象做安全运算,但不需要一次把整个项目的写逻辑也改完。
写一个临时的转换适配器。建立一个 toTemporal(value) 全局函数,能接受 string、Date、dayjs 对象、Temporal 对象多种输入,统一转成 ZonedDateTime。这样可以慢慢替换调用点,不用一步切到底。
给时区做枚举。不要让业务代码直接调用 Temporal.TimeZone.from('Europe/Berlin')——字符串拼错一个字母就会抛错。我们集中维护了一个 TIMEZONES 常量对象,所有时区通过常量引用,运行时校验一次。
开发阶段加严格模式。所有 toZonedDateTime 调用默认带 disambiguation: 'reject',遇到不存在的时间直接抛错。上线之后这个配置保持不变——因为抛错意味着有真实的业务问题,而不是一个可以忽略的边缘情况。
一点个人感受
这次迁移一共改了大约 2000 行代码,花了三周。前两周主要在搬,第三周主要在填坑——上面五个坑基本都是在第三周撞的。
迁移完的第一次 DST 切换(2024 年 10 月,柏林回拨一小时)我们盯了一整个晚上。结果一切正常。凌晨 2:30 的预约自动处理成了两个 1:30 的排队,因为我们加了 getPossibleInstantsFor 的处理逻辑。这是 dayjs 时代完全做不到的。
回头想想,Temporal 带来的真正变化不是”少写代码”,而是让日期时间相关的 bug 从”运行时会悄悄出错”变成”定义的时候就必须想清楚”。这种”把复杂性提前”的设计,是我这次迁移里最认同的地方。
如果你也在犹豫要不要改,我的建议是:先把 PlainDate、ZonedDateTime、Instant 这三种类型的区别在团队里讲清楚,然后挑一个跨时区出过 bug 的模块试改。试完就有答案了。

