前端

為什麼時區錯誤總是在你的本機上無法重現

您的伺服器傳送 UTC 時間,您的筆記型電腦傳送本地時間,而切割字串會隱藏這個差異。為什麼它只在生產環境中出錯,以及如何在本地強制重現此問題。

本文由 AI 模型從英文原文翻譯而來,用字可能與原文有所出入。 閱讀英文原文

內部儀表板上的一個時間戳記顯示為 09:41。事件實際發生的時間是 18:41。相差了九個小時,正好是一個時區的偏移量,而產生這個時間戳記的程式碼早在幾個月前就已上線,卻沒有人注意到。沒有人注意到的原因是有趣之處:在每位開發人員的機器上,相同的程式碼都印出了正確的時間。

這是一種值得命名的特定失敗模式。這個錯誤不僅僅在於格式化邏輯本身。它在於伺服器序列化時間的方式,與開發人員機器恰好所在的時區之間的交互作用。當這兩者一致時,一個有問題的實作看起來就會是正確的。

看似良好但實則不然的程式碼

伺服器以 RFC 3339 字串的形式回傳時間戳記。前端需要 MM-DD HH:mm 格式。有人寫下了很直觀的程式碼:

// Renders "08-05 09:41" from "2026-08-05T09:41:42.240399Z"
function shortTime(iso) {
  return iso ? iso.slice(5, 16).replace('T', ' ') : '-';
}

這裡沒有時區轉換。完全沒有 Date 物件。該函式會擷取字串的第 5 到 16 個字元並印出。無論伺服器編碼了什麼時區,使用者看到的就是那個時區。

在生產環境中,容器在未設定 TZ 的情況下執行,這意味著使用 UTC。負載是 "2026-08-05T09:41:42.240399Z",而切片產生了 08-05 09:41。操作員會將辦公室裡的所有時鐘都當作本地時間讀取,他看到的是一個比實際時間晚了九個小時的數字。

為何您的筆記型電腦會隱藏它

在首爾的開發者機器上執行相同的伺服器,它不會序列化 Z。Go 的 time.Time 帶有地點資訊,而讀取 timestamptz 欄位的驅動程式會將其具現化為工作階段的時區。在一台 TZ=Asia/Seoul 的機器上,JSON 的輸出會是:

{ "startedAt": "2026-08-05T18:41:06.190270+09:00" }

現在,從中切出第 5 到 16 個字元。你會得到 08-05 18:41,這是正確的。這並不是因為函式轉換了任何東西,而是因為伺服器已經發出了本地時間,而這個單純的切片操作保留了它。

這就是整個陷阱所在。該函式沒有任何時區邏輯,所以它回傳的是輸入內容本身所使用的時區。在本地開發環境中,意外地提供了目標時區,所以輸出結果因巧合而正確。在生產環境中,提供了 UTC,所以輸出結果是錯誤的。在這兩種情況下,程式碼是完全相同的。

相同的程式碼,兩種環境 1 本地 2 UTC 開發伺服器 生產伺服器 slice() slice() 看起來正確 相差 9 小時 // 相同的程式碼,輸入的時區決定了結果

在信任修復前,先強制重現生產環境條件

您無法在一台時區與顯示目標相符的機器上驗證時區修復。在那裡,檢查沒有作用,因為有問題的版本也會通過。

啟動伺服器時,請使用生產環境實際使用的時區:

TZ=UTC PORT=8099 go run . serve

現在本機 API 回傳 "2026-08-05T09:41:06.190270Z",與生產環境傳送的內容逐位元組相同。載入頁面。如果螢幕仍然顯示 09:41,那麼你就在自己的機器上重現了這個缺陷,並附有除錯器以及兩秒的編輯迴圈。

這個單一的環境變數,將一份無法重現的生產環境報告,變成了一個普通的本機錯誤。它之所以有效,原因與該錯誤存在的原因相同:伺服器的序列化遵循行程的時區,而行程的時區則由你來設定。

有兩個相關的習慣值得養成。至少執行一個自動化測試,將 TZ 設定為與你自己的時區相差甚遠的值,因為只在你的時區運行的測試套件,無法證明任何關於時區處理的事情。而且,當一份錯誤報告說「時間不對」而你無法重現時,應該詢問伺服器傳送了什麼,而不是螢幕顯示了什麼。酬載能在幾秒內解決問題。

解決方法是先解析,再以固定時區格式化

一旦輸入是真正的 Date 物件,字串中的時區偏移就不再重要了。new Date() 會以相同的方式處理 Z+09:00,將兩者都解析為相同的時間點。接著,你就可以將其格式化為一個明確的時區:

