使用 DllPlugin
认识 DLL
用过Windows系统的人应该会经常看到以.dll
为后缀的文件,这些文件称为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。
要给Web项目构建接入动态链接库的思想,需要完成以下事情:
- 把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块。
- 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取。
- 页面依赖的所有动态链接库需要被加载。
为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。由于动态链接库中大多数包含的是常用的第三方模块,例如react、react-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。
接入 Webpack
Webpack已经内置了对动态链接库的支持,需要通过2个内置的插件接入,它们分别是:
-
DllPlugin
插件:用于打包出一个个单独的动态链接库文件。 -
DllReferencePlugin
插件:用于在主要配置文件中去引入DllPlugin
插件打包好的动态链接库文件。
下面以基本的React项目为例,为其接入DllPlugin
,在开始前先来看下最终构建出的目录结构:
├── main.js
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
其中包含两个动态链接库文件,分别是:
-
polyfill.dll.js
里面包含项目所有依赖的polyfill
,例如Promise
、fetch
等API。 -
react.dll.js
里面包含React的基础运行环境,也就是react
和react-dom
模块。
以react.dll.js
文件为例,其文件内容大致如下:
var _dll_react = (function(modules) {
// ... 此处省略 webpackBootstrap 函数代码
}([
function(module, exports, __webpack_require__) {
// 模块 ID 为 0 的模块对应的代码
},
function(module, exports, __webpack_require__) {
// 模块 ID 为 1 的模块对应的代码
},
// ... 此处省略剩下的模块对应的代码
]));
可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为 ID。并且还通过_dll_react
变量把自己暴露在了全局中,也就是可以通过window._dll_react
可以访问到它里面包含的模块。
其中polyfill.manifest.json
和react.manifest.json
文件也是由DllPlugin
生成出,用于描述动态链接库文件中包含哪些模块, 以react.manifest.json
文件为例,其文件内容大致如下:
{
// 描述该动态链接库文件暴露在全局的变量名称
"name": "_dll_react",
"content": {
"./node_modules/process/browser.js": {
"id": 0,
"meta": {}
},
// ... 此处省略部分模块
"./node_modules/react-dom/lib/ReactBrowserEventEmitter.js": {
"id": 42,
"meta": {}
},
"./node_modules/react/lib/lowPriorityWarning.js": {
"id": 47,
"meta": {}
},
// ... 此处省略部分模块
"./node_modules/react-dom/lib/SyntheticTouchEvent.js": {
"id": 210,
"meta": {}
},
"./node_modules/react-dom/lib/SyntheticTransitionEvent.js": {
"id": 211,
"meta": {}
},
}
}
可见manifest.json
文件清楚地描述了与其对应的dll.js
文件中包含了哪些模块,以及每个模块的路径和 ID。
main.js
文件是编译出来的执行入口文件,当遇到其依赖的模块在dll.js
文件中时,会直接通过dll.js
文件暴露出的全局变量去获取打包在dll.js
文件的模块。 所以在index.html
文件中需要把依赖的两个dll.js
文件给加载进去,index.html
内容如下:
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script src="./dist/main.js"></script>
</body>
</html>
以上就是所有接入DllPlugin
后最终编译出来的代码。
构建出动态链接库文件
构建输出的以下这四个文件
├── polyfill.dll.js
├── polyfill.manifest.json
├── react.dll.js
└── react.manifest.json
和以下这一个文件
├── main.js
是由两份不同的构建分别输出的。
动态链接库文件相关的文件需要由一份独立的构建输出,用于给主构建使用。新建一个Webpack配置文件webpack_dll.config.js
专门用于构建它们,文件内容如下:
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
// JS 执行入口文件
entry: {
// 把 React 相关模块的放到一个单独的动态链接库
react: ['react', 'react-dom'],
// 把项目需要所有的 polyfill 放到一个单独的动态链接库
polyfill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'whatwg-fetch'],
},
output: {
// 输出的动态链接库的文件名称,[name] 代表当前动态链接库的名称,
// 也就是 entry 中配置的 react 和 polyfill
filename: '[name].dll.js',
// 输出的文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
// 存放动态链接库的全局变量名称,例如对应 react 来说就是 _dll_react
// 之所以在前面加上 _dll_ 是为了防止全局变量冲突
library: '_dll_[name]',
},
plugins: [
// 接入 DllPlugin
new DllPlugin({
// 动态链接库的全局变量名称,需要和 output.library 中保持一致
// 该字段的值也就是输出的 manifest.json 文件 中 name 字段的值
// 例如 react.manifest.json 中就有 "name": "_dll_react"
name: '_dll_[name]',
// 描述动态链接库的 manifest.json 文件输出时的文件名称
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
};
使用动态链接库文件
构建出的动态链接库文件用于给其它地方使用,在这里也就是给执行入口使用。
用于输出main.js
的主Webpack配置文件内容如下:
const path = require('path');
const DllReferencePlugin = require('webpack/lib/DllReferencePlugin');
module.exports = {
entry: {
// 定义入口 Chunk
main: './main.js'
},
output: {
// 输出文件的名称
filename: '[name].js',
// 输出文件都放到 dist 目录下
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
// 项目源码使用了 ES6 和 JSX 语法,需要使用 babel-loader 转换
test: /\.js$/,
use: ['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
]
},
plugins: [
// 告诉 Webpack 使用了哪些动态链接库
new DllReferencePlugin({
// 描述 react 动态链接库的文件内容
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
// 描述 polyfill 动态链接库的文件内容
manifest: require('./dist/polyfill.manifest.json'),
}),
],
devtool: 'source-map'
};
注意:在webpack_dll.config.js
文件中,DllPlugin
中的name
参数必须和output.library
中保持一致。 原因在于DllPlugin
中的name
参数会影响输出的manifest.json
文件中name
字段的值, 而在webpack.config.js
文件中DllReferencePlugin
会去manifest.json
文件读取name
字段的值, 把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。
执行构建
在修改好以上两个Webpack配置文件后,需要重新执行构建。 重新执行构建时要注意的是需要先把动态链接库相关的文件编译出来,因为主Webpack配置文件中定义的DllReferencePlugin
依赖这些文件。
执行构建时流程如下:
- 如果动态链接库相关的文件还没有编译出来,就需要先把它们编译出来。方法是执行
webpack --config webpack_dll.config.js
命令。 - 在确保动态链接库存在时,才能正常的编译出入口执行文件。方法是执行
webpack
命令。这时你会发现构建速度有了非常大的提升。
使用 HappyPack
由于有大量文件需要解析和处理,构建是文件读写和计算密集型的操作,特别是当文件数量变多后,Webpack构建慢的问题会显得严重。 运行在Node.js之上的Webpack是单线程模型的,也就是说Webpack 需要处理的任务需要一件件挨着做,不能多个事情一起做。
文件读写和计算操作是无法避免的,那能不能让 Webpack 同一时刻处理多个任务,发挥多核 CPU 电脑的威力,以提升构建速度呢?
HappyPack就能让Webpack做到这点,它把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程。
由于 JavaScript 是单线程模型,要想发挥多核 CPU 的能力,只能通过多进程去实现,而无法通过多线程实现。
分解任务和管理线程的事情HappyPack都会帮你做好,你所需要做的只是接入HappyPack。 接入HappyPack的相关代码如下:
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HappyPack = require('happypack');
module.exports = {
module: {
rules: [
{
test: /\.js$/,
// 把对 .js 文件的处理转交给 id 为 babel 的 HappyPack 实例
use: ['happypack/loader?id=babel'],
// 排除 node_modules 目录下的文件,node_modules 目录下的文件都是采用的 ES5 语法,没必要再通过 Babel 去转换
exclude: path.resolve(__dirname, 'node_modules'),
},
{
// 把对 .css 文件的处理转交给 id 为 css 的 HappyPack 实例
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: ['happypack/loader?id=css'],
}),
},
]
},
plugins: [
new HappyPack({
// 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
id: 'babel',
// 如何处理 .js 文件,用法和 Loader 配置中一样
loaders: ['babel-loader?cacheDirectory'],
// ... 其它配置项
}),
new HappyPack({
id: 'css',
// 如何处理 .css 文件,用法和 Loader 配置中一样
loaders: ['css-loader'],
}),
new ExtractTextPlugin({
filename: `[name].css`,
}),
],
};
以上代码有两点重要的修改:
- 在
Loader
配置中,所有文件的处理都交给了happypack/loader
去处理,使用紧跟其后的querystring?id=babel
去告诉happypack/loader
去选择哪个HappyPack实例去处理文件。 - 在
Plugin
配置中,新增了两个HappyPack 实例分别用于告诉happypack/loader
去如何处理.js
和.css
文件。选项中的id
属性的值和上面querystring
中的?id=babel
相对应,选项中的loaders
属性和Loader
配置中一样。
在实例化HappyPack插件的时候,除了可以传入id
和loaders
两个参数外,HappyPack还支持如下参数:
-
threads
代表开启几个子进程去处理这一类型的文件,默认是3个,类型必须是整数。 -
verbose
是否允许HappyPack输出日志,默认是true
。 -
threadPool
代表共享进程池,即多个HappyPack实例都使用同一个共享进程池中的子进程去处理任务,以防止资源占用过多,相关代码如下:
const HappyPack = require('happypack');
// 构造出共享进程池,进程池中包含5个子进程
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });
module.exports = {
plugins: [
new HappyPack({
// 用唯一的标识符 id 来代表当前的 HappyPack 是用来处理一类特定的文件
id: 'babel',
// 如何处理 .js 文件,用法和 Loader 配置中一样
loaders: ['babel-loader?cacheDirectory'],
// 使用共享进程池中的子进程去处理任务
threadPool: happyThreadPool,
}),
new HappyPack({
id: 'css',
// 如何处理 .css 文件,用法和 Loader 配置中一样
loaders: ['css-loader'],
// 使用共享进程池中的子进程去处理任务
threadPool: happyThreadPool,
}),
new ExtractTextPlugin({
filename: `[name].css`,
}),
],
};
接入 HappyPack 后,你需要给项目安装新的依赖:
npm i -D happypack
安装成功后重新执行构建你就会看到以下由 HappyPack 输出的日志:
Happy[babel]: Version: 4.0.0-beta.5\. Threads: 3
Happy[babel]: All set; signaling webpack to proceed.
Happy[css]: Version: 4.0.0-beta.5\. Threads: 3
Happy[css]: All set; signaling webpack to proceed.
说明HappyPack配置生效了,并且可以得知HappyPack分别启动了3个子进程去并行的处理任务。
HappyPack 原理
在整个 Webpack 构建流程中,最耗时的流程可能就是Loader
对文件的转换操作了,因为要转换的文件数据巨多,而且这些转换操作都只能一个个挨着处理。 HappyPack的核心原理就是把这部分任务分解到多个进程去并行处理,从而减少了总的构建时间。
从前面的使用中可以看出所有需要通过Loader
处理的文件都先交给了happypack/loader
去处理,收集到了这些文件的处理权后HappyPack就好统一分配了。
每通过new HappyPack()
实例化一个HappyPack其实就是告诉HappyPack核心调度器如何通过一系列Loader
去转换一类文件,并且可以指定如何给这类转换操作分配子进程。
核心调度器的逻辑代码在主进程中,也就是运行着Webpack的进程中,核心调度器会把一个个任务分配给当前空闲的子进程,子进程处理完毕后把结果发送给核心调度器,它们之间的数据交换是通过进程间通信API实现的。
核心调度器收到来自子进程处理完毕的结果后会通知Webpack该文件处理完毕。
使用 ParallelUglifyPlugin
在使用Webpack构建出用于发布到线上的代码时,都会有压缩代码这一流程。 最常见的JavaScript代码压缩工具是UglifyJS,并且Webpack也内置了它。
用过UglifyJS的你一定会发现在构建用于开发环境的代码时很快就能完成,但在构建用于线上的代码时构建一直卡在一个时间点迟迟没有反应,其实卡住的这个时候就是在进行代码压缩。
由于压缩JavaScript代码需要先把代码解析成用Object抽象表示的AST语法树,再去应用各种规则分析和处理AST,导致这个过程计算量巨大,耗时非常多。
为什么不把在HappyPack中的多进程并行处理的思想也引入到代码压缩中呢?
ParallelUglifyPlugin 就做了这个事情。 当Webpack有多个JavaScript文件需要输出和压缩时,原本会使用UglifyJS去一个个挨着压缩再输出, 但是ParallelUglifyPlugin则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过UglifyJS去压缩代码,但是变成了并行执行。 所以ParallelUglifyPlugin能更快的完成对多个文件的压缩工作。
使用ParallelUglifyPlugin也非常简单,把原来Webpack配置文件中内置的UglifyJsPlugin去掉后,再替换成 ParallelUglifyPlugin,相关代码如下:
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
module.exports = {
plugins: [
// 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码
new ParallelUglifyPlugin({
// 传递给 UglifyJS 的参数
uglifyJS: {
output: {
// 最紧凑的输出
beautify: false,
// 删除所有的注释
comments: false,
},
compress: {
// 在UglifyJs删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的 `console` 语句,可以兼容ie浏览器
drop_console: true,
// 内嵌定义了但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去引用的静态值
reduce_vars: true,
}
},
}),
],
};
在通过new ParallelUglifyPlugin()
实例化时,支持以下参数:
-
test
:使用正则去匹配哪些文件需要被ParallelUglifyPlugin压缩,默认是/.js$/
,也就是默认压缩所有的 .js 文件。 -
include
:使用正则去命中需要被ParallelUglifyPlugin压缩的文件。默认为[]
。 -
exclude
:使用正则去命中不需要被ParallelUglifyPlugin压缩的文件。默认为[]
。 -
cacheDir
:缓存压缩后的结果,下次遇到一样的输入时直接从缓存中获取压缩后的结果并返回。cacheDir 用于配置缓存存放的目录路径。默认不会缓存,想开启缓存请设置一个目录路径。 -
workerCount
:开启几个子进程去并发的执行压缩。默认是当前运行电脑的CPU核数减去1。 -
sourceMap
:是否输出Source Map,这会导致压缩过程变慢。 -
uglifyJS
:用于压缩ES5代码时的配置,Object类型,直接透传给UglifyJS的参数。 -
uglifyES
:用于压缩ES6代码时的配置,Object类型,直接透传给UglifyES的参数。
其中的 test
、include
、exclude
与配置Loader
时的思想和用法一样。
UglifyES 是 UglifyJS 的变种,专门用于压缩 ES6 代码,它们两都出自于同一个项目,并且它们两不能同时使用。
UglifyES 一般用于给比较新的JavaScript运行环境压缩代码,例如用于ReactNative的代码运行在兼容性较好的 JavaScriptCore 引擎中,为了得到更好的性能和尺寸,采用 UglifyES 压缩效果会更好。
ParallelUglifyPlugin 同时内置了 UglifyJS 和 UglifyES,也就是说 ParallelUglifyPlugin 支持并行压缩 ES6 代码。
接入ParallelUglifyPlugin后,项目需要安装新的依赖:
npm i -D webpack-parallel-uglify-plugin
安装成功后,重新执行构建你会发现速度变快了许多。如果设置cacheDir
开启了缓存,在之后的构建中会变的更快。