Temporal API 与 JavaScript 日期的未来

Temporal 技术概览:JavaScript 新一代原生 API,通过不可变类型、真实时区和纳秒级精度替代 Date 对象。

Temporal API 与 JavaScript 日期的未来
6 分钟阅读
本页内容

摘要

在 JavaScript 中管理时间长期以来一直容易出错。继承自 1995 年 Java 的 Date 对象带有严重的设计缺陷,不断引发各种 Bug。Temporal API(TC39 Stage 3,已在 Chrome 144 和 Firefox 139 中原生支持)通过以下特性解决了这些问题:不可变类型、真实时区、纳秒级精度以及对国际日历的全面支持。

本文分析了 Date 失败的原因、Temporal 如何解决这些问题以及迁移所需了解的知识。


1. Date 对象存在的问题

1.1 带着历史包袱的错误

1995 年 5 月,Brendan Eich 在 10 天内创造了 JavaScript。为了处理日期,他直接复制了 Java 1.0 的 java.util.Date。问题在于:Java 自身很快意识到该类存在严重缺陷,并在两年后将其替换。然而,为了保持 Web 兼容性,JavaScript 至今仍被捆绑在该对象上。

1.2 Date 的主要缺陷

可变性 (Mutability)

Date 是可变的。任何 set* 方法都会修改原始对象,当代码的多处共享同一个引用时,这会导致难以追踪的 Bug:

const date = new Date('2026-03-15T10:00:00');
const copy = date;
copy.setFullYear(2030);

console.log(date.getFullYear()); // 2030 — 原始对象也被修改了!

为了避免这种情况,必须不断进行防御性拷贝:

const safeCopy = new Date(date.getTime());

解析不一致 (Inconsistent Parsing)

Date.parse() 在不同浏览器和引擎中的解析结果可能有所不同:

const date = new Date('2025-01-01');
// 在某些浏览器中:解析为 UTC(UTC 零点)
// 在另一些浏览器中:解析为本地时间(本地零点)
// 结果:可能导致“相差一天”的错误

缺乏时间运算逻辑

没有原生的方式可以正确加上“一个月”。开发者必须手动处理:

const date = new Date('2026-01-31');
date.setMonth(date.getMonth() + 1);
console.log(date.toISOString()); // 2026-03-03 — 直接跳过了整个二月

缺失时区支持

Date 仅了解两个上下文:UTC 和系统的本地时区。无法在纽约的设备上直接表示“东京上午 10:00”,除非使用复杂的 Offset 技巧,但这些技巧在夏令时切换期间极易失效。

1.3 TC39 的回应

TC39 委员会将 Temporal 设计为一个全新的命名空间,摆脱了 Date 的所有束缚。其设计吸收了 Moment.js、Luxon、date-fns、java.time 和 NodaTime 的经验教训。目标:从设计层面使编写错误代码变得极其困难。


2. Temporal 的核心概念

2.1 精确时间 vs. 挂钟时间

Temporal 中最关键的区别:

  • 精确时间 (Exact Time):通用时间线上某个精确的时间点,独立于物理位置。无论发生在伦敦还是悉尼,同一瞬间发生的两个事件都是同步的。由 Temporal.Instant 模型化。

  • 挂钟时间 (Wall-clock Time):本地日历或时钟所显示的时间。“3 月 15 日 09:00”在未与具体时区关联前并不代表某个确切时刻。由 Plain* 系列类模型化。

Date 试图同时扮演这两者,结果在两方面都归于失败。Temporal 强制要求开发者明确意图。

2.2 不可变性 (Immutability)

Temporal 中的所有类均不可变。.add()、.subtract() 或 .with() 等方法始终返回全新的实例:

const today = Temporal.PlainDate.from('2026-02-10');
const tomorrow = today.add({ days: 1 });

console.log(today.toString());    // 2026-02-10 — 未受改变
console.log(tomorrow.toString()); // 2026-02-11

这消除了状态突变引起的 Bug,简化了调试,并使 React 或 Vue 等响应式框架中的高效状态变更检测成为可能。

2.3 纳秒级精度

Date 以毫秒(10⁻³ s)为单位运行。Temporal 使用纳秒(10⁻⁹ s),精度提高了六个数量级。为了在不丢失数值精度的前提下处理这些大数值,Temporal 采用了 BigInt:

const now = Temporal.Now.instant();
console.log(now.epochNanoseconds); // 1739188800000000000n (BigInt)

3. API 数据类型

3.1 Temporal.Instant

表示时间线上的确切时间点:自 Unix 纪元以来的纳秒数。无时区,无日历。

典型用途:数据库时间戳、服务器日志、按时间先后排序。

const now = Temporal.Now.instant();

// 从旧版 Date 转换
const legacyDate = new Date();
const instant = legacyDate.toTemporalInstant();

// 序列化:始终为 UTC 格式
console.log(now.toString()); // 2026-02-10T16:26:10.123456789Z

在映射到具体时区之前,Instant 无法直接展示日、月等人类可读的信息。

3.2 Temporal.ZonedDateTime

