首页 / 文章

五个会悄悄重现日期错误的Temporal API陷阱

了解为何如果不纠正五种常见的误用方式,JavaScript的Temporal API仍可能导致时区、序列化、精度和时长方面的问题。

发布者:REACTAPP.TOP阅读约 9 分钟1977 词

来源: JavaScript Temporal API: 5 Critical Mistakes and How to Fix Them!

每当夏令时调整时,系统会每小时触发一次提醒通知。由于服务器使用UTC时间而客户处于完全不同的时区,导致支持工单被记录在错误的日期下。还有那些在持续集成测试中偶尔无法通过相等性检查的财务报告,且在本地机器上无法明确重现该故障。

这些都不是由Date类引起的错误。它们属于时间相关的故障,而出现在那些原本为避免日期相关问题而采用Temporal框架的代码库中。

从 Node.js 26.8.2 开始,Temporal 已在全球范围内正式发布,无需任何实验性标志。Chrome 144 也直接支持它。TC39 已将该提案推进到第 4 阶段,这意味着它不再只是未来的设想——而是语言中已确定的组成部分,各团队正在积极用它来替代 Moment.js、date-fns 以及自定义的基于 Date 的运算方式。正是这种迁移潮导致了下面这五种常见错误的出现。

Temporal 通过采用更严格、更明确的数据模型,解决了 Date 一直以来存在的诸多问题——可变性、解析歧义,以及日历日期与精确时间点之间缺乏明确界限。问题往往源于开发者将其视为“一个更完善的 Date”而已。正是这种误解导致了此处所述的错误,而且这些错误往往比旧问题更为隐蔽,更难被发现。

错误 1:在未确定“今天”指代何时的情况下调用 Temporal.Now.plainDateISO()

这可能是最常见的错误,主要是因为引发该错误的代码乍看之下完全正确。

// Inside an API route running on a server in us-east-1
function getTodaysDigestDate() {
  const today = Temporal.Now.plainDateISO();
  return today.toString();
}

当不带参数调用 Temporal.Now.plainDateISO() 时,它会使用系统的时区来确定“当前时间”——即服务器所处的时区,而非最终用户所在的时区。想象一下,东京的某位客户在当地时间上午8点打开你的应用:如果服务器处于UTC时区且时间尚未过午夜,那么该客户的记录就会被标记为昨天的日期。这与曾经困扰 new Date().toDateString() 的相同类型的问题如出一辙——只是因为开发者认为该代码使用了Temporal,就误以为其不会出现此类问题,结果这个问题又重新出现了。

更好的解决方案:

function getTodaysDigestDate(userTimeZone) {
  // userTimeZone comes from the user's profile or resolved request context,
  // e.g. "Asia/Tokyo" — never the server's implicit zone.
  const today = Temporal.Now.plainDateISO(userTimeZone);
  return today.toString();
}

Temporal 并没有解决“相对于哪个时区?”的问题——它只是强制你明确回答这个问题,而非让其默认沉默处理。任何省略显式时区参数的 Temporal.Now.* 调用,其实都是你在不知不觉中做出的选择。建议在代码库中查找未指定时区的 Temporal.Now.plainDateISO()、Temporal.Now.zonedDateTimeISO() 以及 Temporal.Now.plainDateTimeISO() 调用——每一处都在默默地以“服务器当前所处的时区”来回答“今天是哪一天?”这一问题。

错误 2:误以为 JSON 的往返传输能将 Temporal 值转换回 Temporal 对象

临时对象提供了 toJSON() 方法,因此将它们传递给 JSON.stringify() 似乎可以无缝工作——而这种表面的成功恰恰会引发后续的错误。

const state = { renewalDate: Temporal.PlainDate.from("2026-11-03") };
localStorage.setItem("sub", JSON.stringify(state));
// ...later, on page reload...
const restored = JSON.parse(localStorage.getItem("sub"));
const daysLeft = restored.renewalDate.since(Temporal.Now.plainDateISO()).days;

JSON.stringify 实际上调用了 toJSON(),并生成标准的 ISO 格式字符串。但 JSON.parse 无法识别该字符串原本表示的是 Temporal.PlainDate —— 它只会返回一个字符串,而非类实例。运行上述代码会抛出 TypeError: restored.renewalDate.since is not a function 错误,这类问题通常发生在生产环境中,即数据经过某种序列化处理之后:通过 localStorage、向 Web Worker 发送的 postMessage 请求、传递给服务器组件的属性,或是持久化的 Redux 存储。在本地开发时,由于仍在同一标签页且处于相同的执行周期内,这类问题很容易被忽视 —— 只有当数据真正被序列化后再被读取时才会出现。

更好的解决方案:

const restored = JSON.parse(localStorage.getItem("sub"));
const renewalDate = Temporal.PlainDate.from(restored.renewalDate);
const daysLeft = renewalDate.since(Temporal.Now.plainDateISO()).days;

基本规则如下:每个Temporal类型都配有对应的.from()静态构造函数用于数据重建。每当这些值经过某个层将其转换为纯文本或JSON时——无论是浏览器存储、工作线程消息、API请求体,还是框架中服务器与客户端之间的数据传递——一旦数据到达目标位置,你就必须负责将其显式转换回来。没有工具会自动为你完成这种重建,也不会在跳过该步骤时发出警告。

错误3:忘记数据库无法达到Temporal的精度

Temporal.Instant能够精确到纳秒级别来衡量时间。但实际上,你将这类数据传递给的所有系统都难以保留如此高的精度。

const capturedAt = Temporal.Now.instant(); // nanosecond precision
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
// later, comparing the freshly captured instant to the one read back from Postgres
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // false — intermittently, not always

