跳到主要内容前端国际化 i18n 实战指南:架构、工具与避坑 | 极客日志Python
前端国际化 i18n 实战指南:架构、工具与避坑
前端国际化 i18n 实战指南:架构、工具与避坑 --- 引言:告别硬编码 在项目中直接使用 if (lang === 'zh') { return '你好' } else { return 'Hello' } 这样的硬编码方式,虽然初期简单,但随着业务扩展(如增加泰语、越南语等),会导致代码维护成本急剧上升,形成严重的技术债。特别是对于 SaaS 产品,多语言支持是基本需求。此外,阿拉伯语等 R…
黑客帝国28K 浏览 前端国际化 i18n 实战指南:架构、工具与避坑
引言:告别硬编码
在项目中直接使用 if (lang === 'zh') { return '你好' } else { return 'Hello' } 这样的硬编码方式,虽然初期简单,但随着业务扩展(如增加泰语、越南语等),会导致代码维护成本急剧上升,形成严重的技术债。特别是对于 SaaS 产品,多语言支持是基本需求。此外,阿拉伯语等 RTL(从右向左)语言对布局的影响也要求架构设计必须提前考虑。
本文将介绍如何构建健壮的前端国际化(i18n)架构。
什么是 i18n
i18n 是 Internationalization(国际化)的缩写(I 和 n 中间有 18 个字母)。L10n 则是 Localization(本地化)的缩写。
- 国际化 (i18n):技术架构层面,让软件有能力支持多种语言和地区。
- 本地化 (L10n):业务内容层面,翻译内容、调整格式、适配文化习惯。
核心思路是将文字抽离成资源文件,运行时根据当前语言动态替换。
主流方案选型
i18next
老牌方案,生态丰富,支持前端、Node.js、React Native 等。插件系统完善,支持复数处理。
import i18next from 'i18next';
i18next.init({
lng: 'zh',
fallbackLng: 'en',
resources: {
zh: {
translation: {
welcome: '欢迎回来,{{name}}!',
items: '你有 {{count}} 条新消息'
}
},
en: {
translation: {
welcome: 'Welcome back, {{name}}!',
items: 'You have {{count}} new messages'
}
}
},
interpolation: {
escapeValue: false
},
pluralSeparator: ,
:
});
i18next.(, { : });
i18next.(, { : });
'_'
nsSeparator
':'
t
'welcome'
name
'张三'
t
'items'
count
5
vue-i18n
Vue 官方推荐方案,支持 Composition API、TypeScript 及单文件组件块。
<!-- Vue 3 + vue-i18n v9 完整示例 -->
<template>
<div>
<h1>{{ $t('user.greeting', { name: user.name }) }}</h1>
<p>{{ $tc('message.unread', unreadCount, { count: unreadCount }) }}</p>
<time>{{ $d(new Date(), 'short') }}</time>
<span>{{ $n(price, 'currency') }}</span>
<button @click="toggleLocale">{{ $t('action.switchLang') }}</button>
</div>
</template>
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, watch } from 'vue';
const { t, locale, setLocaleMessage, mergeLocaleMessage } = useI18n({
inheritLocale: true,
useScope: 'local'
});
const user = ref({ name: '李四' });
const unreadCount = ref(5);
const price = ref(2999.99);
const toggleLocale = () => {
locale.value = locale.value === 'zh' ? 'en' : 'zh';
localStorage.setItem('user-lang', locale.value);
};
const loadMessages = async (lang) => {
const messages = await import(`./locales/${lang}.json`);
setLocaleMessage(lang, messages.default);
locale.value = lang;
};
watch(locale, (newVal) => {
console.log(`语言切换至:${newVal}`);
});
</script>
<i18n>
{
"zh": { "localKey": "这是组件级别的翻译,优先级高于全局" },
"en": { "localKey": "This is component-level translation" }
}
</i18n>
<style scoped>
.user-profile { padding: 20px; }
.price { color: #e74c3c; font-weight: bold; }
</style>
注意:升级到 v9 版本时,$tc 方法被合并到 t 中,复数规则自定义方式也有变化,迁移前需查阅文档。
react-i18next
import React, { Suspense, useCallback } from 'react';
import { useTranslation, Trans, initReactI18next } from 'react-i18next';
import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',
ns: ['common', 'home', 'user', 'checkout'],
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
queryStringParams: { v: '1.0.0' }
},
detection: {
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
caches: ['cookie', 'localStorage'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng'
},
interpolation: {
escapeValue: false
},
react: {
useSuspense: true,
bindI18n: 'languageChanged loaded',
transSupportBasicHtmlNodes: true,
transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'p']
}
});
function UserDashboard() {
const { t, i18n } = useTranslation(['user', 'common']);
const [user, setUser] = React.useState(null);
const changeLanguage = (lng) => {
i18n.changeLanguage(lng);
document.documentElement.lang = lng;
document.documentElement.dir = ['ar', 'he'].includes(lng) ? 'rtl' : 'ltr';
};
return (
<div className="dashboard">
<h1>{t('user:welcomeTitle', { name: user?.name || t('common:guest') })}</h1>
<p>
<Trans i18nKey="user:agreementText" t={t}>
点击注册即表示您同意 <a href="/terms">服务条款</a> 和 <a href="/privacy">隐私政策</a>
</Trans>
</p>
<div className="stats">{t('user:notificationCount', { count: user?.unread || 0 })}</div>
<div className="lang-switcher">
{['zh', 'en', 'ja', 'ar'].map((lng) => (
<button key={lng} onClick={() => changeLanguage(lng)} className={i18n.language === lng ? 'active' : ''}>
{t(`common:languages.${lng}`)}
</button>
))}
</div>
</div>
);
}
export default UserDashboard;
SSR 注意事项:Next.js 等 SSR 框架中,服务端与客户端语言检测不一致会导致水合报错。建议统一语言来源(如 Cookie 或 URL 参数)。
选型建议
- 技术栈:Vue 用 vue-i18n,React 用 react-i18next。
- 项目规模:小项目任选,大项目推荐 i18next 系(生态好)。
- 团队能力:TypeScript 熟练可选类型支持好的方案。
架构设计与实现
文件目录结构
locales/
zh/
common.json
home.json
user.json
en/
common.json
home.json
user.json
JSON 资源文件规范
{
"user": {
"profile": {
"title": "个人资料",
"editButton": "编辑资料",
"saveSuccess": "保存成功!",
"validation": {
"nicknameRequired": "昵称不能为空",
"nicknameTooLong": "昵称不能超过 20 个字符"
}
},
"security": {
"passwordChange": {
"title": "修改密码",
"oldPasswordPlaceholder": "请输入当前密码",
"newPasswordStrength": "密码强度:{{level}}"
}
}
},
"common": {
"actions": {
"confirm": "确认",
"cancel": "取消",
"backToHome": "返回首页"
},
"status": {
"loading": "加载中...",
"error": "出错了,请重试",
"empty": "暂无数据"
}
}
}
动态加载语言包
import { createI18n } from 'vue-i18n';
const i18n = createI18n({
legacy: false,
locale: 'zh',
fallbackLocale: 'en',
messages: {}
});
async function loadLocaleMessages(locale) {
if (i18n.global.availableLocales.includes(locale)) return;
try {
const messages = await import(`./locales/${locale}.json`);
i18n.global.setLocaleMessage(locale, messages.default);
i18n.global.locale.value = locale;
document.querySelector('html').setAttribute('lang', locale);
} catch (error) {
console.error(`Failed to load locale ${locale}:`, error);
i18n.global.locale.value = 'zh';
}
}
const savedLang = localStorage.getItem('user-lang') || navigator.language.split('-')[0];
loadLocaleMessages(savedLang);
语言检测策略
推荐组合策略:URL 参数 > Cookie > 浏览器 Header。
detection: {
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'],
cookieMinutes: 10080,
cookieDomain: 'myDomain.com',
convertDetectedLanguage: (lng) => {
const lngMap = {
'zh-CN': 'zh', 'zh-TW': 'zh', 'zh-HK': 'zh',
'en-US': 'en', 'en-GB': 'en'
};
return lngMap[lng] || lng;
}
}
优雅切换
async function gracefulLanguageSwitch(targetLang) {
const app = document.getElementById('app');
app.style.opacity = '0.5';
app.style.pointerEvents = 'none';
try {
await loadLocaleMessages(targetLang);
i18n.global.locale.value = targetLang;
localStorage.setItem('user-lang', targetLang);
} catch (error) {
} finally {
setTimeout(() => {
app.style.opacity = '1';
app.style.pointerEvents = 'auto';
}, 300);
}
}
复杂场景处理
复数规则
不同语言复数规则差异巨大(如俄语有 3 种,阿拉伯语有 6 种)。i18next 内置 CLDR 规则。
{
"ru": {
"translation": {
"key_one": "{{count}} книга",
"key_few": "{{count}} книги",
"key_many": "{{count}} книг",
"key_other": "{{count}} книг"
}
}
}
性别处理
{
"fr": {
"welcome": {
"male": "Bienvenu, {{name}} !",
"female": "Bienvenue, {{name}} !",
"other": "Bienvenue, {{name}} !"
}
}
}
日期与数字格式化
使用 Intl API 或库内置格式化,避免手动拼接。
const formatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
formatter.format(new Date());
const price = 1234567.89;
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price);
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price);
RTL 布局支持
HTML 属性
<html lang="ar" dir="rtl">
<body>
</body>
</html>
CSS 逻辑属性
使用 inline-start/inline-end 替代 left/right。
.sidebar {
float: left;
margin-right: 20px;
}
.sidebar {
float: inline-start;
margin-inline-end: 20px;
border-inline-end: 2px solid #ccc;
}
.navbar {
display: flex;
justify-content: space-between;
padding-inline: 20px;
}
图标翻转
.icon-arrow {
transform: rotate(0deg);
}
[dir="rtl"] .icon-arrow {
transform: scaleX(-1);
}
常见问题与优化
语言包加载失败兜底
i18next.init({
fallbackLng: 'en',
parseMissingKeyHandler: (key) => {
reportError(`Missing translation key: ${key}`);
return `[${key}]`;
},
missingKeyHandler: (lng, ns, key, fallbackValue) => {
const defaultValue = i18next.getResource('en', ns, key);
return defaultValue || fallbackValue || key;
}
});
内存泄漏处理
onUnmounted(() => {
if (loadedLocale && loadedLocale !== 'zh') {
i18n.global.removeLocaleMessage(loadedLocale);
}
});
SEO 优化
<link rel="alternate" hreflang="zh" href="https://example.com/zh/home" />
<link rel="alternate" hreflang="en" href="https://example.com/en/home" />
<link rel="alternate" hreflang="x-default" href="https://example.com/" />
缓存策略
backend: {
loadPath: '/locales/{{lng}}.json?v={{version}}',
queryStringParams: { version: '1.2.3' }
}
自动化与工具
自动扫描硬编码
对接翻译平台 API
集成 Lokalise、Crowdin 等平台,实现 CI/CD 自动同步。
VS Code 插件
推荐 i18n Ally,支持实时预览、键值跳转、缺失高亮。
{
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.displayLanguage": "zh",
"i18n-ally.enabledFrameworks": ["vue", "react"]
}
TypeScript 类型安全
import 'react-i18next';
import zh from './locales/zh.json';
type Resources = typeof zh;
declare module 'react-i18next' {
interface CustomTypeOptions {
resources: Resources;
strictKeyChecks: true;
}
}
灰度发布
总结
- 保持 Key 语义化:使用
user.profile.saveButton 而非 text1。
- 提供上下文:在翻译平台备注文案用途,避免歧义。
- 尊重文化习惯:注意颜色、图标、图片的本地化差异。
前期架构设计好,后期能节省大量维护成本。避免等到支持第 8 种语言时才重构,届时将面临巨大的工作量。
相关免费在线工具
- curl 转代码
解析常见 curl 参数并生成 fetch、axios、PHP curl 或 Python requests 示例代码。 在线工具,curl 转代码在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online