最完整的类。结合了确切时刻 + 时区 + 日历。

const meeting = Temporal.ZonedDateTime.from({
  year: 2026,
  month: 3,
  day: 15,
  hour: 10,
  minute: 0,
  timeZone: 'America/Lima',
});

console.log(meeting.toString());
// 2026-03-15T10:00:00-05:00[America/Lima]

// 感知夏令时变更
const nextDay = meeting.add({ days: 1 });

关键特性:

  • 感知夏令时 (DST) 调整规则
  • 使用 IANA 时区标识符(如 America/Lima),而非仅使用固定的偏移量
  • 使用 RFC 9557 格式保留完整的时间维度信息

3.3 Plain 系列类:无时区公民时间

PlainDate

无时间、无时区的日期。非常适合生日、节假日、到期日:

const birthday = Temporal.PlainDate.from('1995-04-15');
const daysUntilToday = birthday.until(Temporal.Now.plainDateISO()).total({ unit: 'day' });

PlainTime

无日期、无时区的时间。适合重复发生的日常计划:

const opening = Temporal.PlainTime.from('09:00');
const closing = Temporal.PlainTime.from('17:30');
const duration = opening.until(closing);
console.log(duration.toString()); // PT8H30M

PlainDateTime

日期 + 时间,无时区。相当于“挂钟上显示的时间”:

const event = Temporal.PlainDateTime.from('2026-06-15T14:30');
const withZone = event.toZonedDateTime('America/Bogota');

注意:切勿用于全局事件排序。东京的“2026-01-01 00:00”发生在伦敦之前,但在缺乏时区的情况下无法进行比较。

PlainYearMonth 与 PlainMonthDay

专用于不完整时间数据的类型:

// 信用卡到期日:仅包含年份和月份
const expiration = Temporal.PlainYearMonth.from('2028-10');

// 纪念日:仅包含月份和日期
const christmas = Temporal.PlainMonthDay.from('12-25');

3.4 Temporal.Duration

表示人类时间维度上的时间量。遵循 ISO 8601 标准格式:

const duration = Temporal.Duration.from({ years: 1, months: 2, days: 10, hours: 2 });
console.log(duration.toString()); // P1Y2M10DT2H

// 时间运算尊重日历规则
const jan31 = Temporal.PlainDate.from('2026-01-31');
const oneMonthLater = jan31.add({ months: 1 });
console.log(oneMonthLater.toString()); // 2026-02-28 — 正确处理了月份天数差异

// 比较时间跨度
const d1 = Temporal.Duration.from({ hours: 1, minutes: 30 });
const d2 = Temporal.Duration.from({ minutes: 90 });
console.log(Temporal.Duration.compare(d1, d2)); // 0 — 两者相等

4. 时区与夏令时 (DST)

4.1 IANA 标识符 vs. 数字偏移量

Temporal 明确区分了时区标识符(Europe/Madrid)与固定的数字偏移量(+01:00):

// ❌ 存在隐患:数字偏移量会随夏令时改变
const withOffset = '2026-03-29T02:30:00+01:00';

// ✅ 安全:Temporal 会根据该时区的规则重新计算偏移量
const zdt = Temporal.ZonedDateTime.from({
  year: 2026,
  month: 7,
  day: 15,
  hour: 10,
  timeZone: 'Europe/Madrid',
});
console.log(zdt.offset); // +02:00(夏令时)

4.2 夏令时转换中的歧义处理

当拨动时钟时,会出现特殊情况:

  • 时间隙 (Gap):拨快时钟时,某个小时“不存在”(例如从 02:00 直接跳到 03:00)
  • 时间重叠 (Overlap):拨慢时钟时,某个小时“出现两次”

Temporal 使用 disambiguation 参数来处理此类情况:

// 不存在的时间(西班牙 3 月最后一个星期日的时间隙)
const nonExistent = Temporal.ZonedDateTime.from(
  { year: 2026, month: 3, day: 29, hour: 2, minute: 30, timeZone: 'Europe/Madrid' },
  { disambiguation: 'compatible' } // 自动平滑调整至 03:30(默认策略)
);

// 严格校验:若时间存在歧义则抛出错误
try {
  Temporal.ZonedDateTime.from(
    { year: 2026, month: 3, day: 29, hour: 2, minute: 30, timeZone: 'Europe/Madrid' },
    { disambiguation: 'reject' }
  );
} catch (e) {
  console.log(e); // RangeError
}

4.3 Date 与 Temporal 在夏令时中的对比

场景Date (旧版)Temporal
时区定义隐式(本地或 UTC)显式(TimeZone 对象)
夏令时计算盲目按毫秒累加根据时区规则自动调整偏移量
不存在的时间隐式且不可预测的调整可配置策略 (disambiguation)
重复的时间始终返回第一次出现可配置策略 (earlier, later)

5. 国际日历支持

Date 仅支持公历 (Gregorian)。Temporal 深度集成了对国际日历的支持,与 Intl API 保持一致。

5.1 可用日历类型

