1 webpack 插件
1.1 构建流程
webpack
loader
是负责不同类型文件的转译,将其转换为webpack
能够接收的模块。而webpack
插件则与loader
有很大的区别,webpack
插件是贯穿整个构建流程的,构建流程中的各个阶段会触发不同的钩子函数,在不同的钩子函数中做一些处理就是webpack
插件要做的事情。
webpack
一次完整的打包构建流程如下。
- 初始化参数:将
cli
命令行参数与webpack
配置文件合并、解析得到参数对象 - 加载插件:参数对象传给
webpack
初始化生成compiler
对象,执行配置文件中的插件实例化语句(例如new HtmlWebpackPlugin()
),为webpack
事件流挂上自定义hooks
- 开始编译:执行
compiler
对象的run
方法开始编译,每次run
编译都会生成一个compilation
对象 - 确定入口:触发
compiler
对象的make
方法,开始分析入口文件 - 编译模块:从入口文件出发,调用
loader
对模块进行转译,再查找模块依赖的模块并转译,递归完成所有模块的转译 - 完成编译:根据入口和模块之间的依赖关系,组装成一个个的
chunk
,执行compilation
的seal
方法对每个chunk
进行整理、优化、封装 - 输出资源:执行
compiler
的emitAssets
方法把生成的文件输出到output
的目录中
1.2 自定义插件
webpack
插件特点如下。
- 独立的
js
模块,暴露相应的函数 - 函数原型上的
apply
方法会注入compiler
对象 -
compiler
对象上挂载了相应的webpack
钩子 - 事件钩子的回调函数里能拿到编译后的
compilation
对象,如果是异步钩子还能拿到相应的callback
函数
class CustomDlugins {
constructor() {}
apply(compiler) {
compiler.hooks.emit.tapAsync(
"CustomDlugins",
(compilation, callback) => {}
);
}
}
module.exports = CustomDlugins;
大多数面向用户的插件都首先在compiler
上注册,如下为compiler
上暴露的一些常用的钩子。
钩子 | 类型 | 作用 |
---|---|---|
run |
AsyncSeriesHook |
在编译器开始读取记录前执行 |
compiler |
SyncHook |
在一个新的compilation 创建之前执行 |
compilation |
SyncHook |
在一次compilation 创建后执行插件 |
make |
AsyncSeriesHook |
完成一次编译之前执行 |
emit |
AsyncSeriesHook |
在生成到output 目录之前执行,回调参数compilation
|
afterEmit |
AsyncSeriesHook |
在生成文件到output 目录之后执行 |
assetEmitted |
AsyncSeriesHook |
生成文件的时候执行,提供访问产出文件信息的入口,回调参数file 、info
|
done |
AsyncSeriesHook |
一次编译完成后执行,回调参数stats
|
自定义文件清单插件,打包后自动生成文件清单,记录文件列表、文件数量。
根目录下包括package.json
、webpack.config.js
和src
,src
下包括main.js
。
// package.json
{
...
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "4.29.4",
"webpack-cli": "3.2.3"
}
}
// webpack.config.js
module.exports = {
entry: "./src/main.js",
output: {
filename: "./[name].js"
},
plugins: []
}
// src/main.js
console.log("hello world")
然后继续在根目录下创建plugins
文件夹,其中新建FileListPlugin.js
文件,webpack.config.js
中引入插件。
注意此场景要在文件生成到dist
目录之前进行,所以要注册的是compiler
上的emit
钩子。emit
是一个异步串行钩子,用tapAsync
来注册。
emit
的回调函数里可以拿到compilation
对象,所有待生成的文件都在其assets
属性上。通过compilation.assets
获取文件列表,整理后将其写入新文件准备输出。
最后再往compilation.assets
添加新文件。
// plugins/FileListPlugin.js
class FileListPlugin {
constructor(options) {
this.filename =
options && options.filename ? options.filename : "FILELIST.md"
}
apply(compiler) {
compiler.hooks.emit.tapAsync("FileListPlugin", (compilation, callback) => {
const keys = Object.keys(compilation.assets)
const length = keys.length
var content = `# ${length} file${
length > 1 ? "s" : ""
} emitted by webpack\n\n`
keys.forEach((key) => {
content += `- ${key}\n`
})
compilation.assets[this.filename] = {
source: function () {
return content
},
size: function () {
return content.length
}
}
callback()
})
}
}
module.exports = FileListPlugin
// webpack.config.js
const FileListPlugin = require("./plugins/FileListPlugin")
module.exports = {
...
plugins: [
new FileListPlugin({
filename: "filelist.md"
})
]
}
2 开发优化
2.1 webpack 插件
2.1.1 webpack-dashboard
webpack-dashboard
是用来优化webpack
日志的工具。
根目录下为webpack.config.js
、package.json
和src
,src
下包括main.js
。
// package.json
{
...
"scripts": {
"build": "webpack"
},
"devDependencies": {
"vue": "^2.6.12",
"webpack": "4.29.4",
"webpack-cli": "3.2.3",
"webpack-dashboard": "^2.0.0"
}
}
// webpack.config.js
const DashboardPlugin = require("webpack-dashboard/plugin");
module.exports = {
entry: "./src/main.js",
output: {
filename: "./[name].js",
},
plugins: [
new DashboardPlugin()
],
mode: "development"
}
// src/main.js
import vue from "vue";
console.log(vue);
若要使webpack-dashboard
生效,还要修改原有的启动命令。
// package.json
{
...
"scripts": {
"build": "webpack-dashboard -- webpack"
}
}
运行build
命令后,控制台会打印如下内容,左上角Log
为webpack
本身的日志,左下角Modules
则是此次参与打包的模块,可以查看模块的占用体积和比例,右下角Problems
可以查看构建过程的警告和错误等。
2.1.2 speed-measure-webpack-plugin
speed-measure-webpack-plugin
(SMP
)可以分析出webpack
整个打包过程中在各个loader
和plugin
上耗费的时间,根据分析结果可以找出哪些构建步骤耗时较长,以便于优化和反复测试。
SMP
使用时需要把它的wrap
方法包裹在webpack
的配置对象外面。
// webpack.config.js
const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");
const smp = new SpeedMeasurePlugin();
module.exports = smp.wrap({
entry: "./src/main.js",
output: {
filename: "./[name].js"
},
module: {
rules: [
{
test: /\.js$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
cacheDirectory: true,
presets: [["@babel/preset-env", { modules: false }]]
}
}
]
}
})
// src/main.js
const fn = () => {
console.log("hello world");
};
fn();
// package.json
{
...
"scripts": {
"build": "webpack"
},
"devDependencies": {
"webpack": "4.29.4",
"webpack-cli": "3.2.3",
"@babel/core": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"speed-measure-webpack-plugin": "^1.2.2"
}
}
运行build
脚本打包后控制台输出如下,可以看出babel-loader
转译时耗费了1.16
秒。
2.1.3 webpack-merge
webpack-merge
用于需要配置多种打包环境的项目。
若项目包括本地环境、生产环境,每个环境对应的配置都不同,但也有一些公共的部分,则需要将公共部分提取出来。
根目录下为package.json
、src
和build
,src
下包括index.html
、main.js
,build
下包括webpack.base.conf.js
、webpack.dev.conf.js
和webpack.prod.conf.js
。
// package.json
{
...
"scripts": {
"dev": "webpack-dev-server --config=./build/webpack.dev.conf.js",
"build": "webpack --config=./build/webpack.prod.conf.js"
},
"devDependencies": {
"webpack": "4.29.4",
"webpack-cli": "3.2.3",
"webpack-dev-server": "3.1.14",
"webpack-merge": "^4.1.4",
"file-loader": "^1.1.6",
"css-loader": "^0.28.7",
"style-loader": "^0.19.0",
"html-webpack-plugin": "3.2.0"
}
}
// src/main.js
console.log("hello world");
// src/index.html
<html lang="zh-CN">
<body>
<p>hello world</p>
</body>
</html>
其中开发环境和生产环境的公共配置如下。
// build/webpack.base.conf.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/main.js",
module: {
rules: [
{
test: /\.(png|jpg|gif)$/,
use: "file-loader"
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html"
})
]
}
开发环境的配置如下,其中webpack-merge
在合并module.rules
的过程中,会以test
属性作为标识符,当发现有相同项出现时会以后面的规则覆盖前面的规则,如此就不必添加冗余代码。
如下开发环境的loader
包括file-loader
、css-loader
、babel-loader
,其中css-loader
和babel-loader
覆盖了之前loader
并开启了sourceMap
。
// build/webpack.dev.conf.js
const baseConfig = require("./webpack.base.conf.js");
const merge = require("webpack-merge");
module.exports = merge.smart(baseConfig, {
output: {
filename: "./[name].js",
},
module: {
rules: [
{
test: /\.css$/,
use: [
"style-loader",
{
loader: "css-loader",
options: {
sourceMap: true
}
}
]
}
]
},
devServer: {
port: 3000,
},
mode: "development"
})
生产环境配置如下。
// build/webpack.prod.conf.js
const baseConfig = require("./webpack.base.conf.js");
const merge = require("webpack-merge");
module.exports = merge.smart(baseConfig, {
output: {
filename: "./[name].[chunkhash:8].js",
},
mode: "production"
})
2.2 模块热替换
自动刷新(live reload
)即只要代码改动就会重新构建,再触发网页刷新。而webpack
在此基础上又进了一步,可以在不刷新网页的前提下得到最新的代码改动,即模块热替换(Hot Module Replacement,HMR
)。
2.2.1 配置
HMR
需手动配置开启,如下配置会为每个模块绑定上module.hot
对象,其中包含了HMR
的API
(例如可以对特定模块开启或关闭HMR
等)。
// webpack.config.js
const webpack = require("webpack")
module.exports = {
...
plugins: [
new webpack.HotModuleReplacementPlugin()
],
devServer: {
hot: true
}
}
配置后还需要手动调用module.hot
上的API
来开启HMR
。如下若main.js
是应用的入口,则可以将调用HMR API
的代码放在此入口中,那么main.js
及其依赖的所有模块都会开启HMR
。当发现模块有改动时,HMR
会使应用在当前环境下重新执行main.js
,但是页面本身不会刷新。
// main.js
...
if (module.hot){
module.hot.accept()
}
若应用的逻辑比较复杂,则不推荐使用webpack
的HMR
,因为HMR
触发过程中可能会有预想不到的问题,建议开发者使用第三方提供的HMR
解决方案,例如vue-loader
、react-hot-loader
。
2.2.2 开启 HMR
根目录下为webpack.config.js
、package.json
和src
,src
下包括main.js
、index.html
和utils.js
。
// webpack.config.js
const webpack = require("webpack");
module.exports = {
entry: "./src/main.js",
output: {
filename: "./[name].js",
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new HtmlWebpackPlugin({
template: "./src/index.html"
})
],
devServer: {
hot: true
}
}
// package.json
{
...
"scripts": {
"dev": "webpack-dev-server"
},
"devDependencies": {
"webpack": "4.29.4",
"webpack-cli": "3.2.3",
"webpack-dev-server": "3.1.14",
"html-webpack-plugin": "3.2.0"
}
}
// src/main.js
import { logToHtml } from "./utils.js";
var count = 0;
setInterval(() => {
count += 1;
logToHtml(count);
}, 1000);
// src/utils.js
export function logToHtml(count) {
document.body.innerHTML = `count: ${count}`;
}
// src/index.html
<html lang="zh-CN">
<body></body>
</html>
运行dev
脚本命令后控制台输出如下,单击http://localhost:8080/
打开html
。
html
输出整数并每秒加1
,修改utils.js
如下,保存后查看html
,页面刷新,之前计数的count
重新开始由0
每秒加1
(未局部刷新)。
// src/utils.js
export function logToHtml(count) {
document.body.innerHTML = `count update: ${count}`;
}
utils.js
还原,main.js
添加如下代码,开启HMR
。
// src/main.js
...
if (module.hot) {
module.hot.accept()
}
然后再次修改utils.js
,查看html
未刷新,而是局部更新了,count
数值也是在之前基础上加1
。
但是又会带来另一个问题,当前的html
已经有了一个setInterval
,而HMR
后又会添加新的setInterval
,并未对之前的进行清除,导致最后html
上有不同的数字闪来闪去。
为了避免此问题,当main.js
发生改变则刷新整个页面,防止有多个定时器,但是对于其他模块则继续开启HMR
。
// src/main.js
...
if (module.hot) {
module.hot.decline();
module.hot.accept(["./utils.js"]);
}
2.2.3 HMR 流程
项目初次运行dev
脚本,首先会进行构建打包,同时将如何更新模块和接收后是否更新模块的代码注入到bundle
中。
而bundle
会被写入到内存中,不写入磁盘的原因是因为访问内存中的代码比访问磁盘中的文件快,并且也减少了代码写入文件的性能开销。
紧接着webpack-dev-server
使用express
启动本地服务,让浏览器可以请求到本地资源。然后再启动websocket
服务,用于建立浏览器和本地服务之间的双向通信。
单击http://localhost:8081/
在浏览器打开页面,此时页面建立与本地服务的websocket
连接,同时本地服务会将刚才首次打包的hash
值返回。
页面获取到hash
后,将此hash
作为下一次请求服务端js
和json
的hash
。
修改页面代码,webpack
监听到文件修改,重新开始打包编译。
根据新生成文件名可以发现,上次输出的hash
值会作为本次编译新生成的文件标识。依次类推,本次输出的hash
值会被作为下次热替换的标识。
编译完成后,本地服务通过websocket
发送本次打包的hash
给页面。
页面获取到hash
后,构造[hash].hot-update.json
和[hash].hot-update.js
,紧接着发出一次ajax
请求,获取json
文件,此json
文件包括所有要更新的模块。然后再次通过jsonp
请求,获取到最新的模块代码。
其中json
文件返回内容中,h
表示本次新生成的hash
值,用于下次文件热替换请求资源的前缀,c
表示当前要热替换的文件对应的是main
模块。
js
文件返回内容中则是本次修改的代码。
页面接收到请求数据后,将会对新旧模块进行对比,决定是否更新模块。注意如果在热更新过程中出现错误,热更新将回退到live reload
,即进行浏览器刷新来获取最新的打包代码。
3 打包工具
3.1 RollUp
RollUp 也是JavaScript
模块打包器,其更专注于JavaScript
的打包,在通用性上不及webpack
。但是相较于其他打包工具,RollUp
总能打包出更小更快的包。RollUp
对于代码的tree shaking
和es6
模块有算法优势的支持。所以一般开发应用用webpack
,开发库的时候用RollUp
。
与webpack
一般项目内部安装不同,RollUp
可以直接全局安装。
npm i rollup -g
根目录下包括package.json
、rollup.config.js
和src
,src
下为main.js
。其中rollup.config.js
中output.format
为输出资源的模块形式,此特性是webpack
不具备的。如下使用的是cjs
(CommonJs
),除此之外还有amd
、es
(ES Module
)、umd
、iife
(自执行函数)、system
(SystemJs
加载器格式)。
// package.json
{
...
"scripts": {
"build": "rollup -c rollup.config.js"
}
}
// rollup.config.js
module.exports = {
input: "src/main.js",
output: {
file: "dist/bundle.js",
format: "cjs"
}
}
// src/main.js
console.log("hello world")
运行build
脚本,根目录dist
下输出bundle.js
。可以明显看到打包出来的bundle
非常干净,RollUp
并未添加额外的代码,而同样的源代码,webpack
打包会额外添加很多代码。
// dist/bundle.js
'use strict';
console.log("hello world");
此外tree shaking
特性最开始是由RollUp
实现的,基于对ES6 Module
的静态分析,找出没有被引用的模块,最后将其从生成的bundle
中排除。
3.2 Parcel
Parcel 在JavaScript
打包工具中属于相对后来者,在其官网的测试中,其构建速度相较于webpack
快了好几倍,并且是零配置开箱即用的。
Parcel
在打包速度的优化上主要做了三件事,包括利用worker
来并行执行任务、文件系统缓存、资源编译处理流程优化。
其中前两件webpack
也有,比如webpack
在资源压缩时可以利用多核同时压缩多个资源,babel-loader
会将编译结果缓存到项目隐藏目录下,通过文件的修改时间和状态来判断是否使用上次编译的缓存。
webpack
通过loader
来处理不同类型的资源,loader
本质是一个函数,其输入输出都是字符串。例如babel-loader
,输入ES6+
的内容,语法转换后输出为ES5
。其大致过程为将ES6
字符串内容解析为AST
(抽象语法树)、对AST
进行语法转换、生成ES5
代码并返回字符串。
若是在babel-loader
后再添加多个loader
,其处理大致流程如下。其中涉及大量的String
与AST
的转换,loader
之间互不影响,各司其职,虽然可能会有部分冗余,但是有利于保持loader
的独立性和可维护性。
资源输入
↓
loader1 (String -> AST) --> 语法转换 --> (AST -> String)
↓
loader2 (AST -> String) <-- 语法转换 <-- (String -> AST)
↓
loader3 (String -> AST) --> 语法转换 --> (AST -> String)
↓
资源输出
而Parcel
未明确暴露loader
的概念,其资源处理流程不像webpack
可以对loader
随意组合,也正是由此它不需要太多AST
与String
之间的转换。
如下对于每一步来说,前面已解析过的AST
,那么下一步直接使用上一步解析和转换好的AST
即可,只用在最后一步再将AST
转回String
。对于一个庞大工程,解析AST
非常耗时,优化此处将会节省很多时间。
资源输入
↓
process1 (String -> AST) --> 语法转换
↓ (process1 返回的 AST)
process2 语法转换
↓ (process2 返回的 AST)
process3 语法转换 --> (AST -> String)
↓
资源输出
Parcel
也能直接全局安装。
npm i -g parcel-bundler
根目录下包括package.json
和src
,src
下为index.js
和index.html
。其中Parcel
是可以用html
文件作为项目入口的,从html
开始再进一步寻找依赖的资源。
Parcel
并没有属于自己的配置文件,而本质上是将配置进行了拆分,交给babel
、PostCss
等特定的工具分别进行管理。比如.babelrc
,Parcel
在打包时就会采用它作为ES6
代码解析的配置。
// package.json
{
...
"scripts": {
"dev": "parcel ./src/index.html",
"build": "parcel build ./src/index.html"
}
}
// src/index.html
<html lang="zh-CN">
<body>
<p>hello world</p>
<script src="./index.js"></script>
</body>
</html>
// src/index.js
console.log("hello world");