JavaScript 日期差一天?用 Intl.DateTimeFormat 分開語系與時區
本文目錄
API 回傳 2026-10-19T16:30:00Z,台灣頁面卻顯示 10 月 19 日。若你用 toISOString().slice(0, 10) 取得日期,拿到的是 UTC 日期;同一個時間點在台北已經是 10 月 20 日。
zh-TW 選擇文字格式,Asia/Taipei 才選擇時區。 想讓伺服器與瀏覽器按照相同地區顯示時間,兩端都要明確指定時區,而不是依賴各自的系統預設。
先問輸入代表什麼
這三種資料不該走同一條轉換路徑:
| 輸入 | 資料意義 | 處理方向 |
|---|---|---|
2026-10-19T16:30:00Z | 已確定的時間點 | 轉成 Date,再依顯示時區格式化 |
2026-10-20 | 生日、帳單日等純日期 | 保留日期語意,不任意套用顯示時區 |
2026-10-20T09:00:00 | 未附時區的日期時間 | 先決定它屬於哪個地區,再轉成時間點 |
Date.parse 的規則區分只有日期與沒有 offset 的日期時間:標準日期字串按 UTC 解讀,沒有時區的日期時間則依執行環境的本地時區。伺服器和使用者電腦的設定不同,就可能在解析階段已經得到不同時間點。
因此,API 若要交換確定的事件時間,可約定傳帶 Z/offset 的標準格式或 timestamp;純日期欄位則留在日期型別的契約裡。不要為了統一顯示,替所有生日資料補上午夜 Z。
純日期先驗證真實日曆,再保留字串
「保留生日字串」仍要拒絕 2026-02-30。只檢查 Date.parse(value) 是否為有限數字不夠:不同日期格式和引擎有各自的解析行為;MDN Date.parse列出越界日期可能被正規化的例子。
下面限定西元 0001 到 9999 年、固定 YYYY-MM-DD 格式,使用 UTC 欄位做 round-trip 比對。建立 Date 只是內部驗證工具,回傳仍是原字串,沒有把生日指定成某個時區的時間點。
import assert from 'node:assert/strict';
function readPlainDate(value) { if (typeof value !== 'string' || value.length !== 10 || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value)) { throw new TypeError('Expected YYYY-MM-DD'); } const [year, month, day] = value.split('-').map(Number); if (year < 1 || month < 1 || month > 12 || day < 1 || day > 31) { throw new RangeError('Date outside the supported range'); } const probe = new Date(0); probe.setUTCFullYear(year, month - 1, day); if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) { throw new RangeError('Calendar date does not exist'); } return value;}
for (const value of ['2024-02-29', '2000-02-29', '0099-01-01', '0001-01-01', '9999-12-31']) { assert.equal(readPlainDate(value), value);}for (const value of ['2026-02-30', '2025-02-29', '1900-02-29', '2026-04-31', '0000-01-01', '2026-00-01']) { assert.throws(() => readPlainDate(value), RangeError);}for (const value of ['2026-2-01', '2026-01-01T00:00:00Z', ' 2026-01-01', '2026-01-01\n', null, 20260101]) { assert.throws(() => readPlainDate(value), TypeError);}assert.equal(new Date(Date.UTC(99, 0, 1)).getUTCFullYear(), 1999);console.log('純日期格式、閏年與年份邊界通過');這裡用 setUTCFullYear()設定完整年份,避免 Date.UTC()將 0–99 解讀成 1900–1999 的特殊規則。用 UTC 讀回也避開主機預設時區;若讀回年月日與輸入不同,就拒絕越界正規化後的結果。
把整段存成 plain-date.mjs,可分別執行以下命令。這三次測試由維護流程於 2026-10-28 在 macOS、Node.js v24.13.0 執行通過;沒有操作瀏覽器日期欄位或資料庫。
TZ=UTC node plain-date.mjsTZ=Asia/Taipei node plain-date.mjsTZ=America/Los_Angeles node plain-date.mjs這份契約沒有歷史曆法轉換、民國年或日期運算,也不限制生日不可晚於今天。需要這些業務規則時另加檢查;通過後的純日期不要再交給 dateKeyAt() 做時區轉換。前者是日曆日期,後者接受已確定的時間點。
顯示給台灣讀者時明確指定 timeZone
Intl.DateTimeFormat會按照選項格式化既有時間點,不會改變 Date 內的 timestamp:
const instant = new Date('2026-10-19T16:30:00Z');const formatter = new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', dateStyle: 'medium', timeStyle: 'short',});console.log(formatter.format(instant));console.log(instant.toISOString()); // 仍是 2026-10-19T16:30:00.000ZSSR 也要用相同選項。如果伺服器用 UTC 預設、瀏覽器用使用者時區,兩端就可能顯示不同日。需要個人化時區時,先決定由伺服器取得使用者設定,或等瀏覽器接手後再顯示;不要靠隱藏 hydration 警告解決資料契約差異。
日期鍵不要拆本地化顯示字串
畫面可能使用斜線、空白或在地化數字。若需求是依「台灣當天」分組,可用 formatToParts() 按欄位取值,避免假設 format() 的字串永遠是固定順序。
下面只接受已建立且有效的 Date;上游字串格式、真實日曆日期的嚴格驗證要在輸入邊界另外處理。範例以西元四位數年份的業務資料為範圍。
function dateKeyAt(instant, timeZone) { if (!(instant instanceof Date) || !Number.isFinite(instant.getTime())) { throw new TypeError('需要有效的 Date 時間點'); } const formatter = new Intl.DateTimeFormat('en', { timeZone, calendar: 'gregory', numberingSystem: 'latn', year: 'numeric', month: '2-digit', day: '2-digit', }); const parts = Object.fromEntries( formatter.formatToParts(instant) .filter(({ type }) => ['year', 'month', 'day'].includes(type)) .map(({ type, value }) => [type, value]), ); return `${parts.year}-${parts.month}-${parts.day}`;}
const instant = new Date('2026-10-19T16:30:00Z');if (dateKeyAt(instant, 'Asia/Taipei') !== '2026-10-20') { throw new Error('台北日期錯誤');}if (dateKeyAt(instant, 'UTC') !== '2026-10-19') { throw new Error('UTC 日期錯誤');}calendar 與 numberingSystem 讓這個資料鍵明確使用西元與拉丁數字;它和中文畫面的顯示格式可以分開。若使用不同 iframe realm 的 Date 或 Temporal 型別,要先在邊界正規化,這個簡化 helper 沒有涵蓋那些型別。
夏令時間不能固定加減幾小時
台灣資料可能只顯示台北時間,但跨國使用者會遇到夏令時間。以下兩個 UTC 時間點不同,卻都落在紐約當地的凌晨 1<30>30>;要用時區名稱補充顯示,不能只保存「1<30>30>」當作唯一時間:
const formatter = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZoneName: 'shortOffset',});const a = new Date('2026-11-01T05:30:00Z');const b = new Date('2026-11-01T06:30:00Z');console.log(formatter.format(a)); // 此環境:01:30 GMT-4console.log(formatter.format(b)); // 此環境:01:30 GMT-5if (a.getTime() === b.getTime()) throw new Error('應為不同時間點');這是針對時區資料庫規則的示例,並非宣稱未來已發生某個事件。未來排程若定義成「當地每天 9 點」,還要保存地區時區與排程規則;Intl 負責格式化,不會替你排程或把模糊的牆上時間反解成唯一 timestamp。
本文程式區塊於 2026-10-20 在 Node.js 24.13.0 執行,日期鍵另在 TZ=UTC、TZ=Asia/Taipei、TZ=America/Los_Angeles 驗證。這證明明確時區不依賴上述預設值,不代表所有瀏覽器的標點、縮寫與時區資料庫版本完全相同。測試宜比較 formatToParts() 的欄位與 timestamp,不把整段在地化輸出當成固定 API 格式。
需要產生行事曆事件時,可接著檢查日曆連結的開始/結束時間與 TZID;日期顯示正確,不代表匯出的事件時區也正確。
參考資料: