Things I Learned (Build)

bundledDependencies🔗

Build

由于 semver 的设计,在使用 npm 对项目依赖进行管理的时候,很容易遇到安装版本不完全一致的情况。而一旦开发版本和线上编译版本使用的版本不能保证一致,就非常容易出现意料之外且难于测试的问题,造成线上代码的不稳定。Yarn 和后来的 npm 都分别通过 yarn.lock 以及 package-lock.json 文件来锁定版本,保证开发和线上编译依赖的一致性。

但是,这种保证只是针对最终使用者的。具体来说,使用某个依赖的开发者可以通过 yarn.lock 文件来保证所有同组的开发者一定都使用了相同版本号的依赖;但是这个依赖的开发者没法通过 yarn.lock 来保证当用户使用自己开发的库的时候,可以使用和自己一样的依赖。

举个简单的例子:在 react-resize-detector 这个库(v4.2.0)里,附带了一份 yarn.lock 文件,指明了使用的 lodash 版本(lodash ^4.17.11)为 4.17.11。然而通过下面的命令在新的一个项目中安装,可以看到实际安装的版本会是 4.17.15:

yarn add lodash@4.17.15 react-resize-decector@4.2.0

也就是说,yarn 在安装的时候并没有参考项目内自身携带的 yarn.lock 的配置。

为此,npm 在 package.json 中设计了 bundledDependencies 这个字段来满足上述的需求。根据 npm 给出的说明,当 package.json 中指定了 bundledDependencies 字段后,这些指定的包也将在发布的时候一并被打包。这样,当其他人使用这个包的时候,就可以直接使用打包在项目内的依赖,而不需要在通过包管理器去下载了。

具体定义的方法如下:

{
  "name": "awesome-web-framework",
  "version": "1.0.0",
  "bundledDependencies": [
    "renderized", "super-streams"
  ]
}

如此定义后,renderizedsuper-streams 这两个依赖就会被一并打包发布。

bundle-dependencies 这个包为例,项目的 package.json 中写明了需要将 yargs 打包。因此,在下载到的包中可以看到,yargs 是作为 node_modules 的一个部分,连带其所有 dependencies(包括 dependencies 的 dependencies)全部都打包在内的(因而体积也比较大,tgz 有 237 kB)。

通过下面的命令也可以进一步验证,有了 bundledDependencies 之后,就可以锁定安装的依赖的 dependencies 版本号了:

yarn add yargs@4.8.1 bundle-dependencies@1.0.2

上述命令安装完毕后,bundle-dependencies 需要的 yargs v4.1.0 版本依然存放在其 node_modules 内,最外层有一个 yargs 4.8.1 版本,供其他模块使用。

需要注意的一点是,如果有了 bundledDependencies,那么即使其他地方使用到了相同版本的库,yarn 也不会将内部 node_modules 中的内容提升出来。举个例子:

yarn add yargs@4.1.0 bundle-dependencies@1.0.2

上述命令最终会安装两个 4.1.0 版本的 yargs,一个在最外层的 node_modules 内,一个在 bundle-dependencies 的 node_modules 内,两者互不干扰。


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);
}

Get Package Size🔗

Build

在 npm 中,可以通过 npm publish --dry-run 来“试运行”一次发布命令。不会真的将当前的内容发布到 npm 上,但是会执行完所有的步骤,并完整的列出会发布的文件以及文件对应的大小。通过这个 CLI 命令,可以直观的看到当前的 npm 包占用的体积。

如果希望可以通过 JavaScript API 的方式直接获取,可以考虑如下的方法:

const packlist = require('npm-packlist');
const tar = require('tar');
const cacache = require('cacache');
const rimraf = require('rimraf');

const tmpFolder = '.tmp';

async function getPackedSize(packagePath) {
  const files = await packlist({ path: packagePath });
  const folder = await cacache.tmp.mkdir(tmpFolder);
  const tmpTar = path.join(folder, 'package.tgz');
  await tar.create({
    prefix: 'package/',
    cwd: packagePath,
    file: tmpTar,
    gzip: true
  }, files);
  const { size } = fs.statSync(tmpTar);
  rimraf.sync(tmpFolder);
  return size;
}

