使用 Temporal 捕获与位置无关的数据和部分日期

Web 功能: Temporal

过去,使用旧版 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),按月结算周期

如何实现

  1. 选择与您的数据匹配的最窄 Temporal.Plain* 类型:
    • 使用 Temporal.PlainDate.from() 表示完整的日历日期,使用 Temporal.PlainTime.from() 表示挂钟时间。
    • 在不绑定时区的情况下,将日历日期和挂钟时间结合使用时,请使用 Temporal.PlainDateTime.from()(或 plainDate.toPlainDateTime(plainTime))。
    • 使用 Temporal.PlainMonthDay.from() 表示没有年份的年度日期,并在执行日期算术之前转换为具有 .toPlainDate({ year }) 的 PlainDate。
    • 对于年月值,请使用 Temporal.PlainYearMonth.from(),这样任意日期数字就不会泄露到计算中。
  2. 构建 Plain 类型时请注意 calendar 参数:在解析 ISO 字符串或使用 PlainDate.from({ year, month, day }) 进行构建时,ISO 8601 ('iso8601') 是默认日历。从属性包构造 PlainMonthDay 或 PlainYearMonth 时,或者使用非 ISO 日历时,请明确传递 calendar(例如 { month: 10, day: 31, calendar: 'iso8601' })。
  3. 将 .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();
})();