場景
前端多語系站通常是這樣做 lazy loading 的:
async function loadLocale(locale) {
const res = await fetch(`/locales/${locale}.json`);
if (!res.ok) throw new Error('load failed');
return res.json();
}
export async function initI18n(locale = 'zh-TW') {
try {
const messages = await loadLocale(locale);
i18n.setMessages(messages);
} catch (e) {
// fallback
const messages = await loadLocale('en');
i18n.setMessages(messages);
}
}
看起來 reasonable — 抓不到就用英文。直到有一天客人反應:「我用 Chrome 開你們的網站,第一次進站明明 UI 語言是中文,但突然就跳英文了」。
為什麼會這樣
幾個常見觸發點:
- 第一次進站網路很爛:手機從 4G 轉 WiFi、地下室訊號差、CDN 抖一下。
fetchtimeout / 連線斷線 →catch觸發 → 掉到 en。 - CDN cache miss + 後端短暫 500:
/locales/zh-TW.json第一次真的回 500,之後就正常了。但那位 user 第一次的體驗就是 en。 - service worker 還在 install:第一次 request 沒被 cache,service worker 繞路的過程中 race,fetch 失敗。
後果都一樣:user 其實是中文使用者,但被強制看到英文。糟糕的 UX — 英文使用者反而不會受影響。
解法:fallback 要看瀏覽器語言再決定
function pickFallbackLocale(supported) {
const pref = navigator.languages || [navigator.language];
for (const tag of pref) {
// 先試完整 match
if (supported.includes(tag)) return tag;
// 再試 primary subtag (zh-TW → zh)
const primary = tag.split('-')[0];
const hit = supported.find(s => s.startsWith(primary + '-') || s === primary);
if (hit) return hit;
}
return 'en'; // 真的都沒 match 才英文
}
const SUPPORTED = ['zh-TW', 'zh-CN', 'ja', 'en'];
export async function initI18n(locale) {
const target = locale || pickFallbackLocale(SUPPORTED);
try {
i18n.setMessages(await loadLocale(target));
} catch (e) {
const fallback = pickFallbackLocale(SUPPORTED.filter(s => s !== target));
i18n.setMessages(await loadLocale(fallback));
}
}
要點
navigator.languages優先於navigator.language。前者是 array,按使用者設定的優先順序排。後者只有一個。- Primary subtag match。user browser 是
zh-HK,站只支援zh-TW/zh-CN— 這時候zh-TW(或zh-CN,依你覺得誰離 HK 近) 比 en 合理太多。 - fallback 的 fallback 也不要是 en。
[ja]掛掉時,user 是日文,退到zh-TW都比 en 合理一點 — 但這個 case 太邊界,多數情況下真的都掛掉就給 en 即可。 - BCP 47 case:
zh-tw/zh-TW/ZH-TW要統一 normalize。瀏覽器通常回zh-TW,但要防呆。
如果你用 i18next / vue-i18n
一樣的邏輯已經有現成選項:
// i18next
i18next.init({
fallbackLng: {
'zh-HK': ['zh-TW', 'zh-CN', 'en'],
'zh-MO': ['zh-TW', 'zh-CN', 'en'],
default: ['en'],
},
detection: {
order: ['querystring', 'cookie', 'navigator'],
},
});
重點不是哪個 lib,而是 「載入失敗」跟「語言沒支援」的 fallback 路徑要分開想。大部分 lib 預設只處理「沒支援」的 fallback,載入失敗那條路是你自己要補的。
還要記得:user override 要 persist
第一次靠 navigator 猜語言沒關係,但 user 手動切換語言之後,要記住。
function getPreferredLocale() {
return (
localStorage.getItem('locale') || // 手動選過的
pickFallbackLocale(SUPPORTED) // 沒選過就猜
);
}
function setPreferredLocale(locale) {
localStorage.setItem('locale', locale);
initI18n(locale);
}
不然就會變成「user 選了繁中、關掉視窗再進來,又變回簡中」— 一樣糟。
SSR 的版本
SSR 的情境下沒有 navigator,要從 request header 看:
// Next.js / Remix / Express
function pickFromAcceptLanguage(header, supported) {
if (!header) return 'en';
const candidates = header
.split(',')
.map(part => {
const [tag, q = 'q=1'] = part.trim().split(';');
return { tag: tag.trim(), q: parseFloat(q.split('=')[1]) };
})
.sort((a, b) => b.q - a.q);
for (const { tag } of candidates) {
if (supported.includes(tag)) return tag;
const primary = tag.split('-')[0];
const hit = supported.find(s => s.startsWith(primary));
if (hit) return hit;
}
return 'en';
}
邏輯是一樣的,只是資料來源從 navigator.languages 換成 Accept-Language header。
小結
- 載入 locale JSON 失敗 → 直接 fallback
en是常見 UX bug。 - 用
navigator.languages+ primary subtag match 決定 fallback,英文只是最後的保底。 - user 手動切換過的語言要 persist (localStorage / cookie)。
- SSR 情境讀
Accept-Languageheader,邏輯相同。
REFERENCE / 2