跳至内容

保持小型 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 不匹配任何项,因此会进入下一个偏好,而不会被静默地当作简体中文。

解析顺序

函数在第一个匹配项处返回。检查顺序很重要,因为已保存的语言必须始终优先于系统列表。

解析语言区域的五个编号步骤:偏好不为 system 时直接返回已保存的语言区域,按顺序读取系统列表,对 SUPPORTED 做精确或前缀匹配,检查 zh-cn 等标签的 ALIASES 表,无匹配时返回 en。

系统列表中有两个条目同时受支持时,靠前的条目获胜。macOS 按用户在“语言与地区”中设置的顺序保存列表,因此第一个受支持的条目即为用户的首选受支持语言。

三种已保存偏好的实际结果

下表展示同一函数如何处理三位用户。每一行都是一次 resolveLocale 调用,包含已保存的值和操作系统列表。

三位用户的对比:系统偏好为 fr-FR、de-DE、en-US 时解析为德语;已保存的日语偏好保持日语;系统偏好为 zh-TW、en-GB 时解析为英语。

用户 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 选项卡,共有七个条目。指南说明每个语言名称都以该语言书写。

Temposhot 设置面板中的 Language 菜单:位于设置的 General 中,共七个条目,System default 排在首位,语言名称为自称形式,重启后保留所选语言,界面文字的六种非英语翻译仍在进行中。

最后一行说明当前状态。应用会保存所选语言并立即切换语言区域。在其他六种语言的翻译发布之前,切换后仍有部分界面文字显示为英语。

菜单顺序

菜单顺序与 SUPPORTED 一致,英语排在首位。固定顺序便于测试,并且不会因译者修改名称而变化。若按自称名称排序,条目顺序将取决于每种文字的排序规则,在拉取请求中更难审查。

新增语言时,将其追加到 SUPPORTED。随后 LANGUAGE_NAMES 的类型 Record<Locale, string> 会使构建失败,直到新条目有对应名称为止。因此,解析器中不会出现没有可见菜单标签的语言。

发布前检查菜单

语言菜单的问题通常较小,单元测试无法发现:名称使用了错误的文字、条目缺失,或条目无法保存。每次发布前进行一次简短的人工检查即可发现这些问题。

语言菜单的六项检查:每个语言名称以其自身文字显示;System default 为第一项;六个语言条目,无多余或缺失;切换后菜单标签更新;所选语言在应用重启后保留;中文仅显示简体中文。

在系统语言不是英语的 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 中添加键和英语文本;运行编译器,使每个 t 调用点通过类型检查;在其他每个目录中添加译文;运行完整性和占位符测试;在每种语言中打开页面并检查布局。

最后一步是团队最常跳过的。德语和芬兰语字符串通常比英语长,因此在英语中合适的按钮文字可能换行或被裁切。编译器和测试都无法发现这些问题,因此仍需对最长的字符串进行视觉检查。

目录文件夹的结构

该文件夹为每种语言保存一个文件,并包含读取这些文件的辅助模块。扁平结构使导入路径更短,也使译者能一眼看出应编辑哪个文件。

i18n 文件夹的文件树:英语、德语、意大利语、日语、韩语和简体中文各有一个文案文件,另有 index、resolve 和 format 辅助文件。

只有 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}}`));
}

回退机制使某条字符串未能及时完成时,版本仍可使用。但它也会掩盖缺口,因此需要添加一个测试,在上线语言区域不完整时失败。两者缺一不可:回退保护用户,测试防止团队意外发布回退内容。下文的测试部分展示了这项检查。

缺失字符串的表现

下面的示例使用一份德语目录,该目录在译者完成设置页面之前已被截取。数字来自示例,而非已发布的版本。

回退示例的四项统计:发布截止时 de.ts 缺少 3 个键;运行时其中 3 个键显示为英语;0 个键显示为原始键名,因为英语始终定义了所有键;1 个失败测试阻止发布,直到 de.ts 完整。

用户会看到三个标签为英语,其余内容为德语。屏幕上直接显示 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 的判断适用于英语,但在俄语中会出错。

复数选择对比:英语中计数为 1 时选择 one,计数为 2 和 5 时选择 other;俄语中计数为 1 时选择 one,计数为 2 时选择 few,计数为 5 时选择 many。

目录中为该语言使用的每个类别保存一个变体。辅助函数读取 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 中是一种常见做法,因为设置页面运行在渲染进程中。但此时值保存在某一个窗口的存储中,主进程在窗口打开之前无法读取。菜单和托盘会在第一个窗口之前创建,因此它们需要从主进程的存储中获取该值。

一次语言更改的路径

一次更改会经过两个进程。下面的序列显示每一步由哪一侧负责。

由五个方框组成的流程:渲染进程的选择器调用 setPreference,预加载脚本在 i18n:set 通道上转发该值,主进程验证并保存该值,主进程解析语言区域并重建原生菜单,主进程向每个窗口发送 i18n:changed。

渲染进程不自行解析 "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。

相关文章