前端時間國際化入門

奇舞團發表於2021-10-21
時間只是幻覺。 —— 阿爾伯特·愛因斯坦

最近在開發一個需要完善國際化方案的前端專案,在處理時間國際化的時候遇到了一些問題。於是花了一些時間研究,有了這篇文章。不過由於網上關於 JavaScript 中 Date 物件的坑的文章已經一抓一大把了,因此這篇文章不是 JavaScript 中 Date 物件的使用指南,而是隻專注於前端時間國際化

從時區說起

要想處理時間,UTC 是一個繞不開的名字。協調世界時(Coordinated Universal Time)是目前通用的世界時間標準,計時基於原子鐘,但並不等於 TAI(國際原子時)。TAI 不計算閏秒,但 UTC 會不定期插入閏秒,因此 UTC 與 TAI 的差異正在不斷擴大。UTC 也接近於 GMT(格林威治標準時間),但不完全等同。可能很多人都發現近幾年 GMT 已經越來越少出現了,這是因為 GMT 計時基於地球自轉,由於地球自轉的不規則性且正在逐漸變慢,目前已經基本被 UTC 所取代了。

JavaScript 的 Date 實現不處理閏秒。實際上,由於閏秒增加的不可預測性,Unix/POSIX 時間戳完全不考慮閏秒。在閏秒發生時,Unix 時間戳會重複一秒。這也意味著,一個時間戳對應兩個時間點是有可能發生的。

由於 UTC 是標準的,我們有時會使用 UTC+/-N 的方式表達一個時區。這很容易理解,但並不準確。中國通行的 Asia/Shanghai 時區大部分情況下可以用 UTC+8 表示,但英國通行的 Europe/London 時區並不能用一個 UTC+N 的方式表示——由於夏令時制度,Europe/London 在夏天等於 UTC+1,在冬天等於 UTC/GMT。

一個時區與 UTC 的偏移並不一定是整小時。如 Asia/Yangon 當前為 UTC+6:30,而 Australia/Eucla 目前擁有奇妙的 UTC+8:45 的偏移。

夏令時的存在表明時間的表示不是連續的,時區之間的時差也並不是固定的,我們並不能用固定時差來處理時間,這很容易意識到。但一個不容易意識到的點是,時區還包含了其歷史變更資訊。中國目前不實行夏令時制度,那我們就可以放心用 UTC+8 來表示中國的時區了嗎?你可能已經注意到了上一段中描述 Asia/Shanghai 時區時我使用了大部分一詞。Asia/Shanghai 時區在歷史上實行過夏令時,因此 Asia/Shanghai 在部分時間段可以使用 UTC+9 來表示。

new Date('1988-04-18 00:00:00')
// Mon Apr 18 1988 00:00:00 GMT+0900 (中國夏令時間)

夏令時已經夠混亂了,但它實際上比你想象得更混亂——部分穆斯林國家一年有四次夏令時切換(進入齋月時夏令時會暫時取消),還有一些國家使用混沌的 15/30 分鐘夏令時而非通常的一小時。

不要總是基於 00:00 來判斷一天的開始。部分國家使用 0:00-1:00 切換夏令時,這意味著 23:59 的下一分鐘有可能是 1:00。

事實上,雖然一天只有 24 個小時,但當前(2021.10)正在使用的時區有超過 300 個。每一個時區都包含了其特定的歷史。雖然有些時區在現在看起來是一致的,但它們都包含了不同的歷史。時區也會創造新的歷史。由於政治、經濟或其他原因,一些時區會調整它們與 UTC 的偏差(薩摩亞曾經從 UTC-10 切換到 UTC+14,導致該國 2011.12.30 整一天都消失了),或是啟用/取消夏令時,甚至有可能導致一個時區重新劃分為兩個。因此,為了正確處理各個時區,我們需要一個資料庫來存放時區變更資訊。還好,已經有人幫我們做了這些工作。目前大多數 *nix 系統和大量開源專案都在使用 IANA 維護的時區資料庫(IANA TZ Database),其中包含了自 Unix 時間戳 0 以來各時區的變更資訊。當然這一資料庫也包含了大量 Unix 時間戳 0 之前的時區變更資訊,但並不能保證這些資訊的準確性。IANA 時區資料庫會定期更新,以反映新的時區變更和新發現的歷史史實導致的時區歷史變更。

