使用 Temporal 擷取與位置無關的資料和部分日期

網頁功能: Temporal

記錄不應因檢視者位置而異的時間順序資料 (例如出生日期、每日鬧鐘、信用卡到期日或每月帳單週期),在過去使用舊版 Date 物件時容易出錯。由於 Date 一律代表特定時間點,並以檢視者的當地時區顯示,因此儲存 "1990-01-01" 等日期可能會導致世界標準時間以西的使用者看到 "1989-12-31",而模擬部分日期 (例如僅限月份的卡片到期日) 會強制使用任意預留位置日期,導致溢位錯誤。

Temporal API 提供一系列與位置無關的「Plain」類型,不含時區偏移,並代表日曆日期和時鐘時間,就像從日曆或時鐘讀取一樣:

類型 模型內容 典型應用實例
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,您就必須選擇「某個」時間點 (通常是世界標準時間午夜),而在世界標準時間以西的時區讀取該時間點,則會產生 "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-5" 等標籤在一年中的部分時間會不正確。
  • 務必記住,Temporal 物件不可變更。.add()、.subtract() 或 .with() 等方法一律會傳回具有新參照的新例項,而不是直接變更原始物件,因此可安全地直接儲存在回應式 UI 狀態中。

備用策略

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