一、背景与痛点
在 uni-app(微信小程序优先)项目里接 vue-i18n,最常遇到几个问题:
- 主包体积:如果所有文案都集中放在主包的几个大语言包里,主包会持续膨胀。而小程序对主包体积有严格限制(2MB),分包页面也不该为与自己无关的文案买单。
- 文案归属混乱:页面级文案、共享组件文案、TabBar 文案混在一张表里,改起来互相踩。
- 注册时机:分包页面的语言包如果要在
main.js统一注册,分包资源加载时主包逻辑就得先知道所有页面——耦合且不按需。 - 小程序端的插值坑:uni-app 会把 vue-i18n 重定向到它内置的 runtime 构建,字符串消息的编译器是空实现,
t(key, { name })不会做{name}占位符替换——这几乎必踩。
二、总体思路
一句话:主实例只装「常驻命名空间」,页面文案随页面按需注册。
- 主包只预装应用壳层文案(语言切换、TabBar、导航、共享组件、全局逻辑),用一个
core命名空间; - 每个页面目录下放
locale/zh-CN.js和locale/en.js; - 页面
<script setup>顶部调用一个幂等的mergePageMessages(namespace, { 'zh-CN': …, en }),import 语句一执行,合并就发生了; - 之后组件里
useI18n().t('namespace.key')正常取词。
三、主实例:只注册常驻命名空间
// src/locales/index.jsimport { createI18n } from 'vue-i18n';import zhCN from './core/zh-CN';import en from './core/en';import { DEFAULT_LANG } from '@/constants/i18n';const i18n = createI18n({ legacy: false, // composition 模式,t() 对 locale 响应式 locale: DEFAULT_LANG, messages: { 'zh-CN': zhCN, en }, // 只有 core});export default i18n;
legacy: false 很关键:组件里 useI18n() 返回的 t() 对当前 locale 是响应式的,语言切换后已挂载组件会自动重渲染。
四、页面级 locale 文件 + 自动注册(核心)
目录结构:
src/pages/└── product-detail/ ├── product-detail.vue ├── components/ │ └── filter-popup/ │ ├── filter-popup.vue │ └── locale/ │ ├── zh-CN.js │ └── en.js └── locale/ ├── zh-CN.js └── en.js
页面语言包(比如 product-detail/locale/zh-CN.js):
export default { navTitle: '商品详情', addToCart: '加入购物车', couponHint: '购买更优惠',};
页面里注册并取词:
<script setup>import { useI18n } from 'vue-i18n';import { mergePageMessages } from '@/locales/merge';import zhCN from './locale/zh-CN';import en from './locale/en';// 模块顶层调用:import 加载到这一行即完成注册mergePageMessages('productDetail', { 'zh-CN': zhCN, en });const { t } = useI18n();</script><template> <text>{{ t('productDetail.addToCart') }}</text></template>
核心的 mergePageMessages 只有十几行:
// src/locales/merge.jsimport i18n from './index';const merged = new Set(); // 已注册命名空间集合export function mergePageMessages(namespace, messages) { if (merged.has(namespace)) return; // 幂等:重复加载直接跳过 merged.add(namespace); for (const [locale, msgs] of Object.entries(messages)) { i18n.global.mergeLocaleMessage(locale, { [namespace]: msgs }); }}
它解决了几件事:
- 「自动注册」不是魔法:注册是
import的副作用。页面模块一被加载,顶层调用就执行完,不需要在main.js里集中登记,也没有路由扫描。 - 幂等:
Set保证同一命名空间只合并一次。页面反复进出、组件重复挂载、甚至同一模块被多处 import,都不会重复合并或覆盖。 - 双语言一起合:
zh-CN和en同时合并进全局实例。之后切语言只是「读取已合并的消息」,无需重新加载语言包。 - 合并方向可控:
mergeLocaleMessage(locale, { [namespace]: msgs })把{ namespace: {...} }挂到对应语言下,命名空间天然隔离,互不污染。
五、为什么语言包要和页面放在一起
分包页面(如 subpackages/...)的 locale 文件与其页面同目录,会跟着分包一起打包、随分包按需加载:
- 主包只装
core,体积可控; - 进入某分包时,它的语言包才随模块被 import,进页面即注册、即生效;
- 语言包维护位置与代码位置一致,改文案就在改代码的旁边。
这是整套方案和「集中式大语言包」最本质的区别:归属跟着模块走,加载跟着分包走。
六、非组件代码怎么取词
composable、工具函数、toast 这类不在组件上下文里的代码,拿不到 useI18n()。统一暴露一个取词函数,内部带上空值回退:
// src/locales/index.js 追加export const resolve = (key) => i18n.global.t(key) || i18n.global.t(key, {}, { locale: 'zh-CN' });export const resolveT = (key, params = {}) => i18n.global.t(key, params) || i18n.global.t(key, params, { locale: 'zh-CN' });
用法:
// 某个 composable 里import { resolveT } from '@/locales/index.js';if (total === 0) { uni.showToast({ title: resolveT('productDetail.noResult'), icon: 'none' });}
注意:resolveT 是调用时取值,非响应式——对一次性提示完全够用;如果需要随语言实时更新,必须走组件里的 useI18n().t()。
七、小程序端最大的坑:字符串插值不生效
uni-app 在小程序端会把 vue-i18n 重定向到其内置的 runtime 构建,字符串消息的 messageCompiler 是空实现。这意味着:
// locale 文件里这样写 —— 小程序端不会生效!countHint: '共 {count} 件商品'
调用 t('productDetail.countHint', { count: 3 }) 在小程序端会原样输出 共 {count} 件商品,占位符永远不被替换。H5 端正常,小程序端不正常——跨端一致性问题非常隐蔽。
正确写法:带插值的文案一律写成 MessageFunction(函数消息),在函数内部用 ctx.named('x') 取参并自行兜底:
// zh-CN.jscountHint: (ctx) => `共 ${ctx.named('count') ?? 0} 件商品`,yearWithSuffix: (ctx) => `${ctx.named('year') ?? ''}年`,// en.js(英文无“年”后缀,同样是函数消息)countHint: (ctx) => `${ctx.named('count') ?? 0} items in total`,yearWithSuffix: (ctx) => `${ctx.named('year') ?? ''}`,
调用端完全一致,正常传参:
<text>{{ t('productDetail.yearWithSuffix', { year: 2026 }) }}</text>
排查口诀:只要文案里带 {xxx} 占位符,就先怀疑是不是字符串消息在作祟——改成函数消息,八成能解决。
八、空值回退
英文语言包允许「写一半」:没翻译到的 key 留空,resolve/resolveT 会在英文取到空值时回退中文,页面不会渲染空白。这让英文包的维护可以增量进行,不用一上来就翻译全量。
九、语言切换的响应式链路
- 组件:
useI18n().t()对 locale 响应式 → 切语言自动重渲染。 - 原生层(TabBar 文案、导航栏标题):通过
setNavigationBarTitle等在切语言时主动刷一遍。 - 非组件代码:
resolveT调用时取值,需在切语言后重新触发取词的场景(如 toast),在切换流程里重新调用即可。
十、注意事项与最佳实践
- 一个命名空间只能有一个权威文件。严禁两个页面各自定义同一个 namespace,否则「后合并者覆盖前者」,文案会被悄悄换掉。用目录归属保证唯一性。
- 主包只放跨页共用 + 壳层文案(
core:TabBar、导航、登录弹窗、共享组件、App 级全局逻辑)。页面文案别塞进主包。 - 共享组件用
core,页面内组件自建命名空间并自行注册。被多页引用的组件在自身 setup 顶部自合并,宿主页面无需感知。 - 涉及业务数据的字段名(比如品牌接口返回的中英文名)单独抽一个按 locale 取字段的工具函数,渲染层保持「原始数据 + 渲染时取字段」,这样语言切换即时生效、不用重新拉接口。
- 调试时从「找没找到 namespace」入手:文案没生效先确认该页面是否真的执行过
mergePageMessages(模块是否被加载)。
十一、总结
这套方案的实质是把「国际化」从中央配置变成局部声明:
| 维度 | 传统集中式 | 本方案 |
|---|---|---|
| 语言包位置 | 主包集中 | 各页面/组件目录 |
| 注册方式 | main.js 统一注册 | 模块加载副作用,幂等合并 |
| 主包体积 | 随业务增长 | 只含壳层 |
| 分包加载 | 语言包被整包带走 | 随分包按需 |
| 归属 | 容易混乱 | 跟代码走 |
加上「插值一律用 MessageFunction」这条铁律,就能在 uni-app 小程序里得到一套跨端一致、按需加载、可增量翻译的 i18n 方案。核心代码加起来不过几十行,迁移成本很低,值得一试。