上面的代码展示了如何获取发布包压缩后的代码,几点说明:

  • npm-packlist 这个包是专门用来分析需要发布的文件列表的,文档在这里
  • cacache 这个包可以创建临时的目录,用于存放临时生成的 tar 文件,文档在这里
  • tar 这个包可以用于将所有指定的文件都打包到 tar 中,文档在这里
  • rimraf 这个包可以用于将指定的目录删除,这里用于清理不需要的临时目录,文档在这里

以上的代码参考了 npm 的流程操作,见 npm cli 中的 packDirectory 代码

如果需要计算非压缩的体积,原理也是类似的。不同点在于,通过 npm-packlist 取得文件列表之后,直接依次将每个文件的大小通过 fs.statSync 计算出来,然后加起来就可以了。不需要额外生成辅助的临时文件。


__PURE__🔗

Build

在代码压缩的时候,由于压缩工具很难判断 JavaScript 代码的副作用,因此可能会将某些不需要使用的代码保留下来。针对 uglifyjs 或者 terserjs,可以通过 /*@__PURE__*/ 或者 /*#__PURE__*/ 这样的标签来显式声明定义是不包含副作用的。压缩工具在获取到这个信息之后,就可以放心的将未被使用的定义代码直接删除了。

相关的文档,可以参考 uglifyjsterserjs

或者,可以在 terser online 中尝试如下代码,观察编译结果的区别:

(function () {
  const unused = window.unknown();
  const used = 'used';

  console.log(used);
}());

上面的代码中,因为 window.unknown() 这个函数的调用细节对 terser 是不透明的,压缩工具无法判明使用是否会存在副作用。虽然 unused 这个变量没有被使用到,但是为了避免副作用丢失,terser 只能将 window.unknown() 调用保留下来。最终生成的压缩代码为:

!function(){window.unknown();console.log("used")}();

而如果将代码加上显式声明:

(function () {
  const unused = /*#__PURE__*/ window.unknown();
  const used = 'used';

  console.log(used);
}());

那么,terser 就可以放心的将整个调用删除。最终的压缩结果为:

console.log("used");

exports-loader🔗

Build

exports loader 是 Webpack 官方提供的一个 loader 工具,主要目的是转化已有的 JavaScript 库文件,方便以 import / require 的形式引入到代码中。

在实际的开发过程中,有时会遇到这样的情况:

  • 需要使用的库没有提供 npm 的包,只提供了一个可执行的 JavaScript 文件(比如 FoamTree);
  • 或者为了避免重复的编译工作,希望可以直接用 npm 包中现成的打包后产物。比如 @sentry/browser 使用中除了常规的 dist/index.js 及一众文件外,build/bundle.min.js 提供了打包后的结果。因为打包不涉及对库文件的修改,直接使用打包后的产物显然会更快一些(类似 Webpack DLL 的操作)。

然而,上面的情况都有一个共性的问题:这些打包的产物本意是给 Web 直接引用的。因此,“导出”的方式是在 window 上直接绑定对象,而不是通过 module.exports 或其他类似方式导出。比如,FoamTree 就会在 window 上绑定一个 CarrotSearchFoamTree 对象。

exports loader 就是为了这种情况设计的。

官方的文档可以查看这里。简单来说,通过下面的方式,就可以将 FoamTree 引入了:

module.exports = {
  // ...
  module: {
    rules: [
      // ...
      {
        test: /carrotsearch\.foamtree/u,
        loader: 'exports-loader?CarrotSearchFoamTree'
      },
      // ...
    ],
  },
  resolve: {
    modules: [
      // foamtree 的文件存放在 vendor 目录下,使用时效果等价于 node_modules
      // 但因为 foamtree 本身不提供 npm 包,因此这里只能手动存放,再配置查找路径
      'path-to-vendor-folder',
      'node_modules'
    ],
    // ...
  },
}

配置完成后,项目中,可以直接这么使用:

import FoamTree from 'carrotsearch.foamtree';

exports loader 的操作,就是在原先 FoamTree 的最后,加上一句:

module.exports = CarrotSearchFoamTree;

这里 CarrotSearchFoamTree 是 FoamTree 文件在 window 注入的对象。如果能导出多个变量,也可以进行配置:

require('exports-loader?file,parse=helpers.parse!./file.js');
// 添加如下代码:
//  exports['file'] = file;
//  exports['parse'] = helpers.parse;

Webpack External with Path Transform🔗

Build