function kstParts(iso) {
  if (!iso) return null;
  const d = new Date(iso);
  if (isNaN(d.getTime())) return null;
  const parts = {};
  for (const { type, value } of new Intl.DateTimeFormat('en-US', {
    timeZone: 'Asia/Seoul', hourCycle: 'h23',
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
  }).formatToParts(d)) parts[type] = value;
  return parts;
}

該區塊中的三個決定值得為其辯護。

**鎖定時區,而非使用瀏覽器的時區。**單純使用 toLocaleString() 會遵循客戶端機器的回報。對於每個使用者都想看到自己時鐘的消費性產品來說,這是正確的。但對於公司裡每個人都讀取同一份共享時間軸的內部工具來說,這就是錯的;在這種工具中,一台時區設定錯誤的筆記型電腦會默默地產生與鄰座同事不同的數字。

設定 hourCycle: 'h23'en-US 語系與 hour12: false 的組合會將午夜渲染為 24 點,所以 00:05 會變成 24:05。這在一天之中只對一次,也錯一次,是那種最難透過肉眼檢查發現的錯誤。指定時間週期可以消除這種歧義。

**使用 formatToParts 而非字串輸出。**語系格式化會根據語系規則插入自己的分隔符並排序欄位。自己拉出具名部分並加以組合,可以保持輸出的穩定性,無論語系原本會如何處理。

僅有日期的欄位會偏移一整天

明顯的目標是顯示小時的欄位。被遺漏的欄位只顯示日期:

expiresAt.slice(0, 10)   // "2026-08-05"

這看起來無害,因為沒有可見的時鐘會出錯。它每天在一個九小時的時段內會差上一天。一個在 2026-08-06T07:00:00+09:00 到期的時間,其 UTC 時間是 2026-08-05T22:00:00Z,所以字串切片會為一個在 8 月 6 日到期的東西印出 8 月 5 日。對於授權頁面或帳單畫面來說,這是一個遲早會發生的客服問題,而且比一個明顯錯誤的時鐘更難診斷。

當你在程式碼庫中掃描這個問題時,應該搜尋字串操作而不是概念。切割 ISO 字串是其模式,而且無論目標是日期還是完整的時間戳,它看起來都一樣。

有一類是安全的,不需要更動:相對時間。一個從 Date.now() - new Date(iso) 計算出「3 小時前」的函式,比較的是時間點 (instants),而時間點沒有時區。這些就不用管了。

轉換應該在哪裡進行

儲存時保持 UTC。資料庫保留 timestamptz,API 保留 RFC 3339,日誌保留平台發出的任何格式。轉換只發生一次,在顯示的那一刻,其他地方都不進行。

這不是風格偏好。提早轉換意味著每個下游消費者都會繼承一個它沒有做出也無法看見的時區決定,而且一旦時區被烘焙成格式化字串,它在值中就變得不可見。在邊緣進行轉換,可以在傳輸中保持一種表示法,並在邊界處保持一種呈現規則。

該規則的實際形式是一小組共用的格式化工具。分散的 slice() 呼叫不是風格問題,它們是下一個開發者建構的畫面會出現相同缺陷的原因。當每個呼叫點都透過具名的輔助函式時,修復一次時區就能修復所有問題,而下一個開發者就有一個明顯的東西可以使用。

常見問題

這只會影響 JavaScript 前端嗎? 不會。任何將序列化時間戳記視為文字的層級都有相同的風險。列印原始欄位的範本、串連字串的日誌格式化程式、用字串連接建立的 CSV 匯出。JavaScript 讓這件事變得特別容易,因為切割字串比建構格式化程式更簡短。

為什麼不直接設定容器的時區來符合使用者? 這方法可行,直到它行不通為止。這樣一來,你的系統正確性就取決於部署清單中的一個環境變數,任何忘記設定它的服務都會產生錯誤的輸出。當你有第二個時區的使用者時,它也會馬上出問題。在各處都保持 UTC 並在顯示時轉換,可以將不變性保留在可被測試的程式碼中。

Intl.DateTimeFormat 對於有數百列的表格來說夠快嗎? 建構格式化程式是昂貴的部分,呼叫它則不是。如果你要渲染大型表格,請在模組範圍內建構一次格式化程式並重複使用它,而不是為每個儲存格都建構一個。對於幾十列的資料,差異是無法測量的。

我如何知道我已經找到所有受影響的地方? Grep 機制,而不是意義。搜尋套用在以 AtDate 結尾的欄位上的 .slice(,以及任何時間戳記未經格式化程式就到達 DOM 的地方。然後將伺服器切換到 TZ=UTC 並查看畫面,因為錯誤的值比遺漏的呼叫更容易被發現。