过去,使用旧版 Date 对象记录不应因查看者所在位置而异的时间数据(例如出生日期、每日闹钟、信用卡到期日或每月结算周期)很容易出错。由于 Date 始终表示特定时刻,并使用查看者的本地时区显示,因此保存 "1990-01-01" 这样的日期可能会导致 UTC 以西的用户看到 "1989-12-31",而对部分日期(例如仅包含月份的卡片到期日)进行建模会强制使用任意占位符日期,从而导致溢出 bug。
Temporal API 提供了一系列与位置无关的“普通”类型,这些类型不带时区偏移量,并且表示与日历或时钟上显示的完全一致的日历日期和挂钟时间:
| 类型 | 模型内容 | 典型使用场景 |
|---|---|---|
Temporal.PlainDate |
完整日历日期(year、month、day) |
出生日期、活动日期、订阅结算日期 |
Temporal.PlainTime |
挂钟时间(hour、minute、second…) |
每日闹钟(用户所在时区的上午 9:00)、商店营业时间 |
Temporal.PlainDateTime |
日历日期 + 挂钟时间(不含时区) | 浮动本地时间表,在附加时区之前结合使用 PlainDate 和 PlainTime |
Temporal.PlainMonthDay |
不含年份的月份和日期 | 周期性年度生日、周年纪念日、固定节日 |
Temporal.PlainYearMonth |
不含日期的年份和月份 | 信用卡到期 (2027-12),按月结算周期 |
如何实现
- 选择与您的数据匹配的最窄
Temporal.Plain*类型:- 使用
Temporal.PlainDate.from()表示完整的日历日期,使用Temporal.PlainTime.from()表示挂钟时间。 - 在不绑定时区的情况下,将日历日期和挂钟时间结合使用时,请使用
Temporal.PlainDateTime.from()(或plainDate.toPlainDateTime(plainTime))。 - 使用
Temporal.PlainMonthDay.from()表示没有年份的年度日期,并在执行日期算术之前转换为具有.toPlainDate({ year })的PlainDate。 - 对于年月值,请使用
Temporal.PlainYearMonth.from(),这样任意日期数字就不会泄露到计算中。
- 使用
- 构建 Plain 类型时请注意
calendar参数:在解析 ISO 字符串或使用PlainDate.from({ year, month, day })进行构建时,ISO 8601 ('iso8601') 是默认日历。从属性包构造PlainMonthDay或PlainYearMonth时,或者使用非 ISO 日历时,请明确传递calendar(例如{ month: 10, day: 31, calendar: 'iso8601' })。 - 将
.add()与overflow选项结合使用,以实现周期性日历间隔:调用plainDate.add({ months: 1 })默认会使用{ overflow: 'constrain' },从而将月末日期(例如 1 月 31 日)限制为 2 月 28 日/29 日。如果无效的目标日期应抛出RangeError而不是进行限制,请传递{ overflow: 'reject' }。
示例:完整日期、挂钟时间和 PlainDateTime
// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');
// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);
// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"
// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;
示例:部分日期(PlainMonthDay 和 PlainYearMonth)
// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
month: 10,
day: 31,
calendar: 'iso8601',
});
const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());
// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });
// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
year: 2027,
month: 12,
calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;
示例:周期性月度间隔(overflow: 'constrain' 与 'reject')
const billingStart = Temporal.PlainDate.from('2024-01-31');
// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)
// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
// RangeError: day 31 does not exist in February
}
示例:与旧版 Date 互操作
旧版 Date 是时间戳(一个时刻),而不是日历日期。如果将 "1990-01-01" 存储为 Date,则会强制您选择某个时刻(通常为世界协调时间 [UTC] 的午夜),而在 UTC 以西的时区中读取该时刻会产生 "31/12/1989"。如果库或 API 仍需要 Date,请通过 Temporal.Instant 显式转换,而不是手动构建 ISO 字符串:
// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);
// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
.toZonedDateTimeISO('America/New_York')
.toPlainDate();
// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31
策略性实施和最佳实践
- 务必使用
Temporal.PlainDate表示完整日历日期,使用Temporal.PlainTime表示挂钟时间,使用Temporal.PlainDateTime表示不带时区的本地日期时间对,使用Temporal.PlainMonthDay表示周期性年度日期,使用Temporal.PlainYearMonth表示年月到期时间或结算周期。 - 请勿直接对
Temporal.PlainMonthDay执行算术运算;请先通过.toPlainDate({ year })将其转换为Temporal.PlainDate,因为月份长度因年份而异。 - 务必在
Temporal.PlainDate上使用.add()来设置周期性日历间隔,并依靠overflow: 'constrain'(默认)来限制月末日期,或依靠overflow: 'reject'在不存在的日期抛出RangeError。 - 务必在通过对象构造
PlainMonthDay或非 ISO Plain 类型时指定calendar(例如calendar: 'iso8601')。 - 如果您需要表示物理时间中的特定时刻(瞬间),请不要使用 Plain 类型。对于日志、事件时间戳或跨时区调度,请使用
Temporal.Instant或Temporal.ZonedDateTime。 - 请勿通过手动构建的 ISO 字符串(例如
`${date}T00:00:00Z`)来构建Date对象。请通过Temporal.Instant或Temporal.ZonedDateTime的epochMilliseconds生成Date,并使用Temporal.Instant.fromEpochMilliseconds(date.getTime())或date.toTemporalInstant()读回,以便明确时区假设。 - 请勿使用
Intl.DateTimeFormat.prototype.formatToParts()提取或比较年、月或日值。直接从 Plain 类型读取.year、.month和.day,并与.equals()进行比较。 - 务必使用 IANA 名称(例如
America/New_York)来标识时区,切勿使用固定的世界协调时间 (UTC) 偏移量。偏移量会随着夏令时和法规的变化而变化,因此"UTC-5"等标签在一年中的某些时间会不正确。 - 务必记住,
Temporal对象是不可变的。.add()、.subtract()或.with()等方法始终会返回具有新引用的新实例,而不是就地更改原始对象,因此可以安全地直接存储在响应式界面状态中。
后备策略
Temporal 的回退和浏览器支持
对于尚不支持原生 Temporal API 的浏览器,请使用功能检测并动态加载 temporal-polyfill(约 15 KB min+gz)。
请注意,导入 temporal-polyfill 不会自动将 Temporal 对象分配给全局范围。如果您的代码依赖于 globalThis.Temporal,请明确分配它。
// Check if Temporal is supported natively
(async () => {
if (typeof Temporal === 'undefined') {
// Load the polyfill conditionally
const module = await import('https://esm.sh/temporal-polyfill');
globalThis.Temporal = module.Temporal;
// Extend Date.prototype if needed
Date.prototype.toTemporalInstant = module.toTemporalInstant;
}
initializeApp();
})();