使用 Temporal 计算事件时间差和设置时长格式

过去,计算事件之间经过或剩余的时间(例如试用期到期、订阅倒计时或 SLA 窗口),并以本地化、人类可读的格式呈现这些时间跨度,需要手动计算或使用外部库。旧版 Date 对象仅跟踪纪元毫秒数,这使得日历月份算术、夏令时 (DST) 转换和单位平衡容易出错。

Temporal API 将 Temporal.ZonedDateTime 与 Temporal.Duration 和 Intl.DurationFormat 配对,以提供夏令时安全时间差、可配置的单位平衡、可感知日历的时长比较和本地化格式。

如何实现

  1. 获取 Temporal.ZonedDateTime 端点:使用 Temporal.Now.zonedDateTimeISO() 获取系统时区的当前时间,或者在目标事件的时区与用户的系统默认时区不同时,传递显式 IANA 时区字符串 Temporal.Now.zonedDateTimeISO(timeZone)。
  2. 使用 .since() 和 .until() 计算微分:
    • 使用 now.since(start, options) 计算自过去某个事件以来经过的时间。
    • 使用 now.until(end, options) 计算距离未来事件的剩余时间。
    • 传递 largestUnit(例如 'year'、'month'、'day'、'hour')和 smallestUnit(例如 'minute'),以平衡和舍入生成的 Temporal.Duration。
  3. 平衡或比较独立的 Temporal.Duration 对象:
    • 使用 Temporal.Duration.from({ minutes: 90 }) 创建独立时长,并使用 duration.round({ largestUnit: 'hour' }) 重新平衡单位(在跨日历月、年或夏令时转换进行平衡时,提供 relativeTo)。
    • 使用 Temporal.Duration.compare(d1, d2, { relativeTo }) 比较两个时长。如果任一时长包含日历单位(years、months、weeks)或跨越夏令时边界,则需要将 PlainDate 或 ZonedDateTime 作为 relativeTo 传递。
  4. 针对人类与机器的格式:
    • 使用 duration.toLocaleString(locale, options)(委托给 Intl.DurationFormat)来显示本地化的面向用户的文本。
    • 仅对机器可读的 ISO 8601 时长字符串(例如 P1M2DT4H)使用 duration.toString()。

示例:试用期到期时间差和本地化格式

// 1. Get the current time in an explicit target IANA time zone (or omit the
// argument to use the system's current time zone).
const tz = 'America/New_York';
const now = Temporal.Now.zonedDateTimeISO(tz);

// 2. Combine separate date and time strings directly.
// Do not build an ISO string with template literals; chain Temporal methods instead.
const startDateStr = '2025-01-01';
const startTimeStr = '12:00:00';
const endDateStr = '2025-01-31';
const endTimeStr = '12:00:00';

const start = Temporal.PlainDate.from(startDateStr).toPlainDateTime(startTimeStr).toZonedDateTime(tz);
const end = Temporal.PlainDate.from(endDateStr).toPlainDateTime(endTimeStr).toZonedDateTime(tz);

// 3. Calculate differences using .since() and .until()
// ZonedDateTime differences default to largestUnit: 'hour' (days are not a fixed
// length across DST, so Temporal won't balance into days unless asked). Pass
// largestUnit: 'day' or a calendar unit ('week', 'month', 'year') when you want a
// human-scale breakdown instead of e.g. PT720H. smallestUnit rounds away
// seconds and sub-second noise so the formatted string stays readable.
const timeActive = now.since(start, { largestUnit: 'year', smallestUnit: 'minute' });
const timeRemaining = now.until(end, { largestUnit: 'year', smallestUnit: 'minute' });

// 4. Format durations for display with .toLocaleString() (Intl.DurationFormat)
// instead of manually concatenating unit numbers and suffixes. 'en-US' is used
// here for deterministic output; pass undefined to use the user's locale.
console.log(`Active: ${timeActive.toLocaleString('en-US', { style: 'narrow' })}`);
console.log(`Remaining: ${timeRemaining.toLocaleString('en-US', { style: 'long' })}`);

// Use .toString() for ISO 8601 serialization (e.g., API payloads or <time datetime="...">)
console.log(`ISO 8601: ${timeRemaining.toString()}`);

// 5. Check status via .sign, and compare durations with Temporal.Duration.compare()
const isExpired = timeRemaining.sign < 0;
// Pass relativeTo when comparing durations that may include calendar months/years or DST shifts
const isPastHalfway = Temporal.Duration.compare(timeActive, timeRemaining, { relativeTo: now }) > 0;

示例:平衡独立时长(round 和 Intl.DurationFormat)

// Convert a raw unit count (e.g., 90 minutes) into a balanced breakdown (1 hour, 30 minutes)
const rawDuration = Temporal.Duration.from({ minutes: 90 });
const balanced = rawDuration.round({ largestUnit: 'hour' });

// Format via duration.toLocaleString() or directly with Intl.DurationFormat
const formatter = new Intl.DurationFormat('en-US', { style: 'long' });
console.log(formatter.format(balanced)); // "1 hour, 30 minutes"

策略性实施和最佳实践

  • 务必使用 Temporal.ZonedDateTime 进行涉及特定时区中发生的现实事件(例如订阅续订或事件倒计时)的计算,并在针对系统默认时区以外的时区进行计算时,将显式 timeZone 传递给 Temporal.Now.zonedDateTimeISO(timeZone)。
  • 务必使用 Temporal 方法(例如 Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) 或 Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr }))组合单独的日期和时间字符串,而不是像 `${dateStr}T${timeStr}` 那样插值 ISO 字符串。
  • 务必在 .since()、.until() 或 .round() 中传递 largestUnit(例如 'day'、'month'、'year'),以便在小时之外平衡时长;ZonedDateTime 差值方法默认为 'hour'。
  • 务必在比较两个时长时使用 Temporal.Duration.compare(d1, d2, { relativeTo }),只要其中一个时长涉及日历单位(months、years)或考虑时区的日期,就提供 relativeTo 锚点。
  • 务必使用 duration.toLocaleString() 或 new Intl.DurationFormat(locale, { style })('long'、'short'、'narrow' 或 'digital')设置向用户显示的时长格式,而不是手动插值 ${duration.years}y ${duration.months}m...。
  • 务必将 duration.toString() 用于机器可读的 ISO 8601 时长字符串(例如 PT1H30M),切勿将其用作面向用户的文案。
  • 务必使用 .toPlainTime().toString({ smallestUnit: 'minute' })(而非字符串 .slice(0, 5))将 PlainTime 呈现为 HH:MM(例如,对于 <input type="time">)。
  • 请勿直接修改实例;Temporal 对象是不可变的。.add()、.subtract()、.round() 或 .with() 等操作会返回一个新实例。
  • 请勿回退到 Date / .getTime() 算术来计算差值本身;Date 没有时区感知日历数学,并且只有毫秒精度。
  • 务必使用计算出的 Temporal.Duration(或 Temporal.ZonedDateTime.compare())的原生 .sign 属性来检查时长是表示过去/已过期的时间点 (sign < 0) 还是表示未来待开始的时间点。

后备策略

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();
})();

Intl.DurationFormat