做过前端国际化的同学应该都有同感:这个需求刚提出来的时候,大家普遍觉得“不就是把文案抽出来换一下吗”,可真到了落地阶段,你会发现事情远没那么简单。你既要处理语言包的工程化组织,又要考虑运行时切换、异步加载、复数语法、文本截断、日期格式本地化,甚至还要面对老项目里成千上万条散落文案的迁移问题。这篇文章我就从方案设计的角度,把一个完整的前端国际化落地过程拆开讲清楚,涵盖技术选型、语言包结构、自动化提取、动态切换、SSR/微前端特殊场景,以及线上才会暴露的各类隐蔽问题,争取让你看完之后能直接照着一套可靠思路去实施。
1. 国际化不是把文案换成变量:先定义清楚你面对的问题域
1.1 从一次“硬编码”翻车事故说起
我之前接手过一个管理后台项目,第一版做的时候只支持中文。业务跑了大半年,突然说要开放英文版,当时大家第一个反应就是——把所有写死在组件里的字符串抽出来,换成t('xxx')不就行了。
结果真动手之后才发现,项目里文案散落的形态远比想象中复杂:
// 最普通的场景 const title = '确认删除该用户?' // 带变量的场景 const message = `共有 ${count} 条记录,确定要清空吗?` // 带逻辑的场景 const tip = count > 1 ? '已选中多个文件' : '已选中一个文件' // 藏在第三方组件里的场景 <DatePicker placeholder="请选择日期" /> // 藏在HTML属性里的场景 <img src="logo.png" alt="公司Logo" /> // 藏在后端返回里的场景(后端拼好的错误提示) const errorMsg = res.data.message这还只是静态层面的问题。真正让人头疼的是,当语言包从“一个JSON文件”膨胀成“几百个模块的几百个Key”之后,Key的命名规范、语言包的加载方式、某个Key缺失时的兜底策略、以及产品文案修改后的同步流程,每一件事都值得被当成独立问题来设计。
所以做国际化方案,第一步不是选库,而是把这个问题域画清楚:你是在解决“文案替换”,还是在解决“一套可持续运转的多语言内容管理体系”。前者半天搞定,后者才值得好好设计。
1.2 一套完整方案要覆盖的四个环节
我习惯把前端国际化拆成下面四个环节来审视:
| 环节 | 要解决的问题 | 常见方案 |
|---|---|---|
| 文案提取与维护 | 散落在代码里的中文字符串如何变成结构化语言包 | i18next-scanner、babel-plugin-react-intl、手动维护 |
| 语言包组织与加载 | 多语言文件如何按模块拆分、按需加载 | 按路由/模块拆包,Vite/Webpack动态import |
| 运行时渲染与切换 | 文案如何根据当前语言渲染,切换后如何即时生效 | vue-i18n、react-i18next、ICU MessageFormat |
| 多语言配套能力 | 日期、数字、货币、复数、RTL布局等如何跟随语言变化 | Intl API、CSS逻辑属性、dir属性切换 |
这四个环节不是孤立的,选型时任何一个环节的决策,都会反过来约束其他环节。比如你选了手写JSON语言包,那自动提取就要配套好;你选了按需加载,那语言包就不能全量打进主包;你想让切换语言不刷新页面,那状态管理就得考虑怎么触发全量重渲染。下面我会按这个框架逐个展开。
1.3 先定“业务半径”:全量国际化还是渐进式改造
这里的“业务半径”指的是:你要一次性把整个项目改成多语言,还是先让一部分核心链路跑通?
我的建议是,除非是全新项目,否则一定先做渐进式国际化。做法是先搭好i18n运行时框架和语言包目录结构,然后选择一条核心业务流程(比如登录→列表→详情)做完整切换,其他模块维持中文不动。这样有几个好处:
- 技术选型可以在小范围内快速验证,翻车成本低
- 产品能尽早确认英文文案的语境是否准确,越早发现用词偏差,返工量越小
- 老项目里文案清理是一个长期工程,渐进式改造允许你把优先级低的历史包袱放到后面处理
渐进式改造中有一个技术细节需要注意:未国际化的中文文案必须和国际化文案共存。实践中我会约定,未改造模块继续由产品手动维护中文,改造模块统一从语言包读取。为了避免遗漏,可以在语言包兜底配置里做处理——把中文当作默认语言,任何Key的英文缺失时自动回退到中文,这样至少不会出现白屏或Key名裸奔的情况。
2. 技术选型:vue-i18n、react-i18next,还是自己写
2.1 几个主流方案的边界对比
市面上的i18n方案很多,但如果只考虑Vue和React两个生态,真正值得对比的就三个方向:vue-i18n、react-i18next(底层是i18next)、以及基于ICU MessageFormat的formatjs(react-intl)。
| 对比维度 | vue-i18n | i18next / react-i18next | react-intl (formatjs) |
|---|---|---|---|
| 框架绑定 | 仅Vue | 框架无关,React/Vue/原生JS都能用 | 仅React |
| 复合语法(复数/选择) | 支持,但能力弱于ICU | 通过i18next的插件支持部分ICU | ICU完整支持 |
| 运行时切换 | 原生支持,全局响应式 | 原生支持,有Suspense方案 | 支持,需配合Provider |
| 语言包加载 | 支持按需异步加载 | 支持按需异步加载,且可以分层组合 | 支持 |
| 类型提示 | 较完善(可生成类型) | 较完善 | 完整,有类型安全 |
| 社区与生态 | Vue体系内最佳 | 跨框架场景生态最丰富 | React社区标准之一 |
如果你的项目“纯Vue”或者“纯React”,选对应框架内的主流方案基本不会错。如果你的项目是微前端架构、或者一个工程里同时有Vue和React应用并存,那i18next的优势就非常明显——语言包和i18n核心可以在不同子应用间共享,切换语言时通过自定义事件通知所有子应用重渲染,这是“各自为政”导致语言不统一的解药。
2.2 为什么不建议自己封装一个useI18n
很多团队觉得引入一个库太“重”,想自己写一个全局store,里面放一个language变量和t(key)函数,几十行代码搞定。这个思路对于“只有十个页面、五个语言Key”的工具型页面确实够用,但一旦规模上来,你会发现自己慢慢在重造轮子:
- 复数支持:英文里“1 item”和“2 items”是不同的,中文没有这个区别,手写逻辑很容易漏
- 变量插值的转义规则:文案里包含HTML或特殊字符时,安全的渲染方式需要考虑
- Key缺失时的告警和兜底策略:线上环境某个Key丢了,是静默显示Key名还是回退到另一种语言
- 语言包按需加载:手动实现的话,你需要自己管理异步加载状态和渲染时机
- 日期/数字本地化的配合:这通常要单独封装,和翻译库的联动需要自行设计
当然,自己写能换来一定的灵活性和“零依赖”心理满足感。但从我多年改造经验来看,i18n是一个跨页面、跨模块、跨团队的基础设施,标准化方案的价值在于约束所有人的行为模式。一旦每个人都可以自由地往自己的store里塞语言字段,后续维护会迅速失控。
2.3 老项目接入时的关键技术判断
老项目做国际化改造,最常见的问题就是项目里既有Vue 2又有Vue 3组件,或者既有React类组件又有函数组件。这种情况下选型时必须先确认:
- 框架版本兼容性:vue-i18n v8对应Vue 2,v9+对应Vue 3,不能搞混
- 是否使用Options API:vue-i18n兼容Options API的
this.$t,新项目用法不同 - 是否有SSR/微前端场景:如果有,务必优先考虑i18next这类框架无关方案,或者在每个子应用里设计好独立实例的通信机制
我这里说一个共性的建议:无论选什么库,先把语言包的数据结构设计好,再谈用哪个库渲染。语言包本质上是一个纯数据层的东西,和View层解耦之后,将来即使换了渲染方案,语言包仍然可以复用。
3. 语言包设计是方案的地基:Key命名、命名空间与ICU语法
3.1 语言文件结构:从“单JSON”到“命名空间拆分”
很多人一开始会把所有文案塞进一个zh.json和en.json,几十个Key的时候还好,几百个Key的时候文件就开始失控了。我的实践是按业务模块或路由域来拆分语言文件,每个模块独立成一个JSON,然后通过命名空间(namespace)来组织:
// 目录结构(以i18next为例) /locales /zh-CN common.json // 通用文案:按钮、确认弹窗、表格空状态 auth.json // 登录注册模块 dashboard.json // 仪表盘模块 settings.json // 设置模块 /en-US common.json auth.json dashboard.json settings.json这样设计有几个直接好处:
- 构建时可以按命名空间做代码分割,配合动态import实现“访问某个路由时才加载对应语言包”
- 不同团队维护不同业务模块时,可以避免Key冲突和多人编辑同一个文件的冲突
- 翻译管理平台(TMS)对接时能按模块同步,某个模块的文案更新不会影响其他模块
3.2 Key的命名,是团队契约
Key命名这件事,看起来是小事,但实际决定了语言包的可维护性。我见过的几种风格:
- 中文Key:
"确定": "Confirm"——最容易写错且不推荐 - 语义化英文Key:
"common.confirm": "Confirm"——可读性好,推荐 - 带模块前缀的语义化Key:
"auth.login.submit": "Sign in"——最推荐
推荐命名规范是[模块].[子模块].[动作/描述],例如:
{ "auth": { "login": { "title": "Login", "submit": "Submit", "forgotPassword": "Forgot your password?" } }, "common": { "actions": { "confirm": "Confirm", "cancel": "Cancel" } } }另外,Key的层级不要过深,我见过嵌套5层以上的语言包,看起来结构清晰,但查找和引用时极不友好,建议控制在三层以内。
3.3 插值、复数与上下文:为什么要用ICU MessageFormat
普通字符串替换只能解决“把变量拼进句子”这一种场景。写文案的人很快会发现,真正麻烦的是英文的单复数和选择性表达:
英文说“你有3条新消息”,和说“你有1条新消息”,动词和名词形态都不同。中文没有这个困扰,导致很多中文背景的团队在做英文版时直接把变量拼进字符串,最终在线上出现“1 new messages”这种低级错误。
ICU MessageFormat可以优雅解决这个问题:
# 用react-intl或i18next的ICU插件 "notifications": "你有 {count} 条新消息", "notifications_other": "You have {count} new messages", "notifications_one": "You have {count} new message"i18next原生语法也支持复数:
{ "newMessages": "You have {{count}} new messages", "newMessages_one": "You have {{count}} new message", "newMessages_zero": "You have no new messages" }使用时的关键在于:不同的语言,复数的形态数量不同。中文只有“其他”一种形式,英文有“单数/复数”两种,而俄语、阿拉伯语的复数规则更复杂。库帮你做的正是这套规则映射,你只需要提供对应后缀的文案。
此外,还有一种常见场景是“根据性别/数量选择文案”,ICU的选择语法比在代码里写if/else更干净:
# 伪代码:根据字段选择 "orderStatus": "{status, select, pending {Pending} paid {Paid} shipped {Shipped} other {Unknown}}"所以,当你的项目要支持英文和其他欧洲语言时,强烈建议不要选纯字符串模板方案,直接上支持ICU语法的库。如果现在不想上ICU,至少选i18next并预留插件能力,后续补上不费劲。
3.4 与后端/服务端模板国际化共存的Key设计
从热搜词里可以看到“thymeleaf国际化”这个关联词,说明很多项目里前端国际化并不是孤立的,后端还可能存在Java(Spring Boot + Thymeleaf)渲染页面的场景,或者后端接口会返回错误码和错误消息。
这里有一条重要原则:前后端国际化Key体系统一设计,但运行时彼此隔离。
- 对于后端模板直出的场景(比如Thymeleaf),前端的语言包Key如果和后端保持一致,可以共用一份翻译资源,但需要注意前端和后端语言的切换状态要通过Cookie或Header统一同步
- 对于后端返回错误码的场景,理想情况是后端只返回错误码(如
INVALID_PARAM),前端根据错误码映射成多语言文案。这样后端不必感知当前语言,前端在国际化上更有掌控力 - 如果后端直接返回了拼好的错误消息,前端就无法翻译——这属于架构坑,应在接口设计阶段规避
我经历过的一次坑:某个接口在业务异常时返回msg: "密码错误",前端多语言做了一半才发现这段错误是后端拼好的。最后只能让后端改成返回错误码,前端再根据错误码表来映射,这是典型的“前期接口契约没定义好”。
4. 工程化落地:语言包从代码里“挖”出来,比手写可靠得多
4.1 自动扫描:i18next-scanner与babel插件
语言包的维护是国际化方案里工作量最大、也最容易出错的环节。纯靠人工去维护JSON文件,几乎必然会遇到“代码里加了新文案但忘了加语言包”“某个Key拼错了”“某个Key在语言包里废弃了没人清理”的问题。
所以我会把自动化扫描当成整个方案的第一优先级去落实。以i18next-scanner为例:
// i18next-scanner.config.js module.exports = { input: [ 'src/**/*.{js,jsx,ts,tsx,vue}', // 忽略不需要扫描的文件 '!src/**/*.spec.{js,jsx,ts,tsx}', '!src/locales/**', ], output: './src/locales', options: { func: { list: ['tl', 'i18next.t', 'i18n.t', '$t'], extensions: ['.js', '.jsx', '.ts', '.tsx', '.vue'] }, lngs: ['zh-CN', 'en-US'], ns: ['common', 'auth', 'dashboard'], defaultLng: 'zh-CN', defaultNs: 'common', resource: { loadPath: 'src/locales/{{lng}}/{{ns}}.json', savePath: 'src/locales/{{lng}}/{{ns}}.json' }, keySeparator: '.', nsSeparator: ':', interpolation: { prefix: '{{', suffix: '}}' } } }配置好之后,在代码里写文案的方式就统一成了:
// 以前 const text = '确认删除?' // 现在 const text = t('common.confirmDelete')然后运行扫描命令:
npx i18next-scanner --config i18next-scanner.config.js它会把源码里所有通过t()调用的Key自动提取、合并到对应的语言包文件里。如果一个Key在英文文件里缺失,扫描器还可以通过配置自动用中文翻译占位,后续提交给翻译即可。
4.2 扫描兜底:还是会有漏网之鱼
自动扫描能解决“Key统一注册”的问题,但解决不了“有人绕过t()直接写死文案”的问题。这是老项目里必然存在的现象,也是团队规范落地中最顽固的部分。
我的处理思路是分两层:
- 扫描检查兜底:在CI里加一个检查任务,扫描所有源码文件,如果发现字符串字面量里包含中文/日文/韩文等非ASCII字符,且不在白名单内(比如测试用例里的断言数据),就直接让构建失败
- 运行时兜底:语言包尾部追加一个
notTranslated标记字段,开发环境里打开一个调试面板,可以高亮所有“未翻译”的Key对应的DOM节点,提醒开发者还有没改造完的地方
这两个兜底机制组合起来,基本上能把老项目的漏网之鱼控制在一个很小的范围内。
4.3 对接TMS翻译管理平台:多语言不是“一次翻译”的事
很多中小团队做国际化的误区是:把中文文案翻译成英文,上线,就结束了。但真实业务中,文案是每天都在变的。今天加一个活动入口,明天改一句提示语,产品每次改动都要重新翻译。
所以,语言包必须和翻译流程打通。具体来说有两种做法:
- 轻量做法:语言包JSON直接放在代码仓库里,产品/运营需要改文案时提PR,翻译文件通过外部翻译服务(比如Google翻译API或人工翻译)生成,之后手动合并
- 规范做法:引入TMS(Translation Management System),例如Crowdin、Localize、或自建翻译管理后台,语言包通过CLI/SDK自动拉取和推送,CI在每次构建前从TMS拉取最新的翻译文件
TMS方案的好处是:非技术人员可以方便地在界面上修改文案,翻译文件更新后自动同步到代码仓库,版本管理也不容易乱。缺点是初期需要投入一些接入成本。
按照团队规模来取舍:如果你只是服务一个小产品,轻量做法完全够用;如果产品要长期在多语言市场运营,那不要犹豫,直接上TMS。
4.4 在CI阶段做语言包校验
语言包最容易出现的问题就是某个Key在zh-CN里有、在en-US里缺失。这种缺失在开发环境往往不暴露(因为默认语言是中文),一旦切到英文环境,界面上到处都是Key名或空字符串,体验极差。
我在CI里加了这样一套校验:
# 伪代码:检查所有语言包的Key集合是否一致 node scripts/check-locales.mjs --lngs zh-CN,en-US核心逻辑是:读取所有语言命名空间下的JSON文件,递归对比Key集合,输出差异,如果有缺失就令CI失败。这个脚本还可以扩展成检查“非法Key”(比如含中文字符的Key名)、“Key命名规范”等。
5. 动态切换、首屏加载与SSR/微前端:最容易翻车的三个场景
5.1 语言动态切换时,组件为什么会出现“闪一下旧文案”
这是一个很经典的问题。使用vue-i18n或i18next时,切换语言本质上是修改了全局的locale变量,然后所有依赖t()的组件都会重新渲染。但如果你的组件里有“计算好的、缓存住的结果”,就可能出现闪旧文案的情况。
举个例子:
// 错误示范:文案被缓存到setup之外 let cachedTitle = null function getTitle() { if (!cachedTitle) { cachedTitle = i18n.t('dashboard.title') } return cachedTitle }或者:
// 错误示范:用useMemo缓存了翻译结果 const title = useMemo(() => t('dashboard.title'), []) // 注意:依赖数组为空正确做法是让t放在渲染函数内部,并确保locale的变化能触发重渲染:
// React函数组件里,确保useTranslation处于组件顶层 const { t } = useTranslation() const title = t('dashboard.title') // i18n实例变化后,useTranslation会触发重渲染在Vue里:
<script setup> import { useI18n } from 'vue-i18n' const { t } = useI18n() // locale变化时,模板里的t会自动更新 </script> <template> <div>{{ t('dashboard.title') }}</div> </template>如果你在用事件驱动的跨应用通信(比如微前端里切换语言),要注意通知时机:先更新全局语言状态,再派发事件,最后让各应用做重渲染。顺序错了就会出现“事件到了但语言状态还没更新”的竞态问题。
5.2 首屏语言包按需加载:别把所有语言打进主包
一个常见的性能隐患是:语言包文件直接在入口处同步import进来,结果用户访问一个中文站点时,英文、日文、法文等所有语言的JSON都被一次性加载了。
通过动态import可以很容易地实现按需加载:
// i18next动态加载语言包 i18n.on('languageChanged', async (lng) => { // 只加载当前语言的common命名空间,其他命名空间按需加载 const resources = await import(`../locales/${lng}/common.json`) i18n.addResourceBundle(lng, 'common', resources) })结合路由懒加载,可以做到:访问首页只加载首页对应的语言包,切到设置页面时再加载设置模块的语言包。这个优化在语言包变大之后收益非常明显。
用Vite做构建时,如果不想为每个语言文件单独配置manualChunks,可以让动态import函数显式声明所有可能的模块路径,让Vite/Webpack能正确做拆包:
// vue-i18n按需加载示例 const loadLocaleMessages = async (locale) => { const messages = await import(`./locales/${locale}.json`) return messages.default }需要注意的是:动态import的变量路径不能拼得太“活”,打包器必须能在编译期枚举出可能出现的目标文件。所以尽量保证路径前缀固定、变量只出现在最后一段,否则打包时会提示无法解析。
5.3 SSR和微前端场景下的i18n实例隔离
SSR(服务端渲染)场景下,最常见的错误是:把i18n实例做成了模块级单例。服务端是多请求并发的,如果A请求的语言是中文、B请求是英文,而它们共享了同一个i18n实例,那就会串语言。
正确做法是:每个请求创建一个新的i18n实例(或者至少确保locale是按请求维度的),例如:
// 伪代码:SSR下每个请求创建独立的i18n实例 function createI18nForRequest(locale) { const instance = i18n.createInstance({ lng: locale, resources: loadResourcesFor(locale) }) return instance }Vue SSR里,这一步会在createApp之前调用:每次请求进入时,创建一组全新的{ app, i18n, router, store },避免跨请求状态污染。
微前端场景下的问题则是另一个方向:多个子应用可能用了不同的i18n库或不同版本,切换语言时必须做到“一处切换,处处更新”。我的实践经验是:
- 主应用定义一个
setLocale的全局事件,或通过window对象上的公共方法下发语言变更通知 - 每个子应用监听这个事件,将事件里的locale映射到自己的i18n实例
- 语言包资源如果允许,统一放在主应用或共享CDN上,避免每个子应用各自维护一套翻译文件
5.4 微前端之间语言状态不一致的经典坑
我见过一个微前端项目,主应用是React,子应用A是Vue 2,子应用B是React。最开始每个应用各自用自己那套i18n方案,结果切语言时经常出现主应用已经换成英文、子应用还是中文的情况。
后来统一改成了i18next + 全局事件同步,核心逻辑类似:
// 主应用切换语言时 const changeLanguage = (lng) => { i18n.changeLanguage(lng) window.dispatchEvent(new CustomEvent('app:locale-changed', { detail: lng })) } // 每个子应用注册监听 window.addEventListener('app:locale-changed', (e) => { i18nInstance.changeLanguage(e.detail) })看似简单,但这个方案能跑通的关键在于:语言包的Key命名空间必须全局统一。如果每个应用用不同的Key体系,即使语言切换事件送达,也无法保证界面文案的用词一致。
6. 线上环境才暴露的坑:RTL布局、日期数字与文案截断
6.1 RTL布局:不只是加一个dir="rtl"
如果你的目标语言里包含阿拉伯语、希伯来语,那布局上的适配就会成为硬需求。很多人以为RTL适配就是给<html>加dir="rtl",但其实远远不够。
这个属性确实能让大部分文本的默认对齐方向翻转,但你的布局如果用了Flexbox、Grid或绝对定位,就可能出现视觉错乱。以前写死left/right的样式在RTL下需要反过来。现代解决方案是使用CSS逻辑属性:
/* 以前 */ .title { margin-left: 10px; text-align: left; } /* 逻辑属性写法 */ .title { margin-inline-start: 10px; text-align: start; }margin-inline-start会跟随dir自动切换方向,不需要为每个语言方向写两套样式。
另外还有几个常用注意事项:
- 图标的左右箭头方向要跟着RTL翻转(可以用CSS的
transform: scaleX(-1)做整体翻转) - 轮播图滑动方向在RTL下也应反向
- 富文本编辑器里的对齐按钮图标需要做镜像处理
- 日期/时间排版里,阿拉伯语环境还涉及数字字形(Eastern Arabic numerals)和习惯时间格式
6.2 日期、数字、货币的本地化:让Intl API干它该干的活
文案翻译属于i18n库的职责,但日期、数字、货币这些格式化工作,不要交给翻译库,直接用浏览器内置的Intl API:
const rtf = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' }) console.log(rtf.format(-1, 'day')) // 昨天 const nf = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) console.log(nf.format(12345.67)) // $12,345.67受语言影响的格式包括但不限于:
| 类型 | 中文习惯 | 英文习惯 | 阿拉伯语习惯 |
|---|---|---|---|
| 日期 | 2025年3月15日 | Mar 15, 2025 | 15/3/2025 |
| 时间 | 下午 2:30 | 2:30 PM | 14:30 |
| 数字千分位 | 12,345 | 12,345 | ١٢٬٣٤٥(东阿拉伯数字) |
| 货币 | ¥1,234.56 | $1,234.56 | ر.س 1,234.56 |
如果你需要的是组件级封装,可以基于这些Intl API封一层小的工具函数,让业务里使用统一入口,方便后续加缓存或改格式。
另一个容易忽略的点是时区。如果你的产品目标用户分布在多个时区,日期时间的存储和展示要格外小心。通常的规范是:后端存储UTC时间,前端在展示时用Intl.DateTimeFormat按照用户本地时区(或产品选择的时区)格式化,避免“服务端返回的时间直接显示”带来的8小时偏差。
6.3 文案长度变化导致的布局破坏和截断问题
这是我在多个项目里踩过的坑:中文文案短小精悍,翻译成英文或德文后长度可能膨胀50%以上。按钮、标签、表格列、侧边导航都会因此出现换行错乱、挤压变形甚至样式崩坏。
一些实用的处理策略:
- 表格列宽不要写死,尤其是操作列,英文状态下“Edit / Delete”两个词可能就比中文“编辑/删除”宽出一倍
- 按钮文案要设最小宽度而不是固定宽度,容器允许文案换行时考虑
white-space: nowrap配合收缩 - 长文案截断时别用
text-overflow: ellipsis硬来,可以在可控容器内做多行截断,还是保留完整可读性优先 - 设计阶段就考虑“文案最长语言”的宽度,比如德语在某些场景下会非常长,UI走查时不要只看中文和英文
6.4 搜索引擎对多语言站点的处理基础
虽然这不是传统“前端国际化库”的范畴,但只要是面向公网的多语言站点,就避不开搜索引擎。国际化的实施要顺手把多语言SEO基础做好:
- 使用
<link rel="alternate" hreflang="zh-CN" href="...">和hreflang="en-US"等属性,告诉搜索引擎不同语言的页面地址 - 保持URL结构的语言标记清晰,推荐
/zh-CN/、/en-US/这种路径前缀方式,比用Cookie和Session判断语言对搜索引擎更友好 <html lang="en-US">属性必须正确设置,这不仅利于SEO,也影响屏幕阅读器等无障碍工具的语言识别
这些基础工作不需要写太多代码,但遗漏了会影响整体的国际化方案完成度。
7. 线上实战:我遇到过的3个经典踩坑案例
7.1 案例一:切换语言后图表组件不更新
某个项目里用了ECharts,图标里的图例和Tooltip是通过配置项传入的。切换语言后,图表组件不会因为locale变化而重新渲染,因为ECharts实例是独立于Vue/React响应系统之外的对象。
排查思路:首先确认ECharts初始化代码的位置。通常在mounted或useEffect里,而传入的配置项里的文案已经通过t()解析成了具体的字符串。locale变化后,组件虽然会重渲染,但如果ECharts实例在内部维护了自己的状态并不会自动更新,就会出现“页面文案已经切换,图表还是旧语言”的现象。
解决方式是在locale变化时主动销毁并重建图表实例,或者在切换事件的回调里调用chart.setOption,传入新的文案配置。
7.2 案例二:语言包Key重复导致英文环境出现中文
这个问题特别隐蔽。语言包是多人维护的,有人在en-US/common.json里把一个Key的翻译直接从参考译文复制过来,但有部分复制时没切换到英文文件,导致英文文件里出现了中文字符串。单看文件很难发现,只有线上英文用户反馈“页面里怎么还有中文”。
此后我在扫描校验脚本里增加了一项检查:扫描语言文件里是否含有目标语言范围之外的字符。比如en-US文件里不应该出现中文字符,zh-CN文件里不应该出现阿拉伯文。通过正则跑一遍,基本能拦截这种低级但影响观感的问题。
7.3 案例三:服务端返回的富文本被当纯文本转义
早期接入i18n时,我在语言包里写了"terms": "<a href='/terms'>Terms of Service</a>"这种带HTML的文案,然后在组件里直接<span>{t('terms')}</span>输出。结果页面显示的是<a href='/terms'>Terms of Service</a>的纯文本。
这背后是XSS安全策略在起作用——框架默认转义了所有输出。如果业务确实需要富文本翻译,要注意区分“使用富文本组件的翻译”和“纯文本翻译”,不能让翻译人员随意把HTML塞进字符串。常规做法是先翻译成纯文本,再在组件层通过富文本渲染方式输出:
// React里使用Trans组件处理带链接的翻译 import { Trans } from 'react-i18next' <Trans i18nKey="terms"> <a href="/terms">Terms of Service</a> </Trans>安全性上也要提醒:不要直接给翻译内容开后门支持任意HTML标签,容易被注入攻击。
8. 落地清单:一套可靠的前端国际化方案应该长这样
如果你现在准备从零开始做国际化,或者打算重构现有的多语言方案,可以直接参考下面这份清单来逐项对照:
- 项目里所有用户可见文案统一走
t()或对应框架的翻译函数,禁止硬编码(通过CI扫描强制约束) - 语言包按模块拆分,每个模块独立JSON,命名空间前缀统一
- 语言包Key命名规范为
模块.子模块.动作/描述,层级不超过三层 - 支持复数、上下文选择等语法,优先选择支持ICU MessageFormat的方案
- 语言包按需加载,当前语言和当前模块的语言文件不进入主包
- 支持运行时无刷新切换语言,切换后所有组件(包括图表、表格、第三方组件)同步更新
- 日期、数字、货币通过Intl API格式化,不手工拼字符串
- RTL语言(如阿拉伯语)额外处理布局镜像,使用CSS逻辑属性而非物理定位
- CI中加入语言包Key一致性校验、语言文件字符集校验、未国际化文案扫描
- 老项目渐进式改造,确保默认语言完整,其他语言缺失时回退到默认语言
- 若涉及SSR,确保i18n实例按请求维度独立创建,避免并发串语言
- 若涉及微前端,统一全局语言切换事件,各子应用监听并同步更新
我自己在多个项目里落地下来,最大的体会是:技术方案本身并不复杂,复杂的是把整个团队的工作习惯约束到一个统一的规范上。自动扫描、CI校验、TMS对接,每一环本质上都是在对冲“人类会犯错”这一事实。你在设计阶段投入在工程化上的时间,最终都会以“少熬夜修线上文案错乱”的形式回报给你。
最后再分享一个实际经验:如果你在做方案选型时左右为难,就先写一个“最简可行版本”——集中精力跑通一条核心业务链路的动态切换和语言包按需加载,确认团队能接受这种方式之后再全量铺开。很多问题只有真正跑在业务里才能被发现,纸面上的架构讨论永远替代不了线上用户的真实反馈。