Files
lucky_shop/docs/i18n-guidelines.md
ZF sun 55a5a68517 refactor: 统一多语言key的组件命名格式为短横线分隔
根据i18n规范,将所有组件相关的多语言key从驼峰式改为短横线分隔格式,同步更新文档中的示例说明。
2026-06-30 10:28:36 +08:00

461 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 国际化(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`。
运行时的语言上下文由以下四层按顺序深度合并而成:
1. `lang/<locale>/common.js`(全局公共文案)
2. `SHARED_LANG_PACKS` 中注册的组件/公共 mixin 语言包
3. 当前页面对应的页面语言包
4. 远端覆写 map(若配置为 `remote` / `hybrid`)
合并使用深度合并(`deepMergeLang`),嵌套 key 不会被整体覆盖。
---
## 二、语言包结构与命名规范
### 2.1 文件位置
```text
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 镜像结构一致)
```
每个语言包文件统一导出:
```js
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`:
```js
export const lang = {
viewMore: '查看更多',
noArticles: '暂无文章',
};
```
运行时会被归一化为:
```js
{
componentsDiy: {
diyArticle: {
viewMore: '查看更多',
noArticles: '暂无文章',
}
}
}
```
调用方式:
```vue
{{ $lang('components-diy.diy-article.viewMore') }}
```
> 注意:组件名 `diy-article` 归一化后为 `diyArticle`,不是 `diy-article`,也不是 `diyArticle` 以外的任何形式。若语言包本身已按 `{ componentsDiy: { diyArticle: {...} } }` 嵌套,则直接透传,不再二次包装。
#### 页面语言包
页面语言包优先使用**扁平结构**,key 直接放在 `export const lang = { ... }` 顶层。例如 `order/list.js`:
```js
export const lang = {
title: '我的订单',
status: {
all: '全部',
waitpay: '待付款',
},
};
```
页面内可直接调用:
```vue
{{ $lang('title') }}
{{ $lang('status.waitpay') }}
```
也可以按完整命名空间调用(经过 `normalizePageLangPack` 后会生成):
```vue
{{ $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.*` 命名空间调用。
```js
// common.js
export const lang = {
auth: {
miniProgramConfigError: '小程序配置错误',
},
};
```
调用:
```js
this.$lang('common.auth.miniProgramConfigError');
```
---
## 三、SHARED_LANG_PACKS 注册机制
所有共享组件/公共 mixin 的语言包必须显式注册在 `common/js/lang.js` 的 `SHARED_LANG_PACKS` 数组中,否则组件在任意页面调用 `$lang('components.xxx')` 时会出现 raw key。
```js
const SHARED_LANG_PACKS = [
'components/payment',
'components-diy/diy-article',
'member/public',
// ...
];
```
**新增共享组件语言包时,必须同时完成以下三步:**
1. 创建 `lang/zh-cn/<path>.js` 与 `lang/en-us/<path>.js`;
2. 将 `<path>` 加入 `SHARED_LANG_PACKS`;
3. 运行 `npm run i18n:check` 确保两包 key 一致。
---
## 四、`$lang()` 使用说明
### 4.1 模板中使用
```vue
<!-- 无参数 -->
<text>{{ $lang('order.list.title') }}</text>
<!-- 位置参数 -->
<text>{{ $lang('goods.soldCount', 100) }}</text>
<!-- 文案:已售 {0} 件 -->
<!-- 命名参数 -->
<text>{{ $lang('goods.pointSuffix', { point: 500 }) }}</text>
<!-- 文案:{point}积分 -->
```
### 4.2 JS 中使用
```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}`...
- 全局替换:同一个占位符出现多次,全部替换。
- 命名参数从第二个参数起传入对象;其余情况视为位置参数。
```js
// 位置参数
$lang('hello', 'Tom'); // 你好,{0} → 你好,Tom
// 命名参数
$lang('hello', { name: 'Tom' }); // 你好,{name} → 你好,Tom
```
### 4.4 默认值兜底
从 P2 改造后,动态 key 调用建议传入第二参数作为默认值:
```vue
{{ $lang('components.payment.' + item.type, item.name) }}
```
行为:
- key 存在 → 返回翻译结果;
- key 不存在且传入默认值 → 返回默认值;
- key 不存在且无默认值 → 生产环境返回空串,开发环境返回 raw key 并输出 warning。
---
## 五、解析流程与关键函数
### 5.1 `lang(field)` 主流程
1. 取当前页面 `route`,通过 `processRoutePath` 得到 `langPath`;
2. 构造合并缓存 key:`${locale}__${langPath}__${_overrideVersion}`;
3. 按顺序合并 `common.js` → `SHARED_LANG_PACKS` → 页面语言包 → 远端覆写;
4. 按 `.` 分割 field,在合并后的对象中逐层查找;
5. 未命中且 field 段数 ≥ 3 时,尝试 `tryResolveQualifiedPageField`(按前缀自动加载其他页面包);
6. 仍未命中时,尝试 `tryResolveLoadedPageField`(在已加载的页面包中查找,要求唯一命中);
7. 缓存字段解析结果。
### 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` 中配置:
```js
{
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 为字符串:
```json
{
"code": 0,
"data": {
"version": 123,
"global_version": 456,
"overrides": {
"common.submit": "提交",
"order.list.title": "我的订单(定制)"
}
}
}
```
---
## 七、开发规范与强制校验
### 7.1 提交前必须运行
```bash
npm run i18n:check
```
该脚本会检查:
1. zh-cn / en-us 文件结构是否一致;
2. 同文件下中英文 key 是否完全一致;
3. 粗略扫描源码中的硬编码中文(参考,含注释误报)。
### 7.2 新增/修改 key 流程
1. 在 `lang/zh-cn/<path>.js` 中新增 key;
2. 同步在 `lang/en-us/<path>.js` 中新增同名 key(即使英文暂未翻译,也先用中文占位,避免 raw key);
3. 如果是组件/mixin 语言包,将 `<path>` 加入 `common/js/lang.js` 的 `SHARED_LANG_PACKS`;
4. 源码中使用 `$lang('xxx')` 替换硬编码中文;
5. 运行 `npm run i18n:check`;
6. 对于动态 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 与语言包实际结构不一致(如 `diyListMenu` vs `diyListmenu`);
- 语言包内嵌套结构错误(如 `notice/list.js` 内又包了一层 `notice: {}`)。
排查方法:
1. 检查 `common/js/lang.js` 是否注册;
2. 检查语言包文件路径是否正确;
3. 在 `lang()` 中加入断点,查看 `mergedLang` 中是否存在目标 key;
4. 开发环境下看 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` 无法识别。需要:
1. 枚举所有可能取值;
2. 在对应语言包中补齐 key;
3. 为调用处增加默认值兜底;
4. 运行 `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"。
1. 创建 `lang/zh-cn/components/my-badge.js`:
```js
export const lang = {
newTag: '新品',
};
```
2. 创建 `lang/en-us/components/my-badge.js`:
```js
export const lang = {
newTag: 'New',
};
```
3. 在 `common/js/lang.js` 的 `SHARED_LANG_PACKS` 中追加 `'components/my-badge'`;
4. 组件中使用:
```vue
<text>{{ $lang('components.myBadge.newTag') }}</text>
```
5. 运行 `npm run i18n:check`。
### 10.2 新增一个页面语言包
新增页面 `pages_tool/coupon/list.vue`,需要文案 "优惠券列表"。
1. 创建 `lang/zh-cn/coupon/list.js`:
```js
export const lang = {
title: '优惠券列表',
};
```
2. 创建 `lang/en-us/coupon/list.js`:
```js
export const lang = {
title: 'Coupon List',
};
```
3. 页面中使用:
```vue
<text>{{ $lang('title') }}</text>
<!-- 或 -->
<text>{{ $lang('coupon.list.title') }}</text>
```
4. 运行 `npm run i18n:check`。