Temporal API 实战:重写跨时区循环提醒,把夏令时和月末对齐一次解决

先说一个我见过不止三次的线上问题。一个产品的提醒功能写着“每周一 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 真正省下的东西。

Temporal API 实战:重写跨时区循环提醒,把夏令时和月末对齐一次解决
收藏 (0) 打赏

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

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

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

淘吗网 javascript Temporal API 实战:重写跨时区循环提醒,把夏令时和月末对齐一次解决 https://www.taomawang.com/web/javascript/2963.html

常见问题

相关文章

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

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