Skip to content

时间模型与新特性:从 Date 走向 Temporal ​

第三章学习了 Date、时间戳、时区和 Intl。它们足以解释大量现有代码,但一个 Date 很容易被同时当成“某一瞬间”“日历日期”和“某地钟表时间”。Temporal 把这些含义拆开,帮助我们在数据类型中表达问题。

运行本篇的环境 ​

本篇例子使用 Node.js 26.0.0 原生 Temporal,逐个保存为 .mjs 后运行 node 文件名.mjs。先执行 node --version 确认环境;再运行下面的检查。缺少能力时应切换到这一环境后重试,不能只阅读跳过分支。

js
if (typeof Temporal === 'undefined') {
  throw new Error('请在原生提供 Temporal 的 Node.js 26.0.0 环境运行本篇')
}
console.log(Temporal.PlainDate.from('2024-02-29').toString()) // 2024-02-29

截至 2026-09-15,Temporal 已达到 Stage 4,TC39 完成列表预期归入 2027 年度标准;它与 Node 已实现该能力是两项独立事实。Node 22 仍可运行前面的 Date 对照,但不能用它缺少 API 的结果代替本篇实验。部署到浏览器时还需核对具体目标。

先问数据到底代表什么 ​

业务含义适合的概念为什么
日志在全球时间线上的一个点Instant两地看到的钟表时间不同,但同一个瞬间可比较
生日、账期、节假日PlainDate日期本身无需先绑定午夜与时区
每天的营业时间PlainTime仅有钟表时间,不自动表示某一天的瞬间
某城市的预约ZonedDateTime时区规则参与当地时间与瞬间的换算
尚未指定时区的日期与时间PlainDateTime需要之后补全时区才能定位瞬间
持续了多久Duration表达一段量,与时间点不同

PlainYearMonth、PlainMonthDay 表达“年月”和“月日”;例如每年纪念日不必凭空补一个年份。精确建模首先减少猜测,然后才谈格式化。

Duration 不总是固定秒数:一个月有多少天取决于参考日期,跨时区规则的一天也未必是24小时。把含日历单位的时长换成总小时数,或比较这样的时长时,total、compare 等操作可能需要 relativeTo 提供参照;缺少参照就不应擅自把一个月当30天。

一个日期字段为什么会“前一天” ​

先用已有的 Date 观察建模错误,例子无需 Temporal:

js
const birthday = new Date('2000-06-15')
const format = timeZone => new Intl.DateTimeFormat('en-CA', {
  timeZone, year: 'numeric', month: '2-digit', day: '2-digit'
}).format(birthday)
console.log(birthday.toISOString())      // 2000-06-15T00:00:00.000Z
console.log(format('UTC'))               // 2000-06-15
console.log(format('America/Los_Angeles')) // 2000-06-14

日期形式的 ISO 字符串在这里被解析成 UTC 午夜的瞬间。换一个时区展示同一瞬间,日期可能改变。格式化没有把时间点弄错,错的是把“生日日期”擅自提升成一个全球时间线上的瞬间。修复时先决定数据含义:若只需年月日,就保存日期;若是确切发生时间,就保存瞬间并在展示时明确时区。

反向转换也不能凭空完成:PlainDate 只有年月日,要成为 Instant,还需要一天中的时刻和时区规则;PlainDateTime 虽然补了时分秒,仍缺时区。补全信息是一项业务决定,不能靠类型转换函数猜测。

日历运算不需要借道本地午夜 ​

js
if (typeof Temporal !== 'undefined') {
  const start = Temporal.PlainDate.from('2024-02-28')
  const next = start.add({ days: 2 })
  console.log(start.toString()) // 2024-02-28
  console.log(next.toString())  // 2024-03-01
} else {
  console.log('本环境未提供 Temporal;先学习类型与示例语义')
}

对象的加减返回新结果,不把原日期就地改写。这与 Date 的不少 setter 修改原实例的使用体验不同。上例计算的是日历上的两天,不需要用毫秒表示“48 小时”再猜落在哪个本地日期。

处理非法字段时还需要选定策略。日期字段超范围,是约束到有效范围还是拒绝,需要按 API 的 overflow 选项和输入形式确认。用字符串格式正确,不等于业务日期必然有效。

越界日期有两种不同的处理意图 ​

从表单接收一个“2月31日”,通常应拒绝;计算“1月31日的一个月后”,则可能希望落在2月最后一天。都是日期越界,业务意图却不同。

