去年我接手一个已经上线两年的管理系统,代码里到处是写死的中文文案。“删除成功”“确定要删除这条记录吗”“操作失败,请稍后重试”……产品提了个需求:一个月后要发布英文版。我第一反应不是“哦好的”,而是倒吸一口凉气——因为这活儿看着简单,做起来全是坑。全局搜索中文、逐处替换、还要应付英文语序和复数变化,硬编码一时爽,多语言火葬场。
那段时间我把前后端能踩的坑基本踩了个遍,也总结出一套从硬编码到 i18n 的改造思路。今天把它完整分享出来,核心就三步:把文案从代码里抠出来、用 key 管理翻译、动态切换语言。这套方案我已经在两个中大型项目里落地过,改造完不仅老外看得舒服,测试和产品同事也能少挨不少累。
如果你是刚接触多语言的前端新人,或者正被存量项目的多语言改造折磨,这篇文章可以直接照着抄。
1. 硬编码为什么是坑:先看清多语言改造的本质
1.1 一段硬编码代码引发的加班事故
先说个我实际经历的场景。那套系统里有个状态标签组件,代码长这样:
// 改造前的典型写法 const statusMap = { 1: '进行中', 2: '已完成', 3: '已取消' } function formatDate(date) { return dayjs(date).format('YYYY年MM月DD日') } ElMessage.success('删除成功')单看这段代码没什么问题,中文用户用着也顺。但产品说要支持英文后,问题就炸出来了:
- “删除成功”这个文案出现在 23 个文件里,得一个一个搜出来替换;
- 英文里“删除成功”是 “Deleted successfully”,位置和中文不一样,不是简单换词就完事;
- 同一个“确认”按钮,有人在组件里写“确定”,有人在弹窗里写“确认”,英文版到底用 Confirm 还是 OK,没有一个统一口径;
- 最要命的是,产品说“删除成功太生硬了,改成操作成功”,你以为是改一个变量,实际是改 23 处。
这就是硬编码的本质问题:文案和代码逻辑绑死在了一起,任何文案层面的变更都变成代码层面的改动,任何语言层面的新增都变成全局搜索替换工程。
1.2 i18n 的核心原理:代码与文案解耦
i18n(internationalization,因为首字母 i 和尾字母 n 之间有 18 个字母,所以简称 i18n)做的事情其实非常简单:把“代码逻辑”和“展示文案”拆开,中间用一套 key-value 映射来连接。
核心逻辑就三样东西:
- 语言包:一份 JSON 或 JS 文件,专门存放当前语言下的所有文案;
- key:代码里引用文案的唯一标识;
- 运行时切换:根据当前语言环境,加载对应的语言包并渲染。
拿前面那个删除成功的例子来说,改造后代码里不再出现任何中文字符串,取而代之的是一个 key:
ElMessage.success(t('message.deleteSuccess'))语言包里这样定义:
// zh-CN.json { "message": { "deleteSuccess": "删除成功" } } // en-US.json { "message": { "deleteSuccess": "Deleted successfully" } }两者一对比,优势就出来了。新增语言时,只需要新增一份语言包文件,代码一行不用动;文案调整时,只需要改语言包,不影响任何逻辑;翻译工作还能外包给专职翻译人员,不用他们碰代码。
而且 i18n 库(前端常用的是 vue-i18n、react-i18next)不只是简单的字典替换,还封装了插值、复数、日期/数字格式化、组件级插槽渲染等能力。后面我会详细展开,尤其是“{0} 里插 HTML 标签”这个高频问题,很多教程一笔带过,实际开发里能卡你半天。
2. 3步基础改造:从硬编码到多语言的最小落地
2.1 第1步:搭建语言包与初始化环境
以我现在最常用的 Vue 3 + vue-i18n v9 组合为例,先装依赖:
npm install vue-i18n@9然后在 src 目录下建语言包文件夹,我习惯按 locale 拆文件:
src/ locales/ zh-CN.js en-US.js index.js语言包文件用 ES Module 导出,方便后续做按需加载。以 zh-CN.js 为例:
// src/locales/zh-CN.js export default { common: { confirm: '确认', cancel: '取消', delete: '删除', edit: '编辑', search: '搜索' }, message: { deleteSuccess: '删除成功', deleteConfirm: '确定要删除这条记录吗?', saveSuccess: '保存成功', networkError: '网络异常,请稍后重试' } }en-US.js 同理,对应英文翻译。再写一个 index.js 统一管理和导出:
// src/locales/index.js import zhCN from './zh-CN' import enUS from './en-US' export const messages = { 'zh-CN': zhCN, 'en-US': enUS } export const defaultLocale = 'zh-CN'接下来在 main.js 里注册 i18n 实例:
// src/main.js import { createApp } from 'vue' import { createI18n } from 'vue-i18n' import { messages, defaultLocale } from './locales' const i18n = createI18n({ legacy: false, // 使用 Composition API 风格 locale: defaultLocale, fallbackLocale: 'zh-CN', // 找不到翻译时回退到中文 messages }) const app = createApp(App) app.use(i18n) app.mount('#app')这里说两个细节。
第一,legacy: false表示开启 Composition API 模式,也就是在 setup 里可以用useI18n()的写法。如果用默认的 legacy 模式,Vue 3 里会遇到一些兼容性警告,建议新项目直接从 false 开始。
第二,fallbackLocale非常重要。假设英文语言包漏翻译了一个 key,如果没有回退配置,页面上会直接显示 key 字符串,比如message.deleteSuccess,老外看到会一脸懵。配置了回退后,它会自动用中文兜底,虽然体验不是最佳,但至少不会展示原始 key。
2.2 第2步:把硬编码文案替换成 $t 调用
模板里的替换是最直观的,直接把写死的文字改成$t调用:
<!-- 改造前 --> <el-button>删除</el-button> <el-dialog title="确认删除"> <p>确定要删除这条记录吗?</p> </el-dialog> <!-- 改造后 --> <el-button>{{ $t('common.delete') }}</el-button> <el-dialog :title="$t('common.confirm')"> <p>{{ $t('message.deleteConfirm') }}</p> </el-dialog>在 Composition API 的 script 里,需要先引入useI18n:
<script setup> import { ElMessage, ElMessageBox } from 'element-plus' import { useI18n } from 'vue-i18n' const { t } = useI18n() function handleDelete() { ElMessageBox.confirm(t('message.deleteConfirm'), t('common.confirm'), { type: 'warning' }).then(() => { ElMessage.success(t('message.deleteSuccess')) }) } </script>如果是 Options API 的组件,用this.$t就行,不需要额外引入:
export default { methods: { handleDelete() { ElMessage.success(this.$t('message.deleteSuccess')) } } }替换过程中最容易忽略的是纯数据文件里的文案,比如状态映射、常量配置、路由 meta.title。这些文件里没有模板也没有组件实例,不能直接用$t,但可以引入 i18n 实例的 global 接口:
// 改造前 export const statusMap = { 1: '进行中', 2: '已完成', 3: '已取消' } // 改造后 import i18n from '@/locales' export const statusMap = { 1: () => i18n.global.t('status.processing'), 2: () => i18n.global.t('status.completed'), 3: () => i18n.global.t('status.cancelled') }改成函数是为了让取值延迟到调用时,因为语言可能在运行时切换,如果直接存字符串,切换语言后值不会更新。
路由 meta.title 的翻译我放到后面常见问题里细说,因为这里藏着一个让人很郁闷的坑。
2.3 第3步:语言切换与浏览器自适应
基础替换完成后,就可以做语言切换了。核心方法是修改 i18n 实例的locale属性:
// components/LanguageSwitcher.vue <script setup> import { useI18n } from 'vue-i18n' import { ElSelect } from 'element-plus' const { locale } = useI18n() const currentLocale = ref(localStorage.getItem('locale') || 'zh-CN') function handleChange(val) { locale.value = val localStorage.setItem('locale', val) // 同步 HTML lang 属性,方便浏览器翻译和屏幕阅读器 document.documentElement.lang = val } </script> <template> <el-select :model-value="currentLocale" @change="handleChange"> <el-option label="简体中文" value="zh-CN" /> <el-option label="English" value="en-US" /> </el-select> </template>初始化的时候,最好能自动识别用户浏览器的语言偏好,国外用户第一次打开就直接看到英文:
// src/locales/index.js 里补充 function getBrowserLocale() { const saved = localStorage.getItem('locale') if (saved) return saved const navLang = navigator.language || navigator.userLanguage if (navLang.startsWith('en')) return 'en-US' return 'zh-CN' } export const defaultLocale = getBrowserLocale()有两点需要提醒。第一,不是所有浏览器都支持navigator.language,老浏览器要加navigator.userLanguage兜底;第二,语言偏好的判断不是简单的“非中文就是英文”,如果产品后面要加日语、韩语,这套判断逻辑要扩展成数组遍历匹配,别写成一串 if-else。
3. 重点难点:{0} 里插入 HTML 标签到底怎么处理
3.1 占位符插值方式的局限性
热搜里排名很靠前的一个问题是“vue i18n 怎么在 {0} 里插入 html 标签”,这说明大家在实际开发里都撞到过这堵墙。先看基础插值能做什么:
// 语言包 { "welcome": "欢迎回来,{0}!", "points": "您有 {0} 积分" } // 调用 t('welcome', { 0: username }) // 欢迎回来,张三! t('points', { 0: 100 }) // 您有 100 积分这里的{0}是占位符,编译时会替换成传入的变量。但有个致命限制:所有插值结果都是纯文本。如果你想让欢迎语里的用户名变成粗体,或者让积分数字变色,直接填 HTML 字符串进去,页面渲染出来的就是一堆标签文本,而且还有 XSS 风险。
比如:
t('welcome', { 0: '<strong>张三</strong>' }) // 输出的是字符串:欢迎回来,<strong>张三</strong>! // 如果渲染到 v-html 里,万一语言包被篡改,会执行任意脚本这就是“硬 HTML 拼接”方案的死穴。要正确处理富文本插值,得用 i18n 提供的组件级渲染能力。
3.2 使用 i18n-t 组件实现富文本插值
vue-i18n v9 提供了一对组件:I18nT和Translation,我一般用前者。它的设计思路是:语言包里依然写占位符,但占位符的内容不在 t 函数里拼字符串,而是通过插槽注入组件,既灵活又安全。
先看语言包怎么写:
// zh-CN { "welcome": "欢迎回来,{name}!" } // en-US { "welcome": "Welcome back, {name}!" }注意这里我把占位符从{0}换成了具名占位符{name},虽然 i18n-t 也支持数组索引占位符,但具名在阅读理解上更清晰,翻译人员也不容易搞错顺序。
模板里这样写:
<i18n-t keypath="welcome" tag="p"> <template #name> <strong>{{ username }}</strong> </template> </i18n-t>这里keypath指的是语言包的 key 路径,tag="p"指定最外层渲染成 p 标签,#name插槽对应语言包里的{name}占位符。渲染结果是:
<p>欢迎回来,<strong>张三</strong>!</p>这种方案的巧妙之处在于:语言包里只有一个纯文本占位符{name},真正要插入的 HTML 结构完全由前端模板控制。翻译人员看到的翻译文案永远是可读的纯文本,不会因为是标签打断语序。而且因为是插槽渲染组件,不会执行任何字符串形式的 HTML,天然避开 XSS。
3.3 实战:多语言里嵌入超链接的正确姿势(附完整案例)
光说理论不够,举一个真实业务里非常常见的例子:注册页的“我已阅读并同意《用户协议》和《隐私政策》”。
这个文案有三个难点:
- “用户协议”和“隐私政策”需要带链接;
- 英文语序和中文不一样:中文是“我已阅读并同意 A 和 B”,英文是 “I have read and agree to A and B”;
- 链接数量和位置可能随时间变化,比如以后新增一个《会员条款》。
用 i18n-t 组件可以优雅处理:
// zh-CN { "registerAgree": "我已阅读并同意 {terms} 和 {privacy}" } // en-US { "registerAgree": "I have read and agree to {terms} and {privacy}" }组件里:
<p class="register-agree"> <i18n-t keypath="registerAgree"> <template #terms> <a href="/terms" target="_blank">《用户协议》</a> </template> <template #privacy> <a href="/privacy" target="_blank">《隐私政策》</a> </template> </i18n-t> </p>中文状态下渲染成:
<p>我已阅读并同意 <a href="/terms">《用户协议》</a> 和 <a href="/privacy">《隐私政策》</a></p>英文状态:
<p>I have read and agree to <a href="/terms">Terms</a> and <a href="/privacy">Privacy Policy</a></p>这里有个细节值得注意:英文翻译里我在{privacy}前面也加了“and”,这是英文固定搭配。如果把“和”写成硬编码的中文,翻译成英文时语序就会乱。这就是我反复强调“文案要解耦”的原因——语序这种东西,只有翻译人员看着完整句子才能处理好,代码里写死任何连接词都是灾难。
再补充一个多人协作时的技巧:如果你用的是 VSCode,装一个 i18n-ally 插件,它能在代码里直接预览语言包内容,还能看到哪些 key 缺失翻译,改语言包时非常直观。
4. 工程化落地:让多语言方案扛得住真实项目
4.1 语言包拆分与懒加载
先提醒一个新手常见的错误:把所有文案堆在一个大 JSON 文件里。当一个项目做到几十个页面,语言包会膨胀到几百 KB,首屏加载压力很大,而且多人同时改一个文件,合并冲突能让人崩溃。
我推荐按业务模块拆分语言包:
src/ locales/ zh-CN/ index.js # 公共文案:按钮、提示、状态 user.js # 用户模块 order.js # 订单模块 settings.js # 设置模块 en-US/ index.js user.js order.js settings.js每个文件只导出自己模块的文案对象,在 index.js 里合并:
// src/locales/zh-CN/index.js import user from './user' import order from './order' import common from './common' export default { common, user, order }这样 key 的命名天然有模块前缀,比如user.list.delete、order.detail.cancel,不会互相覆盖,排查问题也更方便。
针对首屏优化,可以做语言包懒加载。这里用 Vite 的import.meta.glob比较简单,思路是:默认只加载公共语言包 + 当前路由模块的语言包,其他模块等真正进入时才加载:
// src/router/index.js import i18n from '@/locales' router.beforeEach(async (to, from, next) => { const locale = i18n.global.locale.value const module = to.meta.i18nModule if (module) { const loader = import.meta.glob('../locales/*/*.js') const filePath = `../locales/${locale}/${module}.js` if (loader[filePath]) { const messages = await loader[filePath]() i18n.global.mergeLocaleMessage(locale, messages.default) } } next() })路由 meta 里标一下当前页面属于哪个模块:
{ path: '/user', component: () => import('@/views/user/index.vue'), meta: { i18nModule: 'user' } }这样用户访问订单页时只加载订单语言包,公共文案和订单文案合起来大概十几 KB,对首屏的影响微乎其微。
4.2 自动提取硬编码文案:一个小脚本解放双手
存量项目改造时最痛苦的不是不会写,而是找不全哪些地方硬编码了。我建议写一个简单的扫描脚本,正则匹配源码里的中文字符串,把所有“漏网之鱼”捞出来。
核心逻辑很简单:
// scripts/extract-cn.js const fs = require('fs') const path = require('path') const TARGET_DIR = path.resolve(__dirname, '../src') const CN_REGEX = /[\u4e00-\u9fa5]+/g const results = [] function walk(dir) { const files = fs.readdirSync(dir) files.forEach((file) => { const fullPath = path.join(dir, file) const stat = fs.statSync(fullPath) if (stat.isDirectory()) { walk(fullPath) } else if (/\.(vue|js|ts|jsx|tsx)$/.test(file)) { const content = fs.readFileSync(fullPath, 'utf-8') const matched = content.matchAll(CN_REGEX) for (const m of matched) { results.push(`${fullPath}:${m.index} => ${m[0]}`) } } }) } walk(TARGET_DIR) fs.writeFileSync('cn-strings.txt', results.join('\n')) console.log(`找到 ${results.length} 处中文字符串,已输出到 cn-strings.txt`)跑完这个脚本,就能看到所有硬编码文案的文件和行号,替换起来就有的放矢了。配合 ESLint 的eslint-plugin-vue-i18n插件,还能在开发阶段直接拦截新增硬编码文案,从源头避免回潮。
另外再推荐一下 i18n-ally 这个 VSCode 插件,它的价值不只是预览翻译,还能在代码里直接点击 key 跳转到语言包定义,对存量项目的改造效率提升非常明显。
4.3 常见框架里的多语言实践:若依、fastadmin 的思路参考
有读者问我“若依实现多语言是怎么搞的”,也有问“fastadmin 多语言源码”。这两个场景分别代表两类技术栈,我分开说。
若依的 RuoYi-Vue-Plus 前端用的是标准 vue-i18n 方案,目录通常在src/lang/下,语言包按功能拆成多个 JS 文件,在src/lang/index.js里 createI18n 注册。它还做了两件很多项目没做的事:一是把字典数据(比如性别、状态这类选项)也做成多语言,不只管页面文案;二是用 i18n-ally 插件集成到编辑器中,翻译状态一目了然。如果你在用若依做二次开发,新增语言时只需要在 lang 目录里加一份对应语言的 JS 文件,再在 index.js 里注册即可,思路和我们上面说的一模一样。
fastadmin 是传统后端渲染 + 前端 jQuery 的架构,它的多语言方案不能直接照搬 vue-i18n,因为前组件都不是 Vue 的。它的思路是:前端用轻量级 i18n JS 库(比如 jquery-i18n-properties 或 i18next),语言包拆成前端静态文件和后端语言包两块。后端菜单、按钮文字通过后端渲染输出,前端动态交互文案通过 ajax 获取或打包在语言文件里。如果你维护的是这种老项目,别强行引入 vue-i18n,用一个能按需加载 JS 文件的轻量库就行,本质还是 key-value 替换,原理完全一样。
4.4 翻译管理与外部协作
前面说的都是技术上的改造,还有一个实际协作问题是绕不开的:翻译人员通常不写代码,怎么让他们高效地改语言包?
我实践下来有两套方案。
小团队、轻量需求:用共享表格维护翻译。开发在表格里新增 key 和中文,找翻译填英文,最后写一个小脚本把表格导出成 JSON,放到项目里。虽然听起来有点土,但对三五个人的团队来说,比教翻译用 Git 效率高得多。
预算充足的团队:上在线翻译平台(比如 Lokalise、Crowdin、Transifex)。流程是:开发提交代码后,CI 自动检测语言包变化,把新增 key 推送到平台,翻译完成后平台自动生成 PR 合入仓库。翻译那边有网页编辑器、上下文截图、术语表,还能做质量控制。这块省心程度提升很大,但不是刚需,小项目可以后面再考虑。
不管用哪种方案,有一条硬性建议:key 命名一定要语义化。不要用title1、title2这种编号,要用user.list.deleteConfirm这种结构,翻译人员看到 key 就能猜出大概场景,比对着数字翻译准确率高很多。
5. 常见问题与排查技巧实录
5.1 六个高频问题速查表
我在各种项目里攒了不少 i18n 的报错和经验,整理成一张速查表,基本能覆盖 90% 的场景:
| 现象 | 原因 | 解决办法 |
|---|---|---|
setup 里$t报错 | Composition API 里没有全局$t,需要用useI18n() | const { t } = useI18n()后使用 |
路由meta.title翻译不生效 | 路由守卫执行时机早于 i18n 实例初始化 | 在守卫里用i18n.global.t,不要用$t |
| 切换语言后 Element Plus 组件还是中文 | 组件库的 locale 没有跟着切换 | 监听 locale 变化,动态设置 Element Plus 的 locale |
| 语言包 key 重复导致翻译串了 | 所有模块放在一个命名空间里互相覆盖 | 按模块拆分,key 带上模块前缀 |
t('msg', { 0: htmlString })输出标签文本 | 插值结果是纯字符串,不渲染 HTML | 用<i18n-t>组件插槽方案 |
| 刷新页面语言回到默认值 | 没有做 localStorage 持久化 | 初始化时先从 localStorage 读,没有再探测浏览器语言 |
下面展开两个最典型的坑。
5.2 路由 title 翻译不生效的完整解法
这个太常见了,先说问题根源。我的路由配置大概是这样的:
// src/router/index.js { path: '/user', meta: { title: '用户管理' } }改成多语言后,我一开始直接写:
meta: { title: t('menu.userManagement') }结果页面标题死活不翻译,或者切语言后不更新。原因是:路由配置文件在应用初始化时就把 meta 里的值固定成了字符串,i18n 实例还没准备好,t()拿到的永远是默认语言。
正确姿势是让 title 变成函数,路由守卫里动态取:
// src/router/index.js import i18n from '@/locales' const routes = [ { path: '/user', meta: { title: () => i18n.global.t('menu.userManagement') } } ] // src/router/guard.js router.afterEach((to) => { const title = to.meta.title document.title = typeof title === 'function' ? title() : title })这样每次跳转路由时都会先调用函数取当前语言的文案,切换语言后跳转页面,标题也会跟着变。
5.3 组件库语言包联动的正确姿势
Element Plus 这一类的组件库自带多语言,但它的语言环境和 vue-i18n 是两套体系。我在改造时遇到的情况是:页面文案都切到英文了,但是日期选择器的“今天”“本月”还是中文,弹窗的“确定”“取消”也是中文。
解决思路是:vue-i18n 的 locale 变化时,同步更新组件库的 locale 配置。
// src/App.vue <script setup> import { watch } from 'vue' import { useI18n } from 'vue-i18n' import zhCn from 'element-plus/es/locale/lang/zh-cn' import en from 'element-plus/es/locale/lang/en' import { useElementPlusLocale } from '@/utils/elementLocale' const { locale } = useI18n() // 组件库 locale 与 i18n locale 联动 const elLocale = ref(zhCn) watch(locale, (val) => { elLocale.value = val === 'en-US' ? en : zhCn }) </script> <template> <el-config-provider :locale="elLocale"> <router-view /> </el-config-provider> </template>这个配置不光影响日期组件,还影响表格分页器里的“上一页”“下一页”,以及表单校验报错的默认文案。如果不联动,老外能看懂你的业务文案,但看不懂组件库的系统文案,体验还是很割裂。
5.4 面试视角:这些 i18n 问题能考住人
多语言改造不只是干活,它还是面试八股文里一个高频考点。我面试别人的时候常问这么几个问题:
- 为什么说硬编码多语言是技术债?答:文案变更成本高、翻译协作困难、语言扩展失控。能结合“状态映射、路由 title”这种容易漏掉的地方说,比单纯背概念强得多;
- vue-i18n 的插值是怎么实现的?答:语言包里用
{name}占位符,t 函数通过正则解析替换,组件插槽方案则用渲染函数把插槽内容注入到占位符位置。能引申到 XSS 风险说明你理解深; - 语言包为什么要懒加载?答:避免首屏加载过多的无用 JSON,按路由模块拆分后动态合并。能说出 mergeLocaleMessage 说明你有实战经验;
- 切换语言后页面上的数据要不要重新请求?答:如果有后端返回的文案、日期时间格式化结果,需要重新请求或做本地映射。这个问题考的是你有没有真正处理过多语言数据的链路。
如果你在准备前端面试,速度把 i18n 原理、组件插值、路由守卫联动这三个点过一遍,基本能应对大多数多语言话题。
6. 一些掏心窝的实操建议
多语言改造这件事,技术上并不难,真正难的是“什么时候接”和“怎么坚持”。我的经验是:新项目第一天就把 i18n 搭好,哪怕只有中文一种语言,也要走 i18n 的 key 引用,这样未来加语言只是加文件的事。存量项目的改造,不要想着一口气全改完,按模块逐个推进,用脚本辅助找出硬编码,改完一个模块合一个模块,风险小很多。
最后分享一个小技巧:语言包的 key 命名规范从一开始就定好,推荐“模块.业务.动作”三层结构,比如user.list.delete、order.detail.submit。命名一旦乱了,后面翻译管理和排查问题的成本都会陡增,这个比任何框架层面的优化都重要。踩过几次坑之后,我现在看到代码里出现裸中文字符串,第一反应已经不是不爽,而是会顺手把它提成语言包的 key。多语言不是功能,而是一种工程习惯,越早养成,后面越省心。