在 Electron 项目打包的过程中,多次出现了一个非常奇怪的现象:深层次 node_modules 文件目录内的同名包被“提升”到了最顶层的 node_modules 目录下,多个不同版本的 npm 包在打包后只保留了一个版本。由于只有某一个版本的 npm 包,因此在实际运行的过程中,很容易出现因 API 不兼容而导致的线上事故。
举个例子,假设有如下的 node_modules 目录结构:
- A
- node_modules
- react-dom@16.10.2
- B
- react-dom@16.9.0
其中 A 依赖 react-dom 的版本是 16.10.2,而 B 依赖的版本是 16.9.0。(注:这里,B 依赖的版本被提升到了顶层目录下,而 A 的依然存放在自己的 node_modules 目录内)
在 electron-builder 打包完成后,app.asar 内的目录结构变为:
原本多份的 react-dom 库只剩下了一份,A 和 B 都同时使用顶层的 react-dom 库。
这里,如果使用 starter 创建一个简单的 demo 并测试编译结果,会发现并不能复现上述的问题。其中,复现使用的 package.json 文件如下:
{
"name": "electron-webpack-quick-start",
"version": "0.0.0",
"license": "MIT",
"scripts": {
"dev": "electron-webpack dev",
"compile": "electron-webpack",
"dist": "yarn compile && electron-builder",
"dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null"
},
"dependencies": {
"source-map": "^7.0.0",
"source-map-support": "^0.5.12",
"@babel/core": "^7.5.0"
},
"devDependencies": {
"electron": "5.0.6",
"electron-builder": "^22.2.0",
"electron-webpack": "^2.7.4",
"webpack": "~4.35.3"
}
}
其中,electron-builder
使用 22.2.0 版本,为当前的最新版本。因为项目本身针对 electron-builder 进行了二次开发,因而并不能非常确定是 electron-builder 本身的问题,还是二次开发的代码造成了这个编译的现象。故只能通过阅读源码以及调试的方法排查问题。
大体的排查过程如下:
首先,从目前已知的情报,可以大致推断出是 asar 的打包出现了问题。故选择从 app-builder-lib 这个库入手。首先从 packages/app-builder-lib/src/platformPackager.ts
中 AsarPackager 调用的 pack 函数开始查起。
这里分析函数的定义和实际运行时各个参数的具体值,注意到两点:
fileSets
这个参数中包含了 node_modules/xxx/node_modules/react-dom/package.json
这个文件;
- 打包程序确实读取了
react-dom/package.json
的数据并进行了写操作(对应这段代码)
也就是说,打包程序确实将各个版本的 react-dom 都写入到了 app.asar 文件中,但是实际在读取的时候,只能找到一份。
由此基本可以推断,是 asar 的头部数据写入出现了问题。
这里通过观察上面写入数据时候用到的 this.fs.header
不难发现,其中不包含 xxx/node_modules/react-dom
数据。因而问题进一步转化为,this.fs.header
的数据为什么出现了记录错误?
这里修改 this.fs.header
的地方基本集中在 createPackageFromFiles
函数内。注意到,在这个函数调用中,针对有问题的模块,比如 xxx/node_modules/react-dom/package.json
文件,this.fs.addFileNode
函数被执行到了(调用位置),但是 this.fs.getOrCreateNode
函数却没有被执行(调用位置)。这也就导致了文件本身被写入了,但是目录却没有被正确创建。分析 this.fs.getOrCreateNode
函数没有被执行的原因(也就是 if (currentDirPath !== fileParent)
这个判断),不难发现是 fileParent
这个变量的值有问题,归根溯源,就是 pathInArchive
这个变量的获取不对。而这个变量的值获取,依赖于 getDestinationPath
这个函数的调用。通过分析这里面的代码,基本可以定位到,是 fileSet.destination
这个变量的值出现了问题(对应使用的位置)。还是以 react-dom 为例,这里的几个变量值分别是:
file
= ~/node_modules/xxx/node_modules/react-dom/package.json
fileSet.src
= ~/xxx/node_modules
fileSet.destination
= ~/Electron.app/Contents/Resources/app/node_modules
(注:这里的 ~
指代当前的工作目录,仅用于省略无用的信息)
这里正确的 destination
应该以 app/node_modules/xxx/node_modules
结尾。
通过代码往上溯源 fileSet
的产生,可以查到是 appFileCopier.ts
中的 computeNodeModuleFileSets
函数给 destination
赋值了。
显然,当错误的情况出现的时候,代码运行到了 else
语句中,直接将本来是深层的 node_modules 目录强行写成了 destination = mainMatcher.to + path.sep + "node_modules"
(代码),也就是根目录的 node_modules 目录。这导致了当有多个不同版本的包时,最终会重复写入到同一个 node_modules 位置,并且最终只有一个版本存在。
注:这里会执行到 else
语句中,是因为项目使用了 lerna 进行管理,同时采用了 Electron 项目常见的双 package.json 目录结构,因此 app 中的 npm 包被提升到了项目的顶层目录中,本身并不存在于 app/node_modules 目录下。
这里,修复的逻辑也非常简单:无论 node_modules 是否存在于 app 目录下,当被打包到 Electron 项目中的时候,node_modules 本身的层级结构应该要被保留。
官方的 electron-builder 已经通过这个 PR 修复了问题,只需要升级到最新的代码即可。
整体修复的代码,简单介绍如下:
首先,在计算 destination
的时候,不再考虑目录是否在实际打包的根目录(app)下,全部都统一调用 getDestinationPath
函数:
const destination =
getDestinationPath(
source,
{
src: mainMatcher.from,
destination: mainMatcher.to,
files: [],
metadata: null as any
}
);
然后在 getDestinationPath
函数内,针对这种情况,进行如下的处理:
if (
file.length > src.length &&
file.startsWith(src) &&
file[src.length] === path.sep
) {
} else {
let index = file.indexOf(NODE_MODULES_PATTERN)
if (index < 0 && file.endsWith(`${path.sep}node_modules`)) {
index = file.length - 13
}
if (index < 0) {
throw new Error('xxx');
}
return dest + file.substring(index);
}