在实际开发的过程中,可能会遇到这样的 Webpack 打包场景:某一个目录内的文件不需要被打包,而是通过 require 的形式在运行时单独加载。对于一般的包来说,只需要简单的配置 Webpack 的 externals 字段就可以了;但是如果引用的位置是一个相对路径,那么在配置 externals 的时候就会相对复杂一些。至少有以下的潜在问题:

  • 不需要被打包的目录是固定的,但是引用的位置可能不是固定的。这导致在相对路径引用的时候,引用的地址不是一个固定的字符串,需要根据引用位置以及引用路径才能确定一个引用是否指向了不需要被打包的文件目录。换句话说,是否 externals 需要动态分析

举个例子来说,假设目录结构如下:

- A
  - should-exclude.js
- B
  - A
    - should-include.js
  - index.js

其中,只有顶层的 A 目录不需要被打包,B 目录下的 A 子目录依然需要被打包。假设 index.js 中的内容如下:

import shouldExclude from '../../A/should-exclude';
import shouldInclude from '../A/should-include';

那么符合预期的编译结果应该大致如下:

const shouldExclude = require('./A/should-exclude').default;
const shouldInclude = __webpack__require('../A/should-include');

在这种场景下,单纯判断引用路径是否包含 A/ 就不够了,需要具体问题具体分析。

  • 另一个问题是打包后的引用路径很可能需要发生变化。还是用上面这个例子来说明。B 目录被打包后会生成一个文件,此时 B 目录内的文件层级关系就不复存在了。这时候生成文件和 A 的目录关系也就固定下来了。原先在 B 目录内各种情况的相对引用,到了打包之后需要根据最终确认的层级关系固定成一个具体的表示。因此,引用的路径也需要转义

针对上面提到的这两点,Webpack 的 externals 也提供了函数回调的配置方案,可以用于灵活的配置。一个参考代码如下;

const path = require('path');
function external(context, request, callback) {
  // 当 request 是相对引用的时候,根据使用文件的绝对路径(context),计算出具体被引用的文件地址
  const requestedFilePath = path.resolve(context, request);
  // 判断被引用的地址是否需要被打包
  if (shouldExternal(requestedFilePath)) {
    // 重新计算引用的层级关系,比如将 ../../A/xx.js 转化成 ../A/xx.js
    const transformedRequest = transformRequest(request);
    return callback(null, `commonjs2 ${transformedRequest}`);
  }
  return null;
}

module.exports = {
  // ...
  externals: [
    external,
  ],
  // ...
};

注:关于 commonjs2commonjs 的区别,可以参考这个 issue


Conflicting order in mini-css-extract-plugin🔗

Build

在使用了 mini-css-extract-plugin 的项目中,有时会遇到如下的 Warning 输出:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

tl;dr

上面这段 Warning 的实际含义是:

由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。

换句话说,如果 e1.css 本来的用途是覆盖 e2.css 的内容,且覆盖依靠的是同优先级下 CSS 定义出现的先后顺序,那么在 plugin 编译完成之后,这个覆盖就会失效。

造成 Warning 的例子

以下通过一个直观的例子来说明 plugin 在何时可能会输出上述 Warning:

假设有两个文件 entry1.js

import './e1.css';
import './e2.css';

entry2.js

import './e2.css';
import './e1.css';

且 Webpack 配置了需要将 e1.csse2.css 打包到同一个文件中。如果 plugin 选择将 e1.css 放在 e2.css 的前面,那么就不满足 entry2.js 的使用顺序;反之则不满足 entry1.js 的。正是在这样的“矛盾”情况下,plugin 输出了一个 Warning,并选择了一个相对最优的解。

更好的 Warning

上面提到的 mini-css-extract-plugin Warning 并不是非常直观,也很难知道具体是哪一个 chunk group 出现了问题。新提交的 PR #465 以及 #468 对此做了优化。优化后的输出类似:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order. Following module has been added:
 * css xxx!./e1.css
despite it was not able to fulfill desired ordering with these modules:
 * css xxx!./e2.css
   - couldn't fulfill desired order of chunk group(s) entry2
   - while fulfilling desired order of chunk group(s) entry1

根据 Warning 的提示,只需要观察 entry2 中对应的代码,就可以找到不满足的引入顺序了。

是否需要关心

