上个月被安排去修一个跨国会议排期工具的bug——美国同事明明选了上午9点到10点的会议,我这边显示的时间却是凌晨两点。一看代码,到处是new Date()、moment.tz和手写的偏移量计算,夏令时开关那几天更是天天出错。正好前阵子看到Temporal API已经进入Stage 3,各大浏览器也陆续开始实现,索性用整个周末把底层日期逻辑从Date迁移到了Temporal。改完之后,时区相关的bug彻底消失了,代码也清晰得不像话。觉得这次迁移踩过的坑和总结的经验值得写下来,给同样被Date折磨的朋友一个参考。
Date的坑为什么填不平
JavaScript的Date对象设计于1995年,那会儿连全球化的边都摸不着。它有几个让人抓狂的问题:月份从0开始,解析字符串在不同浏览器里结果不一致,最致命的是默认使用系统本地时区,跨时区操作必须靠开发者自己计算偏移量。我们之前的会议工具为了支持多时区,引入了一整套工具函数:手动转换UTC、手动加时间差、手动判断夏令时。逻辑分散在十几个文件里,任何一次改动都可能引发连环错误。
我也试着用moment-timezone补过一段时间,但moment本身已经宣布进入维护状态,而且一个库的体积接近70KB,只为了处理日期实在是头重脚轻。Temporal API的出现算是从根本上解决了这个问题:它原生提供了不可变的日期时间对象、清晰的时区支持、以及符合ISO 8601标准的语义。
Temporal核心概念:不再只用一个类糊弄一切
跟Date“什么都往里塞”不同,Temporal拆出了一组职责分明的类型:
- Temporal.Now:获取当前时间的入口,可以指定时区,类似
Temporal.Now.zonedDateTimeISO('Asia/Shanghai')。 - Temporal.Instant:绝对时间点,不受时区影响,相当于UTC时间戳。
- Temporal.ZonedDateTime:带有时区信息的完整日期时间,比如“北京时间2024年3月15日上午10点”。
- Temporal.PlainDate、Temporal.PlainTime:不含时区的纯日期或纯时间,用来表示生日、纪念日、营业时间等。
- Temporal.Duration:表示一段时间长度,可以做精确的算术运算。
这套类型系统的好处是,代码自带了意图。比如你声明了一个ZonedDateTime,阅读代码的人立刻知道它考虑到了时区;如果看到一个PlainDate,就知道它跟地理位置无关。这比到处是Date对象、靠注释说明含义要可靠得多。
实战:重写一个跨时区会议创建功能
会议工具的核心场景是:组织者选择一个时间(基于自己的时区),然后系统计算与会者各自当地时间是多少,并检查是否有日程冲突。用Temporal重写这个流程,代码量直接砍掉一半。下面一步步展示。
第一步:创建会议时间
组织者在东京,想创建一个会议,东京时间2024年4月10日 14:00,持续1小时。用Temporal写出来:
// 组织者创建的会议时间(东京时区)
const start = Temporal.ZonedDateTime.from({
year: 2024,
month: 4,
day: 10,
hour: 14,
minute: 0,
timeZone: 'Asia/Tokyo'
});
const duration = Temporal.Duration.from({ hours: 1 });
const end = start.add(duration);
不需要自己处理时区偏移,ZonedDateTime.from自动处理了东京的UTC+9。
第二步:转换为其他与会者的本地时间
会议有三位参与者,分别在旧金山、伦敦和上海。要把会议时间转成他们当地的时间:
const timeZones = ['America/Los_Angeles', 'Europe/London', 'Asia/Shanghai'];
const localTimes = timeZones.map(tz => ({
zone: tz,
start: start.withTimeZone(tz),
end: end.withTimeZone(tz),
}));
withTimeZone方法返回一个新的ZonedDateTime,表示同一瞬间在不同时区下的墙上时钟。比如东京14:00对应的旧金山时间是前一天的22:00(冬季时差)。这个转换完全由Temporal引擎根据IANA时区数据库自动处理,包括夏令时。
第三步:检查冲突
与会者自己的日历里有一些已有安排。为了简化,假设我们从某个API获取了一段忙碌时间列表,格式为ZonedDateTime区间。检查冲突的逻辑:
function isOverlapping(meetingStart, meetingEnd, busyStart, busyEnd) {
return Temporal.ZonedDateTime.compare(meetingStart, busyEnd) 0;
}
// 检查上海参与者的冲突
const shanghaiStart = start.withTimeZone('Asia/Shanghai');
const shanghaiEnd = end.withTimeZone('Asia/Shanghai');
const busyIntervals = [
// 假设这是该参与者已有的安排
{
start: Temporal.ZonedDateTime.from('2024-04-10T13:30[Asia/Shanghai]'),
end: Temporal.ZonedDateTime.from('2024-04-10T14:15[Asia/Shanghai]')
}
];
const hasConflict = busyIntervals.some(busy =>
isOverlapping(shanghaiStart, shanghaiEnd, busy.start, busy.end)
);
整个判断逻辑完全基于Temporal对象,没有做任何手动的毫秒比较。函数compare返回-1、0或1,行为跟JavaScript的Array.sort比较函数一致,清晰安全。
第四步:处理重复会议
原来处理每周重复的会议是一大坨自定义代码。Temporal提供了一个辅助:until方法可以生成一系列日期。比如计算未来8次每周同一时间的会议:
// 从start开始,每周重复8次
const occurrences = [];
let current = start;
for (let i = 0; i < 8; i++) {
occurrences.push({
sequence: i + 1,
start: current,
end: current.add(duration)
});
current = current.add(Temporal.Duration.from({ weeks: 1 }));
}
注意这里可以直接加weeks,不用担心溢出或者夏令时切换的影响,因为ZonedDateTime的加法会根据时区规则校准墙壁时间,保持“同一时刻每周相同的墙壁时间”这一语义(即使在夏令时边界,也能正确处理)。
迁移过程中碰到的具体适配点
实际替换旧代码时,有几个地方需要额外注意:
- 与现有后端对接。后端接口仍然使用ISO 8601字符串传输时间。Temporal对象的
toString()直接输出带时区的标准格式,比如'2024-04-10T14:00:00+09:00[Asia/Tokyo]',后端解析库基本都能识别。反过来,从后端接收字符串,直接用ZonedDateTime.from(str)或Instant.from(str)即可。 - 混合使用Date的过渡期。有些旧模块还没迁移,需要互转。可以用
Instant.fromEpochMilliseconds(date.getTime())把Date转成Instant,再用.toZonedDateTimeISO('UTC')等进一步转换。 - polyfill的引入。截至2024年底,主流浏览器尚未完全原生支持Temporal。我用了官方推荐的
@js-temporal/polyfill,体积约30KB(gzip后不到10KB)。引入后所有Temporal全局对象自动可用,将来原生支持后可以直接移除polyfill,代码无需改动。
对比Date,开发体验到底好了多少
完成迁移后,我重点观察了三个方面:
- 代码可读性。以前判断“会议是否在今天之内”需要先获取今天的0点,再比较时间戳,还要考虑时区。现在直接用
plainDate.equals(Temporal.Now.plainDateISO()),意图直白。 - bug数量。之前每个月回归测试总能发现几个夏令时相关的错误,迁移后这半年来再也没出现过。因为所有运算都是时区感知的,开发者不需要自己算偏移。
- 包体积。删掉了moment-timezone、date-fns-tz等依赖,换了轻量polyfill,总体积下降约40KB,对前端项目尤其明显。
实际项目中可以怎么逐步引入
如果你感兴趣,但项目里Date的使用已经盘根错节,我建议先选定一个独立模块(比如我们就是会议安排模块)整体切换,因为Temporal对象和Date对象不互通,混用容易混乱。先在模块边界做好转换层,内部全部使用Temporal,验证通过后再一个个模块推进。
另外,充分测试边界条件,尤其是以下场景:
- 夏令时开始/结束的那一天(比如3月的第二个周日凌晨2点);
- 跨日/跨月/跨年的计算;
- 位于时区边界线附近的用户(比如澳大利亚中部,标准时间+9:30)。
我们的测试集专门针对这几个场景增加了30多组用例,迁移后没出现过本地时间计算偏差。
写在最后
JavaScript的Date问题已经折腾了开发者二十多年,Temporal终于给了一个可靠的答案。对我来说,这次迁移不只是技术栈的替换,更像是一种解脱——以后再写日期相关代码时,不用再在脑袋里先想清楚“这是UTC还是本地时间”。
虽然Temporal还处于polyfill阶段,但它的API稳定性已经足够高,而且未来可平滑过渡到原生实现。如果你的项目里有任何跨国时间处理的需求,真的可以考虑尝鲜。至少,下次再有同事问“为什么我的会议时间又错了”,你可以理直气壮地回答:因为我们还没上Temporal。

