# 国际化(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.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//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` 事件。 - 切换语言/租户时自动清理对应缓存并重新拉取。 > 扩展方案:若希望远端语言包完全替代本地语言包(即删除 `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/.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-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 {{ $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`。