Postgres的timestamptz列类型仅能保留微秒级精度,而普通的JavaScript Date对象只能存储毫秒级精度。若直接将纳秒级精度的Instant对象写入这两种类型中,多余的数字会在插入过程中被悄悄截断。上述代码片段中的比较在技术上并非错误——只是用存储实际上无法保持的精度标准来衡量数值。若未被发现,这会导致测试偶尔失败、去重检查让某些“重复项”漏过,或是审计记录中在同一微秒内发生的两个事件在没有人为意图的情况下无法区分。

正确用法:

const capturedAt = Temporal.Now.instant().round({ smallestUnit: "microsecond" });
await db.query(
  "INSERT INTO events (captured_at) VALUES ($1)",
  [capturedAt.toString()]
);
const row = await db.query("SELECT captured_at FROM events WHERE id = $1", [id]);
const stored = Temporal.Instant.from(row.captured_at);
console.log(capturedAt.equals(stored)); // true, consistently

在写入数据之前,先将其四舍五入到存储层实际支持的精度,而不是在反复进行只会偶尔失败的比较之后才处理。

错误4:为尚未发生的事件保存固定的时间点

这种错误尤其危险,因为它通常能通过测试并投入生产环境——问题往往一年才会出现两次。

function scheduleReminder(zone) {
  // "Remind me at 9 AM tomorrow" — computed once, at creation time
  const fireAt = Temporal.Now.zonedDateTimeISO(zone)
    .add({ days: 1 })
    .toInstant();
  return fireAt; // stored as-is, e.g. in a job queue's "run_at" column
}

Instant表示UTC时间线上的一个固定点;一旦将本地时间转换为该值,它就不再“记住”其对应的本地时间为上午9点。如果设定提醒的日期在创建提醒时之后发生了夏令时调整,该时区的UTC偏移量就会改变,从而导致通知在本地时间的上午8点或10点而非9点触发。代码在执行时本身并无问题——只有在时钟时间发生变化后才会出错,而这正是被称作“不稳定”的缺陷类型,且每年春夏都会再次出现。

正确用法:

function scheduleReminder(zone) {
  // Store the wall-clock intent, not a resolved instant
  const wallTime = Temporal.Now.plainDateTimeISO(zone).add({ days: 1 });
  return { wallTime: wallTime.toString(), timeZone: zone };
}
function resolveNextFireTime({ wallTime, timeZone }) {
  // Resolve to an actual Instant only when you're about to act on it
  return Temporal.PlainDateTime.from(wallTime)
    .toZonedDateTime(timeZone)
    .toInstant();
}

如果你的真实意图是“当地时间上午9点,无论时区偏移是多少”,那么应将墙钟显示的时间与时区一起保留,仅在最后时刻——即在真正使用该时间之前——才将其转换为具体的Instant对象,而非在最初安排任务时就进行转换。

错误5:使用毫秒级计算而非Temporal.Duration

从Date.getTime()沿袭下来的习惯并不会因为你更换了数据类型就自动消失。

function oneWeekLater(zonedDateTime) {
  const msPerWeek = 7 * 24 * 60 * 60 * 1000;
  const nextMs = zonedDateTime.epochMilliseconds + msPerWeek;
  return Temporal.Instant.fromEpochMilliseconds(nextMs)
    .toZonedDateTimeISO(zonedDateTime.timeZoneId);
}

一个日历周的长度并不总是恰好为168小时。当计算的时间跨度跨越夏令时边界时,若在时间戳值上加上相当于7 * 24小时的毫秒数,就会得到错误的本地时间——原本应“一周后”进行的预约可能会偏移一小时,尽管日历日期本身并未改变。这本质上是错误4的一种较为隐蔽的表现形式,只要函数仍然依赖原始的毫秒级运算,就很容易再次出现这个问题,因为过去处理Date相关计算就是采用这种方式。

正确用法:

function oneWeekLater(zonedDateTime) {
  return zonedDateTime.add({ weeks: 1 });
}

基于 Temporal.Duration 构建的 .add() 和 .subtract() 等方法会考虑日历和时区因素,因此“一周后”这一时间表述会在包含夏令时的情况下,正确对应到同一工作日的相同实时时刻。一旦你开始直接计算毫秒级偏移量后再进行转换,就意味着你已经超出了 Temporal API 为正确处理此类问题而设计的范围。

五大错误背后的共同点

所有这些问题都源于同一个根本原因:Temporal 要求你必须明确指定时区、精度以及操作意图,而每一个错误都是因为悄悄地使用了 Date 所依赖的隐含假设。Temporal 从来就不是用来机械式替代的——它是一个更为严格的模型,只是使用了看起来相似的方法名称而已。

简要回顾:

  • 在未明确指定所需时区的“当前时间”之前,切勿调用 Temporal.Now.*
  • 在任何序列化边界的两端都需使用 .from() 恢复 Temporal 对象的值——因为在数据往返传输过程中,JSON 会将它们转换为普通字符串,而非实时对象
  • 在保存数据之前,先将数值四舍五入到存储层实际能够保留的精度,而非在后续调试时因比较结果异常而再进行处理
  • 对于未来发生的事件,需同时保存其世界时以及时区信息,只有在准备对其执行操作时才将其转换为Instant类型
  • 在处理任何与日期相关的内容时,优先使用基于Temporal.Duration的运算方法——如.add()、.subtract()——而非基于纪元毫秒的计算方式
  • Temporal确实有效解决了Date类型多年来积累的诸多问题。但它用更少的、更为尖锐的问题替代了那些旧问题,而且既然Node 26和Chrome 144都已默认集成该功能,越来越多的团队在迁移过程中会在生产环境中直接遇到这些新问题。