Windows 不使用 IANA 時區資料庫。微軟為 Windows 自己維護了一套時區資料庫,這有時會導致在一個系統上合法的時間在另一系統上不合法。

既然我們不能使用 UTC 偏移來表示一個時區,那就只能為每個時區定義一個標準名稱。通常地,我們使用 <大洲>/<城市> 來命名一個時區。這裡的城市一般為該時區中人口最多的城市。於是,我們可以將中國的通行時區表示為 Asia/Shanghai。也有一些時區有自己的別名,如太平洋標準時間 PST 和協調世界時 UTC

時區名稱使用城市而非國家,是由於國家的變動通常比城市的變動要快得多。

城市不是時區的最小單位。有很多城市同時處於多個時區,甚至澳大利亞有一個機場的跑道兩端處於不同的時區。

處理時區困難重重

幾個月前的一天,奶冰在他的 Telegram 頻道里發了這樣的一條訊息:

你想的沒錯,這個問題正是由時區與 UTC 偏移的不同造成的。Asia/Shanghai 時區在 1940 年前後和 1986 年前後曾實行過夏令時,而夏令時的切換會導致一小時的出現和消失。具體來說,啟用夏令時當天會有一個小時消失,如 2021.3.28 英國啟用夏令時,1:00 直接跳到 3:00,導致 2021-03-28 01:30:00Europe/London 時區中是不合法的;取消夏令時當天又會有一個小時重複,如 2021.10.31 英國取消夏令時,2:00 會重新跳回 1:00 一次,導致 2021-10-31 01:30:00Europe/London 時區中對應了兩個時間點。而在奶冰的例子中,1988-04-10 00:46:50 正好處於因夏令時啟用而消失的一小時中,因此係統會認為此時間字串不合法而拒絕解析。

你可能會注意到在歷史上 1988.4.10 這一天 Asia/Shanghai 時區實際上是去掉了 1:00-2:00 這一小時而不是 0:00-1:00。上文問題更深層次的原因是,在 IANA TZDB 2018a 及更早版本中,IANA 因缺乏歷史資料而設定了錯誤的夏令時規則,規則設定了夏令時交界於 0:00-1:00 從而導致上文問題發生。而隨後社群發現了更準確的史實,因此 IANA 更新了資料庫。上文的問題在更新了系統的時區資料庫後便解決了。

IANA TZDB 2018a 及之前版本的錯誤資料

再來考慮另一種情況。你的應用的某位巴西使用者在 2018 年儲存了一個未來時間 2022-01-15 12:00(按當時的規律那應該是個夏令時時間),不巧那時候你的應用是以格式化的時間字串形式儲存的時間。之後你發現巴西已經於 2019 年 4 月宣佈徹底取消夏令時制度,那麼 2022-01-15 12:00 這個時間對應的 Unix 時間戳發生了變化,變得不再準確,要正確處理這一字串就需要參考這一字串生成的時間(或生成時計算的 UTC 偏移)來做不同的處理。因此,應用從一開始就應該避免使用字串來傳輸、儲存時間,而是使用 Unix 時間戳。如果不得不使用字串儲存時間,請儘可能:

  • 使用 UTC 描述時間,你永遠不會知道本地時區在未來會發生什麼
  • 如果需要以當地時間描述時間,一定帶上當前 UTC 偏移

時區歷史帶來的問題往往意想不到而且遠比想象得多。實際上時區歷史資料非常詳細而繁多且跨裝置不一致,並沒有簡單而統一的處理方法。在需要嚴謹處理時區時可能需要在應用程式中內嵌一套各端統一的時區資料庫,但這樣的方案放在前端又會帶來不少問題:

  • 體積過大。moment.js 曾經設計過一種簡潔的 TZDB 表示,但儘管已經儘可能壓縮整個檔案仍然達到了 180+KB。在效能優先的 Web 應用中這是不可接受的
  • 需要持續更新。時區資料一直在變動,需要在時區資料更新時儘快更新應用內的時區資料,這帶來了額外的維護成本

ES6 為我們帶來了 Intl 名稱空間。在這裡,JavaScript 執行時提供了不少時間相關的國際化能力。因此,在不使用額外資料的情況下準確處理時區是可能的,但這並不完美:

  • 各端不統一。瀏覽器提供的時區資料受瀏覽器版本、系統版本等可能變化,最新的時區更新可能無法快速反映到所有裝置上
  • 實現複雜。JavaScriptDate 物件的不良設計導致實現完善的時區處理並不容易,且 Intl 名稱空間下的物件例項化效能開銷較大,需要額外優化
Intl 名稱空間下還有很多實用的國際化相關方法,值得我們另開一篇文章來講講了。

在真實開發中,這需要取捨。目前主流的 JavaScript 時間處理庫都已轉向瀏覽器內建方法,並在需要時通過 Polyfill 保證跨端一致性。在這篇文章中,我們將嘗試在不使用第三方庫的情況下實現基本的時間國際化處理。此外,還有一些諸如需要使用 Unix 時間戳才能正確地在各端交換時間等細節需要注意。

時區轉換

JavaScript 中的 Date 並不是不包含時區資訊——實際上,Date 物件表示的一定是當前時區。通過嘗試:

new Date('1970-01-01T00:00:00Z')
// Thu Jan 01 1970 08:00:00 GMT+0800 (中國標準時間)

就可以知道,JavaScript 執行時其實知道當前時區,並會在需要的時候將其他時區的時間轉換為當前時區的時間。那麼,如何將本地時間轉換為其他時區的時間呢?從 Date 的角度看,這並不行,因為我們無法設定一個 Date 物件的時區。但我們可以“投機取巧”:將 Date 物件的時間加上/減去對應的時差,儘管 Date 物件仍然認為自己在本地時區,但這樣不就可以正確顯示了嘛!但我們會碰到上文提到的問題:時區之間的時間差並不固定,在沒有額外資料的情況下很難正確計算。

還好,ES6 基於 Intl 名稱空間擴充套件了 Date.prototype.toLocaleString() 方法,使其可以接受時區引數並按指定時區格式化時間。如果你在搜尋引擎中搜尋如何使用 JavaScript 轉換時區,你大概率會在 StackOverflow 上找到類似這樣的答案:

const convertTimeZone = (date, timeZone) => {
    return new Date(date.toLocaleString('en-US', { timeZone }))
}

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中國標準時間)
convertTimeZone(now, 'Europe/London') // Tue Oct 12 2021 18:00:00 GMT+0800 (中國標準時間)

很好理解,我們使用 en-US 的區域設定要求 JavaScript 執行時以我們指定的時區格式化時間,再將時間字串重新解析為時間物件。這裡的 timeZone 就是諸如 Asia/Shanghai 等的 IANA TZDB 時區名稱。這個字串確實需要自己提供,但這就是我們唯一需要自己準備的資料了!只要提供了時區名稱,瀏覽器就會自動計算正確的時間,無需我們自行計算。

對於時區名稱,你可以考慮使用 @vvo/tzdb。這是一個聲稱為自動更新的 IANA TZDB 的 JSON 匯出,並已被數個大型專案使用。你可以從這個包中匯出所有時區名稱。

這個方法看起來還不錯,對吧?但實際上,它有兩個問題:

  • 指定了區域設定和時區的 toLocaleString() 實際上每次呼叫都會在 JavaScript 執行時中建立新的 Intl.DateTimeFormat 物件(在後文詳述),而後者會帶來昂貴的效能開銷(在 Node 14 中,例項化一次會在 V8 中增加記憶體使用約 46.3Kb。但這是符合預期的,詳見 V8 Issue。因此,在密集呼叫的情況下需要考慮計算並快取時差,並在一定時間後或需要時進行更新
  • 使用 toLocaleString() 並使用 en-US 區域設定格式化的預設時間格式類似於 10/13/2021, 1:00:00 AM。這可以被大部分瀏覽器正確解析,但這是不規範的,不同瀏覽器有可能產生不同結果。你也可以自行配置格式(同下文的 Intl.DateTimeFormat),但仍然無法構造出規範的字串

因此,更佳的方案是,我們需要建立一個可反覆使用的格式化器以避免重複建立 Intl.DateTimeFormat 帶來的額外開銷,並需要手動構造出符合規範的時間字串,並將其重新解析為 Date 物件。

const timeZoneConverter = (timeZone) => {
    // 新建 DateTimeFormat 物件以供對同一目標時區重用
    // 由於時區屬性必須在建立 DateTimeFormat 物件時指定,我們只能為同一時區重用格式化器
    const formatter = new Intl.DateTimeFormat('zh-CN', {
        year: 'numeric',
        month: '2-digit',
        day: '2-digit',
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit',
        hour12: false,
        timeZone
    })
    return {
        // 提供 conver 方法以將提供的 Date 物件轉換為指定時區
        convert (date) {
            // zh-CN 的區域設定會返回類似 1970/01/01 00:00:00 的字串
            // 替換字元即可構造出類似 1970-01-01T00:00:00 的 ISO 8601 標準格式時間字串並被正確解析
            return new Date(formatter.format(date).replace(/\//g, '-').replace(' ', 'T').trim())
        }
    }
}

const toLondonTime = timeZoneConverter('Europe/London') // 對於同一時區,此物件可重用

const now = new Date() // Wed Oct 13 2021 01:00:00 GMT+0800 (中國標準時間)
toLondonTime.convert(now) // Tue Oct 12 2021 18:00:00 GMT+0800 (中國標準時間)
目前 zh-CN 的區域設定會產生類似 1970/01/01 00:00:00 的格式化字串。這一格式目前跨端一致,但由於規範沒有指定時間格式,這個格式在未來有可能變更。更好的方案是使用 formatToParts() 方法(在後文詳述)獲取時間字串的各部分並手動拼接出標準格式的字串,但在這個例子中直接 replace 擁有更好的效能。

現在,嘗試反覆轉換時間至同一時區 1000 次,耗時從 toLocaleString() 1.5 秒降低到了 0.04 秒。儘管程式碼長了點,但這次重寫在最好的情況下為我們帶來了超過 20 倍的效能提升。

需要注意的是,雖然這看起來就算最終方案了,但這個方案依然不完美。主要有以下兩個問題:

  • 在需要密集轉換為不同時區時,由於無法重用格式化器,效能依然較差且難以進一步優化
  • 由於 Intl.DateTimeFormat 不支援格式化毫秒,在格式化字串的過程中毫秒會丟失,導致最終結果可能會與期望結果產生最高 999ms 的誤差,需要額外處理。比如需要計算時差時,我們可能需要這麼寫:
const calcTimeDiff = (date, converter) => {
    const secDate = date - date.getMilliseconds() // 去掉毫秒,避免轉換前後精度差異
    return converter.convert(new Date(secDate), tzName) - secDate
}

calcTimeDiff(new Date(), timeZoneConverter('Europe/London')) // -25200000

無論如何,在折騰一番後我們還是把時區正確轉換了。接下來準備格式化時間字串了嗎?不過在此之前,我們得先來聊聊語言、文字和區域。

語言文字區域傻傻分不清

如何在計算機中表示中文?

“這不簡單,”你可能會說,“用 zh 啊。”

那簡體中文呢?

zh-CN。”你或許會說出這個答案。

那用於新加坡的簡體中文和用於中國大陸的簡體中文該如何區分呢?

嗯……好問題。

要能正確區分不同的簡體中文,我們還得先回到定義上。實際上,“國際化”並不只是語言的翻譯而已,國際化包含的是一整套對於各個區域的本地化方案。要準確表示一個國際化方案,我們實際至少需要確定三個屬性:語言(Language)、文字(Script)和區域(Locale)。

  • 語言通常指的是聲音語言。不同的語言都有一套自己的發音規則,很難互通。如中文和英語都屬於語言
  • 文字對應的是某個語言的書寫方式,同樣的語言可能會有多種書寫方案。如中文主要有簡體和繁體兩種書寫方案
  • 區域指國際化面向的地區,相同的語言和文字,在不同地區也有可能會有不同的使用習慣。如新加坡和中國大陸都使用簡體中文,但兩地的用詞習慣等有些許差異

只有確定了這三個屬性,我們才能正確定義一個國際化方案(或者說區域設定)。當然,還有很多其他屬性可以更準確的表達某個區域設定,但通常有語言、文字和區域就已經足夠了。

於是,基於 BCP 47,我們可以知道:

cmn-Hans-CN = 中文普通話-簡體-中國大陸
cmn-Hans-SG = 中文普通話-簡體-新加坡
cmn-Hant-TW = 中文普通話-繁體-臺灣
yue-Hant-HK = 中文粵語-繁體-香港

等等,這都是啥?還有 BCP 47 又是啥?BCP 是 IETF 釋出的“最佳當前實踐”文件,而 BCP 47 是一些國際化相關的 ISO 和備忘錄的集合,也是目前事實上由 HTML 和 ECMAScript 所使用的表達區域設定的標準。BCP 47 定義的區域設定標籤實際上比較複雜,但對於大部分簡單使用情況,上文示例中的格式已經完全夠用了。簡單來說,要表達一個區域設定,我們會使用 語言[-文字][-區域] 的格式,而文字和區域都是可選的。而對於每個部分的具體程式碼,BCP 47 也有做具體定義。其中:

  • 語言使用 ISO 639-1 定義的兩位字母程式碼(如中文為 zh,英文為 en)或 ISO 639-2/3 定義的三位字母程式碼(如中文普通話為 cmn,英文為 eng),通常小寫
  • 文字使用 ISO 15924 定義的四位字母程式碼,通常首字母大寫。如簡體中文是 Hans,繁體中文是 Hant
  • 區域通常使用 ISO 3166-1 定義的兩位字母程式碼,通常大寫,如中國大陸為 CN,英國為 GB
ISO 639-1/2/3 的關係實際是:ISO 639-1 是最早制定的規範,使用兩位字母表示語言,但語言數量之多並不能只用兩位程式碼表示。因此後來修訂了 ISO 639-2 和 3,使用三位字母表示了更多語言。通常 639-1 程式碼和 ISO-2/3 程式碼是一對多的關係。如中文 zh 其實是中文普通話 cmn 的巨集語言(macrolanguage),同樣使用 zh 為巨集語言的語言還有 wuu(中文吳語)、hak(中文客家話)、yue(中文粵語)等數十種。從規範上我們現在應該使用 ISO 639-2/3 程式碼來替代 ISO 639-1 程式碼了,但由於歷史阻力和真實需求中分類無需如此細緻等原因,使用 ISO 639-1 指定語言仍然非常常見而且完全可以接受。此外,特別地,我們在 ISO 639-3 中定義未指明的語言為 und

因此,對於這一節開頭的兩個問題,在 BCP 47 中正確答案其實是:

zh = 中文
cmn = 中文普通話

zh-Hans = 中文-簡體
cmn-Hans = 中文普通話-簡體

zh-CN 實際是指在中國大陸使用的中文,當然也包含在中國大陸使用的繁體中文。不過,由於大部分情況下一個區域只會通用一種文字,很多情況下我們可以忽略文字這一項,即使用 zh-CN(或者 cmn-CN)來表示中國大陸的簡體中文普通話——畢竟在大部分業務中在中國大陸使用繁體和非普通話的情況非常少。

事實上,類似 zh-Hanszh-Hant 開頭的區域設定名稱已經被標記為 redundant 廢棄,因此儘可能只使用 zh-CN 或者 cmn-Hans-CN 這樣的區域設定名稱。所有區域設定名稱的列表可以在 IANA 找到。

現在我們可以準確定義一個區域設定了。不過我們還有一些小小的需求。比如我們想在 cmn-Hans-CN 的區域設定中使用農曆來表示日期,但顯然我們上文定義的表示方法並不能表達這一需求。好在,Unicode 為 BCP 47 提供了 u 擴充套件。在區域設定名稱後面加上 -u-[選項] 就可以表達更細緻的變體了。所以我們有:

cmn-Hans-CN-u-ca-chinese = 中文普通話-簡體-中國大陸-u-日曆-中國農曆
jpn-Jpan-JP-u-ca-japanese = 日語-日文漢字/平假名/片假名-日本-u-日曆-日本日曆
cmn-Hans-CN-u-nu-hansfin = 中文普通話-簡體-中國大陸-u-數字-簡體大寫數字

u 擴充套件的具體可選項可以在 Unicode 網站上找到。而多個 u 擴充套件還可以連線——於是我們甚至可以寫出 cmn-Hans-CN-u-ca-chinese-nu-hansfin 這種喪心病狂的區域設定名稱。當然,相信你現在已經可以看懂這個區域設定的意思了。

不同地區可能會有不同的日曆使用習慣,如中國有使用農曆的需求,泰國有使用佛曆的需求,我們可以通過 u 擴充套件指定不同的日曆。不過,大部分情況下我們會使用標準的 ISO 8601 日曆(gregory),JavaScript 的 Date 物件也只支援這種日曆。

你可以使用 BCP47 language subtag lookup 工具快速檢查你編寫的 BCP 47 區域標籤是否規範。

終於我們可以正確表達一個完美符合我們需求的區域設定了。接下來,讓我們開始格式化時間吧。

格式化時間

這題我會!

const formatDate(date) => {
    return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${`${date.getDate()}`.padStart(2, '0')} ${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}:${`${date.getSeconds()}`.padStart(2, '0')}`
}

formatDate(new Date()) // 2021-10-13 01:00:00

就完事了……嗎?先不論這樣的格式化程式碼難以閱讀,儘管上文這樣的日期格式國際通用,但並非所有區域都習慣於這樣的日期表示方法。比如英語國家/地區在很多時候習慣在日期中加入星期,而阿拉伯語國家/地區在部分情況下習慣使用阿拉伯語數字(而非常用的阿拉伯-印度數字);再比如美式英語國家/地區習慣月-日-年的日期表示法,而英式英語國家/地區習慣日-月-年的日期表示法……不同區域在時間表示格式習慣上的差異是巨大的,我們很難通過一個簡單的方法來正確地、國際化地格式化一個日期

好在 ES6 早就為我們鋪平了道路。還記得上文提到過的 Intl.DateTimeFormat 嗎?我們通過它來例項化一個日期格式化器並用進行日期的國際化。

直接來看例子吧:

const options = {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long'
}
const now = new Date()

const enUSFormatter = new Intl.DateTimeFormat('en-US', options)

const zhCNFormatter = new Intl.DateTimeFormat('zh-CN', options)
const zhCNAltFormatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', options)
const zhCNAlt2Formatter = new Intl.DateTimeFormat('zh-CN-u-ca-roc-nu-hansfin', options)

const jaFormatter = new Intl.DateTimeFormat('ja', options)
const jaAltFormatter = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', options)

const arEGFormatter = new Intl.DateTimeFormat('ar-EG', options)

enUSFormatter.format(now) // Wednesday, Oct 13, 2021

zhCNFormatter.format(now) // 2021年10月13日星期三
zhCNAltFormatter.format(now) // 2021辛丑年九月8星期三
zhCNAlt2Formatter.format(now) // 民國壹佰壹拾年拾月拾叄日星期三

jaFormatter.format(now) // 2021年10月13日水曜日
jaAltFormatter.format(now) // 令和3年10月13日水曜日

arEGFormatter.format(now) // الأربعاء، ١٣ أكتوبر ٢٠٢١

在這裡我們使用 ISO 639-1 程式碼來表示語言,是由於事實上 ISO 639-1 程式碼更加常見與通用。在大部分支援 Intl.DateTimeFormat 的 JavaScript 執行時中我們也可以使用 ISO 639-2/3 程式碼來表示語言(但實際會 fallback 至對應的 ISO 639-1 程式碼)。

你也可以通過在 options 中設定 calendar 屬性和 numberingSystem 屬性來替換區域設定名稱中對 u 擴充套件的使用。這也是推薦方式。

這非常直觀,我們可以指定區域設定和格式化選項來初始化一個格式化器,並在之後使用格式化器物件的 format 方法來格式化一個 Date 物件。這裡的格式化選項其實非常靈活,能格式化的不只是日期,時間也可以被靈活地格式化,有非常多的組合可以選擇。我們不會在這裡詳細解釋每一個選項,你可以訪問 MDN 文件來了解更多。

如前文所述,Intl.DateTimeFormat 無法格式化毫秒。

不過需要注意的是,JavaScript 執行時不一定支援所有區域設定,也不一定支援所有格式化選項。在遇到不支援的情況時,Intl.DateTimeFormat 預設會靜默 fallback 到最匹配的支援項,因此在處理不常見的區域設定或選項時,你可能需要再額外檢查。你可以通過 Intl.DateTimeFormat.supportedLocalesOf() 靜態方法判斷當前執行時是否支援指定的區域設定,也可以在例項化格式化器後在物件上呼叫 resolvedOptions() 方法來檢查執行時的解析結果是否與預期一致。

new Intl.DateTimeFormat('yue-Hant-CN').resolvedOptions()
// {locale: 'zh-CN', calendar: 'gregory', …}
// fallback 至 zh-CN,與 yue-CN 的預期不一致

此外,正如你所看到的,各種語言在日期格式化中使用的文字 JavaScript 執行時都已經幫我們內建了。因此,我們甚至可以利用這些國際化特性來為我們的應用減少一點需要翻譯的字串——打包進應用的翻譯越少,應用體積也就越小了嘛——比如說獲取一週七天對應的名字:

const getWeekdayNames = (locale) => {
     // 基於一個固定日期計算,這裡選擇 1970.1.1
     // 不能使用 0,因為 Unix 時間戳 0 在不同時區的日期不一樣
    const base = new Date(1970, 0, 1).getTime()
    const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short' })
    return Array.from({ length: 7 }, (_, day) => (
        formatter.format(new Date(base + 3600000 * 24 * (-4 + day))) // 1970.1.1 是週四
    ))
}

getWeekdayNames('en-US') // ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
getWeekdayNames('zh-CN') // ['週日', '週一', '週二', '週三', '週四', '週五', '週六']
getWeekdayNames('ja') // ['日', '月', '火', '水', '木', '金', '土']
getWeekdayNames('ar-EG') // ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']

當然,如果你還是不喜歡執行時為你提供的格式,我們還有上文提到過的 formatToParts() 方法可以用。來看一個簡單的例子吧:

new Intl.DateTimeFormat('zh-CN', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
    weekday: 'long',
    hour: 'numeric',
    minute: 'numeric',
    second: 'numeric',
}).formatToParts(new Date())
// [
//     { type: 'year', value: '2021' },
//     { type: 'literal', value: '年' },
//     { type: 'month', value: '10' },
//     { type: 'literal', value: '月' },
//     { type: 'day', value: '13' },
//     { type: 'literal', value: '日' },
//     { type: 'weekday', value: '星期三' },
//     { type: 'literal', value: ' ' },
//     { type: 'dayPeriod', value: '上午' },
//     { type: 'hour', value: '1' },
//     { type: 'literal', value: ':' },
//     { type: 'minute', value: '00' },
//     { type: 'literal', value: ':' },
//     { type: 'second', value: '00' }
// ]

隨後,你就可以自己解析這個陣列來構造出你想要的時間格式了。最後,我們還可以使用 Intl.RelativeTimeFormat 來格式化相對日期。當然我們不會在這裡詳細講解這個 API,你可以參考 MDN 文件。直接來看一個簡單例子吧:

const getRelativeTime = (num, unit, locale) => {
    return new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(num, unit)
}

getRelativeTime(-3, 'day', 'en-US') // 3 days ago
getRelativeTime(-1, 'day', 'zh-CN') // 昨天
getRelativeTime(0, 'second', 'zh-CN') // 現在
getRelativeTime(3, 'hour', 'ja') // 3 時間後
Intl.RelativeTimeFormat 是一個相對較晚進入標準的物件,因此瀏覽器支援程度較差,可能需要使用 Polyfill。不過目前(2021.10)主流瀏覽器的最新版本均已支援此 API。

未來

我希望這篇文章時區轉換的部分可以很快過時——這並非無稽之談,目前(2021.10)TC39 的 Temporal 提案已經進入 Stage 3 了。Temporal 提案定義了一個新的、時區友好的 Temporal 名稱空間,並期望在不久後就能進入標準並最終應用於生產環境Temporal 定義了完整的時區、時間段、日曆規則的處理,且擁有簡單明瞭的 API。那時候,JavaScript 的時區處理就不會再如此痛苦了。由於目前 Temporal 提案還未進入標準,API 暫未穩定,我們無法將其用於生產環境,但我們可以來看一個簡單的例子感受一下這個 API 的強大。

const zonedDateTime = Temporal.ZonedDateTime.from({
  timeZone: 'America/Los_Angeles',
  year: 1995,
  month: 12,
  day: 7,
  hour: 3,
  minute: 24,
  second: 30,
  millisecond: 0,
  microsecond: 3,
  nanosecond: 500,
  calendar: 'iso8601'
}) // 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]

如果你希望立刻開始使用 Temporal,現在已有 Polyfill 可用。

不過,時區問題不會消失,各地區的習慣也很難融合到一起。時間的國際化處理是極其複雜的,前端中的時間國際化仍然值得我們認真關注。

相關文章