Webpack 内置了很多功能。 通常你可用如下经验去判断如何配置 Webpack:
- 想让源文件加入到构建流程中去被 Webpack 控制,配置 entry;
- 想自定义输出文件的位置和名称,配置 output;
- 想自定义寻找依赖模块时的策略,配置 resolve;
- 想自定义解析和转换文件的策略,配置 module,通常是配置 module.rules 里的 Loader;
- 其它的大部分需求可能要通过 Plugin 去实现,配置 plugin;
一、 webpack原理解析
Webpack 以其使用简单著称,在使用它的过程中,使用者只需把它当作一个黑盒,需要关心的只有它暴露出来的配置。
1、 基本概念
- Entry:入口,Webpack 执行构建的第一步将从 Entry 开始,可抽象成输入;
- Module:模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块;
- Chunk:代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割;
- Loader:模块转换器,用于把模块原内容按照需求转换成新内容;
- Plugin:扩展插件,在 Webpack 构建流程中的特定时机会广播出对应的事件,插件可以监听这些事件的发生,在特定时机做对应的事情;
2、流程概括
Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:
- 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数;
- 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译;
- 确定入口:根据配置中的 entry 找出所有的入口文件;
- 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
- 完成模块编译:在经过第4步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系;
- 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会;
- 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统。
在以上过程中,Webpack 会在特定的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用 Webpack 提供的 API 改变 Webpack 的运行结果。
3、流程细节
Webpack 的构建流程可以分为以下三大阶段:
- 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler;
- 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理;
- 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统;
如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:
二、常见的Loaders
1、加载文件
- raw-loader:把文本文件的内容加载到代码中;
file-loader
:把文件输出到一个文件夹中,在代码中通过相对 URL 去引用输出的文件;url-loader
:和 file-loader 类似,但是能在文件很小的情况下以 base64 的方式把文件内容注入到代码中去;source-map-loader
:加载额外的 Source Map 文件,以方便断点调试;- svg-inline-loader:把压缩后的 SVG 内容注入到代码中;
- node-loader:加载 Node.js 原生模块 .node 文件;
- image-loader:加载并且压缩图片文件;
- json-loader:加载 JSON 文件;
- yaml-loader:加载 YAML 文件;
2、编译模版
- pug-loader:把 Pug 模版转换成 JavaScript 函数返回;
- handlebars-loader:把 Handlebars 模版编译成函数返回;
- ejs-loader:把 EJS 模版编译成函数返回;
- haml-loader:把 HAML 代码转换成 HTML;
- markdown-loader:把 Markdown 文件转换成 HTML;
3、转换脚本语言
babel-loader
:把 ES6 转换成 ES5;- ts-loader:把 TypeScript 转换成 JavaScript;
awesome-typescript-loader
:把 TypeScript 转换成 JavaScript,性能要比 ts-loader 好;- coffee-loader:把 CoffeeScript 转换成 JavaScript;
4、转换样式文件
css-loader
:加载 CSS,支持模块化、压缩、文件导入等特性;style-loader
:把 CSS 代码注入到 JavaScript 中,通过 DOM 操作去加载 CSS;sass-loader
:把 SCSS/SASS 代码转换成 CSS;postcss-loader
:扩展 CSS 语法,使用下一代 CSS;- less-loader:把 Less 代码转换成 CSS 代码;
stylus-loader
:把 Stylus 代码转换成 CSS 代码;
5、检查代码
eslint-loader
:通过 ESLint 检查 JavaScript 代码;tslint-loader
:通过 TSLint 检查 TypeScript 代码;- mocha-loader:加载 Mocha 测试用例代码;
coverjs-loader
:计算测试覆盖率;
6、其它
- vue-loader:加载 Vue.js 单文件组件;
- i18n-loader:加载多语言版本,支持国际化;
- ignore-loader:忽略掉部分文件;
ui-component-loader
:按需加载 UI 组件库,例如在使用 antd UI 组件库时,不会因为只用到了 Button 组件而打包进所有的组件;
二、 常见的Plugins
1、用于修改行为
define-plugin
:定义环境变量;- context-replacement-plugin:修改 require 语句在寻找文件时的默认行为;
- ignore-plugin:用于忽略部分文件;
2、用于优化
commons-chunk-plugin
:提取公共代码;extract-text-webpack-plugin
:提取 JavaScript 中的 CSS 代码到单独的文件中;prepack-webpack-plugin
:通过 Facebook 的 Prepack 优化输出的 JavaScript 代码性能;uglifyjs-webpack-plugin
:通过 UglifyES 压缩 ES6 代码;webpack-parallel-uglify-plugin
:多进程执行 UglifyJS 代码压缩,提升构建速度;imagemin-webpack-plugin
:压缩图片文件;- webpack-spritesmith:用插件制作雪碧图;
- ModuleConcatenationPlugin:开启 Webpack Scope Hoisting 功能;
dll-plugin
:借鉴 DDL 的思想大幅度提升构建速度;hot-module-replacement-plugin
:开启模块热替换功能;
3、其它
serviceworker-webpack-plugin
:给网页应用增加离线缓存功能;- stylelint-webpack-plugin:集成 stylelint 到项目;
- i18n-webpack-plugin:给你的网页支持国际化;
- provide-plugin:从环境中提供的全局变量中加载模块,而不用导入对应的文件;
web-webpack-plugin
:方便的为单页应用输出 HTML,比 html-webpack-plugin 好用;
四、webpack优化
1、缩小文件搜索范围
Webpack 启动后会从配置的 Entry
出发,解析出文件中的导入语句,再递归
的解析。 在遇到导入语句时 Webpack 会做两件事情:
- 根据导入语句去寻找对应的要导入的文件。例如
require('react')
导入语句对应的文件是./node_modules/react/react.js
,require('./util')
对应的文件是./util.js
; - 根据找到的要导入文件的后缀,使用配置中的
Loader
去处理文件。例如使用ES6
开发的 JavaScript 文件需要使用babel-loader
去处理;
以上两件事情虽然对于处理一个文件非常快,但是当项目大了以后文件量会变的非常多,这时候构建速度慢的问题就会暴露出来。 虽然以上两件事情无法避免,但需要尽量减少以上两件事情的发生,以提高速度。
1.1 优化 loader 配置:通过test 、include 去缩小命中范围
由于 Loader
对文件的转换操作很耗时,需要让尽可能少的文件被 Loader 处理。
可以适当的调整项目的目录结构,以方便在配置 Loader 时通过 include 去缩小命中范围。
在使用 Loader 时可以通过 test 、 include 、 exclude
三个配置项来命中 Loader 要应用规则的文件。 为了尽可能少的让文件被 Loader 处理,可以通过 include
去命中只有哪些文件需要被处理。
以采用 ES6 的项目为例,在配置 babel-loader
时,可以这样:
module.exports = {
module: {
rules: [
{
// 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能
test: /\.js$/,
// babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启
use: ['babel-loader?cacheDirectory'],
// 只对项目根目录下的 src 目录中的文件采用 babel-loader
include: path.resolve(__dirname, 'src'),
},
]
},
};
1.2 优化 resolve.modules 配置:可以指明存放第三方模块的绝对路径
,以减少寻找
resolve.modules 的默认值是 ['node_modules']
,含义是先去当前目录下的 ./node_modules 目录下去找想找的模块,如果没找到就去上一级目录 ../node_modules
中找,再没有就去 ../../node_modules
中找,以此类推,这和 Node.js 的模块寻找机制很相似。
当安装的第三方模块都放在项目根目录下的 ./node_modules 目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径
,以减少寻找,配置如下:
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤
// 其中 __dirname 表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
},
};
1.3 优化 resolve.mainFields 配置:明确第三方模块的入口文件描述
resolve.mainFields 用于配置第三方模块使用哪个入口文件
。
安装的第三方模块中都会有一个 package.json 文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields
用于配置采用哪个字段作为入口文件的描述。
可以存在多个字段描述入口文件的原因是因为有些模块可以同时用在多个环境中,针对不同的运行环境需要使用不同的代码。 以 isomorphic-fetch
为例,它是 fetch API
的一个实现,但可同时用于浏览器和 Node.js 环境。 它的 package.json 中就有2个入口文件描述字段:
{
"browser": "fetch-npm-browserify.js",
"main": "fetch-npm-node.js"
}
isomorphic-fetch 在不同的运行环境下使用不同的代码是因为 fetch API 的实现机制不一样,在浏览器中通过原生的 fetch 或者 XMLHttpRequest 实现,在 Node.js 中通过 http 模块实现。
resolve.mainFields 的默认值和当前的 target 配置有关系,对应关系如下:
- 当 target 为 web 或者 webworker 时,值是
["browser", "module", "main"]
- 当 target 为其它情况时,值是
["module", "main"]
以 target 等于 web 为例,Webpack 会先采用第三方模块中的 browser 字段去寻找模块的入口文件,如果不存在就采用 module 字段,以此类推。
为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。 由于大多数第三方模块都采用 main 字段去描述入口文件的位置,可以这样配置 Webpack:
module.exports = {
resolve: {
// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤
mainFields: ['main'],
},
};
使用本方法优化时,你需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行
1.4 优化 resolve.alias 配置:使用具体路径减少解析
resolve.alias 配置项通过别名来把原导入路径映射成一个新的导入路径。
在实战项目中经常会依赖一些庞大的第三方模块,以 React 库为例,安装到 node_modules 目录下的 React 库的目录结构如下:
├── dist
│ ├── react.js
│ └── react.min.js
├── lib
│ ... 还有几十个文件被忽略
│ ├── LinkedStateMixin.js
│ ├── createClass.js
│ └── React.js
├── package.json
└── react.js
可以看到发布出去的 React 库中包含两套代码:
- 一套是采用 CommonJS 规范的模块化代码,这些文件都放在 lib 目录下,以 package.json 中指定的入口文件 react.js 为模块的入口。
- 一套是把 React 所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中 dist/react.js 是用于开发环境,里面包含检查和警告的代码。dist/react.min.js 是用于线上环境,被最小化了。
默认情况下 Webpack 会从入口文件 ./node_modules/react/react.js 开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias 可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。
module.exports = {
resolve: {
// 使用 alias 把导入 react 的语句换成直接使用单独完整的 react.min.js 文件,
// 减少耗时的递归解析操作
alias: {
'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'), // react15
// 'react': path.resolve(__dirname, './node_modules/react/umd/react.production.min.js'), // react16
}
},
};
除了 React 库外,大多数库发布到 Npm 仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置 alias。
但是对于有些库使用本优化方法后会影响到后面要讲的使用 Tree-Shaking
去除无效代码的优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。 一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。 但是对于一些工具类的库,例如 lodash
,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。
1.5 优化 resolve.extensions 配置:源码中尽可能的带上后缀
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。 resolve.extensions 用于配置在尝试过程中用到的后缀列表,默认是:
extensions: ['.js', '.json']
也就是说当遇到 require(‘./data’) 这样的导入语句时,Webpack 会先去寻找 ./data.js 文件,如果该文件不存在就去寻找 ./data.json 文件,如果还是找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions
时你需要遵守以下几点,以做到尽可能的优化构建性能:
- 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中;
- 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程;
- 在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require(‘./data’) 写成 require(‘./data.json’);
相关 Webpack 配置如下:
module.exports = {
resolve: {
// 尽可能的减少后缀尝试的可能性
extensions: ['js'],
},
};
1.6 优化 module.noParse 配置
module.noParse 配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
在上面的 优化 resolve.alias 配置 中讲到单独完整的 react.min.js 文件就没有采用模块化,让我们来通过配置 module.noParse 忽略对 react.min.js 文件的递归解析处理, 相关 Webpack 配置如下:
const path = require('path');
module.exports = {
module: {
// 独完整的 `react.min.js` 文件就没有采用模块化,忽略对 `react.min.js` 文件的递归解析处理
noParse: [/react\.min\.js$/],
},
};
注意被忽略掉的文件里不应该包含 import 、 require 、 define 等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
2、使用 DllPlugin
2.1 什么是DLL?
在介绍 DllPlugin 前先给大家介绍下 DLL。 用过 Windows 系统的人应该会经常看到以 .dll 为后缀的文件,这些文件称为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。
要给 Web 项目构建接入动态链接库的思想,需要完成以下事情:
- 把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块;
- 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次被打包,而是去动态链接库中获取;
- 页面依赖的所有动态链接库需要被加载;
为什么给 Web 项目构建接入动态链接库的思想后,会大大提升构建速度呢? 原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。 由于动态链接库中大多数包含的是常用的第三方模块,例如react、react-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。
2.2 接入 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>
2.3 构建出动态链接库文件
构建输出的以下这四个文件
├── 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'],
},