js
if (typeof Temporal !== 'undefined') {
  const fields = { year: 2024, month: 2, day: 31 }
  console.log(Temporal.PlainDate.from(fields).toString()) // 2024-02-29
  try {
    Temporal.PlainDate.from(fields, { overflow: 'reject' })
  } catch (error) {
    console.log(error.name) // RangeError
  }
  const start = Temporal.PlainDate.from('2024-01-31')
  const after = start.add({ months: 1 })
  console.log(after.toString())                     // 2024-02-29
  console.log(after.subtract({ months: 1 }).toString()) // 2024-01-29
}

约束到有效日期会丢失原先“31号”的信息,因此加一个月再减一个月不一定回到原日期。需要表示“每月最后一天”的日程时,应保存这条规则,在目标月份重新计算,而不是反复对上次结果加减月份。

上例演示的是字段对象与日期运算的越界策略,不意味着任意格式错误的字符串都能自动修正。解析、字段合法性和业务校验应分别检查。对象方法的 overflow 选项也不能代替对用户输入格式的约定。

把“每月最后一天”写成可重复的规则 ​

先计算一个目标月份的月末,再处理一串日程。with 返回替换指定字段后的新日期,未指定字段保持原值;daysInMonth 给出该日期所在月份的天数。下面限定使用 ISO 日历;日期对象的 calendarId 可用于检查历法标识,ISO 对应 iso8601:

js
if (typeof Temporal !== 'undefined') {
  const start = Temporal.PlainDate.from('2024-01-31')
  const nextMonth = start.with({ day: 1 }).add({ months: 1 })
  const lastDay = nextMonth.with({ day: nextMonth.daysInMonth })
  console.log(nextMonth.toString()) // 2024-02-01
  console.log(lastDay.toString())   // 2024-02-29
  console.log(start.toString())     // 2024-01-31:原值未改
}

第一步把日期放到本月 1 号;第二步进入下个月,此时 1 号始终有效;第三步根据目标月份选择末日。每一步都有明确含义,不需要把一个月猜成若干毫秒。

如果希望得到再下个月的月末,应从目标月份重新执行这条规则,或从最初的月初加两个月,不能直接对 2 月 29 日加一个月并把结果当作 3 月末。保存的是“月末”规则,就应每次重新计算目标月的最后一天。with 的默认约束行为也不代替输入校验;本例的日号来自目标日期本身的 daysInMonth,已在有效范围内。

按开头的 Node 26 环境运行后,再把开始日期改为非闰年的 1 月 31 日,观察目标月的 daysInMonth 如何变化。第三章的 Date 月份进位例子可用于比较旧 API 的修改行为,它不替代本篇 Temporal 实验。

某地的一天不保证等于 24 小时 ​

实行夏令时的时区可能调整时钟,因此“明天这个钟表时刻”和“24 小时以后”可能不同:

js
if (typeof Temporal !== 'undefined') {
  const start = Temporal.ZonedDateTime.from(
    '2024-03-09T12:00:00-05:00[America/New_York]'
  )
  console.log(start.add({ days: 1 }).hour)  // 12
  console.log(start.add({ hours: 24 }).hour) // 13
}

跨过春季调时后,当地日历前进一天与时间线前进 24 小时并非同一件事。业务规则说“次日中午”时应使用日历语义;说“有效 24 小时”时应使用经过时长的语义。

回拨会让某些本地时间出现两次,前拨会让某些时间不存在。将本地时间转换为带时区时间时,disambiguation 可以表达选早、选晚、兼容处理或拒绝歧义。不要悄悄把这个业务决定交给用户机器的默认时区。

一个当地时刻,可能对应零个或两个瞬间 ​

从一个确定瞬间投影到指定时区,能得到当地钟表读数;反过来找瞬间,则要考虑时区调时。秋季回拨时,某些钟表读数会出现两次。以下代码明确选择两个候选:

js
if (typeof Temporal !== 'undefined') {
  const fields = {
    timeZone: 'America/New_York',
    year: 2024, month: 11, day: 3, hour: 1, minute: 30
  }
  const earlier = Temporal.ZonedDateTime.from(fields, {
    disambiguation: 'earlier'
  })
  const later = Temporal.ZonedDateTime.from(fields, {
    disambiguation: 'later'
  })
  console.log(earlier.offset, later.offset) // -04:00 -05:00
  console.log(later.epochNanoseconds - earlier.epochNanoseconds)
  // 3600000000000n:两个瞬间相隔一小时
  try {
    Temporal.ZonedDateTime.from(fields, { disambiguation: 'reject' })
  } catch (error) {
    console.log(error.name) // RangeError
  }
}

