让小型 Electron i18n 层易于维护的六个决定
一套实用的 Electron i18n 设计,涵盖语言偏好、类型化词条、回退、Intl 格式化以及渲染进程与主进程同步。

为现有 Electron 应用添加日语时,我们不得不回答一些消息词条无法解决的问题。操作系统语言与已保存的偏好不一致时,应当以哪个为准?渲染进程还没把选择告诉主进程时,该用什么语言?怎样避免原生对话框与 React 界面使用不同语言?
第一版刻意保持精简。它支持英语和日语,以英语回退,并用 TypeScript 和一组简短测试代替大型国际化框架。早期做出的六个决定,让这层小型实现更容易理解和维护。
1. 保存语言偏好,而不只是 locale
只保存 en 或 ja 这样的 locale,无法表示“跟随 macOS”这一选择。只把系统语言当作初始默认值也有同样的问题:保存具体 locale 后,之后更改 macOS 语言就不会生效。
我们分别建模用户选择和当前生效的 locale:
type AppLocale = "en" | "ja";
type AppLocalePreference = "system" | AppLocale;
const resolveAppLocale = (preference: AppLocalePreference, languages: readonly string[]): AppLocale =>
preference === "system" ? detectSystemLocale(languages) : preference;
只有 system 偏好会读取 navigator.languages。手动选择始终优先,即使它与 macOS 不同。使用已保存的值前,我们会先验证;如果值缺失、损坏,或由不再支持的旧版本留下,就回退到 system。
navigator.languages 的顺序也很重要。我们选择用户偏好列表中第一个受支持的语言,而不是只要列表里出现日语就覆盖排在前面的英语。如果没有受支持的条目,应用使用英语。
分开这两个值后,每项状态只有一个职责。偏好记录用户想要什么,locale 则决定界面现在应当渲染什么语言。
2. 选错语言后仍能看懂语言选项
语言菜单始终显示以下标签:
- English
- 日本語
它们是每种语言对自己的称呼。把两个标签都翻译成当前界面语言,会制造一个本可避免的恢复问题:用户切换到看不懂的语言后,可能连切回去所需的标签也认不出来。
我们仍会翻译 System default,因为这个选项描述的是应用行为,而不是一种语言的名称。测试会在两个词条中固定这两个语言自称,避免普通翻译修改在以后意外改动它们。
3. 把英语词条当作 TypeScript 契约
英语词条已经最接近应用的完整文案清单,因此我们用它的键约束所有受支持的 locale。
export const enMessages = {
"settings.language.title": "Language",
"settings.language.english": "English",
"settings.language.japanese": "日本語",
} as const;
export type MessageKey = keyof typeof enMessages;
const catalogs = {
en: enMessages,
ja: jaMessages,
} as const satisfies Record<AppLocale, Record<MessageKey, string>>;
这项词条契约会在开发时发现两类常见错误。组件不能请求拼错或不存在的键,任何 locale 也不能漏掉英语已有的键。如果添加英语消息时没有补上日语项,类型检查会在词条改动附近失败。
类型化的键无法证明翻译质量,但能把结构完整性变成常规编译器检查。对于只有两种语言、消息结构简单的应用,这已经足够。
4. 同时保留运行时回退和完整性检查
编译时完整性不代表可以移除运行时回退。旧的本地数据、意外的构建版本不一致或未来的动态词条,仍可能产生缺失值。返回英语消息比破坏周围界面更安全。
我们也不允许回退掩盖未完成的工作。类型检查要求每个词条使用相同的键集合,测试则比较代表性消息、值替换和固定标签。运行时容错与开发反馈解决的是不同问题,因此两者都要保留。
插值刻意限制在 {count} 这类简单的具名值。需要处理复数规则、语法性别或复杂消息选择时,才值得引入 ICU MessageFormat 或专用库。在文案尚不需要这些能力时先加进来,只会让系统更难检查,并不会解决当前问题。
5. 在消息词条之外格式化日期和数字
只翻译标签,而日期和数字仍沿用错误的格式,界面就不会真正符合当地习惯。组件接收当前生效的应用 locale,并把对应的 BCP 47 标签传给平台格式化工具。
const getIntlLocale = (locale: AppLocale) => (locale === "ja" ? "ja-JP" : "en-US");
const label = new Intl.DateTimeFormat(getIntlLocale(locale), {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
把 Intl.DateTimeFormat 和 Intl.NumberFormat 留在消息函数外,可以防止词条演变成通用格式化层。日期、时间、文件大小或数量在哪里渲染,哪里就能直接看到对 locale 的依赖。
显示格式必须与面向机器的输出分开。本地化日期可能包含斜杠或其他不适合文件名的字符。导出文件名使用可预测、文件安全的格式,本地化格式只用于展示给用户的文本。
6. 把渲染进程的选择同步到主进程
Electron 应用不只在一个位置显示文案。React 渲染大部分界面,但原生对话框、截图相关提示和其他操作系统交互由主进程负责。完成渲染进程词条并不代表整个应用都已本地化。
渲染进程通过类型化 IPC 端点发送已保存的偏好和当前生效的 locale:
type AppLocaleSyncRequest = {
preference: "system" | "en" | "ja";
locale: "en" | "ja";
};
await window.electron.appLocale.syncPreference({
preference,
locale,
});
同时发送两个值可以保留各自的含义。主进程能立即使用当前 locale,同时仍知道它来自 macOS 还是手动覆盖。首次同步前,主进程根据 Electron 的 app.getLocale() 得到保守默认值;如果无法查询,就回退到英语。
provider 挂载时同步一次,窗口重新获得焦点时再同步一次。第二次同步在开发中很有用:重新加载主进程会丢失内存中的上下文,而渲染进程可能仍在运行。IPC 失败不会阻塞渲染进程,只会让原生文案在下次同步前使用安全回退。
渲染进程到主进程的同步也改变了我们的完成标准。现在会分别检查 React 文案、主进程文案和 macOS 管理的界面文本。本地化的设置页不能掩盖渲染进程外仍为英语的退出确认框。
保护这些边界的测试
第一组测试关注状态和进程边界,而不是为每个翻译页面生成快照。它会验证:
- 当
ja-JP是系统偏好列表中第一个受支持的语言时,结果为日语。 - 手动选择的语言覆盖系统语言。
- 缺失或不受支持的已保存值会安全回到系统偏好。
- 每个 locale 都满足英语词条的键集合。
English和日本語在两种界面中都保持不变。- 日期和数字使用当前生效的应用 locale。
- 主进程同时收到偏好和当前生效的 locale。
组件测试仍覆盖重要的渲染状态,但这些较小的测试能在定义行为的函数附近发现大多数回归,而且开销足够低,可以在每次改动时运行。
小型 i18n 层也需要清晰边界
这套设计的价值不在代码量,而在于分开了偏好与 locale、恢复与完整性、消息与格式化,以及渲染进程与主进程。
这些边界为日后替换单个部分留下了空间。增加第三或第四种语言时,可能值得生成词条;语法变得复杂时,可能值得使用 ICU 消息。这些改动都不需要重新定义已保存偏好的含义,也不需要改变原生文案归哪个进程管理。
我们在为 Mac 应用 Shotomatic 的 Action Capture 功能添加日语时构建了这层 i18n。上面的实现示例简化自已发布应用,但状态模型和进程边界与实际代码一致。
相关文章
查看更多文章如何用 Playwright 自动截取网站截图
使用 Playwright 和 JavaScript 自动截取网站截图,包括单页、整页、移动端视图,以及带重试机制的 URL 批量截图。




把点击操作整理成清晰的分步指南
Action Capture 会按顺序记录每次点击。之后可在 Mac 上编辑并导出指南。