尧图精选

Vue 动态多语言切换不刷新:setLocaleMessage 与 mergeLocaleMessage 实践指南

🕒 发布时间:2026/10/1 15:52:44 📁 来源:尧图网络
做动态多语言配置大概是最容易把新人卡住的一个需求系统语言包不是写死在项目里的而是从后端接口拉到前端用户一切换语言页面还得跟着变。我第一次接到这个需求时天真的想法是用window.location.reload()暴力解决后来被测试同事在群里追着问“为什么切个语言要白屏一下”才意识到这条路根本走不通。真正应该做的是借助 vue-i18n 的setLocaleMessage和mergeLocaleMessage把语言包动态注入进 i18n 实例再配合响应式机制让文案自动更新。这篇文章就把我在这条路上踩过的坑、验证过的方案、和最终的实现连完整链路讲清楚给需要做 vue 多语言配置的朋友当一份参考。1. 语言包“切换刷新”的痛点根源静态引入的天然局限1.1 静态语言包的基础写法与两个明显问题多数 Vue 项目刚接入国际化时用的都是最朴素的方案在src/lang目录下建若干个 JS 文件每个文件导出一个包含全部文案的对象然后在创建 i18n 实例时一次性全部传入。结构一般是这样的src/lang/ index.js zh-CN.js en-US.jszh-CN.js长这样export default { common: { confirm: 确认, cancel: 取消, save: 保存 }, login: { title: 登录, username: 用户名, password: 密码 } }然后在src/lang/index.js里这样组装import { createI18n } from vue-i18n import zhCN from ./zh-CN import enUS from ./en-US const i18n createI18n({ legacy: false, locale: zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN, en-US: enUS } }) export default i18n这种写法在项目早期完全够用。但项目滚到一定规模后两个问题会越来越扎眼第一所有语言都打进了主包。假设你有中、英、日、韩四套语言包每套几百条文案用户访问首页时不管用不用得到这四个文件都得加载。在弱网环境下首屏体积的影响是肉眼可见的。第二新增语言或修改文案必须重新发版。运营同事想临时改一句宣传语得找前端改代码、提 MR、走发布流程。如果公司内部有一个内容运营后台那这套静态方案基本就是早晚要推翻的技术债。1.2 “切换刷新”背后其实是两种诉求在和很多同行交流时发现大家嘴里的“切换刷新”往往指向的是两种完全不同的诉求虽然说出来都是同一句话但解法天差地别。第一种诉求是真的需要刷新。比如切换语言后后端返回的路由配置、按钮权限、动态表单结构都变了这些内容跟当前页面状态强绑定不清掉重来容易出逻辑错乱。这种情况用刷新解决问题是合理的。第二种诉求才是大头组件没有自动更新用户以为必须刷新才能生效。常见表现是切换locale之后页面上大部分文案变了但某个弹窗、某个表格列头、某个第三方组件的内部文案还停在旧语言于是开发者下意识补一个location.reload()把响应式失效的问题掩盖掉。我在生产环境里见过好几处代码是“切换语言成功后强制刷新页面”的写法每次看都觉得可惜——vue-i18n 本身是响应式的绝大多数文案都可以做到即时切换根本不需要刷新。真正要做的是先搞清“为什么局部不更新”再决定是否需要兜底重建。1.3 为什么这个需求绕不开 setLocaleMessage 和 mergeLocaleMessage静态方案下语言包是在创建 i18n 实例时通过messages字段传进去的创建之后这个messages对象就固定了。后台下发语言包时你不可能把整个 i18n 实例重新创建一遍也不可能修改createI18n的入参。这时候就需要 i18n 实例提供“运行期注入语言包”的能力。setLocaleMessage和mergeLocaleMessage就是干这件事的。名字里带Message指的是某个语言下完整的消息集合。两者的职责简单粗暴一个是“整体替换”一个是“递归合并”。理解清楚这两个语义后面所有跟语言包注入相关的逻辑都能顺下来。2. setLocaleMessage 与 mergeLocaleMessage 的机制拆解一个替换一个递归合并2.1 两个 API 的签名与行为差异在 vue-i18n v9 及以上的 Composition API 模式下两个方法都挂在全局 i18n 实例上import i18n from /i18n // 将该语言的语言包整体替换为传入对象 i18n.global.setLocaleMessage(en-US, { home: { title: Home } }) // 将该语言的语言包与传入对象递归合并 i18n.global.mergeLocaleMessage(en-US, { home: { desc: This is home page } })如果用 Options API也可以通过this.$i18n.setLocaleMessage(...)和this.$i18n.mergeLocaleMessage(...)访问底层行为完全一致。关键区别在于setLocaleMessage的语义是以传入数据为准把该语言包整体替换。哪怕你只想更新一个 key也得把完整的语言包结构传进去否则其他没传的部分会全部消失。而mergeLocaleMessage的语义是递归合并没涉及的字段会原样保留。2.2 用一组真实数据看 merge 与 set 的结果差异假设当前的zh-CN语言包是{ home: { title: 首页, menu: { name: 菜单 } }, login: { title: 登录 } }如果执行i18n.global.setLocaleMessage(zh-CN, { login: { title: 登录页 } })那么zh-CN语言包会变成{ login: { title: 登录页 } }home整个没了包括home.menu.name。因为你传进去的是什么这个语言包就是什么。如果改用i18n.global.mergeLocaleMessage(zh-CN, { login: { title: 登录页 } })那么结果是{ home: { title: 首页, menu: { name: 菜单 } }, login: { title: 登录页 } }home原封不动login.title被覆盖成新值。如果 merge 时传入层级较深的嵌套对象比如只改home.menu.namei18n.global.mergeLocaleMessage(zh-CN, { home: { menu: { name: 导航 } } })那么home.title依然保留home.menu.name变成导航。这个递归合并是逐层进行的叶子节点才发生真正覆盖熟悉Object.assign的浅合并逻辑的人第一眼容易误判它事实上它是深合并。2.3 场景选型什么时候该合并什么时候必须整体替换这个问题的答案取决于语言包的来源和变更形式。我整理了一张表基本覆盖了日常开发里能遇到的情况场景推荐方法原因首次从后端拉取某个语言的全量语言包setLocaleMessage全量覆盖避免本地残留旧数据后台只下发新增或变更的少量 keymergeLocaleMessage只更新 diff 部分本地原有内容保留语言包做过结构调整或删除了部分 keysetLocaleMessagemerge 会把已删除的 key 继续留在内存里前端有基础文案想叠加后台扩展内容mergeLocaleMessage两者来源互补互不覆盖切换语言前做本地预热缓存mergeLocaleMessage缓存增量合并到已有包避免重复请求这里有一个我踩过的坑需要特别提醒merge 保留旧 key 这件事既是优点也是陷阱。从后台拉了一个全量语言包过来却顺手用了mergeLocaleMessage结果理论上应该被删掉的旧文案由于并不在后台下发的新包里反而继续留在内存中。后续如果某个展示逻辑读到了这些残留 key就可能出现“线上已经下架的入口文案还能在某些旧组件里冒出来”的诡异问题。所以凡是后端下发“完整数据”的场景我一律用setLocaleMessage而不是 merge。3. 我理解的“切换刷新”让页面不刷新文案自动更新3.1 vue-i18n 的响应式更新链路为什么理论上不用刷新vue-i18n 之所以能做到切换语言后文案自动变化是因为它内部维护的可不只是两个普通变量。locale和messages都是响应式数据源组件模板里的t/$t本质上是在 render 阶段读取messages[locale]对应路径的值。只要这两个响应式源发生变化所有依赖它们的组件都会触发重新渲染。用最基础的代码验证一下script setup import { useI18n } from vue-i18n const { t, locale } useI18n() const switchLang () { locale.value locale.value zh-CN ? en-US : zh-CN } /script template button clickswitchLang切换语言/button p{{ t(home.title) }}/p /template只要home.title在en-US这个语言包里真实存在点击按钮后p的内容会瞬间变成英文标题整个过程完全不经过刷新。前提是目标语言的语言包已经注入到 i18n 实例里了。很多人切换后没反应直接把锅甩给“需要刷新”但实际上是忘了先注入或者注入到了错误的 locale 上。3.2 组件不自动更新的常见断点如果排除了“语言包没注入”这种低级错误组件依然不更新那通常卡在这几个地方。第一个断点局部语言包遮蔽全局。有些组件会在useI18n里传入自己的messagesconst { t } useI18n({ messages: { zh-CN: { customTitle: 局部标题 } } })这种局部 messages 的作用域只限当前组件。如果它只有zh-CN一套而你切到en-USi18n 在局部找不到customTitle会回退到全局或 fallbackLocale表现出来就是“这个组件永远是中文”。排查这类问题直接全局搜useI18n({后面的messages:就行。第二个断点读出来之后被缓存了。比如在computed里做了t(xx)而 computed 依赖的是某个不随 locale 变化的值或者用了v-memo对结果做了不合理的缓存那切换 locale 时组件确实不会更新。这类问题比较隐蔽但定位方向基本是从“为什么这个组件没进依赖收集”入手。第三个断点第三方组件根本不读 i18n。日期选择器、分页器、富文本编辑器这类第三方组件很多有自己的 locale 配置项。它们通常是在初始化时把 locale 传入然后内部自己管理文案不会去订阅 vue-i18n 的响应式变化。指望它们跟着t走是不现实的需要单独联动。3.3 兜底方案局部重建而不是整页刷新万一遇到某个组件确实无法响应式更新我的兜底方案是局部重建而不是整页刷新。最简单有效的做法是给组件加一个随 locale 变化的keytemplate date-picker :keylocale :localedateLocale / /templatekey一变Vue 会销毁并重新创建整个组件内部状态重置组件重新执行初始化流程此时正常传入的 locale 就会生效。如果是整个路由页面的配置化内容太多、想整体重置可以给router-view挂keytemplate router-view :keyi18n.global.locale.value / /template这会让整个路由视图子树在新语言环境下重新渲染比location.reload()温和得多。页面不会白屏浏览器的滚动位置、以及其他与语言无关的状态都能保留下来。我最终在项目里采用的就是这套方案能用响应式自动更新的绝不手动刷新必须重建的用局部key或路由级key解决只有极少数“所有状态都需要重置”的场景才考虑location.reload()。4. 从后端动态加载语言包到切换落地完整链路实现4.1 项目结构与初始化配置我建议把动态语言配置相关的代码单独收敛到一个目录里避免散落在各种业务组件中。一个比较清晰的结构是src/ i18n/ index.js # i18n 实例创建 langs/ zh-CN.js # 静态兜底文案 loader.js # 动态加载与注入逻辑 api/ lang.js # 语言包接口请求i18n/index.js里只做最基础的初始化import { createI18n } from vue-i18n import zhCN from ./langs/zh-CN const i18n createI18n({ legacy: false, locale: localStorage.getItem(locale) || zh-CN, fallbackLocale: zh-CN, messages: { zh-CN: zhCN }, globalInjection: true }) export default i18n为什么初始化时只放中文这一套静态包因为zh-CN是默认语言是用户第一次打开页面时立即会看到的文案如果不放一份静态兜底在网络请求还没回来的时候页面可能只能渲染出一堆 key 名。英文和其他语言则放在用户第一次切换时再动态加载。legacy: false是 vue-i18n v9 的 Composition API 模式。这个模式下useI18n()支配合 Vue 3 的组合式写法同时$t通过globalInjection依然可以在模板里直接用。4.2 在应用启动阶段拉取并注入语言包启动流程上建议把“加载语言包”放在app.mount()之前。这个顺序很重要看到后面 5.2 的坑就明白了。i18n/loader.js里封装统一逻辑import i18n from ./index const loadedLocales new Set([zh-CN]) export async function fetchLangMessages(locale) { const res await fetch(/api/i18n/${locale}) if (!res.ok) { throw new Error(Failed to load language pack: ${locale}) } return res.json() } export async function ensureLocaleLoaded(locale) { if (loadedLocales.has(locale)) return const messages await fetchLangMessages(locale) // 全量下发用 set避免旧 key 残留 i18n.global.setLocaleMessage(locale, messages) loadedLocales.add(locale) } export async function initI18n() { const savedLocale i18n.global.locale.value try { await ensureLocaleLoaded(savedLocale) } catch (e) { console.warn(动态语言包加载失败使用静态兜底) } }在main.js里这样调用import { createApp } from vue import App from ./App.vue import i18n, { initI18n } from ./i18n async function bootstrap() { await initI18n() const app createApp(App) app.use(i18n) app.mount(#app) } bootstrap()先拉语言包再挂载应用首屏渲染时就能拿到完整的文案数据。如果接口失败也不会让页面白掉fallbackLocale: zh-CN会兜住大部分 key 的渲染。4.3 切换语言的完整流程与持久化切换语言的函数要保证两个原则先确保语言包加载完成再切换 locale切换后同时持久化用户选择。顺序反过来的话会出现先切语言、语言包还在路上、页面闪现 key 名或空白的情况。import i18n from ./index import { ensureLocaleLoaded } from ./loader const SWITCHING ref(false) export async function switchLocale(locale) { if (i18n.global.locale.value locale) return if (SWITCHING.value) return SWITCHING.value true try { await ensureLocaleLoaded(locale) i18n.global.locale.value locale localStorage.setItem(locale, locale) document.documentElement.lang locale } catch (e) { console.error(切换语言失败: ${locale}, e) // 提示用户或回退到默认语言 i18n.global.locale.value zh-CN localStorage.setItem(locale, zh-CN) } finally { SWITCHING.value false } }document.documentElement.lang locale这一行容易被忽略但它对浏览器翻译、屏幕阅读器、以及部分依赖lang属性的浏览器功能都有影响。建议切换语言时顺手同步一下。4.4 异步加载期间的 UI 状态处理语言包接口如果比较慢用户点击切换后会感觉毫无反应过一两秒突然变语言体验很割裂。所以切换期间最好有一个全局的 loading 状态。我的做法是在顶层布局组件里监听这个状态配合一个细长的顶部进度条或者在切换按钮上做 disablescript setup import { ref } from vue import { useI18n } from vue-i18n import { switchLocale } from /i18n/loader const { locale } useI18n() const switching ref(false) const handleSwitch async () { const target locale.value zh-CN ? en-US : zh-CN switching.value true try { await switchLocale(target) } finally { switching.value false } } /script template button :disabledswitching clickhandleSwitch {{ switching ? 语言切换中... : 切换语言 }} /button /template如果做了本地缓存第二次切换同一语言时基本不需要请求接口loading 状态闪一下就结束或几乎看不见。这个体验会比每次切语言都刷新页面好太多了。5. 实战中我踩过的坑不只是“不刷新”这一个问题5.1 merge 残留旧 key 引发的幽灵文案这个问题的具体表现前面提过就是用了mergeLocaleMessage合并后台全量数据结果已删除的 key 残留。我遇到过的真实案例是活动页下线后运营把活动相关文案从后台语言包中删了但因为前端 merge 时没有清理旧 key某个埋点组件里的旧文案在后续版本依然能被读取到测试在灰度环境看到一条不该出现的悬浮提示排查了很久才定位到是语言包残留。从那以后我定下一条铁律后端下发完整语言包一律用setLocaleMessage。只有当后端明确表示“这条接口只返回增量变更”时才用mergeLocaleMessage。不依赖后端同事的自觉而是从接口契约上区分。5.2 语言包未就绪页面渲染裸 key 名很多新人第一次做动态语言包时会在main.js里先app.use(i18n)再app.mount(#app)然后去发起语言包请求。结果页面第一帧渲染出来的是满屏的common.confirm、login.title这类字符串。原因很简单渲染时$t在 messages 里找不到对应路径vue-i18n 会把 key 路径本身当作兜底输出。等语言包注入成功后虽然会触发重新渲染但用户已经看到过一帧丑陋的裸 key 了体感很差。我的解决方案就是 4.2 里写的启动时先await语言包加载再 mount 应用。如果接口不稳定还可以配合根组件里的v-if控制首屏是否渲染。5.3 局部语言包覆盖全局切换后局部永远不变有一次线上反馈某个功能弹窗在英文版里依然是中文标题。查了很久发现最初开发这个弹窗的同事在组件里这样写过const { t } useI18n({ messages: { zh-CN: { dialogTitle: 确认删除 } } })这里声明的局部 messages 只有zh-CN一套。全局切到en-US后i18n 在组件局部作用域里找不到dialogTitle对应英文只能回退到 fallbackLocale也就是中文。所以这个弹窗永远显示中文跟全局切换完全脱节。这种问题最好的修复方式是不要使用局部语言包把所有业务文案统一放进全局消息里管理。如果确实要保留局部包也必须同步补全所有支持的语言。5.4 第三方组件不响应日期选择器、分页、富文本在管理后台项目里几乎逃不掉第三方组件库的国际化联动。Element Plus 有el-config-providerAnt Design Vue 有ConfigProvider但第三方库和 vue-i18n 之间并没有天然的响应式绑定该传的 locale 配置必须自己维护一个响应式映射。以日期选择器为例script setup import { computed } from vue import { useI18n } from vue-i18n import zhCN from ant-design-vue/es/date-picker/locale/zh_CN import enUS from ant-design-vue/es/date-picker/locale/en_US const { locale } useI18n() const dateLocale computed(() locale.value zh-CN ? zhCN : enUS ) /script template a-date-picker :localedateLocale / /template关键是dateLocale必须是computed不能是普通常量。这样组件会响应式地拿到新语言配置。5.5 动态 import 语言包时的竞态先加载后切换如果你用 webpack 或者 Vite 的import()语法按需加载语言文件会引入一个更隐蔽的竞态问题const messages (await import(../lang/${locale}.ts)).default i18n.global.setLocaleMessage(locale, messages) i18n.global.locale.value locale // 语言包已就绪安全但如果代码顺序反了先改locale.value再await import那么语言包加载期间页面上所有t已经在按新 locale 去取消息了自然全是缺失 key。正确顺序永远是先ensureLocaleLoaded(locale)再设置locale.value。这个竞态坑和 4.3 是一致的逻辑但动态import场景下更容易手滑。6. 进一步优化语言包拆包、缓存策略与我的个人体会6.1 语言包按需拆包减轻首屏压力在构建配置支持 Vite 或 webpack 动态导入的前提下可以把除默认语言外的其他语言包都做成异步 chunk。用户首次访问只加载默认语言切换时才去请求对应语言的代码分包。const loadedLocales new Set([zh-CN]) async function ensureLocaleLoaded(locale) { if (loadedLocales.has(locale)) return const messages (await import(../locales/${locale}.ts)).default i18n.global.setLocaleMessage(locale, messages) loadedLocales.add(locale) }配合构建工具的代码分割每个语言包会生成独立的 JS chunk首屏加载时不会包含这些语言的内容。如果你的语言包是通过后端接口下发的这个场景就不需要拆代码分包只需要做好接口缓存。6.2 缓存策略版本号优先而不是时间优先语言包接口如果每次切换都全量拉取浪费带宽且切换变慢。我建议用localStorage做一层本地缓存并引入版本号控制。接口返回结构可以约定为{ version: 12, data: { home: { title: 首页 } } }前端逻辑大致是const CACHE_KEY i18n_cache export async function fetchLangMessages(locale) { const cached JSON.parse(localStorage.getItem(${CACHE_KEY}_${locale}) || null) // 先读本地缓存如果一致就直接返回 if (cached) return cached.data const res await fetch(/api/i18n/${locale}) const payload await res.json() localStorage.setItem( ${CACHE_KEY}_${locale}, JSON.stringify(payload) ) return payload.data }严格来说还需要对比版本号字段只有版本高于当前版本时才更新缓存、刷新语言包。如果语言包很大不建议塞进localStoragesessionStorage或内存缓存是更稳妥的方案。这里说到底是个取舍缓存提升的是切换体验但也会带来配置更新延迟。如果后台改完文案希望尽快生效版本号机制就是平衡这两个诉求的关键。6.3 我的排查经验与个人体会做了将近一年动态多语言配置后我的最大感受是setLocaleMessage和mergeLocaleMessage只是注入机制真正决定体验的是时序。什么时候加载、加载完再切语言、加载失败用什么兜底、第三方组件怎么联动——这些时序处理到位页面根本不需要刷新。语言切换的体验应当像切换主题色一样顺滑而不是让用户看到一次白屏或一堆 key 名。如果你以后遇到“语言切了但页面没反应”的情况先别着急上location.reload()。按三件事查一看全局locale是否真的切换了二看目标语言的消息资源是否已经注入到 i18n 实例三看当前报问题的组件它读的是全局消息还是局部语言包。我遇到的绝大多数问题最后都落在这三个排查点里。最后再分享一个小技巧语言包数据量大的时候把mergeLocaleMessage当成给已有语言包打补丁的工具把setLocaleMessage当成全量重置工具。后台下发增量就用前者下发全量就用后者。这套组合既能省流量也能避免残留 key 带来的一堆幽灵问题。如果实在不想区分统一用setLocaleMessage也没毛病唯一要求就是后端每次必须下全量数据接口契约上写清楚前端就稳了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →