Web i18n
國際化是架構問題,不是專案尾聲的翻譯工作。目標是讓 UI 在任何語言環境下都能正確呈現,無需特殊處理:不串接句子、不硬編碼格式、不因德語長 35% 或阿拉伯語改變方向而版面崩壞。
何時使用此技能
當使用者要求以下事項時使用:
- 新增語言、語言切換器或語言路由
- 翻譯應用程式、萃取字串或建立翻譯工作流程
- 修正複數、性別或「1 個項目 / 2 個項目」的錯誤
- 根據語言環境格式化日期、數字、貨幣、相對時間或列表
- 支援 RTL(阿拉伯語、希伯來語、波斯語、烏爾都語)
- 新增 hreflang、在地化 metadata 或多語言站點地圖
- 審查現有 i18n 設定、遺漏的鍵值或翻譯偏差
- 在 next-intl、react-i18next、FormatJS、Lingui 或 TMS 之間選擇
相關參考文件在 references/ 中,腳本在 scripts/ 中。僅讀取任務所需的內容。路徑相對於此技能目錄(代理執行時期會在技能載入時列印該目錄)。
非妥協事項
- 絕對不要串接翻譯片段來建立句子。 應翻譯完整訊息並使用佔位符:使用
"{count} 則新訊息",而非t('new') + ' ' + count + ' ' + t('messages')。 - 絕對不要使用英文文字作為鍵值。 鍵值應是穩定的識別碼(
inbox.unreadCount),這樣文字修改才不會破壞查詢或使所有翻譯失效。 - 每個使用者可見的字串都必須外部化,包括
aria-label、alt、title、placeholder、驗證錯誤、空狀態、toast、電子郵件、推播通知和 PDF 輸出。 - 絕對不要硬編碼格式。 不要使用
toFixed(2) + '$'、手動MM/DD/YYYY、英文月份名稱或假設24h。請使用Intl。 - 語言環境是 BCP 47 標籤,不是語言。
pt-BR與pt-PT、zh-Hans與zh-Hant、es-MX與es-ES是不同的產品。請儲存完整標籤。 - 伺服器上的語言狀態必須為請求作用域。 絕不在 SSR 期間將當前語言環境放入模組層級的可變變數中;並發請求會導致語言互相洩漏。
- 若翻譯來自 TMS、社群或 CMS,則視為不可信任的輸入。 在未經清理的情況下,絕不將它們傳遞給
innerHTML/dangerouslySetInnerHTML。
工作流程
1. 在修改程式碼前先審計
- 在提出任何建議前,先檢測堆疊、路由器和現有的 i18n 函式庫。
- 尋找硬編碼字串:
將輸出視為候選項目,而非定論:跳過測試 fixture、常數、URL 和開發者導向的日誌。node scripts/scan-hardcoded.mjs src - 盤點當前提供的每個語言環境以及目錄存放位置。
- 優先找出風險最高的區域:驗證、結帳、錯誤、電子郵件和法律文字。
2. 決定架構(寫下來)
在撰寫程式碼前回答這些問題:
| 決策 | 預設值 | 備註 |
|---|---|---|
| 語言策略 | 路徑前綴 /es/ | SEO 與快取最佳。僅在有充分理由時才使用子網域/國碼頂級域。 |
| 預設語言環境 | 原始語言 | 不要在未提供覆蓋選項的情況下,自動將首次造訪者重新導向到猜測的語言環境。 |
| 語言偵測 | URL → cookie → Accept-Language → 預設值 | URL 優先,這樣共享連結永遠不會改變語言。 |
| 目錄格式 | 每個語言環境每個命名空間一個 JSON | 延遲載入命名空間;永遠不要將所有語言環境發送到客戶端。 |
| 訊息語法 | ICU MessageFormat | 可跨函式庫和 TMS 移植;支援複數/選擇/序數。 |
| 鍵值風格 | feature.component.element | 穩定、可搜尋、無英文文字。 |
| 翻譯來源 | TMS 或儲存庫,不可兩者並存 | 選擇一個;在 CI 中單向同步。 |
| 後備 | 語言環境鏈 es-MX → es → en | 在開發環境中記錄後備情況以找出缺口。 |
3. 外部化並結構化訊息
- 將字串替換為語義鍵值,並將文案移至基礎語言環境目錄。
- 每個完整句子使用一個鍵值。如果句子包含連結或粗體字,請使用富文本插值而非拆分句子。
- 保持佔位符命名(
{userName}),絕對不要使用位置序號({0})。 - 除非函式庫明確支援型別標籤,否則不要在目錄中放入 HTML。
- 按產品表面分組,而非按頁面:
checkout.json、auth.json、errors.json。
關於複數、選擇、序數和富文本規則,請參閱 references/icu.md。
4. 設定語言路由與渲染
- 從路由推導語言環境,驗證其是否在支援的列表中,對於未知語言環境回傳 404,而非靜默地使用後備。
- 提供一個語言切換器,保留當前路徑和查詢字串。
- 在伺服器渲染的文件上設定
<html lang="...">和<html dir="...">。 - 僅載入當前語言環境的目錄以及預設後備目錄。
- 確保伺服器和客戶端渲染使用相同的目錄;避免重複或分歧的翻譯邏輯。
特定框架的設定與陷阱:references/frameworks.md。
路由、偵測、hreflang 和站點地圖:references/routing-seo.md。
5. 處理語言環境敏感值
使用 Intl(或框架的包裝器),絕對不要手動格式化:
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount)
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone }).format(date)
new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(-3, 'day')
new Intl.ListFormat(locale, { type: 'conjunction' }).format(items)
new Intl.PluralRules(locale).select(count)
new Intl.Collator(locale).compare(a, b)
- 將時間戳儲存為 UTC;在使用者的時區呈現,而非伺服器的時區。
- 貨幣與語言環境不同:法國使用者可能使用美元付款。
- 使用
Intl.Collator進行排序/搜尋,不要使用<或未指定語言環境的localeCompare()。 - 大小寫轉換:使用
toLocaleUpperCase(locale),因為土耳其語的i會變成İ。
6. 為擴充與 RTL 設計
- 假設文字長度增加 +30–40%(德語、芬蘭語、俄語),不要使用固定寬度的按鈕或單行標籤。
- 讓容器可以成長,允許換行,並使用偽語言環境進行測試:
node scripts/pseudo-localize.mjs locales/en.json --out locales/en-XA.json - 使用邏輯 CSS 屬性(
margin-inline-start、padding-inline、inset-inline)而非left/right。 - 鏡像方向性圖示(箭頭、V 形、進度條),而非標誌、時鐘、媒體控制項或勾選標記。
- 將使用者產生的內聯內容包裝在
<bdi>或dir="auto"中。
完整的 RTL 檢查清單:references/rtl.md。
7. 在 CI 中設定翻譯品質檢查
至少,當目錄結構無效時要讓建置失敗:
node scripts/check-locales.mjs locales --base en
它會檢查缺少/多餘的鍵值、空值、佔位符不匹配以及損壞的 ICU 語法。如果有的話,可以透過函式庫的 CLI 加入 ICU 編譯:
npx formatjs compile-folder --ast locales/en locales/compiled/en
為複數類別、日期/數字輸出(使用固定時區)和 RTL 渲染加入測試。詳見 references/testing.md。
關鍵決策與陷阱
函式庫選擇
| 需求 | 選擇 |
|---|---|
| Next.js App Router、伺服器元件 | next-intl |
| React SPA、成熟生態系、多種外掛 | react-i18next |
| ICU 優先、框架無關、CLI 萃取 | FormatJS (react-intl) |
| 編譯時目錄、小型執行時期、型別安全 | Lingui |
| Vue | vue-i18n |
| Svelte | svelte-i18n 或 paraglide |
| Astro | 內建 i18n 路由 + 你的訊息函式庫 |
| 原生 / 最小需求 | Intl + 小型目錄載入器 |
不要在已有 i18n 函式庫的應用程式中引入第二個。請擴展現有設定。
複數規則
複數類別因語言而異。英文有 one/other;阿拉伯文有 zero/one/two/few/many/other;日文只有 other。永遠不要寫 count === 1 ? ... : ...。
"inbox.unread": "{count, plural, =0 {沒有未讀訊息} one {# 則未讀訊息} other {# 則未讀訊息}}"
性別與語法一致性
在性別變數上使用 select,並保持變數的值為語言中立(female、male、neutral、unknown),而非在地化的詞彙:
"profile.greeting": "{gender, select, female {歡迎,{name}} male {歡迎,{name}} other {歡迎,{name}}}"
如果語言需要在形容詞、名詞或冠詞上保持一致,請將語法性別/格作為引數暴露出來,而不是駭入字串。
富文本
不要將 "閱讀我們的 " + link + " 條款" 拆分為三個鍵值。請使用標籤插值:
"signup.terms": "我同意<link>服務條款</link>"
t.rich('signup.terms', { link: (chunks) => <a href="/terms">{chunks}</a> })
SEO
- 每個語言環境一個可索引的 URL;永遠不要在相同 URL 背後提供機器翻譯的內容。
- 自我參照的
hreflang加上x-default;使用絕對 URL;包含每個語言環境。 - 在地化
<title>、meta 描述、OG 標籤和結構化資料。 - 不要在地化頁面上同時使用
hreflang與noindex。
詳情請參閱 references/routing-seo.md。
快取
- 路徑中包含語言環境可使 CDN 快取變得簡單。如果透過標頭協商,請傳送
Vary: Accept-Language,並預期會出現快取碎片化。 - 不要在共享的 CDN 回應中快取個人化的語言偏好。
- 保持
Content-Language與渲染的語言環境一致。
複查清單
在宣告 i18n 完成之前:
- 元件、電子郵件或錯誤中沒有硬編碼的使用者可見字串
- 沒有句子串接;每則訊息都是完整的可翻譯單元
- 鍵值是語義化且穩定的;沒有使用英文文字作為鍵值
- 每個佔位符都存在於每個語言環境中;ICU 語法編譯通過
- 複數使用
Intl.PluralRules/ICU,而非count === 1 - 性別/一致性透過
select處理,而非使用 "/a" 後綴 - 日期、數字、貨幣、相對時間使用
Intl並指定語言環境/時區 -
<html lang>和dir在首次繪製(SSR)時正確,而非在 effect 中設定 - 語言切換器保留路徑/查詢,並適用於深層連結
- 未知的語言環境回傳 404,而非靜默使用後備
- 版面能承受 +35% 的文字擴充和 RTL
-
hreflang、canonical、站點地圖和在地化 metadata 正確 - 目錄驗證在 CI 中執行
- 後備行為是有意且可觀察的
反模式
t('hello') + ', ' + name— 破碎的句子key={englishText}— 將翻譯文字作為鍵值count === 1 ? 'item' : 'items'— 僅限英文的複數邏輯new Date().toLocaleDateString()未指定語言環境或時區value.toFixed(2)用於金錢- 在元件中零散使用
locale === 'ar' && { direction: 'rtl' },而非在文件層級設定dir - 在
useEffect內設定<html lang>— 會造成閃爍並破壞爬蟲 - 將所有語言環境載入到初始 bundle 中
- 根據 IP 自動重新導向,且無任何覆蓋方式
- 翻譯專有名詞、產品名稱、程式碼識別碼或 URL
- 機器翻譯字串後未經審查即發佈
- 假設單一目錄檔案能擴充到數百個鍵值以上