ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Vue3前端国际化i18n实战:三步迁移多语言,告别硬编码

Vue3前端国际化i18n实战:三步迁移多语言,告别硬编码 如果你和我一样在某个加班的深夜被产品经理一句“我们要出海了”砸中那恭喜你接下来一整周都会在“把页面上密密麻麻的中文改成英文”的边缘疯狂试探。这不是玩笑。我刚接手国际化需求时第一反应也是打开编辑器全局搜索中文把文案一条条替换成英文甚至想过写个正则批量处理。结果上线前一晚海外用户反馈说支付按钮上显示的是“Confirm”和“确 认”混在一起的半吊子文案那一刻我才真正意识到前端多语言这件事靠硬编码是干不干净的。所谓i18n全称是internationalization因为首尾字母i和n之间有18个字母所以简写为i18n。它解决的从来不只是“把中文换成英文”而是建立一套让文案、日期、数字、复数规则都能按用户语言环境自动切换的资源管理机制。今天这篇文章就围绕这个核心讲清楚我从零给一个Vue项目接入i18n的全过程包含完整的三步落地步骤、不同框架的迁移思路以及团队多人协作时最容易踩的坑。无论是后端转前端、刚接触多语言场景的初级开发还是被国际化需求追着跑的老手应该都能找到直接能用的东西。1. 硬编码之痛为什么你的多语言迟早要还债1.1 从一次海外小额支付事故说起当时我们做的是一个带会员体系的Web应用后台管理端和C端页面混在一个项目里。第一个版本为了赶上线所有中文文案直接写在Vue模板里像“确认支付”“余额不足”“订单已取消”这些都是最普通的写法。等到接海外支付渠道时产品和运营要求三天内出一个英文版我当时的做法是把模板中的中文字符串复制到翻译文件然后手动替换。听起来不难但实际跑了几个小时就发现不对劲同一个文案在好几个组件里都有有的是确认支付有的是确定支付还有写成确认付款的。翻译人员在给词条时经常对着找不到的原文发愣。真正让事情失控的是变量文本。比如“账户余额不足还差¥50”这种句子用了模板字符串拼接翻译成英文时语序完全不同“还差”和“¥50”的位置得反过来。那时候人肉拼字符串的代码大概率长这样const msg 账户余额不足还差¥${diff}英文要表达同样的意思得写成Insufficient balance, need ¥${diff} more。中文和英文的语序不同硬拼接出来的校验逻辑、占位符位置全都得跟着改。一次两次还好当页面里的动态文案有二十多处时每次版本迭代都像在埋雷。1.2 硬编码的真实成本维护、协作和信任硬编码的代价不是上线那一刻显现的而是从第二个版本开始发芽的。第一是翻译资源的不可维护性。文案藏在组件的行间、JS的判断分支里、错误提示的弹窗里运营想看一遍全部文案根本无从下手。第二是语言切换无法动态生效。用户切换语言后页面重新渲染那些写在变量里的字符串不会跟着变必须整页刷新才能看到变化体验非常割裂。第三点最致命——跨角色协作成本。海外市场同事拿着翻译表来同步翻译公司给的是一份带上下文的Excel里面的key叫payment.confirm而代码里根本没有这个概念只有散落的字符串“确认支付”。于是每次同步都要二次翻译一遍两边维护的文档和代码越走越远。后来我干脆做了个对比表把硬编码方案和i18n方案的日常维护成本列出来团队看完当场决定重构对比维度硬编码字符串i18n资源文件新增语言版本需要全局搜索所有文案并逐个替换新增一份语言包文件即可翻译协作翻译人员要进代码、看源码只需维护JSON/JS词条文件动态变量文本手动字符串拼接容易出错通过插值和占位符处理运行时切换语言必须刷新或重新拉取数据响应式更新、局部刷新文案去重重复文案散落各处统一收敛到key-value结构2. i18n的底层逻辑语言包、词条键、插值2.1 别把它当翻译工具它是文案资源管理很多前端同学习惯把i18n理解成“翻译插件”装上之后把中文字符串替换成t(xxx)就完事。这个理解太浅了。i18n的底层核心是语言包和词条键值对。语言包是一个普通对象键是稳定的业务标识值是不同语言下的文案。// locales/zh-CN.js export default { common: { confirm: 确认, cancel: 取消, }, payment: { insufficientBalance: 账户余额不足, } }// locales/en-US.js export default { common: { confirm: Confirm, cancel: Cancel, }, payment: { insufficientBalance: Insufficient account balance, } }页面里不再直接写“确认”而是写t(common.confirm)。common是模块命名空间confirm是词条键中间用点号连接。这样设计最大的优势是文案和界面结构解耦。文案改成什么、要不要针对不同地区调整措辞都是语言包文件的事组件代码完全不用动。这有点像数据库的外键设计界面只认ID不认显示文本。2.2 插值、复数规则和日期数字的本地化纯文本替换只是第一层动态内容才是i18n能打的点。同一个句子中往往有变量、数量、日期这些在不同语言里对应的规则完全不同。以vue-i18n为例插值语法是花括号加变量名// 语言包 { home: { greeting: 你好{name}, unreadMessage: 你有 {count} 条未读消息 } }页面使用时传入对应的值t(home.greeting, { name: 张三 }) t(home.unreadMessage, { count: 12 })英文语言包中对应的文案则是Hello, {name}与You have {count} unread messages。变量名一致语序可以完全不同也不会错位。这比原来你好 name的写法稳健太多。复数规则更值得单独说。中文没有严格的单复数形态但英文、俄语、阿拉伯语都有各自的复数分类。英文至少区分1和其他两种。vue-i18n的复数处理如下message: { apple: 你有 {count} 个苹果 | 你有一个苹果 | 你有 {count} 个苹果 }调用方式为t(message.apple, { count: 1 }, 1)管线符分隔的是单数和复数形态。React生态里的react-i18next也支持类似_one、_other后缀。日期和数字同样不能忽视。同一个2024/12/25在不同地区可能是12/25/2024或者25/12/2024金额千分位和小数点符号也不同。日常项目完全可以借助浏览器原生能力和i18n库自带的datetime/number formats配置来做const i18n createI18n({ datetimeFormats: { en-US: { short: { year: numeric, month: short, day: numeric } }, zh-CN: { short: { year: numeric, month: numeric, day: numeric } } } })模板中用d(new Date(), short)即可按当前语言环境格式化。这样做的意义在于你不用在每个组件里手动判断当前语言来拼接日期格式不管接多少种语言规则都收敛在统一的配置里。2.3 回退机制给语言环境兜个底很多项目刚起步只做了中英文但用户浏览器可能是日文、法文或葡萄牙文。这时候如果没有设置fallbackLocale页面会直接展示key本身比如出现home.title这种刺眼的文本海外用户一眼就知道这产品没做完。推荐的做法是设默认语言为中文所有缺失的语种自动回退到默认语言const i18n createI18n({ locale: localStorage.getItem(locale) || zh-CN, fallbackLocale: zh-CN, messages })回退机制看着简单但在多层语言包中特别有用。比如你维护了zh-CN繁体、zh-HK粤语其中zh-HK只覆盖核心词条其余走zh-CN兜底。这样不会因为某个次要语种词条没补齐就导致页面露出key。我一般在开发环境还会把回退日志打开缺失词条直接打印在控制台方便尽早发现。3. 三步迁移从零给Vue3项目接入多语言3.1 第一步安装并初始化i18n实例主角是vue-i18nVue3项目建议直接用9.x版本因为它在Composition API下用起来很顺。安装命令很简单npm install vue-i18n9然后新建一个src/i18n/index.js文件初始化实例。项目里模块多、路由多时我习惯把语言包按模块拆开再合并避免单文件几百行很难维护。import { createI18n } from vue-i18n import zhCN from ../locales/zh-CN import enUS from ../locales/en-US const i18n createI18n({ legacy: false, globalInjection: true, locale: localStorage.getItem(locale) || zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN, en-US: enUS } }) export default i18nlegacy: false是Composition API模式必选的选项全局注入开启后模板里可以直接用$t或t。之所以把locale初始值从localStorage读是为了让用户刷新后还停留在上次选择的语言这个细节是明显提升体验的点。在main.js中挂载import { createApp } from vue import App from ./App.vue import i18n from ./i18n const app createApp(App) app.use(i18n) app.mount(#app)3.2 第二步建立语言包与统一词条命名语言包文件结构决定后续维护成本建议按模块/页面/词条做三层命名。比如首页的标题就叫home.title支付按钮叫payment.confirmButton。这样的好处是即使项目里有两百个页面看到key就能知道它在哪个模块出现翻译人员拿到词条表也能快速定位上下文。// src/locales/zh-CN.js export default { common: { confirm: 确认, cancel: 取消, loading: 加载中..., }, home: { title: 欢迎回来, unreadMessage: 你有 {count} 条未读消息, }, payment: { payNow: 立即支付, insufficientBalance: 账户余额不足还差 {amount}, } }英文语言包对应写一份。我在实际项目里还会加一层meta字段保存词条更新时间和审核状态方便跟翻译人员对同步进度。不过不建议把翻译说明也放进去语言包文件保持纯粹注释可以用导出对象的注释块来写。3.3 第三步替换模板硬编码并接入语言切换这一步是体力活但可以用结构化方式避免漏网。我把替换分成两个动作先处理模板中的纯文本再处理JS里的动态字符串。模板里的写法很固定template div classhome h1{{ t(home.title) }}/h1 p{{ t(home.unreadMessage, { count: unreadCount }) }}/p el-button typeprimary clickhandlePay {{ t(payment.payNow) }} /el-button /div /templatescript setup import { useI18n } from vue-i18n const { t } useI18n() const unreadCount ref(3) function handlePay() { ElMessage.warning(t(payment.insufficientBalance, { amount: ¥50 })) } /script语言切换放到顶部导航或设置页。最朴素的方案就是改locale的值并持久化script setup import { useI18n } from vue-i18n const { locale } useI18n() function switchLanguage(lang) { locale.value lang localStorage.setItem(locale, lang) } /script如果项目里有Element Plus这样的组件库它的内置文案默认是英文需要额外导入语言包import ElementPlus from element-plus import zhCn from element-plus/es/locale/lang/zh-cn import en from element-plus/es/locale/lang/en const app createApp(App) app.use(ElementPlus, { locale: i18n.global.locale.value zh-CN ? zhCn : en })组件库的多语言一般跟随当前locale变化但有些版本需要手动切换我用了一个比较讨巧的方式监听locale变化通过app.config.globalProperties.$ELEMENT.locale重新赋值。这里就不展开实际项目中注意测试分页器、日期选择器、弹出框的默认文案是否随语言切换了。3.4 第三步的补充还没完记得处理ts提示和构建检查如果项目是TypeScript未定义key时t(home.xxx)可能不会报错。我建议给语言包定义类型利用vue-i18n的DefineLocaleMessage做全局类型扩展// env.d.ts import vue-i18n declare module vue-i18n { export interface DefineLocaleMessage { home: { title: string unreadMessage: string } payment: { payNow: string insufficientBalance: string } } }这样写错key时IDE会直接提示比上线后再发现靠谱得多。另一个常被忽略的是语言包文件体积。多语言文件在构建时会打进主包导致首屏体积变大。Vue3下可以结合动态import按需加载语言包只在用户切换语言时才去加载对应文件这里给出一个懒加载思路const i18n createI18n({ legacy: false, locale: zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN } }) async function loadLocaleMessages(locale) { const messages await import(../locales/${locale}.js) i18n.global.setLocaleMessage(locale, messages.default) i18n.global.locale.value locale }实测下来首屏只保留默认语言包后主包体积能减少几十KB对移动端页面还是有一点意义的。4. React、小程序等场景怎么快速套用4.1 React技术栈react-i18next的核心用法Vue项目用vue-i18nReact项目最常用的是react-i18next。核心逻辑没有变建立语言包、初始化实例、组件里通过hook读取词条。React初始化时通常放在独立的i18n.js中import i18n from i18next import { initReactI18next } from react-i18next import zhCN from ./locales/zh-CN import enUS from ./locales/en-US i18n.use(initReactI18next).init({ resources: { zh-CN: { translation: zhCN }, en-US: { translation: enUS } }, lng: localStorage.getItem(locale) || zh-CN, fallbackLng: zh-CN, interpolation: { escapeValue: false } }) export default i18n组件内使用方式同样简单import { useTranslation } from react-i18next function Home() { const { t, i18n } useTranslation() return ( div h1{t(home.title)}/h1 button onClick{() i18n.changeLanguage(en-US)} {t(common.confirm)} /button /div ) }react-i18next的键也可以是数组或对象还能用t(key, { returnObjects: true })取回数组类型的配置这类高阶特性在富文本和列表文案场景很有用。需要注意的是React 18并发渲染下语言切换引起的组件重渲染是通过状态驱动的如果发现有部分组件不更新可以检查是否用了memo导致props浅比较拦截了翻译上下文的更新必要时用useTranslation内部的bindI18n强制刷新。4.2 小程序和跨端项目的处理思路小程序这边没有统一的i18n库常见的方案是自研一个轻量级工具把语言包挂在全局对象上页面模板中用wxml表达式调用一个全局函数。Taro和uni-app这类跨端框架也有对应的i18n插件比如Taro的tarojs/plugin-i18n但整体都不如Web端成熟。我的建议是如果项目规模小可以只做拦截层封装一个L(key, params)函数语言包用JSON统一维护页面里禁止出现中文字符串。这样以后无论迁移到哪里资源文件都不用重写。5. 团队协作时踩过的坑和我的规避方案5.1 词条命名不统一Review时看得血压升高第一次迁移时团队里三个人同时改有人用home.title有人用homePage_title还有人直接用中文做key。结果同一个页面在两块区域拿到的词条根本对应不上翻译表维护顺序也乱了。后来我在项目里强制执行一套规则所有词条键统一为小驼峰英文模块名和页面名用点号分隔单词之间不出现下划线。并且约定业务判断、错误提示这类非展示型文案统一放在common命名空间方便复用。5.2 语言切换后组件不刷新vue-i18n在legacy: false模式下切换locale会触发热更新组件内t返回值能自动改变。但有个例外是路由配置里直接拼接的标题比如document.title或者meta.title。这种纯JS场景不会自动响应需要在路由守卫里监听locale变化后重新赋值。我遇到过的典型现象是侧边栏菜单文字切换了但浏览器标签页标题还是英文打开一个新的Tab才恢复。解决办法是写一个watch(locale, () setPageTitle(route))的监听函数在全局路由守卫中调用。5.3 词条漏了key构建期没有任何报错i18n的key写错了在运行时不会立刻崩溃往往是控制台一条warn页面显示key原文。线上环境如果关闭了告警这些异常文案很容易漏过去。我常用的解法是在pipeline里加一个检查脚本扫描所有源码中t\(([^])\)的调用再读取语言包对象校验每一个key是否都存在缺了就中断构建。这不需要额外引入工具一个Node脚本就能跑const fs require(fs) const glob require(glob) const sourceFiles glob.sync(src/**/*.{vue,js,ts}) const missingKeys [] sourceFiles.forEach((file) { const content fs.readFileSync(file, utf-8) const matched content.matchAll(/t\(\s*[]([^])[]/g) for (const match of matched) { const key match[1] if (!hasKey(zhCN, key)) { missingKeys.push(${file}: ${key}) } } }) if (missingKeys.length 0) { console.error(以下词条缺失:, missingKeys) process.exit(1) }hasKey就是把点号路径拆分后逐级查找对象属性。这个脚本看起来简单但确实能挡住大部分手误。5.4 翻译文件多了之后格式化提交乱套中英文语言包同时被多人改每次merge都很容易冲突。尤其是翻译公司回传Excel后由一个人手动粘贴进JSON文件经常出现前后逗号不匹配或者多出一个括号的问题。我后来把语言包从JS改成JSON并统一用JSON5或带Comment的格式保证可读性。同时在Git提交钩子里加了一个JSON解析校验解析失败就不允许提交这个习惯帮我少熬了好几个夜。另外我还强烈建议给每个语言包文件维护一个独立的词条量检测脚本。每周定时打印当前词条总数和新增词条数量当某份语言包比默认语言包少了5%以上时自动提醒运营补充翻译。这样不是等上线前一晚才发现英文包缺了十几个词而是持续跟进整个过程对合作方也透明很多。结尾一点个人习惯做多语言这两年我最大的体会是i18n不是加一个依赖库就结束了它更像一套内容治理规范。现在我的习惯是任何新页面开发之前先把该页面的所有文案列出来配好key再写模板。哪怕产品还没提多语言需求我也会用t函数包裹文案。因为等到用户真的来了海外改动成本最低的时刻永远是当下。最后分享一个让我在团队里经常被夸的小技巧在语言包里写注释标明词条出现的页面截图编号和业务背景。比如// 查看[登录页-设计稿v2]-右侧卡片标题这种。翻译人员、海外同事看到这句话能少问好几轮。语言包不只是一堆字符串它本身就是产品的地基。地基扎实了后续加再多语言版本都只是添砖加瓦的事。
返回列表