uni-app 国际化填坑:vue-i18n 占位符丢失问题解析
在 uni-app 跨端开发过程中,我与不少开发者交流过,发现大家在多端项目里实现国际化时,常常踩到一些隐蔽的坑。举个典型场景:某个列表页或详情页,需要展示接口返回的数量、时间、状态等动态信息。

接口返回的数据本身是正常的,比如:
复制代码{
title: '任务名称',
totalCount: 1,
pointCount: 1,
startTime: '2026-06-30 10:59:00',
endTime: '2026-06-30 10:59:48',
type: 1
}
但页面上显示出来的内容却变成了:
复制代码总用时:
张地图
个点位
开始时间:
结束时间:
数字和时间信息全部丢失了。
最初代码中的隐患
项目中原本的格式化方法是这样写的:
复制代码const formatCountText = (localeKey, count) => t(localeKey).replace('{count}', count)
const formatTimeText = (localeKey, time) => t(localeKey).replace('{time}', time)
语言包的定义如下:
复制代码{
totalTime: '总用时:{time}',
durationMinutes: '{count}分钟',
mapCount: '{count}张地图',
markerCount: '{count}个点位',
startTime: '开始时间:{time}',
endTime: '结束时间:{time}',
}
表面看起来逻辑没什么问题,但在 uni-app 的多端环境里,问题开始悄悄浮现。
根因分析
这个问题的核心根源在于:
复制代码t(localeKey)
返回时,{count}、{time} 这类占位符很可能已经被 vue-i18n 内部机制提前处理掉了。
代码原本期望得到的结果是:
复制代码t('module.mapCount')
// "{count}张地图"
但实际获取到的可能是:
复制代码"张地图"
这时候再执行:
复制代码.replace('{count}', count)
已经没有任何占位符可以替换,最终显示就变成了:
复制代码张地图
时间信息同理,会变成:
复制代码开始时间:
为什么不直接使用 t(key, params)
在普通 Vue 项目中,可以这样写:
复制代码t('module.mapCount', { count: 1 })
但在 uni-app 的 App、小程序等非 H5 端,vue-i18n 的参数插值存在兼容性问题,导致 {count} 无法被正确替换。
因此不能简单依赖:
复制代码t(localeKey, { count })
需要封装一个跨端稳定的格式化方法。
封装目标
我们的目标是让组件内部统一使用:
复制代码tf(localeKey, data)
例如:
复制代码tf('module.mapCount', { count: 1 })
// "1张地图"tf('module.startTime', { time: '2026-06-30 10:59:00' })
// "开始时间:2026-06-30 10:59:00"
核心封装实现
首先封装一个获取翻译文本的基础方法:
复制代码export const translate = (key: string): string => {
return i18n.global.t(key) as string
}
再封装一个手动替换占位符的工具函数:
复制代码export function formatI18n(template: string, data: Record<string, any>): string {
if (!template || !data) {
return template
} const match = /{(.*?)}/g.exec(template) if (match) {
const variableList = match[0].replace('{', '').replace('}', '').split('.')
let result: any = data for (let i = 0; i < variableList.length; i++) {
result = result?.[variableList[i]] ?? ''
} return formatI18n(template.replace(match[0], String(result)), data)
} return template
}
这个方法不仅支持普通占位符:
复制代码'{count}个点位'
还支持嵌套属性:
复制代码'身高 {detail.height}'
封装 translateFormat 函数
复制代码export function translateFormat(key: string, data?: Record<string, any>): string {
if (!data) {
return translate(key)
} if (isH5) {
return i18n.global.t(key, data) as string
} const template = translate(key)
return formatI18n(template, data)
}
这里的策略是:
- H5 端:继续使用 vue-i18n 原生插值功能
- 非 H5 端:先获取模板字符串,再手动替换占位符
这样能够兼顾 H5 和 App/小程序不同的运行环境。
封装 Composable
为了方便在组件中使用,我们可以再封装一层 useI18nFormat:
复制代码import { translate, formatI18n, translateFormat } from '@/locales'export const useI18nFormat = () => {
return {
translate,
formatI18n,
tf: translateFormat,
translateFormat,
}
}
组件中的使用方式:
复制代码import { useI18nFormat } from '@/hooks/useI18nFormat'const { tf } = useI18nFormat()
组件中的改造方法
原来的写法:
复制代码const formatCountText = (localeKey, count) => t(localeKey).replace('{count}', count)
const formatTimeText = (localeKey, time) => t(localeKey).replace('{time}', time)
改造后:
复制代码const formatCountText = (localeKey, count) => tf(localeKey, { count })
const formatTimeText = (localeKey, time) => tf(localeKey, { time })
业务映射时:
复制代码countText: formatCountText('module.countText', item.count),
timeText: formatTimeText('module.startTime', item.startTime),
模板中:
复制代码{{ item.countText }}
{{ formatTimeText('module.endTime', item.endTime) }}
排查经验总结
这类问题很容易被误判为接口字段问题。
排查时建议按以下顺序进行:
- 先检查接口返回数据,确认字段是否存在。
- 再打印映射后的列表数据,观察数值是否被替换。
- 如果映射后已经变成
"张地图"、"分钟",说明问题出在 i18n 格式化阶段。 - 如果映射后是
"1张地图",但页面不显示,才需要继续排查模板或样式。 - 如果接口有值但映射后为空,才要检查字段名或响应层级。
本次问题就是通过打印映射后的数据,确认接口数据已经顺利进入前端,但 {count}、{time} 在格式化过程中被意外处理掉了。
总结与最佳实践
在 uni-app 多端项目里,不要依赖下面这种写法:
复制代码t(key).replace('{count}', count)
也不要盲目地改为:
复制代码t(key, { count })
更稳妥的做法是封装统一的跨端格式化方法:
复制代码tf(key, params)
让组件只关注业务语义,无需关心当前运行环境是 H5、App 还是小程序。
最终代码会更加稳定,也更容易统一维护。