这个 Warning 是否需要关注,取决于 CSS 在项目中是如何被使用的:只要在项目的使用过程中,没有 CSS 是通过加载的顺序进行优先级覆盖的,那么就可以忽略 plugin 给出的 Warning;反之,如果有这样的情况,则一定需要根据 Warning 修正输出,防止编译导致的意外。

当然,最保险的做法是启用 CSS Module,从源头上保证各个模块间的 CSS 是不会相互覆盖的。

代码分析

以下从这个 Warning 入手,分析 plugin 是如何从 modules 生成最终的打包产物的。

观察 mini-css-extract-plugin 的源代码,不难找到这个 Warning 的具体输出代码。接下来,将针对这段代码所在的 renderContentAsset 进行分析,了解 plugin 生成打包产物的算法。

首先观察 renderContentAsset 函数最开始的比较语句

if (typeof chunkGroup.getModuleIndex2 === 'function') {
  // ...
} else {
  // ...
}

这里,chunkGroup.getModuleIndex2 是 Webpack 4 中的 API,在之前的版本中不存在(Webpack 3 及之前使用的是 extract-text-webpack-plugin)。接下来,重点关心 Webpack 4 对应的代码段。

首先看 moduleDependencies 变量的初始化(源码):

const moduleDependencies = new Map(modules.map((m) => [m, new Set()]));

moduleDependencies 为每一个 module 都定义了一个对应的空 Set(具体 Set 内的值会在后续填充)。

接下来看 modulesByChunkGroup 变量的定义(源码):

const modulesByChunkGroup = Array.from(chunk.groupsIterable, (cg) => {
  // ...
  return sortedModules;
});

可以看到,modulesByChunkGroup 本质上,是将所有的 chunk group(chunk.groupsIterable)转化成了对应的 sortedModules。这里 sortedModules 变量的定义为(源码):

const sortedModules = modules
  .map((m) => {
    return {
      module: m,
      index: cg.getModuleIndex2(m),
    };
  })
  // eslint-disable-next-line no-undefined
  .filter((item) => item.index !== undefined)
  .sort((a, b) => b.index - a.index)
  .map((item) => item.module);

简单来说,做了几件事情:

  1. modules 中不属于当前 chunk group(cg)的部分剔除,对应的是 .filter((item) => item.index !== undefined)
  2. 根据 module 在 chunk group 中实际出现的位置,按从后往前进行排序,对应的是 .sort((a, b) => b.index - a.index)

这里,越是先出现的 module 在最终的 sortedModules 数组中排的越靠后。换句话说,出现在数组最后的一个 module,没有任何前置的依赖(在该 chunk group 中,这个 module 是第一个被引入的);而理论上来说,数组的第一个 module 依赖了数组后面的所有 modules(从 CSS 的角度来说,这个模块出现在最后。在所有选择器优先级一样的前提下,这个 module 理论上可以对之前所有的 module 进行覆盖)。这里采用倒叙的方式组织数组,是为了后续可以方便的使用 Array.prototype.pop 函数去获取当前没有依赖的 module。

接下来,代码sortedModules 变量值进行了填充:

for (let i = 0; i < sortedModules.length; i++) {
  const set = moduleDependencies.get(sortedModules[i]);

  for (let j = i + 1; j < sortedModules.length; j++) {
    set.add(sortedModules[j]);
  }
}

如上所述,sortedModules 中后出现的 module 是先出现 module 的依赖。这里的 moduleDependencies 变量记录了各个 module 的所有依赖(不仅仅是当前 chunk group 的依赖,所有 chunk group 的依赖最终都会被写入到这个 Set 中),其中 key 是各个 module,而对应的 value 则是一个 Set,Set 中的每个元素都是当前这个 module 的依赖。

之后定义了 usedModulesunusedModulesFilter 函数(源码),目的是为了判断某一个 module 是否已经被当前的 plugin 使用了。

接下来的代码需要确保所有的 modules 都会根据某个具体的算法在最终的编译产物中被使用到。这里判断的方法就是 usedModules 是否包含了 modules 中所有的内容。

while (usedModules.size < modules.length) {
  let success = false;
  let bestMatch;
  let bestMatchDeps;
  // ...
}

至此,准备工作都做完了。接下来就是核心的部分:如何在各个 chunk group 中选取合适的 module,依次放到最终生成的 CSS 文件中。代码如下:

