本文首发微信公众号:前端徐徐。
大家好,我是徐徐。今天我们讲讲 electron-builder 的原理。
前言
我们前面浅析了 Electron 的相关原理,这一节我们来讲讲 electron-builder 的原理。为什么要讲它呢,因为它的原理基本上包含了业界跨端的桌面端开发框架的打包构建原理,搞懂了这款工具的原理,其他的类似的跨端开发框架的构建思路都差不多,比如 NW.js 之类的,都差不多。而且它在构建 electron 应用这一块是非常成熟和优秀的,如果你想无痛构建 electron 应用,它是首选。下面我们就来看看它的整体原理与构建流程。
原理概览
electron-builder 是一个专注于将 Electron 应用与前端项目整合的打包工具,它负责管理依赖、生成配置文件、打包资源,并支持多平台构建。首先,在开始构建应用之前,需要确保前端项目经过构建,生成正确的输出文件和 package.json。只有这样,electron-builder才会进行后面的操作,比如收集配置信息并处理依赖,特别是原生模块的编译。然后,通过生成 ASAR 包、创建安装和卸载程序,最终输出完整的安装包。下面是每一部分的详细讲解。
前端项目构建
electron-builder 是不会构建你的前端项目的,它只负责你的前端项目和 electron 的整合,所以你需要自己完成以下步骤:
- 使用现代前端框架(如React, Vue, Angular)开发应用
- 配置webpack, Rollup或Vite进行打包
- 优化构建过程,如代码分割、懒加载、Tree Shaking等
- 输出构建结果到指定目录,通常是
dist
或build
- 确保所有静态资源(图片、字体等)都被正确处理和复制
上面这些步骤跟你打包一个普通的前端应用差别不是太大,但是还是有一些细微的差别,比如:
- 渲染进程和主进程的代码分离
- 依赖管理需要正确,可能需要处理特定的原生模块依赖
- 网络请求需要设置绝对路径,去除 nginx 的配置
上面的步骤做好了,才会真正的走到 electron 的构建世界。
准备package.json
这里可能大家会有疑问,为什么会需要准备 package.json
呢,项目里面肯定有 package.json
,这里说的 package.json
可不是项目里面的 package.json
,而是 electron-builder 打包需要的 package.json
,你可以理解为现在需要创建一个项目,这个项目专门是为了打包 electron 应用而构建的项目,项目里面需要前端构建的文件和一个 package.json
,然后用这个项目打包。大概是如下情形:
- 主项目package.json:
{ "name": "your-app", "version": "1.0.0", "main": "main.js", "scripts": { "build": "vite build && electron-builder" }, "devDependencies": { "electron": "^13.0.0", "electron-builder": "^22.11.7", "vite": "^2.5.0" } }
- 输出目录的 dist package.json:
{ "name": "your-app", "version": "1.0.0", "main": "main.js", "dependencies": { "sqlite3": "^5.0.2" // 仅包含原生模块 } }
当然,我们在实际开发的过程中肯定是不会单独搞两个项目的,打包构建前端项目和构建 electron 应用都是在一个项目中完成的,上面提到的只是为了让大家理解 electron-builder 如何开始工作的,以及 package.json 对它的重要性。这个地方会有相应的解释:https://www.electron.build/tutorials/two-package-structure
收集配置信息
要打包成自己想要的应用,肯定需要一些配置信息,有了这些配置信息 electron-builder 才会正常的运行并获取相应的配置去构建应用。
它支持多种方式的配置信息:
- 第一种是从 package.json 的 "build" 字段读取配置:
"build": { "appId": "com.example.app", "productName": "Your App", "mac": { "category": "public.app-category.productivity" }, "win": { "target": ["nsis", "portable"] }, "linux": { "target": ["AppImage", "deb"] }, "extraResources": [ {"from": "assets/", "to": "assets/"} ] }
- 或从electron-builder.yml 或者 electron-builder.json 读取配置
当 electron-builder 读取到用户的配置之后,它会合并默认配置和用户配置,最后组成一个完整的打包配置信息。
源码路径在这里:
export async function getConfig( projectDir: string, configPath: string | null, configFromOptions: Configuration | null | undefined, packageMetadata: Lazy<{ [key: string]: any } | null> = new Lazy(() => orNullIfFileNotExist(readJson(path.join(projectDir, "package.json")))) ): Promise<Configuration> { const configRequest: ReadConfigRequest = { packageKey: "build", configFilename: "electron-builder", projectDir, packageMetadata } // 省略一些代码 return doMergeConfigs([...parentConfigs, config]) }
在这个方法中,会收集所有的配置信息然后经过处理得到最终的打包配置信息。
依赖处理
依赖处理是非常重要的一个环境,他可能决定你的应用是否打包成功,这一个步骤大概分成以下几个小的步骤:
- 分析package.json中的dependencies
- 使用npm或yarn安装这些依赖
- 对于原生模块(如sqlite3),使用electron-rebuild重新编译:
./node_modules/.bin/electron-rebuild
- 确保所有依赖与Electron版本兼容
这一步其实最最重要的就是安装依赖,不管是 electron 本身的包还是需要重新编译的包,都会在这一步完成。
源码路径在这里:
public async installAppDependencies(platform: Platform, arch: Arch): Promise<any> { if (this.options.prepackaged != null || !this.framework.isNpmRebuildRequired) { return } const frameworkInfo = { version: this.framework.version, useCustomDist: true } const config = this.config if (config.nodeGypRebuild === true) { await nodeGypRebuild(platform.nodeName, Arch[arch], frameworkInfo) } if (config.npmRebuild === false) { log.info({ reason: "npmRebuild is set to false" }, "skipped dependencies rebuild") return } const beforeBuild = await resolveFunction(this.appInfo.type, config.beforeBuild, "beforeBuild") if (beforeBuild != null) { const performDependenciesInstallOrRebuild = await beforeBuild({ appDir: this.appDir, electronVersion: this.config.electronVersion!, platform, arch: Arch[arch], }) // If beforeBuild resolves to false, it means that handling node_modules is done outside of electron-builder. this._nodeModulesHandledExternally = !performDependenciesInstallOrRebuild if (!performDependenciesInstallOrRebuild) { return } } if (config.buildDependenciesFromSource === true && platform.nodeName !== process.platform) { log.info({ reason: "platform is different and buildDependenciesFromSource is set to true" }, "skipped dependencies rebuild") } else { await installOrRebuild(config, this.appDir, { frameworkInfo, platform: platform.nodeName, arch: Arch[arch], productionDeps: this.getNodeDependencyInfo(null, false) as Lazy<Array<NodeModuleDirInfo>>, }) } }
这个方法主要用于确保在不同的平台和架构上,应用的依赖项能够正确地安装和重建,以支持 Electron 应用的构建过程。
生成asar
这个是 electron-builder 提供的能力,可以对源码进行一定的加密保护。默认情况下,它会将应用文件打包成app.asar,当然你也可以通过配置禁用 asar 打包:
"build": { "asar": false }
asar 打包可以提高加载速度,但可能影响某些原生模块的使用,这个在处理的时候需要特别注意,另外就是打包之后的路径问题处理。
准备附加资源
除了 Electron 相关的文件和 app.asar 文件外,有的时候还有一些不需要进入构建的附加资源,这些文件根据extraResources 配置复制额外文件,然后会一并打入到整个应用程序里,它们可以通过 process.resourcesPath 访问。
Electron打包
经过上面的前端项目的构建,依赖的下载,附加资源等很多资源的准备,到这一步就开始最最核心的打包工作了,大概的步骤如下:
- 首先检查缓存中是否有指定版本的 Electron
- 如果没有,从官方源下载 Electron
- 将 Electron 文件复制到临时目录,如 win-unpacked/
- 将应用资源复制到 Electron 结构中的正确位置
- 然后根据配置分端组装构建不同的目标文件
源码路径在这里:
async build(repositoryInfo?: SourceRepositoryInfo): Promise<BuildResult> { await this.validateConfig() if (repositoryInfo != null) { this._repositoryInfo.value = Promise.resolve(repositoryInfo) } this._appInfo = new AppInfo(this, null) this._framework = await createFrameworkInfo(this.config, this) const commonOutDirWithoutPossibleOsMacro = path.resolve( this.projectDir, expandMacro(this.config.directories!.output!, null, this._appInfo, { os: "", }) ) if (!isCI && (process.stdout as any).isTTY) { const effectiveConfigFile = path.join(commonOutDirWithoutPossibleOsMacro, "builder-effective-config.yaml") log.info({ file: log.filePath(effectiveConfigFile) }, "writing effective config") await outputFile(effectiveConfigFile, getSafeEffectiveConfig(this.config)) } // because artifact event maybe dispatched several times for different publish providers const artifactPaths = new Set<string>() this.artifactCreated(event => { if (event.file != null) { artifactPaths.add(event.file) } }) this.disposeOnBuildFinish(() => this.tempDirManager.cleanup()) const platformToTargets = await executeFinally(this.doBuild(), async () => { if (this.debugLogger.isEnabled) { await this.debugLogger.save(path.join(commonOutDirWithoutPossibleOsMacro, "builder-debug.yml")) } const toDispose = this.toDispose.slice() this.toDispose.length = 0 for (const disposer of toDispose) { await disposer().catch((e: any) => { log.warn({ error: e }, "cannot dispose") }) } }) return { outDir: commonOutDirWithoutPossibleOsMacro, artifactPaths: Array.from(artifactPaths), platformToTargets, configuration: this.config, } }
这段代码定义了一个异步的 electron 的 build
方法,主要流程包括验证配置、处理应用信息、创建框架信息和设置输出目录。它会记录有效配置到文件中,并管理构建生成的工件。最后,它调用 doBuild
方法进行实际构建,并在完成后返回构建结果,包括输出目录、工件路径和目标平台信息。
private async doBuild(): Promise<Map<Platform, Map<string, Target>>> { const taskManager = new AsyncTaskManager(this.cancellationToken) const syncTargetsIfAny = [] as Target[] const platformToTarget = new Map<Platform, Map<string, Target>>() const createdOutDirs = new Set<string>() for (const [platform, archToType] of this.options.targets!) { if (this.cancellationToken.cancelled) { break } if (platform === Platform.MAC && process.platform === Platform.WINDOWS.nodeName) { throw new InvalidConfigurationError("Build for macOS is supported only on macOS, please see https://electron.build/multi-platform-build") } const packager = await this.createHelper(platform) const nameToTarget: Map<string, Target> = new Map() platformToTarget.set(platform, nameToTarget) for (const [arch, targetNames] of computeArchToTargetNamesMap(archToType, packager, platform)) { if (this.cancellationToken.cancelled) { break } // support os and arch macro in output value const outDir = path.resolve(this.projectDir, packager.expandMacro(this.config.directories!.output!, Arch[arch])) const targetList = createTargets(nameToTarget, targetNames.length === 0 ? packager.defaultTarget : targetNames, outDir, packager) await createOutDirIfNeed(targetList, createdOutDirs) await packager.pack(outDir, arch, targetList, taskManager) } if (this.cancellationToken.cancelled) { break } for (const target of nameToTarget.values()) { if (target.isAsyncSupported) { taskManager.addTask(target.finishBuild()) } else { syncTargetsIfAny.push(target) } } } await taskManager.awaitTasks() for (const target of syncTargetsIfAny) { await target.finishBuild() } return platformToTarget }
上面的代码实现了一个跨平台的构建过程,通过管理不同平台和架构的构建任务,使用异步任务管理器提高效率。它动态创建输出目录,处理宏替换,确保兼容性,并最终返回包含所有构建目标的映射,形成灵活可扩展的构建框架。 每个平台都有一个 packager 文件,可以在对应的 packager 下看每个平台的打包构建过程。
代码签名
经过上面最核心的步骤之后就开始进行代码签名的步骤了,在这个阶段会使用指定的证书对可执行文件进行签名,Windows 上使用 signtool,macOS上使用 codesign,签名的目的是增加用户信任,减少安全警告,当然你也可以配置不签名,不签名的应该会被系统判断为危险应用,所以商用软件都是有正规签名的。
压缩资源
压缩资源也是一个非常重要的工作, electron-builder 也把这项工作包含进去了。它会使用压缩工具将应用文件压缩成单个包,高压缩比可以显著减小安装包大小,其压缩文件名格式大概为:appName-version-arch.zip 或者 以 7z 结尾的压缩包。
生成卸载程序
electron-builder 会使用一个名为NSIS的工具生成卸载程序的可执行文件,这个卸载程序记录了 win-ia32-unpacked 目录下所有文件的相对路径。当用户卸载我们的应用时,卸载程序会根据这些相对路径删除我们的文件,同时它也会记录一些安装时使用的注册表信息,在卸载时清除这些注册表信息。如果开发者配置了签名逻辑,则 electron-builder 也会为卸载程序的可执行文件进行签名。
生成安装程序
同样的,electron-builder 会使用 NSIS 创建安装程序,在里面嵌入压缩的应用文件和卸载程序,而且可以配置安装过程,如创建快捷方式、注册文件关联等,如果配置了签名,对安装程序进行签名。
输出最终安装包
最后将生成的安装程序移动到指定的输出目录生成安装包,这里可能进行额外的处理,如重命名、生成校验和等。
结语
通过对 electron-builder 原理的剖析,揭示了跨端桌面应用打包构建的核心流程与细节。无论是依赖处理、ASAR 生成,还是代码签名与安装程序的创建,这些环节都直接影响到应用的最终质量和用户体验。那么,你是否已经掌握了这些关键要素,能够在实际开发中灵活运用呢?