docs: add i18n guidelines documentation

add the complete internationalization specification document for the shop-projects/frontend uni-app Vue2 project, covering architecture, specification, usage, development norms and other content
This commit is contained in:
2026-06-29 15:43:14 +08:00
parent d124d58552
commit 04a1d3c287

457
docs/i18n-guidelines.md Normal file
View File

@@ -0,0 +1,457 @@
# 国际化(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.diyArticle.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` 事件。
- 切换语言/租户时自动清理对应缓存并重新拉取。
### 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-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`。