小程序端 vue-i18n `{name}` 插值不生效的排查与修复

一、问题现象

登录页标题在手动切成英文后显示:

code
Log in to {shop} for full access

{shop} 没有被替换成店铺名;切回中文也一样:

code
登录{shop},使用完整功能

代码写的是标准 vue-i18n 具名插值调用:

vue
<text class="main-title">{{ t('accountLogin.title', { shop: shopName || '示例店铺' }) }}</text>

locale 文件也没有问题:

js
// locale/zh-CN.jstitle: '登录{shop},使用完整功能',// locale/en.jstitle: 'Log in to {shop} for full access',

结论先行:问题不在我们的业务代码,而在 uni-app 构建时把 vue-i18n 偷偷换成了它内置的一个 runtime 空编译器版本t() 根本不会做 {name} 插值。本文记录完整的排查链路与修复方案。


二、排查思路(从代码到字节码,逐层缩小)

第 1 步:先排除「写错」——看业务代码与 locale

  1. 模板调用:t('accountLogin.title', { shop: shopName || '示例店铺' }) —— 传参正确。
  2. shopName 初值 ref(''),所以 shopName || '示例店铺' 永远有值('' 是 falsy),不可能因为值为 undefined 导致占位符保留。
  3. locale 文件里 {shop}{countdown} 都是标准具名占位符。
  4. 页面用 useI18n().t,合并用 mergePageMessages,都正常。

关键判断:既然 shop 一定有值、消息文本也能取到(用户看到的是完整英文句子而非 key),那问题只可能出在 runtime 的插值环节

第 2 步:看编译产物,确认不是模板编译器搞坏了参数

dist/dev/mp-weixin/pages/accountLogin/accountLogin.js 里编译后的渲染函数:

js
c: e.t(e.unref(t)("accountLogin.title", { shop: I.value || "示例店铺" }))

对象字面量 { shop: I.value || "示例店铺" } 原封不动传给了 t()。所以不是 uni-app 模板编译器把参数弄丢了——参数确实到了 vue-i18n。

第 3 步:用 Node 单测复现「标准 vue-i18n」的行为

直接用项目 node_modules 里的 vue-i18n(9.14.5)写了个最小复现:

js
import { createI18n } from 'vue-i18n';const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: {  'zh-CN': { accountLogin: { title: '登录{shop},使用完整功能' } } } });i18n.global.t('accountLogin.title', { shop: '示例店铺' });// => "登录示例店铺,使用完整功能"   ✅ 插值正常

标准 vue-i18n 完全正常,而且缺参时是「把占位符删掉」,绝不是「原样输出 {shop}」。

于是矛盾点浮现:同一份依赖,Node 里能插值,mp 打包产物里不能。 说明被打包的 vue-i18n 与 node_modules 里的不是同一个东西。

第 4 步:解剖打包产物 vendor.js

dist/dev/mp-weixin/common/vendor.js(439KB)里搜关键特征:

bash
# 消息编译器注册调用 —— 出现次数grep -c "registerMessageCompiler" vendor.js        # => 0 次!grep -o 'messageCompiler:function(y){return Q=>Q.normalize([y])}' vendor.js# 命中:一个「空实现」编译器
  • registerMessageCompiler 0 次:标准 vue-i18n 全量版会在模块顶层 registerMessageCompiler(compile) 注册真编译器,这里没有,说明不是全量版。
  • 找到空实现:messageCompiler: (source) => (ctx) => ctx.normalize([source]) —— 把消息包成数组再 join等于原样返回
  • 版本标记 jm = "9.1.9":而项目 vue-i18n 是 9.14.5、core-base 依赖要求 9.14.5(node_modules 里却是 9.1.9,版本错位)。

顺着 messageCompiler 找到它的来源,@intlify/runtimecreateMessageContextnormalize 的实现:

js
normalize: (n) => (n.length === 0 ? '' : n.join(''))   // 纯拼接,无插值

到这里根因基本锁定:mp 构建打包的是一套「空编译器」的 vue-i18n runtime,t(key, {name}) 收到的插值参数被无视,消息文本原样返回。

第 5 步:追出「是谁换掉了 vue-i18n」

uni-app 的 vite 插件里有个内置模块映射(node_modules/@dcloudio/vite-plugin-uni/dist/configResolved/plugins/resolveId.js):

