保持小型 Electron i18n 层可维护的六项决策
面向语言偏好、类型化文案目录、回退行为、Intl 格式化以及渲染进程到主进程同步的 Electron i18n 实用设计。
8 分钟阅读指南作者:Temposhot Team
只有五到十种语言的桌面应用通常不需要大型 i18n 框架。但它需要几条明确的规则,因为 Electron 应用有两个进程都会显示文字:渲染进程绘制界面,主进程负责菜单、对话框和通知。下面六项决策使一个手写的小型 i18n 层在应用和文案目录增长时保持正确。
1. 保存偏好,而不只是语言区域
用户选择德语时保存 "de",在一开始看似够用,但当有人希望应用跟随操作系统语言时就会出问题。应改为保存用户的选择意图,并在运行时解析实际的语言区域:
export const SUPPORTED = ["en", "de", "it", "ja", "ko", "zh-Hans"] as const;
export type Locale = (typeof SUPPORTED)[number];
export type LanguagePreference = "system" | Locale;
const ALIASES: Record<string, Locale> = { "zh-cn": "zh-Hans", "zh-sg": "zh-Hans" };
export function resolveLocale(pref: LanguagePreference, systemLanguages: string[]): Locale {
if (pref !== "system") return pref;
for (const tag of systemLanguages) {
const lower = tag.toLowerCase();
const exact = SUPPORTED.find((l) => lower === l.toLowerCase() || lower.startsWith(l.toLowerCase() + "-"));
if (exact) return exact;
if (ALIASES[lower]) return ALIASES[lower];
}
return "en";
}
在主进程中,app.getPreferredSystemLanguages() 返回操作系统提供的有序语言列表。保存的值为 "system" 时,用户更改系统语言后,下次启动即可使用新语言,无需修改应用设置。
解析器会遍历整个偏好列表,而不只检查第一项。设置为“葡萄牙语,其次为英语”的用户会得到英语,因为英语是其第二选择,而不是默认值。前缀匹配会把 de-CH 映射为德语,把 zh-Hans-CN 映射为简体中文。zh-TW 不匹配任何项,因此会进入下一个偏好,而不会被静默地当作简体中文。
解析顺序
函数在第一个匹配项处返回。检查顺序很重要,因为已保存的语言必须始终优先于系统列表。
系统列表中有两个条目同时受支持时,靠前的条目获胜。macOS 按用户在“语言与地区”中设置的顺序保存列表,因此第一个受支持的条目即为用户的首选受支持语言。
三种已保存偏好的实际结果
下表展示同一函数如何处理三位用户。每一行都是一次 resolveLocale 调用,包含已保存的值和操作系统列表。
用户 1 首选法语,但应用不提供法语。循环跳过 fr-FR,在第二项匹配 de-DE,返回德语。用户 3 得到英语的原因不同:zh-TW 既不是别名,也不与简体中文做前缀匹配,因此循环继续到 en-GB,返回英语。用户 2 不会读取系统列表,因为已保存的值是语言区域而不是 "system"。
移除语言后的已保存值
后续版本可能会移除某种语言。此时已保存的值不再出现在 SUPPORTED 中,文案目录也找不到对应文件。设置加载时,将未知的已保存值映射为 "system",并回写一次修正后的值,使保存的文件与代码一致。用户在更新后的第一次启动时仍能使用可用的语言。
2. 选错语言后仍能看懂语言列表
如果用户误选了日语,而所有语言名称都变成日语,就很难再找到 English。语言列表应使用每种语言的自称名称(endonym),无论当前界面语言是什么,列表都保持一致:
export const LANGUAGE_NAMES: Record<Locale, string> = {
en: "English",
de: "Deutsch",
it: "Italiano",
ja: "日本語",
ko: "한국어",
"zh-Hans": "简体中文",
};
Intl.DisplayNames 可以生成这些名称,但不同引擎和版本对脚本变体的输出不同。六个固定字符串的表更容易审查。只有 System default 选项是短语,需要本地化文本,其余条目都是语言名称。
Temposhot 中的同一规则
Temposhot Mac 应用的语言菜单采用相同的自称名称模式。该菜单位于设置中的 General 选项卡,共有七个条目。指南说明每个语言名称都以该语言书写。
最后一行说明当前状态。应用会保存所选语言并立即切换语言区域。在其他六种语言的翻译发布之前,切换后仍有部分界面文字显示为英语。
菜单顺序
菜单顺序与 SUPPORTED 一致,英语排在首位。固定顺序便于测试,并且不会因译者修改名称而变化。若按自称名称排序,条目顺序将取决于每种文字的排序规则,在拉取请求中更难审查。
新增语言时,将其追加到 SUPPORTED。随后 LANGUAGE_NAMES 的类型 Record<Locale, string> 会使构建失败,直到新条目有对应名称为止。因此,解析器中不会出现没有可见菜单标签的语言。
发布前检查菜单
语言菜单的问题通常较小,单元测试无法发现:名称使用了错误的文字、条目缺失,或条目无法保存。每次发布前进行一次简短的人工检查即可发现这些问题。
在系统语言不是英语的 Mac 上运行此检查。在英语系统上的检查只能证明英语路径正常。
3. 将英语文案目录用作 TypeScript 契约
以英语作为事实来源,并从中推导类型:
export const en = {
"settings.language": "Language",
"settings.language.system": "System default",
"capture.count": "{count} screenshots",
} as const;
export type MessageKey = keyof typeof en;
export type Catalog = Record<MessageKey, string>;
现在,每次调用 t("settings.language") 都会由编译器检查。拼写错误或从英语中删除的键都会导致构建失败。译者处理其他目录文件,但无法添加英语中不存在的键,因此英语文件始终列出界面使用的全部键。
扁平的点分隔键使类型保持简单,也便于阅读差异。嵌套对象同样可行,但需要递归类型,并且会让缺失键的报告更难理解。
谁编辑哪个文件
工程师编辑 en.ts。译者每人编辑一个语言区域文件,不修改 en.ts。若拉取请求只在 en.ts 中添加键,编译器仍会通过,因为其他目录被类型化为 partial。随后完整性测试会列出每种语言缺失的键,为团队提供一个查看某次发布中未完成翻译的位置。
添加一个键
新增一个键会涉及四处位置。以下顺序使编译器从第一步起就能发挥作用。
最后一步是团队最常跳过的。德语和芬兰语字符串通常比英语长,因此在英语中合适的按钮文字可能换行或被裁切。编译器和测试都无法发现这些问题,因此仍需对最长的字符串进行视觉检查。
目录文件夹的结构
该文件夹为每种语言保存一个文件,并包含读取这些文件的辅助模块。扁平结构使导入路径更短,也使译者能一眼看出应编辑哪个文件。
只有 en.ts 导出 as const 对象。其他目录文件从 index.ts 导入 Partial<Catalog>,因此译文文件无法定义英语中不存在的键。
键的命名
每个键以使用它的页面或区域作为前缀。settings.language 和 capture.count 不打开代码就能看出字符串所在位置,在代码中搜索 settings. 即可列出该页面的全部字符串。避免使用英语句子作为键。例如,键 "Language" 在之后改写文字时,键也必须随之修改。
一个键对应一种含义
键对应句子的含义,而不是显示它的位置数量。示例应用在结果标题、状态栏和导出对话框中都显示捕获数量。三处都使用 capture.count,由调用方填入 {count}。译者只需修改一次措辞即可同时作用于三处。如果为相同英语文本设置第二个键,两者会随时间产生差异。
4. 保留运行时回退和完整性检查
其他目录通常以 JSON 形式来自翻译工具,编译器无法为其内容提供保证。将它们类型化为 Partial<Catalog>,并在运行时按键回退到英语:
let current: Partial<Catalog> = {};
export function setCatalog(next: Partial<Catalog>) {
current = next;
}
export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
const template = current[key] ?? en[key];
return template.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? `{${name}}`));
}
回退机制使某条字符串未能及时完成时,版本仍可使用。但它也会掩盖缺口,因此需要添加一个测试,在上线语言区域不完整时失败。两者缺一不可:回退保护用户,测试防止团队意外发布回退内容。下文的测试部分展示了这项检查。
缺失字符串的表现
下面的示例使用一份德语目录,该目录在译者完成设置页面之前已被截取。数字来自示例,而非已发布的版本。
用户会看到三个标签为英语,其余内容为德语。屏幕上直接显示 settings.language 这样的原始键会更糟,而回退机制可以避免这种情况,因为英语始终定义所有键。
缺失变量保持可见
当 vars 中没有某个变量的值时,t 函数会在输出中保留 {name}。应用仍能正常运行,审阅者可以在截图或测试失败信息中看到未填充的占位符。与静默的空字符串相比,占位符更容易被发现,并且不会阻止页面其余部分的渲染。
测试回退路径
从未在测试中执行过的回退逻辑可能在无人察觉的情况下失效。以下是一个使用空目录的简短测试,用于覆盖该路径:
it("falls back to English for a missing key", () => {
setCatalog({});
expect(t("settings.language")).toBe("Language");
});
在其他测试中,使用真实目录调用 setCatalog,使每个测试从已知状态开始。
完整性测试应从 SUPPORTED 读取语言列表,而不是读取磁盘上的文件。这样,存在但未列出的文件不会意外上线,而列出但没有文件的语言会立即导致测试失败。
回退以键为单位生效
回退逻辑以键为单位,而不是以整个页面为单位。一份德语目录中如果有 42 个设置键,其中 40 个已翻译,则两个缺失的标签显示英语,其余 40 个显示德语。若按页面回退,则会因两个缺失的键而让 40 条已完成的字符串都显示为英语,对用户来说结果更差。
5. 在消息目录之外格式化日期和数字
文案目录应保存句子,而不是格式规则。日期、数字、文件大小和复数形式应使用 Intl,并以解析后的语言区域为参数:
const date = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
const number = new Intl.NumberFormat(locale);
const plural = new Intl.PluralRules(locale);
此时,文案字符串包含占位符,例如 {date} 或 {count},由调用方在传入前完成格式化。这使译者无需接触日期模式,并且无需任何人编写具体模式,美国英语用户看到“Mar 2, 2026”,德语用户看到“02.03.2026”。复数形式的数量因语言而异,因此应使用 Intl.PluralRules 选择消息变体,而不是判断 count === 1。
复数类别因语言而异
Intl.PluralRules 最多返回六个类别:zero、one、two、few、many 和 other。英语使用其中两个,俄语使用四个。对 count === 1 的判断适用于英语,但在俄语中会出错。
目录中为该语言使用的每个类别保存一个变体。辅助函数读取 plural.select(count),并查找对应的键,例如 capture.count.one 或 capture.count.few。缺少某个类别时回退到 other,使部分翻译的复数形式仍可使用。
单位属于数值
Intl.NumberFormat 负责分组和小数分隔符,因此英语中的 1.2 MB 和德语中的 1,2 MB 来自同一段代码。单位是格式化数值的一部分,而不是目录的一部分。像 "{size} saved" 这样的条目由调用方提供格式化后的大小,译者只需修改数值周围的句子。
具体日期输出取决于运行时
Intl.DateTimeFormat 的输出遵循运行时中的 ICU 数据,因此 Electron 版本升级后,具体字符串可能发生变化。硬编码“Mar 2, 2026, 9:05 AM”的测试会在升级后失败,而失败原因与应用无关。应将输出与测试中同一 Intl 调用的结果进行比较,并固定时区,使测试在每台机器上都能通过。
6. 将渲染进程的选择与主进程同步
设置页面位于渲染进程中,但原生菜单、对话框、托盘和通知都由主进程创建。应让主进程成为该偏好的唯一所有者:
// preload.ts
contextBridge.exposeInMainWorld("i18n", {
setPreference: (pref: LanguagePreference) => ipcRenderer.invoke("i18n:set", pref),
onChange: (cb: (locale: Locale) => void) =>
ipcRenderer.on("i18n:changed", (_e, locale: Locale) => cb(locale)),
});
// main.ts
ipcMain.handle("i18n:set", (_e, pref: unknown) => {
if (!isPreference(pref)) throw new Error("invalid language preference");
store.set("language", pref);
const locale = resolveLocale(pref, app.getPreferredSystemLanguages());
rebuildMenu(locale);
for (const win of BrowserWindow.getAllWindows()) win.webContents.send("i18n:changed", locale);
return locale;
});
渲染进程发出请求,主进程负责决定、保存并广播。每个窗口,包括之后打开的窗口,都读取同一个解析后的语言区域。处理函数会检查传入的值,因为 IPC 输入是不可信的。
将选择保存在渲染进程的 localStorage 中是一种常见做法,因为设置页面运行在渲染进程中。但此时值保存在某一个窗口的存储中,主进程在窗口打开之前无法读取。菜单和托盘会在第一个窗口之前创建,因此它们需要从主进程的存储中获取该值。
一次语言更改的路径
一次更改会经过两个进程。下面的序列显示每一步由哪一侧负责。
渲染进程不自行解析 "system"。如果每个进程分别解析偏好,操作系统语言变化后,某个窗口可能显示与菜单不同的语言。主进程中只有一个解析器,可以保证两者一致。
在处理函数中验证值
预加载脚本对参数进行了类型约束,但渲染进程可以通过 ipcRenderer.invoke 发送任何值。主进程会将该值与类型来源的同一列表进行比对:
const isPreference = (value: unknown): value is LanguagePreference =>
value === "system" || (typeof value === "string" && (SUPPORTED as readonly string[]).includes(value));
被拒绝的值会抛出异常,且不会写入存储。只有来自这两个列表的值才能进入 store.set。
更改后打开的窗口
在更改之后创建的窗口不必等待广播。它在加载时向主进程请求当前值:
ipcMain.handle("i18n:get", () =>
resolveLocale(store.get("language", "system") as LanguagePreference, app.getPreferredSystemLanguages()),
);
渲染进程在启动时调用一次 i18n:get,之后使用 i18n:changed。两条路径调用同一个解析器,因此新窗口和旧窗口不会得到不同的结果。
更改期间关闭的窗口
在广播之前关闭的窗口已不在 BrowserWindow.getAllWindows() 中,因此循环会跳过它。重新加载的窗口会再次调用 i18n:get,并收到当前值。广播是尽力而为的,加载路径负责提供正确的值。
测试保护各边界
- 解析器表。 输入
["de-CH", "en-US"](德语)、["zh-TW", "en"](英语)和[](英语)应映射为预期的语言区域。 - 完整性。 对每种上线语言,
en中的每个键都必须存在且不为空。 - 占位符一致。 每条译文使用与英语相同的占位符,因此
{count}不会被删除或重命名。 - 格式化。 使用固定时区检查日期输出,使测试在每台机器上都能通过。
- IPC 处理函数。 无效的偏好会被拒绝,有效的偏好会触发菜单重建和广播。
for (const [locale, catalog] of Object.entries(catalogs)) {
for (const key of Object.keys(en) as MessageKey[]) {
expect(catalog[key], `${locale}: ${key}`).toBeTruthy();
}
}
每个测试覆盖一个边界。下表列出测试失败时对应的问题。
| 测试 | 失败表示 |
|---|---|
| 解析器表 | 检查顺序或别名表已更改 |
| 完整性 | 上线目录缺少英语中定义的键 |
| 占位符一致 | 译文重命名或删除了变量 |
| 格式化 | 日期或数字输出取决于机器,而不是语言区域 |
| IPC 处理函数 | 无效输入进入了存储,或窗口未收到通知 |
每次修改文案目录都应运行测试套件,而不只是在发布前运行。重命名占位符的译文,在引入它的拉取请求中修复成本最低,而在发布截止时发现则代价最高。
小型 i18n 层仍需要清晰的边界
每项决策都划定了一条界线:意图与解析后的语言区域、名称与译文标签、以英语作为契约、回退与完整性、句子与格式化、渲染进程与主进程。保持这些界线,由几百行代码组成的手写层比配置相同功能的框架更容易理解。
当文案目录需要框架具备的功能时,框架才值得其成本,例如从源代码中提取消息、跟踪审阅状态的翻译工具,或运行时加载独立于应用分发的文案目录。在这些需求出现之前,上述六项决策已覆盖常见问题。
同样的边界也适用于其他显示文字的界面,例如 web view 或设置面板。每个界面都从主进程读取解析后的语言区域,使用同一个文案目录类型,并通过同一个完整性测试。新界面无需新规则,只需调用 t。
Temposhot 是本站介绍的 Mac 应用,它是使用 Xcode 构建的原生 macOS 应用,而不是 Electron 应用。其语言设置提供 7 个选项:System default 以及六种以各自名称显示的语言。
将点击转换为清晰的分步指南
若要记录与上述类似的设置更改,可使用 Action Capture 进行录制。Temposhot 可从下载页面免费下载,适用于运行 macOS 14 或更高版本的 Apple 芯片 Mac。
electroni18ntypescriptengineering
把重复性截图整理为成品
Temposhot 可按定时或每次点击进行截图,并将结果导出为 PDF、图片、ZIP 或 MP4。
相关文章
- 如何通过命令行在 Mac 上自动截图使用 macOS screencapture 循环进行全屏、固定区域和翻页截图,包含安全的文件命名和故障排查。
- 如何使用 Playwright 自动化网站截图使用 JavaScript 和 Playwright 自动化网站截图。涵盖单个页面、整页、移动端视图,以及带重试的 URL 批量截图。
- 如何使用 Puppeteer 自动化网站截图使用 JavaScript 和 Puppeteer 自动化网站截图。涵盖单个页面、整页截图、URL 批量处理和移动端视口,并附可复用的脚本。
- 2026 年 Mac 上的 Xnapper 替代工具推荐对比 Mac 上的 Xnapper 替代工具:重复截图、滚动截图与录制、免费背景、跨平台指南以及内置应用。