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,源码在这里