vue-i18n 动态语言包实战:setLocaleMessage 与 mergeLocaleMessage 的切换刷新策略
做 Vue 项目多语言配置最难缠的不是把“你好”改成“Hello”而是语言包切过去之后页面上的文案和格式能不能真正跟着变。最近一个后台管理系统里我用 vue-i18n 做中文和英文切换一开始想着把所有 JSON 一次性引进来就完事结果实战中遇到语言包体积过大、切换后文案不刷新、动态加载的 message 又和已有配置打架这几个问题最后是靠 setLocaleMessage 和 mergeLocaleMessage 这两个 API 才把链路理顺的。这篇文章就把我从语言包目录划分、异步加载、切换刷新到 {0} 插值里塞 HTML 标签这些经验的完整过程捋一遍适合正在做 Vue 2/Vue 3 国际化改造或者已经接了 vue-i18n 但被动态语言包和刷新问题卡住的同学参考。1. 先想清楚你的多语言方案是“一次性全量”还是“按需加载”1.1 项目背景与目标当时接手的后台管理系统菜单、权限、表格、表单校验、弹窗提示各有几十条文案产品还计划后面加日语和法语。最初按网上教程的做法把所有语言包统一引用到messages里一次性注册页面能跑但有两个明显不舒服的地方一是中英文语言包全部打进首屏接近 600KB 的 JSON 内容开发环境无所谓上线后移动端远程调试和弱网环境都能感觉到加载变慢二是团队里好几个同学同时维护同一个语言文件天天遇到 key 冲突和覆盖问题合代码的时候非常痛苦。所以定下的目标很明确语言包按业务模块拆开默认语言只注册最基础的部分其余模块和语言在用户切换时异步加载切换语言时不能整页刷新至少框架层面要响应式更新动态加载的新语言包不能覆盖掉已经注册好的其他模块。这些需求直接决定了后面 API 的选择。很多人只知道 vue-i18n 有setLocaleMessage把它们混着用结果语言包越加越乱。其实这两个方法在源码层面和实际行为上有本质区别理解了它们动态加载语言包的思路会清晰很多。1.2 全量加载与按需加载的取舍先给个对比你就能判断自己的项目到底需要哪种方案对比维度全量加载按需加载首屏体积所有语言全部进包几十上百 KB 是常态只进默认语言其他语言按需拉取切换速度本地已有内容切换几乎零成本首次切换需要等待网络或懒加载完成实现成本最简单初始化时写全即可需要维护加载逻辑、合并策略、加载状态语言包冲突风险低因为一次性写全高动态注册同一个 key 时容易互相覆盖适用项目个人项目、语言数量少、包体不敏感的小应用中后台系统、多语言模块多、注重首屏性能的场景我给出的判断是除非你的语言包真的只有几 KB或者项目生命周期很短否则都建议至少把业务模块拆开做成可异步加载的结构。这不是炫技而是团队协作和后续多语言扩展的刚需。按需加载落地时核心问题就变成了“新语言包到达后用什么方式塞进 vue-i18n 的 messages 里”这就绕不开标题里那两个方法了。2. setLocaleMessage 和 mergeLocaleMessage两个 API 到底差在哪2.1 从源码角度看两者的行为差异先说结论setLocaleMessage(locale, message)是“整体替换”mergeLocaleMessage(locale, message)是“递归合并”。这个区别在实际项目里引发的坑比大部分教程里写的要严重得多。举例说明。假设当前zh-CN语言包里已经有这样的结构// 当前 zh-CN messages { common: { save: 保存, cancel: 取消 }, menu: { home: 首页 } }然后你动态拉回来一个新的模块const patch { common: { save: 保存 }, user: { name: 姓名 } }如果此时调用setLocaleMessage(zh-CN, patch)最终语言包会变成{ common: { save: 保存 }, user: { name: 姓名 } }menu.home和common.cancel直接消失。如果页面恰好有用到t(menu.home)就会显示原始的 key 字符串甚至直接告警。如果改用mergeLocaleMessage(zh-CN, patch)结果是这样的{ common: { save: 保存, cancel: 取消 }, menu: { home: 首页 }, user: { name: 姓名 } }原有的 key 都还在新模块被加进来了同名的save也被新值覆盖。从 vue-i18n v9 的实现来看setLocaleMessage内部就是对 messages 里某个 locale 属性直接赋值mergeLocaleMessage则会对旧对象和新对象做递归合并子对象会一层一层合并下去。注意数组不会按 index 合并而是整体替换嵌套对象才会走深合并这个细节在语言包结构复杂时要格外小心。2.2 什么场景用 merge什么场景用 set既然行为差异这么大我建议按下面这套规则来用基本不会出错初始化默认语言包用set因为这时候本来就是要建立一套全新的完整配置。异步加载某个新语言包用merge推荐在已有基础上增量补充避免覆盖其他模块。从后台拉取了某个模块的最新文案想局部更新用merge只改要改的 key其他内容不受影响。确定某语言的 message 已经完全过期想彻底重置才用set重新挂载一整套完整包。这里有个容易被忽略的问题反复merge会让语言包对象逐渐膨胀。比如你切了三次语言、拉了几个模块某个语言包里可能残留着旧模块的数据。如果项目对语言包的干净程度要求很高可以在某个语言包“全量可用”之后改用set做整体重灌或者在合并前先删掉不再需要的顶层模块。我的经验是正常业务里大部分用merge就够了但必须约定好“哪些 key 属于公共模块、哪些属于异步模块”否则两个人同时往同一个模块里加 key还是会有覆盖隐患。2.3 从“切换刷新”说开去locale 变化后 UI 为什么有时不更新标题里提到“切换刷新”这其实是个很容易被误解的点。理论上vue-i18n 的locale是响应式数据你只要在模板里用了$t或 composition 的t切换locale.value时全页面应该自动更新根本不需要刷新页面。那为什么很多人遇到“切语言后页面纹丝不动”的情况我在项目里排查过几类真正的根因把t(xxx)的结果存到了普通变量里。比如setup里写const label t(menu.home)这个label只是一次性字符串locale 变了它也不会变。解决办法是包一层computed(() t(menu.home))或者直接在模板里用。第三方组件和图表有独立的 locale 机制。element-plus、Ant Design Vue、ECharts 都有自己的一套文案或日期格式vue-i18n 切换 locale 时不会自动通知它们。看起来像没刷新实际是第三方库的国际化没有跟着切。使用了 canvas 或手动操作 DOM 渲染的内容没有感知响应式变化。需要在watch(locale)里手动重绘。所以“切换刷新”应当拆成两个层面看框架层面不需要刷新业务和组件层面需要做好联动。最忌讳的是在切换语言后直接window.location.reload()这等于把性能问题掩盖了还会导致切换时有白屏闪烁。极少数不得不刷的场景我愿意承担的代价也很大会先用路由参数或 localStorage 把语言状态存好再做页面重载。3. 完整实操从语言包目录设计到动态切换落地3.1 语言包目录与模块拆分先放一个我后来在项目中稳定使用的目录结构你可以直接抄src/locales/ ├── index.js └── lang/ ├── zh-CN/ │ ├── index.js │ ├── common.js │ ├── menu.js │ ├── table.js │ └── validate.js └── en-US/ ├── index.js ├── common.js ├── menu.js ├── table.js └── validate.js每个模块文件只导出自己的对象比如common.jsexport default { appName: 后台管理系统, confirm: 确定, cancel: 取消, save: 保存, delete: 删除 }index.js聚合所有模块import common from ./common import menu from ./menu import table from ./table import validate from ./validate export default { common, menu, table, validate }这样拆分的好处很多。首先是多人协作不打架每个人负责自己的文件冲突范围可控其次是按需加载很自然比如菜单模块在登录后就要用表格和校验模块进入相应页面才用完全可以拆成更细粒度的懒加载最后是后期维护清晰想改某个弹窗按钮文案直接去common.js里搜就行不用在一个几千行的 JSON 里翻来翻去。初始化时我通常只注册common和menu这种全局必用的模块其他模块等到页面真正需要时再动态合并。这样首屏只加载基础文案体积小很多。3.2 异步加载语言包的三种姿势不同构建工具和项目形态加载语言包的方式不一样。我实际用下来有三种比较主流的姿势。第一种Vite 项目直接用import.meta.glob批量导入const langModules import.meta.glob(../lang/*/index.js) export async function loadLanguage(lang) { const module await langModules[../lang/${lang}/index.js]() return module.default }这种方式只要语言包目录命名规范新增语言只需要加一个文件夹加载逻辑不用改。第二种Webpack 项目用require.contextconst langModules require.context(../lang, true, /index\.js$/) export function loadLanguage(lang) { return langModules(./${lang}/index.js).default }第三种语言包由后台管理、需要热更新文案时直接走接口拉取export async function fetchLanguageFromServer(lang) { const res await fetch(/api/locales/${lang}) return res.json() }这里必须提醒一个打包工具的老坑动态import()的路径如果包含运行时拼接的变量比如import(\/locales/lang/${lang}/index.js)很多打包器无法静态分析要么把整个目录都打进包要么直接报错。稳妥的做法是维护一个显式映射const loaders { zh-CN: () import(/locales/lang/zh-CN/index.js), en-US: () import(/locales/lang/en-US/index.js) }后期加语言时顺手加一行映射比在构建配置里排查半天要省事得多。3.3 切换语言的核心流程下面这套代码以 Vue 3 vue-i18n v9 为例。Vue 2 vue-i18n v8 的差异主要是 API 挂在this.$i18n上核心逻辑一致。先创建 i18n 实例默认只挂中文基础包// src/locales/index.js import { createI18n } from vue-i18n import zhCN from ./lang/zh-CN/index.js const i18n createI18n({ legacy: false, locale: localStorage.getItem(lang) || zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN } }) export default i18n然后写一个专门管理语言切换的 composable// src/composables/useLocale.js import { reactive } from vue import { useI18n } from vue-i18n import i18n from /locales const loadedLocales reactive(new Set([zh-CN])) const loaderMap { zh-CN: () import(/locales/lang/zh-CN/index.js), en-US: () import(/locales/lang/en-US/index.js) } export function useLocale() { const { locale } useI18n() async function switchLocale(nextLocale) { if (nextLocale locale.value) return if (!loadedLocales.has(nextLocale)) { const module await loaderMap[nextLocale]() i18n.global.mergeLocaleMessage(nextLocale, module.default) loadedLocales.add(nextLocale) } locale.value nextLocale localStorage.setItem(lang, nextLocale) // 这里可以做第三方库的同步 // await syncThirdPartyLocale(nextLocale) } return { locale, switchLocale } }这里用mergeLocaleMessage而不是setLocaleMessage的核心原因就是因为同一个语言可能在运行期间被多次动态加载不同的模块用merge可以保证之前注册过的内容不丢。如果你能确保每次加载的都是完整语言包用set也是可以的但merge的容错性更好我建议默认都用merge。切换动作本身不需要刷新页面。只要模板里用的是t或$tlocale.value一变化所有依赖组件就会自动重新渲染。3.4 在 {0} 插槽里插入 HTML一个高频真实需求标题里特别提到了“vue i18n 怎么在{0}里插入 html标签”这个需求确实很多人踩坑。默认情况下vue-i18n 的插值是会转义 HTML 的你传一个b张三/b进去页面只会显示一串源码不会变成加粗效果。比如语言包里有一条{ tip: 你好{0}欢迎回来 }常规写法p{{ t(tip, [b张三/b]) }}/p页面最终会输出b张三/b这段文本看起来就像写法错了。想在插槽位置插入真正的 HTML有三类方案第一种使用i18n-t组件配合具名插槽i18n-t keypathtip tagp template #0 b{{ username }}/b /template /i18n-t#0对应 list 插值里的{0}如果语言页里用的是命名插值比如{name}模板就写成#name。这种方式保持语言包纯文本HTML 模板部分留在组件里是个人最推荐的做法。第二种整条消息本来就包含 HTML且内容来源可信可以直接用v-html渲染p v-htmlt(noticeHtml)/p语言包{ noticeHtml: 新的通知已经送达b点此查看/b。 }vue-i18n 对消息里自带的 HTML 标签默认不会拦截渲染出来就是真实标签。但这里必须强调 XSS 风险如果这条消息里有任何用户可控的内容绝对不能这么干。真要这么做也最好先用 DOMPurify 之类的库做白名单过滤。第三种拆散文案用多个文本节点拼装p{{ t(prefix) }}b{{ username }}/b{{ t(suffix) }}/p这个最安全缺点是语言顺序调整时不如一条完整文案灵活。很多团队会直接约定“语言包里禁止出现 HTML 标签”然后把第三种作为标准写法省心很多。4. 多语言项目里那些绕不开的边角问题4.1 刷新后语言闪烁与持久化只做上面那套切换逻辑刷新页面时大概率会有一个短暂闪烁入口初始化默认是中文如果用户上次选的是英文英文语言包还在异步加载页面先显示中文再变成英文观感很不好。解决办法是把语言恢复和语言包加载提到应用挂载之前。在main.js里改成异步入口async function bootstrap() { const savedLang localStorage.getItem(lang) || zh-CN if (savedLang ! zh-CN !loadedLocales.has(savedLang)) { const module await loaderMap[savedLang]() i18n.global.mergeLocaleMessage(savedLang, module.default) loadedLocales.add(savedLang) } i18n.global.locale.value savedLang createApp(App) .use(i18n) .use(router) .mount(#app) } bootstrap()这样首帧渲染时语言包已经就位不会出现闪中文再闪英文的尴尬。4.2 fallback 语言与缺失 key 的兜底策略异步加载语言包后最常遇到的一个问题就是“某个语言缺 key”。比如英文包漏了几条文案页面会直接显示 key 本身对用户很不友好。vue-i18n 的fallbackLocale就是干这个的const i18n createI18n({ legacy: false, locale: zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN } })当en-US找不到某个 key 时会自动去zh-CN里找中文也没有时才显示 key。这在多语言维护跟不上业务迭代时能保证页面不因为缺文案而破相。同时建议把missing钩子接上方便追踪哪些 key 没翻译到位const i18n createI18n({ missing: (locale, key) { console.warn([i18n] missing key in ${locale}:, key) } })配合这个日志后续新语言包上线时能快速列出缺失列表直接发给翻译同学或接入翻译平台。4.3 路由、动态 import 和 i18n 的联动“刷新后语言保持”除了用 localStorage还有一个更优雅的方案把语言信息放到 URL 里例如/zh/dashboard、/en/dashboard。这样做的好处是刷新、分享、甚至 SEO 时语言都不会丢。实现思路是在路由前置守卫里做语言包预加载router.beforeEach(async (to) { const lang to.params.lang || localStorage.getItem(lang) || zh-CN if (!loadedLocales.has(lang)) { const module await loadLanguage(lang) i18n.global.mergeLocaleMessage(lang, module.default) loadedLocales.add(lang) } i18n.global.locale.value lang return true })需要注意别在守卫里无脑重定向否则每个路由都要处理一遍语言前缀。折中的做法是语言通过 query 传递比如/dashboard?langen切换语言时用router.push({ query: { lang: en } })刷新后从当前路由的 query 里读lang侵入性小很多。如果已经接了动态 import还要注意语言包加载失败的情况。比如后台返回了不存在的语言码loaderMap里没有对应的 loader建议先做白名单校验非法语言一律回落到默认语言。4.4 日期、数字、货币的本地化格式只做文案切换还不够日期、数字、货币的格式也要跟着语言走否则英语页面看到的中文日期格式会很别扭。vue-i18n 除了messages还支持datetimeFormats和numberFormatsconst i18n createI18n({ legacy: false, locale: zh-CN, datetimeFormats: { zh-CN: { short: { year: numeric, month: short, day: numeric } }, en-US: { short: { month: short, day: numeric, year: numeric } } }, numberFormats: { zh-CN: { currency: { style: currency, currency: CNY } }, en-US: { currency: { style: currency, currency: USD } } } })模板里这样用p{{ d(new Date(), short) }}/p p{{ n(1024.5, currency) }}/p切换locale后d和n会自动应用对应语言格式。这里有个常见的坑很多人只配了messages忘了配置datetimeFormats和numberFormats英文界面照样显示中文格式只能靠浏览器或第三方库撑一段最终还是要回到 vue-i18n 的配置上来。4.5 常见问题与排查技巧速查表把这次实战中遇到的高频问题整理成一张表方便以后直接照表排查现象根本原因解决建议切换语言后页面文案不更新t()结果被放进普通变量没走响应式链路用computed(() t(...))包裹或直接在模板里使用动态加载语言包后旧 key 丢失用了setLocaleMessage整体替换改成mergeLocaleMessage增量合并第三方组件、图表文案没跟着切它们有独立 locale 机制没有联动watch(locale)手动同步第三方 locale必要时重绘刷新后语言变回默认locale 没有持久化或恢复晚了入口处从 localStorage 或 URL 参数恢复并预先加载语言包{0}插槽想插 HTML 却显示源码默认插值转义了 HTML用i18n-t具名插槽或对可信文案用v-html新语言包部分 key 显示为 keyfallback 语言包也没有该 key检查 fallbackLocale 配置配合 missing() 日志排查打包后语言包或动态加载白屏动态 import 路径拼接导致打包器分析失败用显式 loaderMap 映射不要运行时拼路径排查这类问题的常规步骤是先看当前locale值对不对再在控制台执行i18n.global.getLocaleMessage(en-US)检查语言包对象是否完整最后才去判断是渲染依赖没触发还是语言包本身没加载进来。这三步能过滤掉八成的问题。最后分享一个我踩过几次坑之后养成的习惯所有语言包只存字符串模板不存已经拼接好的句子所有从t()拿到的值只放在模板和computed里绝不让它进入普通的data或ref。这样 locale 一变整个页面几乎天然跟着刷新根本不需要 reload。另外语言包的合并策略我统一用mergeLocaleMessage只有在确定要重置某语言包时才用setLocaleMessage这个约定在多人协作的项目里尤其重要能少吵很多次架。希望这篇能帮你的多语言链路少踩几个坑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →