先说一个我见过不止三次的线上问题。一个产品的提醒功能写着“每周一 09:00”,用户在新加坡,服务器在法兰克福,运维在俄勒冈。上线三个月没人发现不对,直到三月底欧洲切夏令时,一部分用户收到提醒的时间整体偏移了一小时,客服工单里全是“我的提醒早了一小时”。
根因不复杂:Date 对象本身没有时区类型,它只是“一个时间戳 + 宿主环境的本地时区视图”。你在哪里 setHours,结果就跟着哪里走。而“每周一 09:00”这句话里 09:00 是用户时区的墙钟时间,这个信息在 Date 里根本存不下。
Temporal 就是为这类问题设计的。这篇用一个完整的循环提醒模块把它跑一遍:规则解析、下一次触发、区间展开、DST 缺口处理、定时器分段、存储格式,全部给可跑的代码。
一、先把 Date 的三个硬伤点出来
不是要批判 Date,而是搞清楚边界在哪,才知道什么时候该换。
- 没有“带时区的日期时间”这个类型。
Date内部是一个 epoch 毫秒数,所有本地时间方法(getHours、setDate)都隐式依赖宿主时区。同一条数据在服务器和浏览器里解读结果可能完全不同。 - 本地时间算术不是匀速的。在纽约,2025 年 11 月 2 日这一天有 25 个小时;2025 年 3 月 9 日只有 23 个小时。用
setDate(+1)混着setTime(+86400000)写,DST 当天必翻车。 - 解析出来的字符串歧义。
new Date('2025-03-09T02:30:00')在现代引擎里按本地时区解析,但历史上不同引擎处理不一致;带Z的按 UTC,带偏移的按偏移。同一段字符串在三种写法下有三个含义。
Temporal 把这几件事拆成了不同的类型:Instant 是绝对时间点,ZonedDateTime 是“时间点 + 时区”,PlainDate / PlainTime / PlainDateTime 是墙钟时间(不带时区),Duration 是时长。用哪个,取决于你在表达什么。
| 类型 | 表达什么 | 典型用途 |
|---|---|---|
| Temporal.Instant | 绝对时间点(UTC 纳秒) | 日志、事件时间戳、数据库存储 |
| Temporal.ZonedDateTime | 绝对时间点 + 时区 + 偏移 | 跨时区展示、DST 安全算术 |
| Temporal.PlainDateTime | 墙钟日期时间,无时区 | 表单输入、生日、账期 |
| Temporal.PlainDate / PlainTime | 纯日期 / 纯时间 | 日历、排班、营业时间 |
| Temporal.Duration | 时长 | 倒计时、区间差、人性化展示 |
二、场景定义:一个循环提醒规则
需求写成一句话:用户在自己所在的时区,每周一、三、五的 09:00 收到提醒。规则长这样:
const RULE = {
timeZone: 'America/New_York',
daysOfWeek: [1, 3, 5],
hour: 9,
minute: 0
};
注意 timeZone 存在规则里,而不是存在某条“下一次触发时间”里。这是整个设计的关键:规则描述的是重复行为,必须带时区;单次触发时间是从规则算出来的派生结果。
三、算下一次触发
思路很直接:从参考时间的本地日期开始,逐天往后找,命中 daysOfWeek 的那天拼上 09:00,再和参考时间比大小。
function nextOccurrence(rule, after = Temporal.Now.zonedDateTimeISO(rule.timeZone)) {
// 关键:先把参考时间换到规则所在时区,
// 否则 toPlainDate() 拿到的是另一个时区的“今天”。
const local = after.withTimeZone(rule.timeZone);
const baseDate = local.toPlainDate();
const time = Temporal.PlainTime.from({ hour: rule.hour, minute: rule.minute });
for (let i = 0; i <= 8; i += 1) {
const date = baseDate.add({ days: i });
if (!rule.daysOfWeek.includes(date.dayOfWeek)) continue;
const candidate = date.toZonedDateTime({
timeZone: rule.timeZone,
plainTime: time
});
if (Temporal.ZonedDateTime.compare(candidate, local) > 0) {
return candidate;
}
}
throw new Error(`规则在 8 天内没有命中,检查 daysOfWeek: ${rule.daysOfWeek}`);
}
几个点值得单独说:
dayOfWeek是1到7,周一为 1。跟Date.prototype.getDay()的周日为 0 不一样,迁移代码时最容易错的就是这个。withTimeZone()那一行不能省。如果after是东京时间的“周三 22:00”,它对应的纽约日期其实是“周三 08:00”,但如果你在东京时区上取toPlainDate()再拿去和纽约时间比较,边界情况会算错一天。- 循环上限写 8 而不是 7。7 天一定能覆盖一整周,但加上
i = 0的今天和“必须严格大于参考时间”的条件,8 是更稳的边界。 Temporal.ZonedDateTime.compare返回-1 / 0 / 1,直接拿来判断比用<运算符安全,因为 Temporal 对象之间没有隐式类型转换。
跑一下:
const start = Temporal.ZonedDateTime.from('2025-03-07T12:00:00-05:00[America/New_York]');
console.log(nextOccurrence(RULE, start).toString());
// 2025-03-10T09:00:00-04:00[America/New_York]
注意偏移从 -05:00 变成了 -04:00。3 月 9 日纽约切进夏令时,3 月 10 日的 09:00 就是 EDT 了。这一步正是 Date 最容易算错的地方,Temporal 靠“墙钟时间 + 时区”直接算对。
四、DST 缺口:一个不存在的 02:30
上面的例子选 09:00,是因为它不落在切换窗口里。换个规则试试:每天 02:30 提醒。2025 年 3 月 9 日凌晨 2 点,纽约直接跳到 3 点,02:30 这一分钟压根不存在。
// 默认 disambiguation: 'compatible'
Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2025, month: 3, day: 9,
hour: 2, minute: 30
}).toString()
// '2025-03-09T03:30:00-04:00[America/New_York]' ← 被推后了一小时
这个“推后”不是 bug,是规范定义的消歧策略。Temporal 提供四个选项,都通过 disambiguation 参数控制:
| disambiguation | 缺口(时间不存在) | 重叠(时间出现两次) |
|---|---|---|
| compatible(默认) | 取之后的时间 | 取之前的时间 |
| earlier | 取之前的时间 | 取之前的时间 |
| later | 取之后的时间 | 取之后的时间 |
| reject | 抛 RangeError | 抛 RangeError |
对于提醒这类业务,默认的 compatible 通常是对的:用户设了 02:30,那天没有 02:30,那就 03:30 提醒,至少不会漏。但如果你在做账单结算、合同到期这种“时间点必须精确”的场景,就该用 reject 让上游显式处理冲突,而不是悄悄挪一小时。
// 想做“静默修正”的,用 compatible
const shifted = Temporal.PlainDate.from('2025-03-09').toZonedDateTime({
timeZone: 'America/New_York',
plainTime: Temporal.PlainTime.from('02:30')
});
// 想发现异常的,比对墙钟时间有没有被改过
const requested = Temporal.PlainTime.from('02:30');
if (Temporal.PlainTime.compare(shifted.toPlainTime(), requested) !== 0) {
console.warn(`提醒时间被 DST 调整:${requested} → ${shifted.toPlainTime()}`);
}
后面这段检查很实用:toPlainTime() 返回的是消歧之后的实际墙钟时间,跟原始请求比对一下就知道有没有被挪过。不比对的话,这类调整是完全静默的。
五、反过来:秋天的重叠时间
11 月 2 日凌晨 1 点,纽约的时钟拨回 1 点,01:00 到 02:00 这段时间会走两遍。这时候 Temporal.ZonedDateTime.from() 里那个偏移量就派上用场了,它用来消歧:
// 第一遍,EDT,偏移 -04:00
Temporal.ZonedDateTime.from('2025-11-02T01:30:00-04:00[America/New_York]').toString()
// '2025-11-02T01:30:00-04:00[America/New_York]'
// 第二遍,EST,偏移 -05:00
Temporal.ZonedDateTime.from('2025-11-02T01:30:00-05:00[America/New_York]').toString()
// '2025-11-02T01:30:00-05:00[America/New_York]'
同样写着 01:30,两个字符串代表相差一小时的两个绝对时间点。所以序列化的时候一定要把偏移带上,只存 2025-11-02T01:30:00[America/New_York] 是丢失信息的,解析回来会按 disambiguation 默认值挑一个,可能跟你原本存的那个差一小时。
顺带一个容易忽略的点:如果传入的偏移和时区在该时刻的实际偏移冲突,比如 '2025-11-02T01:30:00+09:00[America/New_York]',Temporal 会以时区为准,把偏移忽略掉,而不是报错。不要指望它帮你校验数据一致性。
六、展开区间内所有触发
做日历预览或者批量生成推送任务的时候,需要把一段区间内的所有触发点列出来。用 generator 写最自然:
function* occurrencesBetween(rule, start, end) {
// nextOccurrence 是严格大于 after 的,
// 想让 start 本身被包含进来,往回挪一点点。
let cursor = nextOccurrence(rule, start.subtract({ nanoseconds: 1 }));
while (Temporal.ZonedDateTime.compare(cursor, end) <= 0) {
yield cursor;
cursor = nextOccurrence(rule, cursor);
}
}
const from = Temporal.ZonedDateTime.from('2025-03-01T00:00:00-05:00[America/New_York]');
const to = Temporal.ZonedDateTime.from('2025-03-31T23:59:59-04:00[America/New_York]');
for (const t of occurrencesBetween(RULE, from, to)) {
console.log(t.toString());
}
// 2025-03-03T09:00:00-05:00[America/New_York]
// 2025-03-05T09:00:00-05:00[America/New_York]
// ... 3 月 9 日之后偏移统一变成 -04:00
// 2025-03-31T09:00:00-04:00[America/New_York]
subtract({ nanoseconds: 1 }) 这个小技巧比“判断是否等于 start”要干净。纳秒是最小粒度,减掉之后 compare 一定严格小于 start,不会出现边界漏掉一个的情况。
七、人性化倒计时
“下次提醒还有多久”这种展示,用 Duration 做差再格式化。这里有个参数必须显式写:
const now = Temporal.Now.zonedDateTimeISO(RULE.timeZone);
const next = nextOccurrence(RULE, now);
const gap = now.until(next, { largestUnit: 'day' });
console.log(gap.toLocaleString('zh-CN', { style: 'long' }));
// 例如:2天4小时32分18秒
largestUnit 不写的话,默认是 hour,跨三天会给你 76小时 这种谁都看不懂的结果。ZonedDateTime.prototype.until 默认最大单位是小时,这一点和很多人的直觉相反。
更关键的是,指定 largestUnit: 'day' 之后,跨 DST 的计算会按日历天算,而不是按 24 小时算。举个例子:
console.log(
Temporal.Duration.from({ days: 1 })
.total({ unit: 'hours', relativeTo: '2025-11-02T00:00[America/New_York]' })
);
// 25 —— 那天真的多了一小时
console.log(
Temporal.Duration.from({ days: 1 })
.total({ unit: 'hours', relativeTo: '2025-11-03T00:00[America/New_York]' })
);
// 24
这就是 relativeTo 存在的意义:一个“天”到底是多少小时,取决于它落在哪一天、哪个时区。这也是为什么 Duration 里的年、月、周必须配 relativeTo 才能做总计,脱离参照点的“一个月”没有确定长度。
八、定时器:别指望一次 setTimeout 撑一个月
setTimeout 的延迟参数是 32 位有符号整数,上限 2147483647 毫秒,约 24.8 天。超过这个值,各家引擎的行为不太一致,最稳妥的做法是分段睡。
const MAX_DELAY = 2 ** 31 - 1;
function scheduleAt(target, callback) {
const tick = () => {
const remain = target.toInstant().epochMilliseconds
- Temporal.Now.instant().epochMilliseconds;
if (remain <= 0) {
callback(target);
return;
}
setTimeout(tick, Math.min(remain, MAX_DELAY));
};
tick();
}
用法:
scheduleAt(nextOccurrence(RULE), (at) => {
console.log('触发', at.toString());
// 触发后立刻排下一次
scheduleAt(nextOccurrence(RULE, at), arguments.callee);
});
上面这段只是演示结构,arguments.callee 在严格模式下不可用,真实代码里应该把回调抽成具名函数再递归。另外,浏览器标签页休眠、移动端后台挂起都会让长定时器失准,生产环境建议配合 visibilitychange 重新校准一次:唤醒后算一下当前时间,如果已经超过目标点,就补一次触发并把下一次重排。
九、存储与序列化
这块是实践里翻车最多的,规则只有一条:分清“事件”和“规则”,分别存。
- 一次性事件(订单超时、会议开始):存绝对时间点。数据库里用
Temporal.Instant,序列化成带Z的 ISO 字符串,或者直接存 epoch 毫秒。 - 循环提醒(每周一 09:00):存规则。规则里必须包含时区标识,不要存“下一次触发时间”—— 那是派生数据,存了就要在用户改时区、改规则时同步更新,迟早不一致。
// 一次性事件
const instant = Temporal.Instant.from('2025-06-01T12:00:00Z');
JSON.stringify({ fireAt: instant.toString() }); // '2025-06-01T12:00:00Z'
// 循环规则(存派生出的下一次,但只是缓存)
const next = nextOccurrence(RULE);
const payload = {
rule: RULE, // 源数据
nextRunAt: next.toString(), // 缓存,可随时重算
nextRunAtEpoch: next.toInstant().epochMilliseconds
};
反序列化的时候直接用 Temporal.ZonedDateTime.from() 或 Temporal.Instant.from(),它们能完整解析自己的序列化格式。别用 new Date() 中转,Date 只有毫秒精度,而且会把时区信息压平。
还有一个精度问题:Temporal 的 Instant 是纳秒精度,JS 的 number 只有 53 位有效数字,epochMilliseconds 是安全的,但如果你想拿 epochNanoseconds 存成 number 就溢出了,得用 BigInt。跨语言传输时,ISO 字符串比数字更安全。
十、兼容性与落地策略
截至现在,Chrome 已经默认开启 Temporal,Firefox 和 Safari 还在推进中,Node 侧需要较新的版本或者启用实验标志。生产环境直接写 Temporal.xxx 会遇到 ReferenceError,所以需要一个切换层。
业界有两个常用的 polyfill:@js-temporal/polyfill 是 TC39 提案团队维护的参考实现,完整度高、包体大;temporal-polyfill 是社区做的精简版,体积小很多,覆盖了绝大多数 API。
let Temporal;
if (globalThis.Temporal) {
Temporal = globalThis.Temporal;
} else {
({ Temporal } = await import('@js-temporal/polyfill'));
}
注意这里用动态 import,让不支持的浏览器只在运行时才去拉 polyfill,支持的浏览器一行额外代码都不会下载。配合打包工具的代码分割,能做到零成本渐进增强。
如果不想引入 polyfill,退一步的做法是把 Temporal 调用收拢到一个适配层模块里,内部用 Date + Intl.DateTimeFormat 的 timeZone 选项手动实现时区换算。能做,但 DST 消歧、纳秒精度、跨时区的日历算术都得自己扛,代码量会翻好几倍 —— 这也是为什么值得等 Temporal 正式铺开。
十一、几个实战里踩过的坑
dayOfWeek从 1 开始,周日是 7。从getDay()迁移过来时,周日那条规则几乎必然写错。until的默认最大单位是小时。想要“3 天 2 小时”这种结果,必须显式传largestUnit: 'day'。- Temporal 对象之间不能用
<比较。不会隐式转成数字,结果是false而不会报错,比报错更难查。统一用Temporal.ZonedDateTime.compare或Temporal.Instant.compare。 - 序列化必须带偏移。
2025-11-02T01:30:00[America/New_York]在秋季重叠时段是有歧义的,解析回来可能差一小时。 toZonedDateTime的消歧是静默的。想发现“时间被挪过”,要拿toPlainTime()和原始时间比对,不能指望抛错。- 别把
Temporal.Now写在模块顶层。模块加载时求值一次,之后永远返回那个时间。要在函数内部调用。
把上面这些拼起来,一个提醒模块大概两百行能写完,覆盖规则解析、下次触发、区间展开、DST 处理、跨时区展示、定时调度和存储格式。比用 Date 硬扛要短,而且每一行在做什么是清楚的 —— 这大概才是 Temporal 真正省下的东西。

