13 KiB
国际化(i18n)技术说明文档
适用范围:
shop-projects/frontend(uni-app Vue2 项目) 核心模块:common/js/lang.js维护要求:新增/修改翻译 key 后,必须运行npm run i18n:check
一、整体架构
项目采用 本地语言包 + 远端覆写 的混合方案:
- 本地语言包:位于
lang/zh-cn/与lang/en-us/,随包体发布,作为默认文案。 - 远端覆写:启动/切换语言时从后端拉取扁平化覆写 map,按租户、平台、语种三维隔离,用于运营 Hotfix 或 SaaS 多租户文案定制。
- 解析入口:全局注入的
$lang(key, ...args)与$langConfig对象,核心实现在common/js/lang.js。
运行时的语言上下文由以下四层按顺序深度合并而成:
lang/<locale>/common.js(全局公共文案)SHARED_LANG_PACKS中注册的组件/公共 mixin 语言包- 当前页面对应的页面语言包
- 远端覆写 map(若配置为
remote/hybrid)
合并使用深度合并(deepMergeLang),嵌套 key 不会被整体覆盖。
二、语言包结构与命名规范
2.1 文件位置
lang/
├── zh-cn/
│ ├── common.js # 全局公共文案
│ ├── components/
│ │ ├── payment.js # 组件级文案
│ │ └── ns-goods-sku.js
│ ├── components-diy/
│ │ ├── diy-article.js
│ │ └── diy-listmenu.js
│ ├── goods/
│ │ ├── detail.js
│ │ └── list.js
│ ├── order/
│ │ └── list.js
│ └── ...
└── en-us/
└── ...(与 zh-cn 镜像结构一致)
每个语言包文件统一导出:
export const lang = {
// 文案 key / 嵌套对象
};
2.2 页面语言包路径映射
页面路由到语言包路径的映射由 processRoutePath 处理,主要规则:
| 页面路径 | 语言包路径 | 说明 |
|---|---|---|
pages/index/index |
index/index |
去掉 pages 前缀 |
pages_tool/member/index |
member/index |
pages_tool 子目录直接提升 |
pages_goods/goods/detail |
goods/detail |
pages_goods 映射为 goods |
pages_order/order/list |
order/list |
pages_order 映射为 order |
pages_member/member/index |
member/index |
pages_member 映射为 member |
pages_promotion/point/order_list |
point/order_list |
point/fenxiao/merch 子模块提升 |
pages_promotion/fenxiao/order |
fenxiao/order |
同上 |
因此,新增页面语言包时,必须按上述映射关系创建文件,否则 $lang() 找不到对应 key。
2.3 命名空间规范
组件语言包
组件语言包统一挂在 components 或 components-diy 命名空间下,组件名使用 camelCase(短横线/下划线会被转换)。
例如 components-diy/diy-article.js:
export const lang = {
viewMore: '查看更多',
noArticles: '暂无文章',
};
运行时会被归一化为:
{
componentsDiy: {
diyArticle: {
viewMore: '查看更多',
noArticles: '暂无文章',
}
}
}
调用方式:
{{ $lang('components-diy.diy-article.viewMore') }}
注意:组件名
diy-article归一化后为diyArticle,不是diy-article,也不是diyArticle以外的任何形式。若语言包本身已按{ componentsDiy: { diyArticle: {...} } }嵌套,则直接透传,不再二次包装。
页面语言包
页面语言包优先使用扁平结构,key 直接放在 export const lang = { ... } 顶层。例如 order/list.js:
export const lang = {
title: '我的订单',
status: {
all: '全部',
waitpay: '待付款',
},
};
页面内可直接调用:
{{ $lang('title') }}
{{ $lang('status.waitpay') }}
也可以按完整命名空间调用(经过 normalizePageLangPack 后会生成):
{{ $lang('order.list.title') }}
{{ $lang('order.list.status.waitpay') }}
不建议把页面语言包写成
{ order: { list: { title: '...' } } }这种完整嵌套结构,除非旧代码已经这样写。扁平结构更利于页面内直接调用,同时归一化逻辑会自动补全order.list.*命名空间。
公共 JS 文案
公共 JS(如 common/js/auth.js)使用的文案应放入 lang/<locale>/common.js,按 common.* 命名空间调用。
// common.js
export const lang = {
auth: {
miniProgramConfigError: '小程序配置错误',
},
};
调用:
this.$lang('common.auth.miniProgramConfigError');
三、SHARED_LANG_PACKS 注册机制
所有共享组件/公共 mixin 的语言包必须显式注册在 common/js/lang.js 的 SHARED_LANG_PACKS 数组中,否则组件在任意页面调用 $lang('components.xxx') 时会出现 raw key。
const SHARED_LANG_PACKS = [
'components/payment',
'components-diy/diy-article',
'member/public',
// ...
];
新增共享组件语言包时,必须同时完成以下三步:
- 创建
lang/zh-cn/<path>.js与lang/en-us/<path>.js; - 将
<path>加入SHARED_LANG_PACKS; - 运行
npm run i18n:check确保两包 key 一致。
四、$lang() 使用说明
4.1 模板中使用
<!-- 无参数 -->
<text>{{ $lang('order.list.title') }}</text>
<!-- 位置参数 -->
<text>{{ $lang('goods.soldCount', 100) }}</text>
<!-- 文案:已售 {0} 件 -->
<!-- 命名参数 -->
<text>{{ $lang('goods.pointSuffix', { point: 500 }) }}</text>
<!-- 文案:{point}积分 -->
4.2 JS 中使用
// Vue 实例内
const title = this.$lang('order.list.title');
// 带默认值兜底(推荐用于动态 key)
const name = this.$lang('components.payment.' + type, item.name);
4.3 参数替换规则
- 位置参数:
{0}、{1}、{2}... - 命名参数:
{name}、{count}、{point}... - 全局替换:同一个占位符出现多次,全部替换。
- 命名参数从第二个参数起传入对象;其余情况视为位置参数。
// 位置参数
$lang('hello', 'Tom'); // 你好,{0} → 你好,Tom
// 命名参数
$lang('hello', { name: 'Tom' }); // 你好,{name} → 你好,Tom
4.4 默认值兜底
从 P2 改造后,动态 key 调用建议传入第二参数作为默认值:
{{ $lang('components.payment.' + item.type, item.name) }}
行为:
- key 存在 → 返回翻译结果;
- key 不存在且传入默认值 → 返回默认值;
- key 不存在且无默认值 → 生产环境返回空串,开发环境返回 raw key 并输出 warning。
五、解析流程与关键函数
5.1 lang(field) 主流程
- 取当前页面
route,通过processRoutePath得到langPath; - 构造合并缓存 key:
${locale}__${langPath}__${_overrideVersion}; - 按顺序合并
common.js→SHARED_LANG_PACKS→ 页面语言包 → 远端覆写; - 按
.分割 field,在合并后的对象中逐层查找; - 未命中且 field 段数 ≥ 3 时,尝试
tryResolveQualifiedPageField(按前缀自动加载其他页面包); - 仍未命中时,尝试
tryResolveLoadedPageField(在已加载的页面包中查找,要求唯一命中); - 缓存字段解析结果。
5.2 normalizeSharedLangPack(path, langData)
将组件语言包归一化到 components.<ComponentCamelCase> 或 components-diy.<ComponentCamelCase> 命名空间。
5.3 normalizePageLangPack(path, langData)
将页面语言包展开为三层访问方式:
- 扁平 key(如
$lang('title')) - 文件名命名空间(如
$lang('list.title')) - 完整路径命名空间(如
$lang('order.list.title'))
5.4 tryResolveQualifiedPageField(locale, field)
用于处理跨页面包引用。例如 components/ns-goods-sku.vue 中调用 $lang('goods.detail.stock') 时,即使当前页面不是 goods/detail,也会自动加载 lang/<locale>/goods/detail.js 并解析。
六、远端覆写机制
6.1 配置
在 common/js/config-external.js 的 langConfig 中配置:
{
source: 'remote', // 'local' | 'remote' | 'hybrid'
defaultLang: 'zh-cn',
langList: ['zh-cn', 'en-us'],
remote: {
overridesUrl: '/api/lang/overrides',
requestTimeout: 5000,
bootTimeout: 3000,
storagePrefix: 'lang_override_',
refreshOnUniacidChange: true,
}
}
6.2 行为
local:完全走本地语言包,不拉远端。remote/hybrid:启动时先从本地 storage 读取上一份覆写作为首屏兜底,再异步拉取远端;成功后合并到内存并通知全局刷新。- 拉取失败时不抛错,静默使用本地兜底,并触发
lang:fallback事件。 - 切换语言/租户时自动清理对应缓存并重新拉取。
扩展方案:若希望远端语言包完全替代本地语言包(即删除
lang/目录后仍能正常运行),参见docs/i18n-remote-only-solution.md。
6.3 覆写数据格式
后端返回的 overrides 是一个扁平 map,key 为点分路径,value 为字符串:
{
"code": 0,
"data": {
"version": 123,
"global_version": 456,
"overrides": {
"common.submit": "提交",
"order.list.title": "我的订单(定制)"
}
}
}
七、开发规范与强制校验
7.1 提交前必须运行
npm run i18n:check
该脚本会检查:
- zh-cn / en-us 文件结构是否一致;
- 同文件下中英文 key 是否完全一致;
- 粗略扫描源码中的硬编码中文(参考,含注释误报)。
7.2 新增/修改 key 流程
- 在
lang/zh-cn/<path>.js中新增 key; - 同步在
lang/en-us/<path>.js中新增同名 key(即使英文暂未翻译,也先用中文占位,避免 raw key); - 如果是组件/mixin 语言包,将
<path>加入common/js/lang.js的SHARED_LANG_PACKS; - 源码中使用
$lang('xxx')替换硬编码中文; - 运行
npm run i18n:check; - 对于动态 key,运行
node scripts/verify-p2-keys.js。
7.3 禁止事项
- 禁止用
Object.assign()浅合并语言包(会导致嵌套 key 丢失); - 禁止在生产环境返回 raw key(已兜底为空串,开发环境返回 raw key 便于调试);
- 禁止翻译参数只替换第一次出现(必须全局替换);
- 禁止组件语言包不注册
SHARED_LANG_PACKS就直接使用。
八、常见问题与排查
8.1 英文环境下显示 raw key
可能原因:
- 组件语言包未加入
SHARED_LANG_PACKS; - 页面语言包路径与
processRoutePath映射不匹配; - 调用 key 与语言包实际结构不一致(如
diyListMenuvsdiyListmenu); - 语言包内嵌套结构错误(如
notice/list.js内又包了一层notice: {})。
排查方法:
- 检查
common/js/lang.js是否注册; - 检查语言包文件路径是否正确;
- 在
lang()中加入断点,查看mergedLang中是否存在目标 key; - 开发环境下看 console 是否有
[i18n] missing key "xxx"warning。
8.2 嵌套 key 被覆盖
确保 deepMergeLang 正常工作,不要使用 Object.assign 合并语言包。若发现 common.currencySymbol 等子 key 丢失,检查是否有地方做了浅合并。
8.3 动态 key 无法静态校验
对于 $lang('status.' + statusItem.status) 这类动态 key,i18n-check.js 无法识别。需要:
- 枚举所有可能取值;
- 在对应语言包中补齐 key;
- 为调用处增加默认值兜底;
- 运行
node scripts/verify-p2-keys.js验证。
九、相关脚本与文档
| 文件 | 作用 |
|---|---|
common/js/lang.js |
i18n 核心解析器 |
scripts/i18n-check.js |
zh-cn / en-us key 一致性检查 + 硬编码中文扫描 |
scripts/verify-p2-keys.js |
动态 key 枚举验证 |
docs/i18n-audit-report.md |
全量审计报告 |
docs/i18n-p2-solution-plan.md |
P2 动态 key 处理方案 |
docs/i18n-remote-only-solution.md |
远端完全替代本地语言包方案 |
docs/i18n-guidelines.md |
本说明文档 |
十、附录:示例
10.1 新增一个组件语言包
新增 components/my-badge.vue,需要文案 "新品" / "New"。
- 创建
lang/zh-cn/components/my-badge.js:
export const lang = {
newTag: '新品',
};
- 创建
lang/en-us/components/my-badge.js:
export const lang = {
newTag: 'New',
};
- 在
common/js/lang.js的SHARED_LANG_PACKS中追加'components/my-badge'; - 组件中使用:
<text>{{ $lang('components.myBadge.newTag') }}</text>
- 运行
npm run i18n:check。
10.2 新增一个页面语言包
新增页面 pages_tool/coupon/list.vue,需要文案 "优惠券列表"。
- 创建
lang/zh-cn/coupon/list.js:
export const lang = {
title: '优惠券列表',
};
- 创建
lang/en-us/coupon/list.js:
export const lang = {
title: 'Coupon List',
};
- 页面中使用:
<text>{{ $lang('title') }}</text>
<!-- 或 -->
<text>{{ $lang('coupon.list.title') }}</text>
- 运行
npm run i18n:check。