这不是两个不同的显示格式,而是两次不同的实际发生时间。对人工预约,拒绝歧义并要求用户选择可能更合适;对于导入的旧数据,则可能需要明确的兼容规则。默认的 compatible 在重叠时偏向较早候选,在缺口时按较晚方向处理,不能把默认行为理解为“永远最早”。

春季前拨出现的是另一种问题:一段当地时间根本没有对应瞬间。此时 earlier、later 的调整方向与重叠情形不同,reject 则继续表达“不要替我做决定”。测试日程代码时,应包含普通日期、重复时刻和不存在的时刻。

输入若同时携带偏移量和命名时区,还要考虑两者是否一致。偏移量描述某次换算结果;命名时区提供随日期变化的规则。当二者冲突,offset 策略与当地时间的 disambiguation 是不同的选项,不能把所有冲突都当成夏令时歧义。

序列化与展示仍是两层工作 ​

Instant 适合存储可比较的时间线位置,PlainDate 可以保存稳定的日期字符串;预约常常还需要时区标识,只有 UTC 偏移未必能保存未来的地区规则。

Temporal 对象可以产生标准文本,也能结合国际化能力展示。展示给读者的本地化文本通常不适合再拿来解析存储,例如不同地区的“03/04”可能意味着不同日期。序列化使用明确协议,展示使用明确 locale 与时区,两者分工清楚才能避免来回转换时丢信息。

重复日程应保存规则,而不只保存第一次的瞬间 ​

“每周一当地九点开会”和“每隔168小时运行一次”可能在调时周产生不同结果。前者应保留星期、当地时刻和时区,再为每次发生计算候选瞬间;后者表达经过时长,可以沿时间线推进。只保存第一次的 UTC 时间,再连续加固定小时数,会丢失前者的规则。

Duration 同样要区分日历量与固定时间量。下面的一个月,在不同参照日期下具有不同天数:

js
if (typeof Temporal !== 'undefined') {
  const month = Temporal.Duration.from({ months: 1 })
  console.log(month.total({ unit: 'days', relativeTo: '2024-02-01' })) // 29
  console.log(month.total({ unit: 'days', relativeTo: '2024-03-01' })) // 31
}

存储日程时至少明确:输入表达的是日期、瞬间还是重复规则;时区由哪个字段提供;遇到缺口或重叠如何处理。显示格式可以以后再改变,但这些语义信息一旦没有保存,后面很难从一串时间戳恢复出来。

新能力进入代码前,要分清三件事 ​

  1. 语言或平台的定义:规范或提案说它应如何工作。
  2. 目标运行环境的支持:特定版本是否实现它,是否仍有缺项。
  3. 当前代码的发布方式:原样运行、转译,还是引入兼容实现。

一个 API 存在不代表相关全套能力都存在。某个 polyfill 也不意味着新语法能被旧解析器接受。选择兼容实现后还要遵循其实际 API、体积和行为,本书不为学习示例默认增加依赖。

扩展知识的学习目标不是追着新名称跑,而是判断它解决了原来哪一类表达困难。Temporal 解决时间概念混用,Iterator helpers 解决惰性消费的表达,资源释放协议解决清理责任;你能解释旧问题,就更容易判断是否需要新能力。

练习 ​

  1. 设计“生日”“一次日志发生时间”“纽约每周一九点的预约”的数据表示,说明哪些必须保留时区。
  2. 为什么不能用 24 * 60 * 60 * 1000 统一实现所有“加一天”的需求?
  3. 一个旧浏览器缺少 Temporal,而正文已经使用它,你会在哪一层解决兼容性?为什么不能仅加一个 typeof 就假装功能完成?
  4. 为什么 1月31日加一个月再减一个月可能无法还原?纽约回拨日的 01:30 要怎样明确表示拒绝歧义?
  5. 写 monthEnds(start, count),接收 ISO 日期文本,返回从该日期所在月开始的 count 个月末文本,且 count 为 0 时返回空数组。用 2024-01-31、3 验证得到 01-31、02-29、03-31;再用 2023-12-15、3 验证跨年和闰月。count 限定为 0~24 的整数,非法值应拒绝。说明为什么要始终从同一个月初计算偏移。

查看本篇答案

用心记录每一步成长