摘要
在 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 gzip | 100% 完整实现 | 测试与概念验证 |
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 规范状态。