跳到主要內容

多語系:不要拼句子,交給 Intl

字典結構、語言協商、為什麼字串拼接一定會出錯、日期金額數字怎麼格式化,以及 CJK 排版要注意的事。

約 7 分鐘 · i18n.md

1. 不要拼句子

// 錯
`已選取 ${count} 個項目`
`${name} 在 ${time} 更新了 ${target}`

問題不只是翻譯 —— 是語序。中文說「在三點更新了檔案」,日文會把動詞放最後,阿拉伯文從右到左。拼接假設了語序固定。

正確做法是整句帶插槽:

'list.selected': '已選取 {count} 個項目',
'activity.updated': '{name} 在 {time} 更新了 {target}',
export function translate(locale, key, values) {
  const template = dictionaries[locale]?.[key] ?? dictionaries[DEFAULT][key] ?? key;
  if (!values) return template;
  return template.replace(/\{(\w+)\}/g, (m, name) => (name in values ? String(values[name]) : m));
}

翻譯者拿到完整句子,可以自由調換語序。

2. 缺字典要看得見

const template = dict[key] ?? fallbackDict[key] ?? key;

回傳 key 本身,畫面上會出現 nav.settings 這種醜東西 —— 這正是我們要的,它會被發現然後被修好。回傳空字串則會靜靜地在畫面上開一個洞。

3. 語言協商

export function negotiateLocale(header: string | null): Locale {
  if (!header) return DEFAULT_LOCALE;

  const wanted = header
    .split(',')
    .map((part) => {
      const [tag, q] = part.trim().split(';q=');
      return { tag: tag.toLowerCase(), q: q ? Number(q) : 1 };
    })
    .sort((a, b) => b.q - a.q);

  for (const { tag } of wanted) {
    if (tag.startsWith('zh')) return 'zh-TW';
    if (tag.startsWith('en')) return 'en';
  }
  return DEFAULT_LOCALE;
}

順序是:cookie(使用者明確選過)→ Accept-Language → 預設。使用者選過就不要再猜。

而且要在伺服器做,否則第一幀會是錯的語言,然後閃一下。

4. Intl 處理所有格式

數字與金額

new Intl.NumberFormat('zh-TW').format(1234567);              // 1,234,567
new Intl.NumberFormat('de-DE').format(1234567);              // 1.234.567

千分位符號各國不同,手寫的 replace(/\B(?=(\d{3})+(?!\d))/g, ',') 只對英美是對的。

金額有一個實務陷阱:

new Intl.NumberFormat('zh-TW', { style: 'currency', currency: 'TWD' }).format(120);
// "$120"  ← 技術上正確,實務上會被讀成美金

解法是讓 Intl 決定位置與分位,只換掉符號本身:

const DISAMBIGUATED = { TWD: 'NT$' };

export function formatMoney(locale, amount, currency = 'TWD') {
  const parts = new Intl.NumberFormat(locale, {
    style: 'currency', currency, maximumFractionDigits: 0
  }).formatToParts(amount);

  const override = DISAMBIGUATED[currency];
  return parts.map((p) => (p.type === 'currency' && override ? override : p.value)).join('');
}

日期

new Intl.DateTimeFormat('zh-TW', { dateStyle: 'medium' }).format(date);

永遠不要手寫 ${y}/${m}/${d} 美國是 M/D/Y,歐洲是 D/M/Y,同一組數字會被讀成兩個不同的日期。

相對時間

const rtf = new Intl.RelativeTimeFormat('zh-TW', { numeric: 'auto' });
rtf.format(-1, 'day');   // 「昨天」而不是「1 天前」

numeric: 'auto' 是關鍵,它讓「昨天」「明天」用自然說法。

複數

英文有單複數,中文沒有,俄文有四種形式:

const pr = new Intl.PluralRules('en-US');
pr.select(1);   // 'one'
pr.select(2);   // 'other'

字典裡就寫成多個 key:item.count.one / item.count.other。中文只需要 other

排序

list.sort((a, b) => new Intl.Collator('zh-TW').compare(a.name, b.name));

a < b 是碼位比較,中文會排出無意義的順序。

5. CJK 排版

  • 行高至少 1.6,1.75 更好。方塊字沒有 x-height 的視覺喘息,行高太低會糊掉
  • 字體 fallback 必須指名 CJK,否則會掉到系統的隨機襯線字:
--ds-font-cjk: 'PingFang TC', 'Noto Sans TC', 'Microsoft JhengHei', sans-serif;
--ds-font-body: Geologica, var(--ds-font-cjk);
  • 中英混排不要手動加空白,交給字體 metrics 與 text-wrap: pretty
  • 標點不要跑到行首line-break: strictoverflow-wrap: break-word
  • 不要用 text-transform: uppercase 在中文上(沒效果),但要注意它會影響混排中的英文
  • 簡繁不是換字表就好:「軟體 / 软件」「程式 / 程序」是不同的詞。當成兩個語言處理

6. 版面要能承受長度變化

德文平均比英文長 30%,中文比英文短 40%。

  • 按鈕不要固定寬度,用 min-width + padding
  • 導覽列的項目數與長度會變 → 用 flex wrap 或收合
  • 表格欄寬不要寫死
  • 測試方法:把所有文字換成最長的那個語言看會不會爆

7. 切換語言後要重抓

伺服器渲染的內容跟語言有關,所以切換後要讓 load 重跑:

async function pickLocale(locale: Locale) {
  settings.set('locale', locale);
  await invalidateAll();
}

只改 client 端的字典,會留下一半舊語言的畫面。

8. 檢查清單

  • 沒有字串拼接組句子
  • 缺字典時顯示 key,不是空白
  • 語言協商在伺服器,順序是 cookie → header → 預設
  • 數字、金額、日期、相對時間全部走 Intl
  • 金額符號有處理歧義(NT$ 對 $)
  • 排序用 Collator
  • CJK 行高 ≥ 1.6,字體 fallback 有指名
  • 版面能承受 ±40% 的文字長度變化
  • 切換語言會重跑伺服器 load
  • <html lang> 有跟著變

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言