for (const list of modulesByChunkGroup) {
  // skip and remove already added modules
  while (list.length > 0 && usedModules.has(list[list.length - 1])) {
    list.pop();
  }

  // skip empty lists
  if (list.length !== 0) {
    const module = list[list.length - 1];
    const deps = moduleDependencies.get(module);
    // determine dependencies that are not yet included
    const failedDeps = Array.from(deps).filter(unusedModulesFilter);

    // store best match for fallback behavior
    if (!bestMatchDeps || bestMatchDeps.length > failedDeps.length) {
      bestMatch = list;
      bestMatchDeps = failedDeps;
    }

    if (failedDeps.length === 0) {
      // use this module and remove it from list
      usedModules.add(list.pop());
      success = true;
      break;
    }
  }
}

理想情况下,两个 CSS module 在最终编译产物中的先后顺序,应该和这两个 CSS module 在某一个 chunk group 中的先后顺序是一致的。如果顺序上无法保证一致,那么应该尽可能将不一致的情况降到最低。mini-css-extract-plugin 就是遵循这一个原则来对 module 进行排序的。在每一次的选取步骤中,算法都会依次遍历每一个 chunk group,做下面几个事情:

  1. 拿到当前该 chunk group 中没有依赖的那个 CSS module(const module = list[list.length - 1];);
  2. 判断这个拿到的 CSS module 是否有前置的依赖还没有被放到最终产物中去(const failedDeps = Array.from(deps).filter(unusedModulesFilter));
  3. 如果没有前置依赖了,那么这个 CSS module 就可以被“安全”的放到当前的编译产物中去(if (failedDeps.length === 0) { 对应的部分);
  4. 如果有不满足的前置依赖,那么就去寻找不满足情况最少的一个(bestMatchDeps.length > failedDeps.length),然后记下来(bestMatchDeps = failedDeps;

如果找到了没有前置依赖的 module,代码就直接 break,跳到下一次 while 循环中去了;如果没有找到完美匹配的情况,就会进入接下来的代码

if (!success) {
  // no module found => there is a conflict
  // use list with fewest failed deps
  // and emit a warning
  const fallbackModule = bestMatch.pop();
  if (!this.options.ignoreOrder) {
    compilation.warnings.push(
      new Error(
        `chunk ${chunk.name || chunk.id} [${pluginName}]\n` +
          'Conflicting order between:\n' +
          ` * ${fallbackModule.readableIdentifier(
            requestShortener
          )}\n` +
          `${bestMatchDeps
            .map((m) => ` * ${m.readableIdentifier(requestShortener)}`)
            .join('\n')}`
      )
    );
  }

  usedModules.add(fallbackModule);
}

上面代码中的 bestMatch 就是对应了最佳情况时候 modules 排序的数组,这里 fallbackModule = bestMatch.pop() 就可以拿到当前这个最佳情况的 chunk group 中,没有依赖的那个 CSS module。和之前代码中 module = list[list.length - 1] 拿到的数据是一样的。

同时,bestMatchDeps 对应的就是上面代码里的 failedDeps 数组,表示的是当前这个 fallbackModule 被选中时,有哪些该 module 的前置依赖并没有被事先放到编译产物中去。

接下来就是向 Webpack 输出 Warning 的代码了。再来看下面这段 Warning:

WARNING in chunk styles [mini-css-extract-plugin]
Conflicting order between:
* css xxx/css-loader/dist/cjs.js!./e1.css
* css xxx/css-loader/dist/cjs.js!./e2.css
* css xxx/css-loader/dist/cjs.js!./e3.css

可知道,具体的含义是:由于没法找到最合适的解,plugin 被迫使用了相对最优的解,将 e1.css 放到了编译产物中。在某些 chunk group 中,e2.csse3.css 这两个文件引用位置在 e1.css 之前;而在 plugin 将 e1.css 加入编译产物的时候,e2.csse3.css 还没有被添加。


Chunk Group🔗

Build

Chunk Group 是 Webpack 4 中新产生的一个概念。

在 Webpack 中,一个文件/资源在打包时就是一个 module,一些 module 被打包到一起,生成一个 chunk。在原来的 Webpack 中,chunk 之间的依赖是一种父子关系:如果一个 chunk 有父 chunk,那么就可以认定,在这个 chunk 被加载的时候,至少其一个父 chunk 已经被加载了。在这种假设下,Webpack 可以对 chunk 进行一些优化,比如:如果一个 chunk 中的某个 module 被所有父 chunk 使用了,那么这个 module 就可以从 chunk 中删除(因为在加载 chunk 的时候,至少有一个父 chunk 已经被加载了,故这个 module 肯定已经存在了)。

然而,这种父子关系的模式,并不利于 chunk 的拆分:在 CommonsChunkPlugin 中,如果一个 chunk 被拆分了出来,那么这个 chunk 会被“当作”是原来 chunk 的父 chunk 进行处理。这种父子关系其实是不正确的,因此也会影响到其他一些优化的进行。

因此,在 Webpack 4 中,引入了 chunk group 的概念,目的就是修正这一概念。

一个 chunk group,顾名思义,就是一组 chunk 的集合。一个 Webpack 的加载入口、或是一个异步加载点就是一个 chunk group,这个 group 中所有的 chunk 都是可以被并行加载的。同时,一个 chunk 也可以归属于多个不同的 chunk group。有了 chunk group 的概念,在通过 SplitChunksPlugin 进行拆分的时候,只需要将拆出来的 chunk 划分到对应的各个 chunk group 中去,就可以保证正常的使用了。

参考文档见这里


Webpack File Limit Error🔗

Build

在 Webpack 的配置中,有一个 performance 选项。根据 performance 中的配置,Webpack 可以针对打包后的结果的实际大小,进行警告或报错。

具体的配置参数如下:

  • performance.hints

这个参数用于告诉 Webpack 最终产生的报告需要以什么样的方式呈现出来。可能的配置包括 false'warning''error'。建议在 CI 中将这部分配置成 'error',保证过大体积的文件无法被发布到线上。

而具体多大的文件算“过大”,则需要用到下面提到的两个参数:

  • performance.maxEntrypointSize
  • performance.maxAssetSize

前者表示入口文件能接受的最大文件尺寸(单位是 byte),后者表示其他生成的文件所能接受的最大尺寸(默认情况下包括了所有的 CSS,非入口 JS 文件,以及字体、图片等文件)。

比如,入口 JavaScript 文件不能超过 250kb,而其余文件不能超过 100kb:

module.exports = {
  performance: {
    maxEntrypointSize: 250_000,
    maxAssetSize: 100_000,
  }
}

默认情况下,除了 .map 文件外,所有其他产生的文件都会被考虑在内。如果希望改变这个默认的行为,可以使用下面的这个参数:

  • performance.assetFilter

比如,只考虑 JavaScript 文件:

module.exports = {
  performance: {
    assetFilter(assetFilename) {
      return assetFilename.endsWith('.js');
    },
  },
};

官方文档在这里


Webpack Require Performance🔗

Build

在 JavaScript 中,对模块的引用声明一般写在文件的顶部,而实际引用的 API,可能在运行时的非常晚才会被真正的使用到。看上去,这些 import 语句并没有什么问题。但实际上,由于引用模块自身的初始化工作以及可能的副作用,import 带来的性能损耗有时候也是不容忽视的。

首先来看下面这个 JavaScript 文件:

import { defaults } from 'lodash';

console.log(defaults({ 'a': 1 }, { 'a': 3, 'b': 2 }));
// → { 'a': 1, 'b': 2 }

看上去是一段非常简单的 JavaScript 代码,只是执行了一个很简单的操作。基本等价于下面这段代码(Lodash 的 API 可以参考文档):

console.log(Object.assign({ 'a': 3, 'b': 2 }, { 'a': 1 }));

然而两者有一个很重要的区别,就是前者引用了 Lodash 的 API。这个看上去是一个非常简单的操作,但实际上也有不小的消耗。在程序执行 import 语句的时候,会加载 Lodash 完整的初始化代码,并给 defaults 变量赋值 Lodash 的 defaults API。其中,Lodash 的初始化代码完整执行完成,需要大概 15ms 左右的时间。实际上,如果改成只引用 defaults 这一个 API,最终的效果就会好很多:

import defaults from 'lodash/defaults';

console.log(defaults({ 'a': 1 }, { 'a': 3, 'b': 2 }));

如果累计了很多这样小的初始化成本,最终就会导致在应用实际启动的过程中,产生几百毫秒的延迟。这一点在 Web 应用中相对还好,毕竟体积和初始化速度多少存在着一些关系,而 Web 应用对体积非常的敏感;但是同样的问题,到了 Electron 项目中,就有可能变得不容小觑起来。作为 PC 级别的应用,Electron 的打包往往对体积没有那么严苛的要求。很多时候多一个库,少一个库,都没有太大的差别。然而,各个库初始化的速度累计起来,却有可能拖累本就不快的 App 启动速度。

再举一个小例子。下面的这段代码看上去似乎没有什么问题:

const crypto = require('crypto');

function md5(input) {
  return crypto.createHash('md5').update(input).digest('hex');
}

然而,实际加载 crypto 模块可能需要 5ms 的时间。这个时间在初始化的时候就用掉了,但实际用到 crypto 模块的时间却可能还早(或者压根最终没触发)。考虑到 require 本身就有缓存的机制,将这一步骤放到第一次执行的时候再做,就可以省下这 5ms 的加载时间:

function md5(input) {
  return require('crypto').createHash('md5').update(input).digest('hex');
}

当然,上面只是一些例子。真正在实际的项目中需要解决这一问题,第一步,就是知道有哪些代码在初始阶段被加载了,分别花了多长的时间。这看上去是一个挺麻烦的工作,但如果应用是使用 Webpack 进行打包的,那么问题就变得不那么麻烦了。

Webpack 由于需要支持 HMR 以及 Dynamic Import,在编译的时候需要打包一个运行时进去,用于管理各个 Chunk 之间的引用(正因如此,Webpack 的打包体积往往会大于用 Rollup 打包的体积)。而正因为有了这个统一的运行时,使得模块间引用的耗时变得非常容易统计了。只需要在下面这行代码的前和后,分别用 Performance 进行一次打点计时,就可以很容易的知道每一个模块实际的加载耗时了。

modules[moduleId].call(
  module.exports,
  module,
  module.exports,
  __webpack_require__
);

修改后的代码大概如下:

if (typeof performance !== "undefined") performance.mark(moduleId);

modules[moduleId].call(
  module.exports,
  module,
  module.exports,
  __webpack_require__
);

if (typeof performance !== "undefined") {
  performance.measure(moduleId, moduleId);
  performance.clearMarks(moduleId);
  performance.clearMeasures(moduleId);
}

这里需要加上 typeof performance !== 'undefined' 的主要原因是,一些 loader(如 css-loader)可能会在 Node 环境执行运行时的代码,这种情况下不可以直接调用 Performance 相关的 API,会报错。

由于 Webpack 基于 Tapable 架构的关系,要编写一个插件来修改 Webpack 原本的运行时代码也非常的容易。观察 Webpack 的源码 不难发现,只需要针对 mainTemplaterequire 进行一些改动就可以了。同时,从 Webpack 的代码历史来看,上面这句代码前后的 Comment 一直都没有变过。于是,只需要找到模块引用前后的注释,用字符串替换的方式,插入这些新的性能打点语句就可以了。

最终的代码可以参考 NPM 的库 webpack-require-performance-plugin,源码在这里


SourceMap in Webpack🔗

Build

在 Webpack 的编译过程中,可以通过 devtool 的配置选项选择以什么样的形式输出 SourceMap。Webpack 提供了非常多的选择方案,不同的选项可以达到的效果是不一样的,也会极大的影响最终编译的时间。Webpack 的官方文档,从编译时间、重编译时间(针对 Watch 下的修改重编译)以及最终的使用效果三个纬度,给出了各个配置选项的实际效果。(中文版文档在这里

一般情况下:生产环境最终的打包,建议使用 source-map 作为配置。这种配置会将 SourceMap 文件打包到另外一个独立的文件中,线上代码不会暴露源文件,同时提供了最精细的代码映射关系,方便线上代码的调试和问题定位。当然,这种配置的缺点也非常明显,就是构建过程比较花费时间,因此一般只建议在最终要上线的版本中使用这种配置。

对于开发环境 Watch 模式下打包 Hot Reload 的版本,建议使用 eval-source-map 或者 cheap-module-eval-source-map。这两种模式,都会将代码用 eval 函数包裹起来,重编译的速度比较快,区别主要在于 SourceMap 的生成方案。前者会生成高品质的 SourceMap,因而初次构建的速度会比较慢,但是提供了行和列的映射;后者只提供了行层面的代码映射,因此会更加快一些,但是断点的效果会略打折扣(无法提供到列的映射关系)。

更多的类型以及各种情况的说明,可以参考官方文档(链接在上面给出)。


Webpack Speed Measure🔗

Build

优化的第一步,是知道瓶颈在哪里。

在针对 Webpack 编译速度优化的过程中,知道哪些 loader / plugin 运行耗费了很多时间就显得非常重要了。

Speed Measure Plugin 是一款针对 Webpack 的插件,只需要一些非常简单的操作,插件就可以在已有配置的基础上,给 Webpack 的打包过程增加必要的计时功能,同时清晰的列举出各个 loader / plugin 在本次编译中的耗时情况。Plugin 的文档在这里

简单来说,只需要在原有的配置基础上,这么额外包一层 Speed Measure Plugin 就可以了:

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin({ disable: false });

const config = { /* ... */ };

module.exports = smp.wrap(config);

这里,disable: false 表示需要 Speed Measure Plugin 记录时间,这也是默认的值,可以不传。如果赋值是 true,那么就会告知 Speed Measure Plugin 不要做任何处理,结果等同于没有使用 Speed Measure Plugin。如果需要经常对 Webpack 的打包进行优化,可以将 Speed Measure Plugin 的代码写入到库中,并通过环境变量等方法在打包的时候开启或关闭这个记录的功能。


Babel JSX Improvement🔗

Build

在 React 的开发中,需要在 Component 的 render 函数或是 Functional Component 的函数中,返回一个定义好的 JSX 内容,用于表示具体需要渲染出来的 UI 样式。Babel 或 TypeScript 会在编译时将这个对象转化成一个 JavaScript 可以理解的一般函数调用(具体调用的函数根据库的不同可能存在差异,对于 React 来说就是 React.createElement 函数,对于 Preact 来说则是 h 函数)。这个函数会在运行时被执行,并返回一个普通的 Object。React 拿到这个 Object 之后,就可以根据其中的内容来渲染出对应的 UI(根据具体执行的环境,这个步骤可能通过 React-DOM 或 React Native 来完成)。

既然 JSX 的部分会被编译成普通的函数调用,并在运行时被反复执行,这里必然会有一些性能上的损耗。

而在实际的开发中,存在着很多的组件,实际需要返回的 JSX 是固定不变的。比如说:

const LoadableButton = ({ loading, ...rest }) => {
  if (loading) return <Loading />;
  return <Button {...rest} />;
}

在上面这个例子中,实际上条件的第一种结果,返回的 JSX 是一个固定的值。手动的优化可以这么写:

const loadingComponent = <Loading />;
const LoadableButton = ({ loading, ...rest }) => {
  if (loading) return loadingComponent;
  return <Button {...rest} />;
}

这样,每次当 loading = true 的时候,都会直接返回 loadingComponent,而不需要反复执行 React.createElement(Loading) 这个函数去拿到最终的返回 Object。除了在运行时减少了重复计算,节省了时间和内存开销(这个在 re-render 非常频繁的时候有一定的优势),另一个好处是,React 可以通过比较返回的结果知道 Object 并没有发生变化,从而直接结束渲染的流程,不再进行接下来更深层次的渲染。

这部分的操作,其实可以交给编译器去完成。Babel 有一个插件 @babel/plugin-transform-react-constant-elements 可以拿来做这方面的优化,具体的使用方式以及可能存在的问题可以参考文档

当然,上面的优化依然存在小的瑕疵:因为把创建 Object 的操作提到了初始化的时候就直接进行了,如果存在大量类似的优化,会导致 JavaScript 初始运行的速度被减慢。大量的 Object 被事先创建了出来,而实际上这部分内容都远还没有到需要用的时候。一个更极致的优化可以这么写:

let loadingComponent;
const LoadableButton = ({ loading, ...rest }) => {
  if (loading) {
    if (!loadingComponent) loadingComponent = <Loading />;
    return loadingComponent;
  }
  return <Button {...rest} />;
}

这部分的操作就不是 Babel 插件原生支持的了。

从目前的实际情况来看,生成 JSX 对应 Object 拖慢初始化的例子暂时还不存在(毕竟 React.createElement 的执行速度并不是非常慢,而且一个项目中的 JSX 数量也不会非常庞大)。如果有必要,可以 GitHub 上提出 PR,按照类似上面提到的方式进行进一步的优化。