Appearance
概览
本文把你现有代码里**"获取当前用户语言"**这块抽出来,整理成一套可复用的思路与最简实现,覆盖以下来源的检测顺序:
- 本地存储(
localStorage/@vueuse/core的useStorage) - 浏览器首选语言列表(
navigator.languages) - 浏览器主语言(
navigator.language) - 操作系统语言(通过浏览器 UA 能力间接获得,现代浏览器已暴露到
navigator.*language*) - 兜底语言(fallback)
并包含语言规范化(大小写、连字符)、地区-语言映射与常见方言回退。
整体实现思路
- 唯一数据源:维护
LANGS(支持的 i18n 码与后端码映射)。 - 规范化:把用户环境提供的原始语言字符串(可能是
en_US、en-us、ZH)统一转成 BCP-47 风格(lang-REGION,如en-US、zh-CN)。 - 就近匹配:尝试"原样 → 下划线转连字符 → 仅语言部分"三轮匹配。
- 语言级回退映射:如只拿到
zh,映射到zh-CN;ja→ja-JP等。 - 检测顺序:
localStorage优先;否则遍历navigator.languages;否则navigator.language;都没有时用兜底en。 - 落盘与副作用:选出最终语言后,同步更新
localStorage与<html lang>,并准备好后端需要的语言码(如X-App-Lang)。
分步实现
1) 定义支持语言与后端映射(唯一数据源)
ts
// 唯一数据源:UI 展示名 + i18n 标准码 + 后端语言码
export const LANGS = [
{ i18n: "en", backend: "en", autonym: "English" },
{ i18n: "zh-CN", backend: "zhCn", autonym: "简体中文" },
{ i18n: "zh-TW", backend: "zhTw", autonym: "繁體中文" },
{ i18n: "ja-JP", backend: "jp", autonym: "日本語" },
{ i18n: "ko-KR", backend: "kr", autonym: "한국어" },
// ...按需补充
] as const;
export type LocaleCode = (typeof LANGS)[number]["i18n"];
export const FALLBACK_LANG: LocaleCode = "en";
export const BACKEND_LANG_MAP: Record<string, string> = Object.fromEntries(
LANGS.map((x) => [x.i18n, x.backend])
);
// 常见浏览器变体回退
(BACKEND_LANG_MAP as any)["en-US"] = BACKEND_LANG_MAP["en"];2) 语言规范化(大小写、连字符、就近匹配)
ts
// 复杂逻辑:把 raw 语言字符串规范化为受支持的 i18n 码
function normalizeToSupported(raw: string): LocaleCode | undefined {
if (!raw) return;
const code = raw.trim().toLowerCase();
// 候选序列:原样 -> 下划线改连字符 -> 仅语言部分
const candidates = [code, code.replace("_", "-"), code.split("-")[0]];
const supported = new Set<LocaleCode>(LANGS.map((x) => x.i18n));
for (const c of candidates) {
// 复杂逻辑:标准化大小写(lang-REGION)
const canonical = c
.split("-")
.map((seg, i) => (i === 0 ? seg : seg.toUpperCase()))
.join("-") as LocaleCode;
if (supported.has(canonical)) return canonical;
}
// 复杂逻辑:语言级兜底映射(仅语言 → 指定地区)
const languageOnly = code.split("-")[0];
const languageToExact: Partial<Record<string, LocaleCode>> = {
en: "en",
zh: "zh-CN",
"zh-hk": "zh-TW",
"zh-mo": "zh-TW",
"zh-sg": "zh-CN",
ja: "ja-JP",
ko: "ko-KR",
};
const mapped = (languageToExact[languageOnly] ?? languageToExact[code]) as
| LocaleCode
| undefined;
if (mapped && supported.has(mapped)) return mapped;
return;
}3) 读取 localStorage 与浏览器 / 操作系统配置
ts
// 使用 @vueuse/core 时:useStorage;否则可用原生 localStorage
import { useStorage } from "@vueuse/core";
const STORAGE_KEY_LANG = "APP_LANG";
// 本地存储中的当前语言(值为 i18n 码)
export const lang = useStorage<LocaleCode>(STORAGE_KEY_LANG, FALLBACK_LANG);
// 复杂逻辑:获取初始语言(优先本地存储;再浏览器/系统;再回退 EN)
export function getInitialLocale(): LocaleCode {
// 1) 本地存储优先
if (lang.value) return lang.value as LocaleCode;
// 2) 浏览器首选语言列表(现代浏览器会把 OS 语言反映到这里)
const navList =
navigator.languages && navigator.languages.length > 0
? navigator.languages
: [navigator.language].filter(Boolean);
for (const raw of navList) {
const matched = normalizeToSupported(raw);
if (matched) return matched;
}
// 3) 兜底
return FALLBACK_LANG;
}4) 应用副作用(<html lang> 与后端请求头)
ts
import { http } from "@/libs";
// 复杂逻辑:设置后端请求头与 <html lang>
export function applyLanguageSideEffects(locale: LocaleCode) {
const backend = BACKEND_LANG_MAP[locale] ?? locale;
// Accept-Language 和自定义头
http.defaults.headers.common["Accept-Language"] = backend;
http.defaults.headers.common["X-App-Lang"] = backend;
// HTML 根节点的 lang(利于可访问性与 SEO)
document.documentElement?.setAttribute("lang", locale);
}5) 初始化与一次性落盘
ts
// 复杂逻辑:选择语言 → 写入 localStorage → 应用副作用
export function ensureLocaleInitialized() {
const initial = getInitialLocale();
// 若本地没有存过语言,把检测到的 initial 存起来
if (!lang.value) lang.value = initial;
applyLanguageSideEffects(initial);
return initial;
}最简可复用示例(整合版)
适合在任意前端项目中落地,不绑定框架。把此段放到你的 i18n 初始化附近。
ts
// i18n-locale.detector.ts
type LangItem = { i18n: string; backend: string; autonym: string };
export const LANGS = [
{ i18n: "en", backend: "en", autonym: "English" },
{ i18n: "zh-CN", backend: "zhCn", autonym: "简体中文" },
{ i18n: "zh-TW", backend: "zhTw", autonym: "繁體中文" },
{ i18n: "ja-JP", backend: "jp", autonym: "日本語" },
{ i18n: "ko-KR", backend: "kr", autonym: "한국어" },
] as const;
export type LocaleCode = (typeof LANGS)[number]["i18n"];
export const FALLBACK_LANG: LocaleCode = "en";
export const BACKEND_LANG_MAP: Record<string, string> = Object.fromEntries(
LANGS.map((x) => [x.i18n, x.backend])
);
(BACKEND_LANG_MAP as any)["en-US"] = BACKEND_LANG_MAP["en"];
// ——— 复杂逻辑:规范化 ———
function normalizeToSupported(raw: string): LocaleCode | undefined {
if (!raw) return;
const code = raw.trim().toLowerCase();
const candidates = [code, code.replace("_", "-"), code.split("-")[0]];
const supported = new Set<LocaleCode>(LANGS.map((x) => x.i18n));
for (const c of candidates) {
// 复杂逻辑:lang-REGION 标准化
const canonical = c
.split("-")
.map((seg, i) => (i === 0 ? seg : seg.toUpperCase()))
.join("-") as LocaleCode;
if (supported.has(canonical)) return canonical;
}
// 复杂逻辑:语言级映射
const languageOnly = code.split("-")[0];
const languageToExact: Partial<Record<string, LocaleCode>> = {
en: "en",
zh: "zh-CN",
"zh-hk": "zh-TW",
"zh-mo": "zh-TW",
"zh-sg": "zh-CN",
ja: "ja-JP",
ko: "ko-KR",
};
const mapped = (languageToExact[languageOnly] ?? languageToExact[code]) as
| LocaleCode
| undefined;
if (mapped && supported.has(mapped)) return mapped;
return;
}
// ——— 复杂逻辑:检测顺序 ———
export function detectLocale(
getItem = (k: string) => localStorage.getItem(k)
): LocaleCode {
const saved = getItem("APP_LANG") as LocaleCode | null;
if (saved) return saved;
const navList =
navigator.languages && navigator.languages.length > 0
? navigator.languages
: [navigator.language].filter(Boolean);
for (const raw of navList) {
const matched = normalizeToSupported(raw);
if (matched) return matched;
}
return FALLBACK_LANG;
}
// ——— 复杂逻辑:副作用(请求头与 <html lang>)———
export function commitLocale(
locale: LocaleCode,
setHeader?: (k: string, v: string) => void
) {
const backend = BACKEND_LANG_MAP[locale] ?? locale;
// 可选:对接你的 http 客户端
setHeader?.("Accept-Language", backend);
setHeader?.("X-App-Lang", backend);
document.documentElement?.setAttribute("lang", locale);
}使用方式(以 Vue 项目为例):
ts
// main.ts
import { detectLocale, commitLocale } from "./i18n-locale.detector";
import i18n from "./i18n";
const initial = detectLocale();
localStorage.setItem("APP_LANG", initial);
// 复杂逻辑:应用副作用(若你用 axios,可在这里设置默认头)
commitLocale(initial, (k, v) =>
(window as any).http?.defaults?.headers?.common
? ((window as any).http.defaults.headers.common[k] = v)
: null
);
// 然后把 initial 传给你的 i18n 实例
i18n.global.locale = initial;检测顺序流程图
开始检测
↓
1. 读取 localStorage['APP_LANG']
├─ 有值 → 返回
└─ 无值 → 继续
↓
2. 遍历 navigator.languages[]
├─ 找到匹配 → 返回
└─ 无匹配 → 继续
↓
3. 检查 navigator.language
├─ 找到匹配 → 返回
└─ 无匹配 → 继续
↓
4. 返回兜底语言 (en)规范化与映射示例
| 输入原始值 | 规范化步骤 | 最终结果 | 说明 |
|---|---|---|---|
en_US | 下划线转连字符 → en-US | en | 映射到通用英语 |
zh-cn | 大小写标准化 → zh-CN | zh-CN | 简体中文 |
zh | 语言级映射 → zh-CN | zh-CN | 默认简体中文 |
zh-HK | 语言级映射 → zh-TW | zh-TW | 香港繁体映射到台湾繁体 |
ja | 语言级映射 → ja-JP | ja-JP | 日语 |
unknown | 无匹配 | en | 兜底到英语 |
ZH | 小写化 → zh → zh-CN | zh-CN | 大写输入也能正确处理 |
en-us | 大小写标准化 → en-US | en | 统一到 en |
zh-sg | 地区映射 → zh-CN | zh-CN | 新加坡使用简体中文 |
zh-MO | 地区映射 → zh-TW | zh-TW | 澳门使用繁体中文 |
兼容性与注意事项
navigator.languages在现代浏览器广泛支持;老环境可回退到navigator.language。- 操作系统语言通常已映射到浏览器语言 API;不建议再读 UA 或非标准字段。
- 建议把规范化与映射表集中维护在一处,便于审计与扩展。
- 若后端有自定义语言码(如
zhCn),务必维护好BACKEND_LANG_MAP并在请求头里统一使用。 - SSR 场景:请从请求头
Accept-Language解析并在服务端先行选择语言,再把结果注水到客户端,避免首屏闪烁。
常见问答
❓ Q1:只有 zh 时选 zh-CN 还是 zh-TW?
A:取业务主流市场作为默认(本文示例用 zh-CN)。如有面向地区,可用 GeoIP/域名/部署区域决定初始映射。
❓ Q2:en 和 en-US 怎么处理?
A:统一以 en 作为语言集合里的最小单元;必要时对 en-US 做后端码回退到 en。
❓ Q3:如何支持更多地区变体?
A:在 languageToExact 映射表中添加更多规则,例如:
ts
const languageToExact: Partial<Record<string, LocaleCode>> = {
en: "en",
"en-gb": "en-GB", // 英国英语
"en-au": "en-AU", // 澳大利亚英语
zh: "zh-CN",
"zh-hk": "zh-TW",
"zh-mo": "zh-TW",
"zh-sg": "zh-CN",
ja: "ja-JP",
ko: "ko-KR",
"pt-br": "pt-BR", // 巴西葡萄牙语
"pt-pt": "pt-PT", // 葡萄牙葡萄牙语
};❓ Q4:为什么要同步更新 <html lang> 属性?
A:
- 可访问性:屏幕阅读器会根据
lang属性选择正确的发音。 - SEO:搜索引擎使用
lang属性来确定页面语言。 - 浏览器行为:某些浏览器功能(如翻译提示)依赖此属性。
❓ Q5:如何在 SSR 项目中实现?
A:SSR 场景建议:
ts
// 服务端
import { detectLocale } from "./i18n-locale.detector";
app.get("*", (req, res) => {
// 从请求头获取语言
const acceptLanguage = req.headers["accept-language"];
const locale = detectLocaleFromHeader(acceptLanguage);
// 渲染时注入到 HTML
const html = renderToString(app, { locale });
res.send(html);
});
// 客户端
// 从服务端注水的数据中读取语言,避免客户端重新检测导致闪烁
const locale = window.__INITIAL_STATE__.locale;与其他国际化方案对比
| 方案特点 | 本文方案 | 仅用浏览器语言 | 仅用 localStorage | 后端返回语言 |
|---|---|---|---|---|
| 记住用户选择 | ✅ | ❌ | ✅ | ✅ |
| 首次访问自动检测 | ✅ | ✅ | ❌ | ✅ |
| 支持地区方言映射 | ✅ | ⚠️ | ❌ | ⚠️ |
| 无需后端接口 | ✅ | ✅ | ✅ | ❌ |
| 规范化处理(大小写、连字符) | ✅ | ❌ | ❌ | ⚠️ |
完整使用示例(Vue 3 + Vite + vue-i18n)
ts
// src/locales/detector.ts
export {
detectLocale,
commitLocale,
LANGS,
LocaleCode,
} from "./i18n-locale.detector";
// src/main.ts
import { createApp } from "vue";
import { createI18n } from "vue-i18n";
import App from "./App.vue";
import { detectLocale, commitLocale } from "./locales/detector";
import axios from "axios";
// 1. 检测语言
const initialLocale = detectLocale();
// 2. 创建 i18n 实例
const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: "en",
messages: {
// 你的语言包
},
});
// 3. 应用副作用
commitLocale(initialLocale, (k, v) => {
axios.defaults.headers.common[k] = v;
});
// 4. 创建并挂载应用
const app = createApp(App);
app.use(i18n);
app.mount("#app");最佳实践总结
✅ 推荐做法
- 统一数据源:所有语言配置集中在
LANGS数组中管理 - 优先级明确:localStorage > navigator.languages > navigator.language > fallback
- 规范化处理:统一转换为 BCP-47 格式(
lang-REGION) - 地区映射:为常见地区(如香港、新加坡)提供合理的默认映射
- 副作用同步:同时更新 localStorage、HTML lang、axios 请求头
- TypeScript 类型:使用
as const和类型推导确保类型安全
🚫 不推荐的做法
- 硬编码语言判断:避免
if (lang === 'zh-CN')这种散落在各处的判断 - 忽略大小写:不处理
ZH、zh、Zh等变体 - 不做规范化:直接使用原始的
navigator.language值 - 忘记更新副作用:只更新 i18n locale,不更新 HTML lang 和请求头
结语
把"获取用户语言"的逻辑抽象为检测 → 规范化 → 映射 → 兜底 → 副作用五步法,可以在任意框架落地复用。你可以直接把本文的最简可复用示例拎走,用在现有 i18n 初始化流程中即可。
这套方案的核心优势:
- ✅ 多来源检测:覆盖 localStorage、浏览器、操作系统
- ✅ 智能规范化:处理各种大小写和连字符变体
- ✅ 地区映射:为常见地区提供合理的语言默认值
- ✅ 副作用同步:确保 i18n、HTML、请求头保持一致
- ✅ 框架无关:可用于 Vue、React、Angular 等任意前端项目
现在你可以在项目中实现一个健壮的多语言检测系统了!🌍