uni-app 小程序页面级 i18n:随页面自动注册的命名空间方案

一、背景与痛点

在 uni-app(微信小程序优先)项目里接 vue-i18n,最常遇到几个问题:

  1. 主包体积:如果所有文案都集中放在主包的几个大语言包里,主包会持续膨胀。而小程序对主包体积有严格限制(2MB),分包页面也不该为与自己无关的文案买单。
  2. 文案归属混乱:页面级文案、共享组件文案、TabBar 文案混在一张表里,改起来互相踩。
  3. 注册时机:分包页面的语言包如果要在 main.js 统一注册,分包资源加载时主包逻辑就得先知道所有页面——耦合且不按需。
  4. 小程序端的插值坑:uni-app 会把 vue-i18n 重定向到它内置的 runtime 构建,字符串消息的编译器是空实现,t(key, { name }) 不会做 {name} 占位符替换——这几乎必踩。

二、总体思路

一句话:主实例只装「常驻命名空间」,页面文案随页面按需注册。

  • 主包只预装应用壳层文案(语言切换、TabBar、导航、共享组件、全局逻辑),用一个 core 命名空间;
  • 每个页面目录下放 locale/zh-CN.jslocale/en.js
  • 页面 <script setup> 顶部调用一个幂等的 mergePageMessages(namespace, { 'zh-CN': …, en })import 语句一执行,合并就发生了
  • 之后组件里 useI18n().t('namespace.key') 正常取词。

三、主实例:只注册常驻命名空间

js
// 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 文件 + 自动注册(核心)

目录结构:

code
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):

js
export default {  navTitle: '商品详情',  addToCart: '加入购物车',  couponHint: '购买更优惠',};

页面里注册并取词:

vue
<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 只有十几行:

js
// 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-CNen 同时合并进全局实例。之后切语言只是「读取已合并的消息」,无需重新加载语言包。
  • 合并方向可控mergeLocaleMessage(locale, { [namespace]: msgs }){ namespace: {...} } 挂到对应语言下,命名空间天然隔离,互不污染。

五、为什么语言包要和页面放在一起

分包页面(如 subpackages/...)的 locale 文件与其页面同目录,会跟着分包一起打包、随分包按需加载:

  • 主包只装 core,体积可控;
  • 进入某分包时,它的语言包才随模块被 import,进页面即注册、即生效
  • 语言包维护位置与代码位置一致,改文案就在改代码的旁边。

这是整套方案和「集中式大语言包」最本质的区别:归属跟着模块走,加载跟着分包走。

六、非组件代码怎么取词

composable、工具函数、toast 这类不在组件上下文里的代码,拿不到 useI18n()。统一暴露一个取词函数,内部带上空值回退:

js
// 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' });

用法:

js
// 某个 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 是空实现。这意味着:

js
// locale 文件里这样写 —— 小程序端不会生效!countHint: '共 {count} 件商品'

调用 t('productDetail.countHint', { count: 3 }) 在小程序端会原样输出 共 {count} 件商品,占位符永远不被替换。H5 端正常,小程序端不正常——跨端一致性问题非常隐蔽。

正确写法:带插值的文案一律写成 MessageFunction(函数消息),在函数内部用 ctx.named('x') 取参并自行兜底:

js
// 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') ?? ''}`,

调用端完全一致,正常传参:

vue
<text>{{ t('productDetail.yearWithSuffix', { year: 2026 }) }}</text>

排查口诀:只要文案里带 {xxx} 占位符,就先怀疑是不是字符串消息在作祟——改成函数消息,八成能解决。

八、空值回退

英文语言包允许「写一半」:没翻译到的 key 留空,resolve/resolveT 会在英文取到空值时回退中文,页面不会渲染空白。这让英文包的维护可以增量进行,不用一上来就翻译全量。

九、语言切换的响应式链路

  • 组件useI18n().t() 对 locale 响应式 → 切语言自动重渲染。
  • 原生层(TabBar 文案、导航栏标题):通过 setNavigationBarTitle 等在切语言时主动刷一遍。
  • 非组件代码resolveT 调用时取值,需在切语言后重新触发取词的场景(如 toast),在切换流程里重新调用即可。

十、注意事项与最佳实践

  1. 一个命名空间只能有一个权威文件。严禁两个页面各自定义同一个 namespace,否则「后合并者覆盖前者」,文案会被悄悄换掉。用目录归属保证唯一性。
  2. 主包只放跨页共用 + 壳层文案core:TabBar、导航、登录弹窗、共享组件、App 级全局逻辑)。页面文案别塞进主包。
  3. 共享组件用 core,页面内组件自建命名空间并自行注册。被多页引用的组件在自身 setup 顶部自合并,宿主页面无需感知。
  4. 涉及业务数据的字段名(比如品牌接口返回的中英文名)单独抽一个按 locale 取字段的工具函数,渲染层保持「原始数据 + 渲染时取字段」,这样语言切换即时生效、不用重新拉接口。
  5. 调试时从「找没找到 namespace」入手:文案没生效先确认该页面是否真的执行过 mergePageMessages(模块是否被加载)。

十一、总结

这套方案的实质是把「国际化」从中央配置变成局部声明

维度 传统集中式 本方案
语言包位置 主包集中 各页面/组件目录
注册方式 main.js 统一注册 模块加载副作用,幂等合并
主包体积 随业务增长 只含壳层
分包加载 语言包被整包带走 随分包按需
归属 容易混乱 跟代码走

加上「插值一律用 MessageFunction」这条铁律,就能在 uni-app 小程序里得到一套跨端一致、按需加载、可增量翻译的 i18n 方案。核心代码加起来不过几十行,迁移成本很低,值得一试。