本文记录了一次针对 Electron 桌面端多语言的优化:我将首屏语言加载体积降低了 94%,并赋予了用户在不重新发版的情况下自定义修改翻译的能力。
一、背景与问题
1.1 原有架构的"技术债"
在项目早期,为了快速实现国际化,我们采用了最直接的静态导入 + 构建时打包方案。主进程和渲染进程在启动时,直接将 18 种语言的 JSON 文件全量引入:
typescript
// electron/main/lang.ts(旧方案)
import en from '../../src/locales/language/pc/en_US.json'
import zh from '../../src/locales/language/pc/zh_CN.json'
import km from '../../src/locales/language/pc/km_KH.json'
// ... 枯燥的 import 重复 18 次
export const languages = { 'en_US': { ...en }, 'zh_CN': { ...zh }, ... }
这种方案在语言种类少、项目初期看似没问题,但随着业务出海,支持语言达到 18 种时,痛点彻底爆发。
1.2 暴露的四大痛点
| 问题 | 根因与影响 |
|---|---|
| 首屏加载慢 | 18 种语言包(~3.3MB 纯文本)在主进程启动时全量读取,拖慢应用启动白屏时间。 |
| 内存占用高 | 用户明明只用简体中文,但高棉语、西班牙语等 16 种语言的数据却常驻内存,造成浪费。 |
| 无法热修改 | 客户现场想调整某个业务专有名词,只能改代码、重新打包、走发版流程,极其低效。 |
| 包体积大 | 所有语言被编译进主 bundle 或 asar,增加了安装包的下载和安装时间。 |
1.3 优化目标
针对上述痛点,本次重构确立了四个核心目标:
- 按需懒加载:首屏只加载当前语言(~200KB),其他语言用时再取。
- 用户自定义:提供可视化界面,用户修改的翻译持久化到本地用户目录。
- 无感热更新:保存自定义翻译后,无需重启应用,界面立即刷新。
- 安全与兼容:文件操作收归主进程;H5 Web 端保持原有的静态导入逻辑不变。
二、整体架构设计
2.1 设计原则
在 Electron 应用中,涉及文件系统(fs)的操作理应交由主进程处理。之前的方案在主进程全量加载不仅慢,而且没有利用好 Electron 的 IPC 机制。新架构遵循以下原则:
- 主进程权威:主进程作为唯一的"数据源",负责语言包的读取、合并、缓存和持久化。
- IPC 解耦 :渲染进程不直接接触
fs,通过ipcRenderer.invoke按需索取数据。 - 缓存优先 :主进程内部使用
Map缓存已处理的语言包,避免频繁的磁盘 I/O。
2.2 架构流转图
下面是优化后的完整数据流转架构。从用户的点击操作,到渲染进程的 IPC 通信,再到主进程的数据聚合与缓存,形成了一个闭环:
2.3 核心模块职责划分
| 模块 | 所在进程 | 核心职责 |
|---|---|---|
LangService |
Main | 语言文件读写、默认与自定义的深度合并、内存缓存管理 |
| IPC Handlers | Main | 暴露 lang:get、lang:save 等标准接口 |
src/locales/index.js |
Renderer | 拦截原有静态导入,改为 IPC 获取;提供热更新方法 |
langmanager/index.vue |
Renderer | 多语言对比表格、单元格编辑、修改追踪与批量保存 |
三、核心实现详解
3.1 主进程语言服务
LangService 是本次优化的绝对核心。它巧妙地解决了环境差异、数据结构冲突等问题。
3.1.1 跨环境路径解析
开发环境和打包后的生产环境,语言包的存放路径是完全不同的。我们需要在 LangService 初始化时做好兼容:
typescript
// electron/main/langService.ts
constructor() {
if (app.isPackaged) {
// ✅ 生产环境:从 electron-builder 额外复制的 resources 目录读取
this.basePath = path.join(process.resourcesPath, 'locales');
} else {
// ⚠️ 开发环境:直接从源码目录读取,方便本地实时调试
this.basePath = path.join(__dirname, '../../src/locales/language/pc');
}
// 用户自定义语言包:存放在系统用户数据目录下(跨版本不会丢失)
// Win: %APPDATA%/jack-dg/custom_lang/ | Mac: ~/Library/Application Support/...
this.customPath = path.join(app.getPath('userData'), 'custom_lang');
}
3.1.2 "嵌套 ↔ 扁平"转换设计
这里是一个关键的设计决策:
- 源码里的语言包 是嵌套结构(如
{ menu: { home: '首页' } }),符合前端编程习惯。 - 用户自定义的语言包如果也存嵌套结构,合并时代码会非常复杂,且用户重置某一个 key 时不好操作。
因此,我们规定用户目录下统一使用扁平化 key-value 存储 。LangService 内部实现了双向转换器:
typescript
// 扁平转嵌套(用于与默认语言包合并时)
private unflattenObject(flatObj: Record<string, string>): any {
const result: any = {};
for (const [key, value] of Object.entries(flatObj)) {
const keys = key.split('.'); // 例如 ['menu', 'home']
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
if (!(keys[i] in current)) current[keys[i]] = {};
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
}
return result;
}
3.1.3 带缓存的高效合并策略
获取语言包时,优先读缓存,未命中才走磁盘 I/O,最后将用户自定义数据"浅覆盖"到默认数据上:
typescript
getMergedLanguage(locale: string = 'zh_CN'): any {
if (this.cache.has(locale)) return this.cache.get(locale);
const defaultData = this.loadJson(this.getDefaultFilePath(locale));
const customData = this.loadJson(this.getCustomFilePath(locale));
// 深度合并,用户自定义优先级最高
const mergedData = this.deepMerge(defaultData, this.unflattenObject(customData || {}));
this.cache.set(locale, mergedData);
return mergedData;
}
💡 缓存一致性保障 :在
saveCustomLanguage成功写入磁盘后,必须手动执行this.cache.delete(locale),确保下次读取到的是最新数据。
3.2 渲染进程的"降级式"懒加载
改造渲染进程的 doLoadLanguage 函数时,最怕的是破坏 H5 端的正常运行,或者导致脱离 Electron 环境调试 Vue 组件时报错。因此我们引入了降级策略:
javascript
// src/locales/index.js
const doLoadLanguage = async (locale) => {
// 1. antd 的组件库语言包,依然保持动态 import
const antModule = await config.ant();
const antLocale = antModule.default || antModule;
let finalMessages = {};
try {
if (!isH5) {
// ✅ Electron 环境:走 IPC 懒加载(支持用户自定义)
const { ipcRenderer } = require('electron');
finalMessages = await ipcRenderer.invoke('lang:get', { locale });
} else {
// ✅ Web 环境:保持静态 import(无需主进程)
const msgModule = await config.messages();
finalMessages = msgModule.default || msgModule;
}
} catch (e) {
// ✅ 降级容错:如果 IPC 通信异常(如开发时单独跑 web server),回退到静态导入
console.warn('[i18n] IPC 加载失败,降级为静态导入:', e);
const msgModule = await config.messages();
finalMessages = msgModule.default || msgModule;
}
i18n.global.setLocaleMessage(locale, finalMessages);
// ...
};
同时,对外暴露 reloadLanguage 方法,供管理页面保存后调用,实现界面的无感热更新。
3.3 菜单导航的 IPC 穿透
桌面端的特色是原生菜单。我们通过注册 F8 快捷键,并在主进程点击时发送自定义事件通知渲染进程进行路由跳转,实现了 Native 到 Web 的顺畅联动。
typescript
// 主进程:发送导航指令
{ label: '多语言支持', accelerator: 'F8', click: () => {
win?.webContents.send('navigate-to', '/langManager');
}}
// 渲染进程:监听并执行路由
ipcRenderer.on('navigate-to', (_event, path) => {
if (path) this.$router.push(path);
});
四、语言管理页面实现亮点
管理页面(langmanager/index.vue)不仅是一个表格,更是一个小型状态机。
4.1 动态列与多语言对比
表格的列不是写死的,而是根据用户勾选的语言动态生成 computed 列配置:
javascript
const columns = computed(() => {
return visibleLocales.value.map((locale, index) => {
// 自定义表头:语言名称 + 独立的重置按钮
const customTitle = h('div', {}, [
h('span', localeInfo?.name),
h(Button, { danger: true, onClick: () => handleReset(locale) }, () => '重置')
]);
return { title: customTitle, dataIndex: locale, fixed: index < 2 ? 'left' : undefined };
});
});
细节: 我这边其实也就是把中文和英文两种语言当做key。
fixed: index < 2 ? 'left' : undefined保证了在横向滚动查看几十种语言时,主要语言列始终钉在最左侧,不会迷失方向。
4.2 修改追踪
我们在内存中维护了一个 originalValues 哈希表。每次单元格输入时,对比当前值与原始值,只有真正发生变化的项才会被记录到 modifiedValues 中。保存时,只需将 modifiedValues 提交给 IPC,极大减少了数据传输量。
五、打包配置
为了让主进程在生产环境能读取到语言包,且不把它们打进 asar 包(便于以后可能的热更新或排查问题),我们在 electron-builder.js 中使用 extraFiles:
javascript
module.exports = {
extraFiles: [
{
from: './src/locales/language/pc',
to: './resources/locales', // 打包后位于 exe 同级的 resources 目录下
filter: ['**/*.json']
}
],
};
六、效果
自定义页面: 
修改的内容,缓存在用户目录下: 
对标我们一开始的痛点的话,客户端多语言这部分的修改带来的效果/价值:
| 指标 | 优化前 | 优化后 | 收益 |
|---|---|---|---|
| 首屏加载体积 | ~3.3MB (18种全量) | ~200KB (仅当前) | 下降 90%肯定有的 |
| 内存占用 | 持续膨胀 | 按需驻留 + Map缓存 | 显著降低 |
| 修改文案成本 | 改代码 → 打包 → 发版 | 界面输入 → 秒级生效 | 0 成本 |
| 多语言切换 | 刷新页面 | 调用 reloadLanguage | 无感切换 |
七、后续展望
这套架构目前已经稳定运行,后续还可以这么玩:
- 导入导出 :支持将
custom_lang下的 JSON 导出,方便项目经理收集后统一下发。 - 云端同步:将用户自定义的 delta 数据同步到服务端,实现"一次修改,多端生效"。
- 自动翻译:在管理界面接入翻译 API,辅助翻译,点击按钮即可对空白翻译项进行自动填充哈哈。
总结: 在 Electron 应用中处理繁重的静态资源时,将职责合理划分给主进程,利用 IPC 进行按需分发,配合本地的持久化策略,往往能以极小的代码改动代价,换来用户体验的巨大提升。
希望这个思路能对大家有所启发。
下一期能写点啥呢?看吧,最近在重构其他项目,应该还会有些可以唠唠的!