From 04a1d3c2878e3aefd2d0c88ff47c2607fa1a77d7 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Mon, 29 Jun 2026 15:43:14 +0800 Subject: [PATCH] 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 --- docs/i18n-guidelines.md | 457 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 docs/i18n-guidelines.md diff --git a/docs/i18n-guidelines.md b/docs/i18n-guidelines.md new file mode 100644 index 0000000..4d3d42a --- /dev/null +++ b/docs/i18n-guidelines.md @@ -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//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//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/.js` 与 `lang/en-us/.js`; +2. 将 `` 加入 `SHARED_LANG_PACKS`; +3. 运行 `npm run i18n:check` 确保两包 key 一致。 + +--- + +## 四、`$lang()` 使用说明 + +### 4.1 模板中使用 + +```vue + +{{ $lang('order.list.title') }} + + +{{ $lang('goods.soldCount', 100) }} + + + +{{ $lang('goods.pointSuffix', { point: 500 }) }} + +``` + +### 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.` 或 `components-diy.` 命名空间。 + +### 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//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/.js` 中新增 key; +2. 同步在 `lang/en-us/.js` 中新增同名 key(即使英文暂未翻译,也先用中文占位,避免 raw key); +3. 如果是组件/mixin 语言包,将 `` 加入 `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 +{{ $lang('components.myBadge.newTag') }} +``` + +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 +{{ $lang('title') }} + +{{ $lang('coupon.list.title') }} +``` + +4. 运行 `npm run i18n:check`。