ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从硬编码到 i18n:前端多语言改造实战指南

从硬编码到 i18n:前端多语言改造实战指南 去年我接手一个已经上线两年的管理系统代码里到处是写死的中文文案。“删除成功”“确定要删除这条记录吗”“操作失败请稍后重试”……产品提了个需求一个月后要发布英文版。我第一反应不是“哦好的”而是倒吸一口凉气——因为这活儿看着简单做起来全是坑。全局搜索中文、逐处替换、还要应付英文语序和复数变化硬编码一时爽多语言火葬场。那段时间我把前后端能踩的坑基本踩了个遍也总结出一套从硬编码到 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 的核心原理代码与文案解耦i18ninternationalization因为首字母 i 和尾字母 n 之间有 18 个字母所以简称 i18n做的事情其实非常简单把“代码逻辑”和“展示文案”拆开中间用一套 key-value 映射来连接。核心逻辑就三样东西语言包一份 JSON 或 JS 文件专门存放当前语言下的所有文案key代码里引用文案的唯一标识运行时切换根据当前语言环境加载对应的语言包并渲染。拿前面那个删除成功的例子来说改造后代码里不再出现任何中文字符串取而代之的是一个 keyElMessage.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-i18n9然后在 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 里需要先引入useI18nscript 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-valuecurrentLocale changehandleChange el-option label简体中文 valuezh-CN / el-option labelEnglish valueen-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 keypathwelcome tagp template #name strong{{ username }}/strong /template /i18n-t这里keypath指的是语言包的 key 路径tagp指定最外层渲染成 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 classregister-agree i18n-t keypathregisterAgree 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英文状态pI have read and agree to a href/termsTerms/a and a href/privacyPrivacy 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 :localeelLocale 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。多语言不是功能而是一种工程习惯越早养成后面越省心。
返回列表