JavaScript Temporal:用對型別處理日期與時區

介紹 JavaScript Temporal 的 Instant、ZonedDateTime、PlainDate 與日期運算,並分享在 Electron 專案 Solyx 的實作與支援注意事項。

CChia1104
  • 筆記
  • 7 分鐘閱讀

作者以交易應用 Solyx 的改寫經驗說明:日期處理應先區分確切時間點、帶時區的當地時間與不帶時區的日曆日期,而不是一律交給 Date 或引入 dayjs。Temporal 的不同型別讓交易所時區轉換、週與月份的日期運算,以及當地一天起點的計算更直接,也避免手動推算時區偏移或假設每天都是 24 小時。文章主張讓 Temporal 負責時區與日曆計算、Intl 負責在地化顯示,並在序列化及資料介面等邊界保留 Date。採用前仍須確認實際執行環境是否支援 Temporal;TypeScript 的型別宣告不會替舊環境提供實作。

以前在 JavaScript 專案處理日期,我通常會先裝 dayjs。它的 API 比原生 Date 順手,格式化和日期加減都內建,時區轉換也能透過 plugin 補上。最近我整理自己的 Electron app Solyx,一開始也想把散落的 Date 和 Intl 統一改成 dayjs;盤點後發現,真正需要處理的是少數時區與日曆運算。

solyx-demo

Solyx 用來交易台股與美股,agent 會分析並提出交易建議,每筆下單都需要使用者確認。我最後沒有換成 dayjs,而是用 Temporal 改寫日期運算,顯示格式則保留 Intl。原因是 dayjs 的 timezone plugin 底層仍需透過 Intl.DateTimeFormat#formatToParts 推算時區偏移,並沒有省去這部分邏輯;locale 也要另外載入與維護,plugin 還需要透過全域的 dayjs.extend(...) 註冊。套件間原本傳遞的是 Date、Unix 秒和日期字串,若改傳 dayjs 物件,還得調整介面或在邊界反覆轉換。

Temporal 是 JavaScript 新的日期時間 API,將時間點、帶時區的日期時間和不帶時區的日曆日期分開表示。以下整理我在 Solyx 實際用到的幾個型別。

先分清楚時間代表什麼

Temporal 不再用一種日期物件承擔所有語意,常用型別包括:

型別表示的資料常見用途
Temporal.Instant時間軸上的確切時間點Unix timestamp、事件發生時間
Temporal.ZonedDateTime日期時間加上時區顯示或計算某個時區的當地時間
Temporal.PlainDate不帶時間與時區的日曆日期生日、交易日、帳單日期
Temporal.PlainTime不帶日期與時區的時間每日開盤時間、提醒時間
Temporal.PlainDateTime不帶時區的日期與時間尚未指定時區的當地時間
Temporal.Duration一段時間或日期差值表示加減或兩個時間之間的差距

例如手上有一個 Date,可以先轉成 Instant,再依交易所時區取得當地日期時間:

const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
const exchangeTime = instant.toZonedDateTimeISO("America/New_York");

exchangeTime.toPlainDate().toString(); // 例如 "2026-10-06"

這裡的 Instant 是同一個確切時間點;ZonedDateTime 則用時區規則呈現它在紐約當地的日期與時間。若只需要日曆日期,就取出 PlainDate,不必把「某天」當成 UTC 午夜來處理。

日期加減直接使用日曆語意

PlainDate 支援日曆日期運算。加一天、找該月第一天,或取得該週星期一,不需要自行換算毫秒:

const day = Temporal.PlainDate.from("2026-01-31");

day.add({ days: 1 }).toString();   // "2026-02-01"
day.add({ months: 1 }).toString(); // "2026-02-28"

day.with({ day: 1 }).toString();                      // 該月第一天 "2026-01-01"
day.subtract({ days: day.dayOfWeek - 1 }).toString(); // 該週的星期一 "2026-01-26"

dayOfWeek 使用 ISO 星期編號:週一是 1,週日是 7。月份加減若遇到目標月份沒有相同日期,預設會夾到月底,所以 1 月 31 日加一個月是 2 月 28 日。

Solyx 的圖表需要找出某一天所在週的週一、或當月第一天;交易時段也會依交易所時區判斷星期幾。這些都是 PlainDate 和 ZonedDateTime 比手動拆字串或呼叫 getUTCDay() 更直接的地方。

取得某個時區的當地午夜

若有 YYYY-MM-DD 日曆日期,要取得它在指定時區的一天起點,可以將 PlainDate 轉成 ZonedDateTime:

const startOfDay = Temporal.PlainDate
  .from("2026-10-06")
  .toZonedDateTime("America/New_York");

const unixSeconds = startOfDay.epochMilliseconds / 1000;

這比先假設日期是 UTC 午夜、再用 offset 推回當地時間來得直接。時區規則包含日光節約時間等變化;某些地區切換時間時,當地午夜可能不存在或重複,因此不要假設每個日曆日都剛好是 24 小時。

在 Solyx 的舊版實作裡,我曾用 UTC 午夜的 offset 推算交易所當地午夜,這依賴「交易所不會在午夜附近調時」的假設。改用 Temporal 後,程式直接以日期和時區建立當地起點,不必自行估算 offset。

計算與顯示分開

Temporal 適合處理日期時間的表示與計算;要把結果呈現給使用者,仍可交給 Intl,讓格式依 locale 決定:

const formatter = new Intl.DateTimeFormat("zh-TW", {
  timeZone: "America/New_York",
  dateStyle: "medium",
  timeStyle: "short",
});

formatter.format(new Date());

要注意 Intl.DateTimeFormat#format 不接受 ZonedDateTime,直接傳進去會丟 TypeError。要格式化 Temporal 算出的結果,例如前面的 exchangeTime,可以呼叫它自己的 toLocaleString:

exchangeTime.toLocaleString("zh-TW", { dateStyle: "medium", timeStyle: "short" });

同一組格式選項會重複使用時,建立並重用 Intl.DateTimeFormat 實例即可,不必每次都重新建立 formatter。相對時間和數字也可以使用 Intl.RelativeTimeFormat、Intl.NumberFormat 等 API。

使用前先確認 runtime

有 TypeScript 型別不代表 runtime 一定支援 Temporal。Node.js 從 26.0.0 起預設啟用 Temporal(Node.js 26 release notes),使用前仍要確認目標 runtime 的支援情況。

TypeScript 也需要適當的 library 宣告:我使用的 TypeScript 7.0.2 在 lib: ["es2023"] 下找不到 Temporal,加入 "esnext.temporal" 後才通過型別檢查。型別設定只影響編譯器認不認得 API,不會替舊 runtime 補上實作。

caniuse-temporal

截至 2026 年 10 月,MDN 相容性表列出 Chrome/Edge 144、Firefox 139 與 Node.js 26 已支援 Temporal;Safari 目前只有 Technology Preview 支援,iOS Safari 與 WKWebView 尚不支援(MDN:Temporal)。Electron 或 Node 等 runtime 可控的專案,可以先確認 app 和測試實際使用的版本;公開網站則需考慮 polyfill 或功能偵測。

我目前採用的簡單分工是:Date 留在序列化、DB/API 資料和時鐘等邊界;Temporal 負責時區與日曆計算;Intl 負責在地化顯示。

最後更新

Written by: Chia1104 CC BY-NC-SA 4.0