每个 PlainDate、PlainDateTime 和 ZonedDateTime 对象都包含一个 calendar 属性。支持的日历包括:hebrew、islamic、chinese、japanese、buddhist、persian 等。

5.2 基于日历的运算

在不同日历体系中,“增加一个月”具有不同的含义:

// 希伯来历:每年可能包含 12 或 13 个月
const hebrewDate = Temporal.PlainDate.from({
  year: 5782,
  month: 5,
  day: 1,
  calendar: 'hebrew',
});
const nextMonth = hebrewDate.add({ months: 1 });

// 日本年号历
const japaneseDate = Temporal.PlainDate.from({
  era: 'reiwa',
  eraYear: 8,
  month: 2,
  day: 10,
  calendar: 'japanese',
});
console.log(japaneseDate.toString()); // 2026-02-10[u-ca=japanese]

5.3 文化信息的持久化

日历信息会在序列化过程中保留:

const date = Temporal.PlainDate.from({
  year: 5782,
  month: 5,
  day: 1,
  calendar: 'hebrew',
});
console.log(date.toString()); // 2022-01-03[u-ca=hebrew]
// 反序列化时,会还原为正确的日历类型

6. 基于 RFC 9557 的序列化

6.1 ISO 8601 的局限性

ISO 8601 格式(YYYY-MM-DDTHH:mm:ssZ)在转换为 UTC 时会丢失原始上下文。如果某地的时区规则在未来发生变更,保存在 UTC 中的固定时间戳可能会变得不再符合预期。

6.2 RFC 9557 格式

Temporal 采用了保留完整维度的扩展格式:

2026-03-15T10:00:00-05:00[America/Lima][u-ca=iso8601]
│              │        │        │              │
│              │        │        │              └─ 日历体系
│              │        │        └──────────────── 时区(单一事实来源)
│              │        └───────────────────────── 偏移量(快速参考)
│              └────────────────────────────────── 民用时间(用户看到的时刻)
└───────────────────────────────────────────────── 日期

6.3 冲突解决策略

若保存的偏移量与时区不一致(如因政治政策变动导致),Temporal 提供了多种解析选项:

const str = '2026-11-01T01:30:00-04:00[America/New_York]';

// use: 信任偏移量(保留确切时刻)
// ignore: 信任时区(保留本地挂钟时间)
// prefer: 若偏移量有效则使用,否则重新计算
// reject: 若存在矛盾则抛出错误
const zdt = Temporal.ZonedDateTime.from(str, { offset: 'prefer' });

7. 生态系统与迁移指南

7.1 浏览器支持情况 (2025-2026)

  • Chrome / Edge:从 144 版本(2026 年 1 月)起全面原生支持
  • Firefox:从 139 版本(2025 年 5 月)起原生支持
  • Safari:WebKit 正在积极实现中,可在 Technical Preview 中体验

7.2 Polyfill 方案

对于尚未原生支持的环境:

Polyfill体积规范契合度推荐场景
@js-temporal/polyfill~50 KB gzip100% 完整实现测试与概念验证
fullcalendar/temporal-polyfill~20 KB gzip满足生产基本需求对包体积敏感的应用
// 条件加载 Polyfill
if (!globalThis.Temporal) {
  await import('@js-temporal/polyfill');
}

7.3 Temporal 与现有库的对比

Temporal vs. Moment.js:Moment 是可变、笨重的,且已处于维护模式。Temporal 是不可变、原生的直接继承者。

Temporal vs. date-fns:date-fns 基于旧版 Date 运行,继承了其底层限制。未来可能演化出围绕 Temporal 对象的工具函数。

7.4 迁移指南

迁移可以循序渐进地完成,Temporal 与 Date 可以和谐共存:

// 第一步:在应用的输入/输出边界处进行转换
const legacyDate = new Date();
const instant = legacyDate.toTemporalInstant();
const zdt = instant.toZonedDateTimeISO('America/Bogota');

// 第二步:所有内部核心逻辑改用 Temporal 编写
const inOneWeek = zdt.add({ weeks: 1 });
const dateOnly = zdt.toPlainDate();

// 第三步:仅在必要时(如第三方库要求)转换回旧版 Date
const backToDate = new Date(inOneWeek.epochMilliseconds);

8. 总结

Temporal 不仅仅是对 Date 的微小改进,而是经过三十年实践后做出的关键修复。不可变类型、真实时区、纳秒精度以及多文化支持将消除整类潜在的 Bug。

随着主流浏览器在 2026 年提供原生支持,现在是开始迁移的合适时机。建议先在系统边界处进行转换,再逐步迁移内部逻辑,并规划最终移除 Date 及其周边库。


参考来源:TC39 官方提案、MDN Web 文档、浏览器实现状态及社区 Polyfill 评估。示例符合 2026 年 2 月 Stage 3 规范状态。

拖动可移动图示。按 + 和 − 缩放,方向键移动,0 重置视图。

一起合作

软件工程师、高级全栈开发工程师,常驻玻利维亚Santa Cruz,远程办公。欢迎新的工作机会。