4. 多语言资源管理与国际化基础

电子作业指导系统(e-SOP)通常要在同一套界面里同时服务中文、英文、越南语、泰语等多条产线。如果界面文字被硬编码在页面或组件里,每增加一种语言就要复制一份页面,维护成本会迅速失控。国际化的核心思路是把“界面结构”和“界面文字”彻底分离:结构由代码负责,文字由语言资源负责,运行时再按当前语言把两者拼装起来。

i18n 与 l10n:两个不同阶段的工作

i18n(internationalization,国际化)是“让系统具备支持多语言的能力”,属于工程改造阶段:抽取文案、定义翻译键、建立语言包加载机制、处理日期数字格式。l10n(localization,本地化)是“为某个具体地区产出可用内容”,属于内容交付阶段:把键翻译成目标语言、校对术语、适配当地计量单位与日期习惯。一句话概括:i18n 是一次性的架构能力,l10n 是每种语言重复一次的内容工作。二者关系可用下图表示。

i18n 能力层与 l10n 内容层的分离结构 i18n 国际化(一次性工程能力) • 文案抽取,替换为翻译键 • 语言包目录与命名规范 • 运行时加载与语言切换 • 日期/数字/单位格式化接口 l10n 本地化(每种语言一次) • zh-CN / en-US / vi-VN 译文 • 工艺术语统一与校对 • 计量单位与日期习惯适配 • 长文本截断与排版检查 提供键位 运行时装配:t("sop.step.title") + 当前语言包 → 渲染文本 同一份界面结构,切换语言包即可输出不同语言界面 zh-CN.json 中文语言包 en-US.json 英文语言包 vi-VN.json 越南语语言包

翻译键设计:决定后期维护成本的关键

翻译键(translation key)是代码与译文之间的唯一契约。键一旦发布就被大量引用,改名意味着全量替换,因此键的命名必须在项目初期就定好规则。推荐采用“模块.对象.用途”的点分层级结构,全部小写,用点号分隔,不使用中文、空格和驼峰。

// 推荐:语义清晰、可分层、可批量检索
"sop.step.title"          // 作业步骤标题
"sop.step.duration"       // 步骤标准工时
"device.status.running"   // 设备状态:运行中
"device.status.fault"     // 设备状态:故障
"common.button.confirm"   // 通用按钮:确认
"common.button.cancel"    // 通用按钮:取消

// 不推荐:把中文或整句当键,改文案即改键
"作业步骤标题"
"请点击确认按钮继续"

键设计有三条硬规则。第一,键只描述“位置和用途”,不描述“内容”,例如用 device.status.fault 而不是 device.status.red,因为颜色可能随主题变化。第二,带变量的句子必须使用占位符,且占位符顺序由命名参数决定,不能依赖位置拼接:

// zh-CN.json
"alarm.overTemp": "设备 {device} 温度 {value}℃ 超过上限 {limit}℃"

// en-US.json
"alarm.overTemp": "Device {device} temperature {value}°C exceeds limit {limit}°C"

第三,复数、性别等语言差异不要用 if 判断写在代码里,而应交给 i18n 库的复数规则处理,例如英文 1 step 与 3 steps,中文则无复数变化。若把复数逻辑写死在组件中,新增语言时必然返工。

语言包组织:按模块拆分,按需合并

单文件语言包在小型系统中够用,但 e-SOP 往往包含工单、设备、质检、报表等十几个模块,一个 zh-CN.json 可能膨胀到数千行,多人协作时冲突频繁。更实用的做法是按模块拆分为多个命名空间文件,再由入口统一合并:

locales/
├── zh-CN/
│   ├── common.json      // 通用按钮、提示
│   ├── sop.json         // 作业指导相关
│   ├── device.json      // 设备状态与参数
│   └── quality.json     // 质检项与判定
├── en-US/
│   ├── common.json
│   ├── sop.json
│   ├── device.json
│   └── quality.json
└── index.js             // 汇总导出

拆分后,键的命名空间与文件名保持一致,例如 sop.json 中的键统一以 sop. 开头。这样翻译人员只需拿到自己负责的模块文件,代码评审时也能快速定位改动范围。合并入口示例:

import commonZh from './zh-CN/common.json';
import sopZh from './zh-CN/sop.json';
import deviceZh from './zh-CN/device.json';

export const zhCN = { ...commonZh, ...sopZh, ...deviceZh };

动态加载策略:首屏轻量,切换按需

把所有语言包一次性打进主包,会让首屏体积随语言数量线性增长。合理的策略是:当前语言的核心模块随首屏加载,其余语言和非核心模块在语言切换或进入对应页面时再异步拉取。判断依据是“用户是否马上需要看到”。

const loaded = {};

async function loadNamespace(lang, ns) {
  const key = `${lang}/${ns}`;
  if (loaded[key]) return loaded[key];
  const res = await fetch(`/locales/${lang}/${ns}.json`);
  loaded[key] = await res.json();
  return loaded[key];
}

// 进入设备页面时按需加载
await loadNamespace('vi-VN', 'device');

加载失败必须有兜底:先回退到默认语言(如 zh-CN)的同名键,再回退到键本身,避免界面出现空白。同时应缓存已加载的语言包,避免同一语言反复请求。切换语言时,若目标语言包尚未加载,先显示加载态,加载完成后再整体重渲染,而不是逐条替换文本,否则会出现中英混排的中间状态。

实践建议:在 CI 中加入“键一致性检查”,对比各语言包与基准语言包的键集合,输出缺失键和多余键清单。缺失键会导致回退显示,多余键则是废弃文案,两者都应在上线前清理。
常见误区:把翻译键当作可随意修改的普通字符串。键一旦被引用,改名必须同步更新所有语言包和所有调用点。建议在代码评审中把“修改已有键名”列为需要重点确认的变更。

把以上四件事落实到位——明确 i18n 与 l10n 的分工、用点分层级设计翻译键、按模块拆分语言包、按需动态加载并做好回退——系统就具备了可持续扩展的多语言能力。后续新增一种语言,只需增加一个语言目录并补齐键值,无需改动任何界面代码。