一、问题现象
登录页标题在手动切成英文后显示:
Log in to {shop} for full access
{shop} 没有被替换成店铺名;切回中文也一样:
登录{shop},使用完整功能
代码写的是标准 vue-i18n 具名插值调用:
<text class="main-title">{{ t('accountLogin.title', { shop: shopName || '示例店铺' }) }}</text>
locale 文件也没有问题:
// locale/zh-CN.jstitle: '登录{shop},使用完整功能',// locale/en.jstitle: 'Log in to {shop} for full access',
结论先行:问题不在我们的业务代码,而在 uni-app 构建时把 vue-i18n 偷偷换成了它内置的一个 runtime 空编译器版本,t() 根本不会做 {name} 插值。本文记录完整的排查链路与修复方案。
二、排查思路(从代码到字节码,逐层缩小)
第 1 步:先排除「写错」——看业务代码与 locale
- 模板调用:
t('accountLogin.title', { shop: shopName || '示例店铺' })—— 传参正确。 shopName初值ref(''),所以shopName || '示例店铺'永远有值(''是 falsy),不可能因为值为undefined导致占位符保留。- locale 文件里
{shop}、{countdown}都是标准具名占位符。 - 页面用
useI18n().t,合并用mergePageMessages,都正常。
关键判断:既然
shop一定有值、消息文本也能取到(用户看到的是完整英文句子而非 key),那问题只可能出在 runtime 的插值环节。
第 2 步:看编译产物,确认不是模板编译器搞坏了参数
dist/dev/mp-weixin/pages/accountLogin/accountLogin.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)写了个最小复现:
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)里搜关键特征:
# 消息编译器注册调用 —— 出现次数grep -c "registerMessageCompiler" vendor.js # => 0 次!grep -o 'messageCompiler:function(y){return Q=>Q.normalize([y])}' vendor.js# 命中:一个「空实现」编译器
registerMessageCompiler0 次:标准 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/runtime 的 createMessageContext 里 normalize 的实现:
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):
const BUILT_IN_MODULES = { 'vue-i18n': 'dist/vue-i18n.esm-bundler.js', // ← 把 vue-i18n 指向 uni-app 内置副本 ...};
并在 @dcloudio/uni-cli-shared/dist/resolve.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() 硬编码了空编译器:
messageCompiler: function compileToFunction(source) { return (ctx) => ctx.normalize([source]); // 永远不做插值}
第 6 步:确认「能否用配置覆盖」——不能
尝试给 createI18n 传 messageCompiler: 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.9 的 compile 正是用 new Function 实现:
const { code } = baseCompile(source, options);const msg = new Function(`return ${code}`)(); // ← Android 上 TypeError: no access
所以覆盖 alias 用回全量 vue-i18n,会把 uni-app 特意规避的 Android 崩溃引回来;createI18n 的
messageCompiler 选项也被内置 runtime 硬编码忽略。结论:不对抗构建,改用官方消息形态绕开。
思路:用 MessageFunction(函数消息)
vue-i18n 的 messageCompiler 把字符串编译出来的产物本身就是一个函数(接收上下文、返回插值后的字符串)。
MessageFunction 就是手动把这个"编译产物"写出来。translate 检测到 isFunction(message) 会直接调用它
(evaluateMessage),根本不碰字符串编译器——这是 vue-i18n 官方 API(MessageFunction),不是 hack。
translate 源码里关键就是一行短路:
S = isFunction(message) ? message // 函数消息:直接用,跳过编译器 : messageCompiler(message, ctx); // 字符串消息:才进空编译器(坏)
用实际打进 mp 包的 @intlify/core-base@9.1.9 验证过三种情况:
string+params : 登录{shop},使用完整功能 ← 现状 bugfn+params : 登录示例店铺,使用完整功能 ← 函数消息 ✅fn+no-param : 登录示例店铺,使用完整功能 ← 函数内兜底 ✅
实现:14 条占位符文案转函数
// 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 语法:
<input placeholder="{{ t('accountLogin.phonePlaceholder') }}" />
项目里其它页面用的是 Vue 绑定语法 :placeholder="t(...)"。placeholder="{{ }}" 这种 WXML 写法在 Vue3 模板里不会被编译成动态绑定,t 在 WXML 作用域里也不存在,占位符根本不渲染。已一并改为 :placeholder="t(...)"(共 3 处)。
四、验证
- 端到端单测(Node):用「空编译器模拟」跑转换后的真实 locale 文件,标题 / 倒计时 / 登录拦截 / 购物车已选数量全部正确:
登录示例店铺,使用完整功能、58s后重发、下单需要登录、已选择3件商品,英文Log in to 示例店铺 for full access。 - ESLint:改动行无新增 error/warning(现存 7 个 error 均为历史遗留,与本次无关)。
- 回归注意:需重新编译小程序包(
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 崩溃。
附:排查命令速查
# 看编译产物里有没有注册真编译器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