js
const BUILT_IN_MODULES = {  'vue-i18n': 'dist/vue-i18n.esm-bundler.js',   // ← 把 vue-i18n 指向 uni-app 内置副本  ...};

并在 @dcloudio/uni-cli-shared/dist/resolve.js 里有个专门函数:

js
function resolveVueI18nRuntime() {  return path.resolve(__dirname, '../lib/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js');}

找到这个内置 runtime 文件(uni-cli-shared/lib/vue-i18n/dist/ 下只有 vue-i18n.runtime.esm-bundler.js),里面 getCoreContext() 硬编码了空编译器:

js
messageCompiler: function compileToFunction(source) {  return (ctx) => ctx.normalize([source]);   // 永远不做插值}

第 6 步:确认「能否用配置覆盖」——不能

尝试给 createI18nmessageCompiler: compile,结果:

  • 内置 runtime 的 getCoreContext() 不读 options,空实现是写死的;
  • 内置 runtime 也没有暴露 setMessageCompiler / setProcessor 之类的 setter。

即:项目侧没有任何配置能恢复插值能力。这也解释了为什么简单改一行配置解决不了。


三、修复方案

为什么不直接覆盖 uni-app 的 alias

uni-app 换掉 vue-i18n 是故意的:@dcloudio/uni-app-vite/dist/plugin/index.js 里注释写明——

vue-i18n 默认会启用 new Function 来构造翻译函数,导致在 Android 上可能报 TypeError: no access 错误,故启用 runtime 模式。

而项目实际打包的 @intlify/core-base@9.1.9compile 正是用 new Function 实现

js
const { code } = baseCompile(source, options);const msg = new Function(`return ${code}`)();   // ← Android 上 TypeError: no access

所以覆盖 alias 用回全量 vue-i18n,会把 uni-app 特意规避的 Android 崩溃引回来;createI18nmessageCompiler 选项也被内置 runtime 硬编码忽略。结论:不对抗构建,改用官方消息形态绕开。

思路:用 MessageFunction(函数消息)

vue-i18n 的 messageCompiler 把字符串编译出来的产物本身就是一个函数(接收上下文、返回插值后的字符串)。 MessageFunction 就是手动把这个"编译产物"写出来。translate 检测到 isFunction(message)直接调用它 (evaluateMessage),根本不碰字符串编译器——这是 vue-i18n 官方 API(MessageFunction),不是 hack。

translate 源码里关键就是一行短路:

js
S = isFunction(message) ? message            // 函数消息:直接用,跳过编译器                        : messageCompiler(message, ctx);  // 字符串消息:才进空编译器(坏)

用实际打进 mp 包的 @intlify/core-base@9.1.9 验证过三种情况:

code
string+params : 登录{shop},使用完整功能   ← 现状 bugfn+params     : 登录示例店铺,使用完整功能    ← 函数消息 ✅fn+no-param   : 登录示例店铺,使用完整功能    ← 函数内兜底 ✅

实现:14 条占位符文案转函数

js
// locale/zh-CN.jstitle: (ctx) => `登录${ctx.named('shop') || '示例店铺'},使用完整功能`,resendIn: (ctx) => `${ctx.named('countdown') ?? 0}s后重发`,
  • ctx.named('x') 读取 t(key, { x }) 传入的具名参数(ctx.list(i) / ctx.plural(n) 对应列表 / 复数);
  • 缺参必须带默认值ctx.named('shop') 缺参返回 undefined,不写 || / ?? 会拼出 登录undefined
  • 调用端完全不需要改,还是原生 t('accountLogin.title', { shop: shopName })
  • @/locales/index.js 保持原生 resolve / resolveT(函数消息自己插值,无需 fillParams / fillT 兜底)。

顺手修掉一个「同类不同源」的 bug

排查中在编译产物里发现登录页的占位符是原始 WXML 语法:

html
<input placeholder="{{ t('accountLogin.phonePlaceholder') }}" />

项目里其它页面用的是 Vue 绑定语法 :placeholder="t(...)"placeholder="{{ }}" 这种 WXML 写法在 Vue3 模板里不会被编译成动态绑定,t 在 WXML 作用域里也不存在,占位符根本不渲染。已一并改为 :placeholder="t(...)"(共 3 处)。


四、验证

  1. 端到端单测(Node):用「空编译器模拟」跑转换后的真实 locale 文件,标题 / 倒计时 / 登录拦截 / 购物车已选数量全部正确: 登录示例店铺,使用完整功能58s后重发下单需要登录已选择3件商品,英文 Log in to 示例店铺 for full access
  2. ESLint:改动行无新增 error/warning(现存 7 个 error 均为历史遗留,与本次无关)。
  3. 回归注意:需重新编译小程序包(npm run dev:mp-weixin / build:mp-weixin)后在开发者工具确认标题、倒计时、购物车已选数量、我的页跳转确认等插值文案。

五、给后续开发的约定

本项目小程序端 带插值的文案必须写成 MessageFunction(函数消息),字符串消息里的 {name} 不会生效。

  • {name} 的文案写成 (ctx) => \…${ctx.named(‘x’) || 默认}…``,占位符处自行取参并带默认值;
  • 调用端一律原生 t(key, params)不要再包一层 fillT / fillParams(已废弃删除);
  • 缺参必须 || / ?? 兜底(否则拼 undefined);en 不能留空字符串,要么写全函数、要么 () => ''
  • 不要试图覆盖 uni-app 的 vue-i18n alias 用回全量版——会重新引入 new Function 导致的 Android 崩溃。

附:排查命令速查

bash
# 看编译产物里有没有注册真编译器grep -c "registerMessageCompiler" dist/dev/mp-weixin/common/vendor.js# 看空实现编译器与版本标记grep -o "messageCompiler:function(y){return Q=>Q.normalize(\[y\])}" dist/dev/mp-weixin/common/vendor.jsgrep -o 'jm="[^"]*"' dist/dev/mp-weixin/common/vendor.js# 追出 uni-app 内置 vue-i18ngrep -rn "vue-i18n" node_modules/@dcloudio/vite-plugin-uni/dist/configResolved/plugins/resolveId.jsgrep -n "resolveVueI18nRuntime" node_modules/@dcloudio/uni-cli-shared/dist/resolve.js# 确认内置 runtime 的空实现grep -o "messageCompiler: function compileToFunction" \  node_modules/@dcloudio/uni-cli-shared/lib/vue-i18n/dist/vue-i18n.runtime.esm-bundler.js