Temporal API 实战:把 dayjs 请出预约系统,聊聊 DST 平移和月末夹取

触发这次迁移的是一次真实的客诉。德国的一个用户在网站上预约了 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 的模块试改。试完就有答案了。

Temporal API 实战:把 dayjs 请出预约系统,聊聊 DST 平移和月末夹取
收藏 (0) 打赏

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

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

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

淘吗网 javascript Temporal API 实战:把 dayjs 请出预约系统,聊聊 DST 平移和月末夹取 https://www.taomawang.com/web/javascript/2959.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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