Electron Builder with node_modules hoist

Build

在 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 内的目录结构变为:

- A
- B
- react-dom

原本多份的 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 函数开始查起。 这里分析函数的定义和实际运行时各个参数的具体值,注意到两点:

  1. fileSets 这个参数中包含了 node_modules/xxx/node_modules/react-dom/package.json 这个文件;
  2. 打包程序确实读取了 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
) {
  // 这种情况种 file 就在打包的根目录(app)下,略
} else {
  // 这种是出现问题的情况

  // 这里 NODE_MODULES_PATTERN === "/node_modules/"
  // 这种情况下,返回的值应该是 dest + 第一层 node_modules 后所有的内容
  // 举例来说:
  // 如果 file 的目录是:~/node_modules/xxx/node_modules
  // 那么最终返回的结果就是 dest + xxx/node_modules
  //
  // 这里 dest 就是最终打包结果,app.asar 的位置
  // 以 Mac 为例,就是 xxx/Electron.app/Contents/Resources/app,
  // 这里 xxx 是 Electron.app 打包的具体目录
  // 根据打包的配置,Electron.app 的名字可能有所不同
  let index = file.indexOf(NODE_MODULES_PATTERN)
  if (index < 0 && file.endsWith(`${path.sep}node_modules`)) {
    // 这种情况下 file 是以 /node_modules 结尾的,13 === '/node_modules'.length
    // 此时,返回的值应该就是 dest + /node_modules
    index = file.length - 13
  }
  if (index < 0) {
    throw new Error('xxx');
  }
  return dest + file.substring(index);
}