多語系:不要拼句子,交給 Intl
字典結構、語言協商、為什麼字串拼接一定會出錯、日期金額數字怎麼格式化,以及 CJK 排版要注意的事。
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: strict加overflow-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>有跟著變