使用 Temporal 計算事件差異及設定時間長度格式

網頁功能: Temporal Intl.DurationFormat

過去,如要計算事件 (例如試用期到期、訂閱倒數或服務水準協議時間範圍) 之間經過或剩餘的時間,並以本地化、人類可讀的格式呈現這些時間範圍,必須手動計算或使用外部程式庫。舊版 Date 物件只會追蹤 Epoch 毫秒,因此日曆月份算術、日光節約時間 (DST) 轉換和單位平衡容易出錯。

Temporal API 會將 Temporal.ZonedDateTime 與 Temporal.Duration 和 Intl.DurationFormat 配對,提供 DST 安全時間差、可設定的單位平衡、日曆感知時間長度比較,以及本地化格式。

導入方式

  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) 處理本地化的使用者介面文字。
    • duration.toString() 僅適用於機器可讀的 ISO 8601 時間長度字串 (例如 P1M2DT4H)。

範例:試用期到期差異和本地化格式

// 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