跳到主要内容前端国际化实战:i18n 选型、架构设计与 RTL 布局避坑 | 极客日志JavaScriptNode.jsSaaSReact Native大前端
前端国际化实战:i18n 选型、架构设计与 RTL 布局避坑
综述由AI生成前端国际化开发涉及资源管理、动态加载及 RTL 布局适配。对比了 i18next、vue-i18n 等主流方案,详解了 JSON 规范、复数处理及 SEO 优化策略。重点分析了 SSR 水合问题、内存泄漏风险及自动化翻译流程,提供了一套完整的工程化落地指南,帮助团队避免硬编码带来的维护成本。
ServerBase4 浏览 前端国际化实战:i18n 选型、架构设计与 RTL 布局避坑
引言
每次看到项目里那种 if (lang === 'zh') { return '你好' } else { return 'Hello' } 的代码,我都想穿越回去给当时的自己两巴掌。这玩意儿就跟在代码里写死密码一样,当时觉得简单快捷,等产品经理突然要求支持泰语和越南语的时候,你就知道什么叫技术债利滚利了。
我记得最惨的一次是 18 年做跨境电商项目,刚开始就中英双语,变量名起得随心所欲:text1、text2、btnText… 结果三个月后业务增长,要加西班牙语、法语、德语。对着满屏的乱码和错位布局,差点把键盘吃了。最绝的是德语,那个长度你懂的,'设置'在德语里叫'Einstellungen',按钮直接撑爆,UI 看我的眼神就像在看一个杀人凶手。
现在的项目要是没 i18n(国际化),就像吃泡面没调料包——能吃,但索然无味。特别是做 SaaS 产品的,上来第一句话就是'支不支持多语言',你要说没有,人家转头就走。
所以今天咱们聊聊怎么把 i18n 这玩意儿整明白,让你下次遇到这种需求时能优雅地处理。
什么是 i18n
i18n 这个看起来像是密码学的缩写,其实就是 Internationalization(国际化)的偷懒写法——I 和 n 中间有 18 个字母。同理还有 L10n(Localization,本地化),中间 10 个字母。
这俩词儿经常被人混着用,但其实有微妙差别。国际化(i18n)是'让软件有能力支持多种语言和地区',是技术架构层面的;本地化(L10n)是'真正去翻译内容、调整格式、适配文化习惯',是业务内容层面的。简单说,i18n 是搭舞台,L10n 是上台唱戏。
现在的前端框架里,这玩意儿已经长得非常成熟了。Vue 有 vue-i18n,React 有 react-i18next,Angular 自己内置了 $locale。它们的核心思路都差不多:把文字抽离成资源文件,运行时根据当前语言动态替换。
主流方案对比
市面上方案多得让人眼花,我挑几个主流的给你唠唠,省得你选型时抓瞎。
i18next:老牌劲旅
这货可以说是 i18n 界的 jQuery,老牌、稳定、生态丰富到让你怀疑人生。不光支持前端,Node.js、React Native、甚至 Flutter 都能用。插件系统更是离谱,你要啥功能基本都有现成的。
import i18next from 'i18next';
i18next.init({
lng: 'zh',
fallbackLng: 'en',
resources: {
zh: {
translation: {
welcome: '欢迎回来,{{name}}!',
items: '你有 {{count}} 条新消息'
}
},
en: {
translation: {
welcome: ,
:
}
}
},
: {
: ,
},
: ,
:
});
i18next.(, { : });
i18next.(, { : });
'Welcome back, {{name}}!'
items
'You have {{count}} new messages'
interpolation
escapeValue
false
pluralSeparator
'_'
nsSeparator
':'
t
'welcome'
name
'张三'
t
'items'
count
5
看到那个 {{name}} 没?这就是插值,比字符串拼接优雅一万倍。而且 i18next 自带复数处理,英文里 count 为 1 和不为 1 时自动切换单复数,不用你写 if-else。
vue-i18n:Vue 亲儿子
如果你是 Vue 党,这个基本是标配。Composition API 支持、TypeScript 支持、甚至支持 <i18n> 单文件组件块,直接在 .vue 文件里写翻译。
<!-- 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 版本时那些坑,我踩得膝盖都青了。最大的 breaking change 是 $tc(复数方法)被合并到 t 里了,旧项目迁移时满屏报错。还有 getChoiceIndex 这种复数规则自定义方式也变了,升级前一定要看迁移文档。
react-i18next:React 界的扛把子
React 玩家基本都用这个,Hooks 用起来确实香。useTranslation 一调,翻译函数到手。
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']
}
});
export function withTranslation(Component) {
return function TranslatedComponent(props) {
const { t, i18n } = useTranslation();
return <Component {...props} t={t} i18n={i18n} />;
};
}
function UserDashboard() {
const { t, i18n } = useTranslation(['user', 'common']);
const [user, setUser] = React.useState(null);
const { t: checkoutT, ready: checkoutReady } = useTranslation('checkout', { useSuspense: false });
const changeLanguage = (lng) => {
i18n.changeLanguage(lng);
document.documentElement.lang = lng;
document.documentElement.dir = ['ar', 'he'].includes(lng) ? 'rtl' : 'ltr';
};
if (!checkoutReady) return <div>Loading translations...</div>;
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>
);
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserDashboard />
</Suspense>
);
}
export default App;
SSR 水合报错怎么破? 这是 Next.js 玩家的痛。服务端渲染时语言检测和客户端不一致,导致 HTML 不匹配。解决方案是强制统一语言来源,比如都从 Cookie 读,或者 SSR 时禁用自动检测,直接用 URL 参数定死语言。
formatjs:Google 出品
Google 搞的,主打轻量和标准化。如果你追求极致性能,或者做移动端 H5 怕包体积太大,可以考虑。但它生态没 i18next 丰富,很多功能要自己造轮子。
到底怎么选?
- 技术栈:Vue 就用 vue-i18n,React 就用 react-i18next,别瞎折腾
- 项目规模:小项目随便哪个都行,大项目选生态好的(i18next 系)
- 团队能力:如果团队 TS 玩得 6,选类型支持好的;如果都是老前端,选文档全的
从零搭建多语言架构
文件目录怎么摆?
locales/
zh/
common.json
home.json
en/
common.json
home.json
locales/
common/
zh.json
en.json
home/
zh.json
en.json
我推荐方案 A,因为翻译人员通常按语言工作,给他们一个文件夹就完事。而且代码里动态加载时路径规则更统一:/locales/${lang}/${namespace}.json。
JSON 资源文件编写规范
别再写 btn.submit、title.home 这种烂大街的了,毫无语义,后期维护想死。推荐用层级命名空间:
{
"user": {
"profile": {
"title": "个人资料",
"editButton": "编辑资料",
"saveSuccess": "保存成功!",
"validation": {
"nicknameRequired": "昵称不能为空",
"nicknameTooLong": "昵称不能超过 20 个字符"
}
},
"security": {
"passwordChange": {
"title": "修改密码",
"oldPasswordPlaceholder": "请输入当前密码",
"newPasswordStrength": "密码强度:{{level}}"
}
}
},
"common": {
"actions": {
"confirm": "确认",
"cancel": "取消",
"backToHome": "返回首页"
},
"status": {
"loading": "加载中...",
"error": "出错了,请重试",
"empty": "暂无数据"
}
}
}
看到没?按业务模块 > 页面 > 功能 > 具体元素的层级来,找起来快,改起来不容易漏。
动态加载语言包
千万别把所有语言打包进 JS!用户只看一种语言,你塞十几种进去,首屏直接爆炸。
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 参数:?lang=en,最 SEO 友好,搜索引擎能分别收录不同语言版本
Cookie:i18next=zh,用户选择后记住偏好,下次访问直接生效
浏览器 Header:Accept-Language: zh-CN,zh;q=0.9,第一次访问时猜用户语言
推荐组合策略:优先 URL 参数(方便分享和 SEO),其次 Cookie(记住用户选择),最后浏览器 Header(首次访问)。
detection: {
order: ['querystring', 'cookie', 'localStorage', 'sessionStorage', 'navigator', 'htmlTag'],
lookupQuerystring: 'lng',
lookupCookie: 'i18next',
lookupLocalStorage: 'i18nextLng',
caches: ['localStorage', 'cookie'],
cookieMinutes: 10080,
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);
trackEvent('language_change', { from: currentLang, to: targetLang });
} catch (error) {
toast.error('语言切换失败,请重试');
} finally {
setTimeout(() => {
app.style.opacity = '1';
app.style.pointerEvents = 'auto';
}, 300);
}
}
深水区:复数、性别和日期格式化
以为翻译就是字符串替换?Too young!自然语言的复杂度能把你 CPU 干烧。
复数地狱
英语复数简单,就单数(one)和复数(other)。但俄语有三种:one(1)、few(2-4)、many(5+)。阿拉伯语有六种!zero、one、two、few、many、other。
{
"ru": {
"translation": {
"key_one": "{{count}} книга",
"key_few": "{{count}} книги",
"key_many": "{{count}} книг",
"key_other": "{{count}} книг"
}
}
}
i18next 内置了 CLDR 规则,自动根据语言选择复数形式。但你得提供完整的翻译 key,别偷懒只写两个。
性别相关的文案处理
法语、德语、西班牙语里,形容词要跟着名词性别变。比如'欢迎来到我们的平台',如果用户资料里选了女性,法语要说'Bienvenue sur notre plateforme',男性则是'Bienvenu'。
{
"fr": {
"welcome": {
"male": "Bienvenu, {{name}} !",
"female": "Bienvenue, {{name}} !",
"other": "Bienvenue, {{name}} !"
}
}
}
时间和数字的本地化陷阱
日期陷阱:美国的 12/01/2024 是 12 月 1 日,英国人的第一反应是 1 月 12 日。千万别自己拼接字符串!
const dateStr = `${month}/${day}/${year}`;
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);
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price);
RTL(从右向左)布局
支持阿拉伯语、希伯来语时,整个世界都要反过来。不是简单的文字右对齐,是整个布局逻辑镜像。
dir="rtl"属性的魔力
HTML5 有 dir 属性,设为 rtl 后,浏览器会自动:
- 文字从右向左排
- Flexbox 和 Grid 的
start 和 end 方向反转
<html lang="ar" dir="rtl">
<body>
</body>
</html>
CSS 怎么写才不炸?
不要用物理方向(left/right),用逻辑方向(start/end):
.sidebar {
float: left;
margin-right: 20px;
border-right: 2px solid #ccc;
}
.sidebar {
float: inline-start;
margin-inline-end: 20px;
border-inline-end: 2px solid #ccc;
}
.navbar {
display: flex;
justify-content: space-between;
padding-inline: 20px;
}
PostCSS 插件自动转换:
如果你已有大量历史代码,手动改要改到明年。用 postcss-rtlcss 插件,自动把 left 转成 right,margin-left 转成 margin-right(在 RTL 模式下)。
module.exports = {
plugins: [
require('postcss-rtlcss')({
mode: 'combined',
ltrPrefix: '[dir="ltr"]',
rtlPrefix: '[dir="rtl"]',
ignorePrefixedRules: ['.fa', '.icon']
})
]
};
图标和箭头的方向尴尬
- 如果是'返回上一页'的功能箭头,应该指向右(因为 RTL 的阅读流是从右到左,'回去'就是往右)
- 如果是'下一步'的箭头,应该指向左(继续往阅读流方向走)
.icon-arrow {
transform: rotate(0deg);
transition: transform 0.3s;
}
[dir="rtl"] .icon-arrow {
transform: scaleX(-1);
}
混合文本(BiDi)处理
当中文、英文和阿拉伯文混在一起时,浏览器可能搞不清楚文字方向,导致光标乱跳、选区错乱。用 <bdi> 标签或 dir="auto":
<div class="comment">
<bdi>用户昵称可能是阿拉伯语:محمد</bdi>
<span>说:</span>
<bdi dir="auto">这里可能是混合内容:Hello مرحبا 你好</bdi>
</div>
线上爆雷后的紧急抢救指南
语言包加载失败
最社死的现场:用户打开页面,满屏 home.title、user.welcome,跟密码本似的。
i18next.init({
fallbackLng: 'en',
parseMissingKeyHandler: (key) => {
reportError(`Missing translation key: ${key} in ${i18next.language}`);
return `[${key}]`;
},
missingKeyHandler: (lng, ns, key, fallbackValue) => {
const defaultValue = i18next.getResource('en', ns, key);
if (defaultValue) return defaultValue;
return fallbackValue || key;
}
});
内存泄漏警告
动态注册的语言包没销毁,单页应用跑久了,内存里堆积了几十种语言包。
onUnmounted(() => {
if (loadedLocale && loadedLocale !== 'zh') {
i18n.global.removeLocaleMessage(loadedLocale);
}
});
SEO 优化:hreflang 标签
<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' }
}
const refreshTranslations = async () => {
i18next.services.backendConnector.backend.clear();
await i18next.reloadResources(i18next.language);
i18next.emit('languageChanged', i18next.language);
};
开发小技巧与自动化流程
自动扫描硬编码字符串
const fs = require('fs');
const path = require('path');
const glob = require('glob');
const chineseRegex = /[一-龥]+/g;
const results = new Map();
function extractFromFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
lines.forEach((line, index) => {
if (line.trim().startsWith('//') || line.trim().startsWith('*')) return;
const matches = line.match(chineseRegex) || [];
matches.forEach(match => {
const key = match;
if (!results.has(key)) {
results.set(key, { key: `auto.${generateKey(match)}`, zh: match, files: [] });
}
results.get(key).files.push(`${filePath}:${index + 1}`);
});
});
}
function generateKey(str) {
return str.slice(0, 5).replace(/\s/g, '_');
}
glob.sync('src/**/*.{js,vue,tsx}').forEach(extractFromFile);
const output = {};
results.forEach((value, key) => {
output[value.key] = { zh: value.zh, context: `出现在:${value.files.join(', ')}` };
});
fs.writeFileSync('extracted-keys.json', JSON.stringify(output, null, 2));
console.log(`提取了 ${results.size} 个待翻译条目`);
对接翻译平台 API
代码提交后自动触发翻译,第二天早上直接验收成果。主流平台(Lokalise、Crowdin、Transifex)都有 API:
name: Sync Translations
on:
push:
branches: [main]
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: node scripts/sync-i18n.js
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
VS Code 插件推荐
i18n Ally:实时预览翻译效果,键值跳转,缺失 key 高亮。再也不用反复刷新页面看效果。
配置 .vscode/settings.json:
{
"i18n-ally.localesPaths": ["src/locales"],
"i18n-ally.keystyle": "nested",
"i18n-ally.displayLanguage": "zh",
"i18n-ally.enabledFrameworks": ["vue", "react"]
}
TypeScript 类型安全
给翻译 Key 加上智能提示,手滑写错 Key 编译直接报错:
import 'react-i18next';
import zh from './locales/zh.json';
type Resources = typeof zh;
declare module 'react-i18next' {
interface CustomTypeOptions {
resources: Resources;
strictKeyChecks: true;
}
}
const { t } = useTranslation();
t('user.profile.title');
t('user.profile.titel');
灰度发布多语言功能
function shouldEnableI18n(userId) {
if (isEmployee(userId)) return true;
return hashUserId(userId) % 100 < 10;
}
function shouldEnableI18nByRegion(ip) {
const region = getRegionFromIP(ip);
return ['HK', 'SG'].includes(region);
}
结语
保持 Key 的语义化:别用 text1、label2 这种,到时候改个文案要改几十个文件,翻译人员也看不懂上下文。用 user.profile.saveButton 这种自描述的。
给翻译人员留点'活路':上下文备注一定要写清楚。比如 "confirm": "确认",要备注这是'删除确认'还是'订单确认',不然翻译出来可能完全不对。
记住,多语言不仅仅是文字替换,更是文化和习惯的尊重。颜色、图标、甚至图片都可能需要本地化。比如白色在西方代表纯洁,在东方某些场合代表丧事;竖起大拇指在有些国家是冒犯。别让老外觉得你的产品'怪怪的',不然会被笑话的,而且是真的会丢订单。
i18n 这玩意儿,前期架构设计好,后期能省你 80% 的力气。别等到要支持第 8 种语言时才想起来重构,那时候你就只能像我一样,对着满屏的 if-else 和乱码,一边改一边骂娘了。
代码写完了,去泡杯咖啡吧,顺便想想怎么跟产品经理解释为什么阿拉伯语的上线时间要比预期多两周——因为 RTL 真的能把人逼疯。☕️
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online