基本概念
plugin
(插件)是webpack
的支柱功能,webpack
整体的程序架构也是基于插件系统之上搭建的,plugin
的目的在于解决loader
无法实现的其他功能.
plugin
使用方式如下面代码.通常我们需要集成某款plugin
时,会先通过npm
安装到本地,然后在配置文件(webpack.config.js
)的头部引入,在plugins
那一栏使用new
关键字生成插件的实例注入到webpack
.
webpack
注入了plugin
之后,那么在webpack
后续构建的某个时间节点就会触发plugin
定义的功能.
狭义上理解,webpack
完整的打包构建流程被切割成了流水线上的一道道工序,第一道工序处理完,马上进入第二道工序,依此类推直至完成所有的工序操作.
每一道工序相当于一个生命周期函数,plugin
一旦注入到webpack
中后,它会在对应的生命周期函数里绑定一个事件函数,当webpack
的主程序执行到那个生命周期对应的处理工序时,plugin
绑定的事件就会触发.
简而言之,plugin
可以在webpack
运行到某个时刻帮你做一些事情. plugin
会在webpack
初始化时,给相应的生命周期函数绑定监听事件,直至webpack
执行到对应的那个生命周期函数,plugin
绑定的事件就会触发.
不同的plugin
定义了不同的功能,比如clean-webpack-plugin
插件,它会在webpack
重新打包前自动清空输出文件夹,它绑定的事件处于webpack
生命周期中的emit
.
再以下面代码使用的插件HtmlWebpackPlugin
举例,它会在打包结束后根据配置的模板路径自动生成一个html
文件,并把打包生成的js
路径自动引入到这个html
文件中.这样便刨去了单调的人工操作,提高了开发效率.
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin'); // 通过 npm 安装
const webpack = require('webpack'); // 访问内置的插件
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /.(js|jsx)$/,
use: 'babel-loader',
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './src/index.html' }) //
]
};
webpack程序架构
上一小结我们知道了webpack
将整个打包构建过程切割成了很多个环节,每一个环节对应着一个生命周期函数(简称钩子函数,也可称hook
).
webpack
官方文档记录的所有hook
函数的数量达到上百个,我们抽取其中小部分的核心钩子作为学习素材.
观察下图,我们首先要对webpack
的执行过程构建立一个宏观上的整体认知.
webpack
包含两个很重要的基础概念,分别是compiler
和compilation
,下面一一讲解.
compiler
是webpack
的支柱引擎,它相当于系统中枢,控制着程序的执行.
上图第一行有6
个钩子函数(为方便讲解,中间环节省略了很多其他钩子),compiler
会从左到右执行依次执行每一个钩子定义的监听事件队列.
文字描述仍然有些生硬,通过阅读下面代码可以对compiler
建立初步的认知.
代码头部首先引入webpack
和配置文件参数options
,通过执行webpack(options)
即可生成compiler
对象,再执行对象的run
方法就能开始启动代码编译.
const webpack = require("webpack");
const options = require("../webpack.config.js");
const compiler = webpack(options);
compiler.run(); // 启动代码编译
上图中,当compiler
执行make
阶段时,标志着代码的编译工作正式开始,这时候会创建compilation
对象完成相关任务.
compilation
会依次执行第二行标记的3
个钩子,等到代码的编译工作结束后,主线程又回到了compiler
,继续往下执行emit
钩子.
简而言之,compiler
执行到make
和emit
之间时,compilation
对象便出场了,它会依次执行它定义的一系列钩子函数,像代码的编译、依赖分析、优化、封装正是在这个阶段完成的.
compilation
实例主要负责代码的编译和构建.每进行一次代码的编译(例如日常开发时按ctrl + s
保存修改后的代码),都会重新生成一个compilation
实例负责本次的构建任务.
整体执行流程已经梳理了一遍,接下来深入到上图中标记的每一个钩子函数,理解其对应的时间节点.
entryOption:
webpack
开始读取配置文件的Entries
,递归遍历所有的入口文件.run:
程序即将进入构建环节compile:
程序即将创建compilation
实例对象make:
compilation
实例启动对代码的编译和构建emit:
所有打包生成的文件内容已经在内存中按照相应的数据结构处理完毕,下一步会将文件内容输出到文件系统,emit
钩子会在生成文件之前执行(通常想操作打包后的文件可以在emit
阶段编写plugin
实现).done:
编译后的文件已经输出到目标目录,整体代码的构建工作结束时触发
compilation
下的钩子含义如下.
buildModule:
在模块构建开始之前触发,这个钩子下可以用来修改模块的参数seal:
构建工作完成了,compilation
对象停止接收新的模块时触发optimize:
优化阶段开始时触发
compiler
进入make
阶段后,compilation
实例被创建出来,它会先触发buildModule
阶段定义的钩子,此时compilation
实例依次进入每一个入口文件(entry
),加载相应的loader
对代码编译.
代码编译完成后,再将编译好的文件内容调用 acorn
解析生成AST
语法树,按照此方法继续递归、重复执行该过程.
所有模块和和依赖分析完成后,compilation
进入seal
阶段,对每个chunk
进行整理,接下来进入optimize
阶段,开启代码的优化和封装.
文章看到这里,我们就明白了webpack
基于插件的架构体系.我们编写的plugin
就是在上面这些不同的时间节点里绑定一个事件监听函数,等到webpack
执行到那里便触发函数.
假设我现在想在compiler
的emit
钩子下绑定几个监听函数,那么应该如何绑定,其次又如何确保绑定的函数到了相应的时间节点会触发?
这里涉及到了发布-订阅
的事件机制,webpack
内部借助了Tapable
第三方库实现了事件的绑定和触发.
Tapable简介
Tapable
是一个用于事件发布订阅的第三方库,需要通过npm
安装使用,它和Node.js
中的EventEmitter
类似.
webpack
中的compiler
和compilation
都继承了Tapable
,因此compiler
和compilation
才具备了事件绑定和触发事件的能力.
我们接下里直接通过代码快速学习Tapable
的使用方式.
同步钩子
代码头部引入同步钩子函数SyncHook
,分别绑定三个事件开始刷牙
、正在洗脸
和吃早餐
.
const { SyncHook } = require("tapable");
const prepareHook = new SyncHook(["arg1","arg2"]); // 创建钩子,定义参数
prepareHook.tap("brushTeeth",(arg)=>{ //绑定事件
console.log(`开始刷牙:${arg}`)
})
prepareHook.tap("washFace",(arg)=>{ //绑定事件
console.log(`正在洗脸:${arg}`)
})
prepareHook.tap("breakfast",(arg)=>{ //绑定事件
console.log(`吃早餐:${arg}`)
})
prepareHook.call("准备阶段"); //触发事件
prepareHook.call("准备阶段")
一执行就会触发上面绑定的三个事件,输出结果如下.
开始刷牙:准备阶段
正在洗脸:准备阶段
吃早餐:准备阶段
从上面案例可以看出,只要call
命令一触发,SyncHook
绑定的事件会按照定义的顺序依次执行.
异步钩子
有时候我们定义的事件不光只包含同步行为,它可能也存在发起ajax
请求、文件上传下载这样的异步任务.
Tapable
提供的AsyncSeriesHook
钩子可以帮助我们定义异步任务.它绑定事件的回调函数的最后一个参数next
,需要在当前异步任务执行完成后调用一下,如此才能进入下一个异步任务.
const { AsyncSeriesHook } = require("tapable");
const workHook = new AsyncSeriesHook(["arg1"]);
workHook.tapAsync("openComputer",(arg,next)=>{ //绑定事件
setTimeout(()=>{
console.log(`打开电脑:${arg}`);
next();
},1000)
})
workHook.tapAsync("todoList",(arg,next)=>{ //绑定事件
setTimeout(()=>{
console.log(`列出日程安排:${arg}`);
next();
},1000)
})
workHook.tapAsync("processEmail",(arg,next)=>{ //绑定事件
setTimeout(()=>{
console.log(`处理邮件:${arg}`);
next();
},2000)
})
workHook.callAsync("工作阶段",()=>{ //触发事件
console.log(`异步任务完成`) // 所有异步任务全部执行完毕,回调函数才会触发
});
workHook.callAsync
一执行便触发绑定的异步事件,输出结果如下:
打开电脑:工作阶段
列出日程安排:工作阶段
处理邮件:工作阶段
异步任务完成
打开电脑:工作阶段
最先输出,过了1s
后输出列出日程安排:工作阶段
,再过2s
输出处理邮件:工作阶段
.最后输出异步任务完成
.
上面代码分别使用同步钩子和异步钩子做演示,输出结果很容易理解.如果同一份代码同时定义了同步钩子和异步钩子,一起触发执行顺序如何呢?
经过测试,同步任务都执行完毕后才会执行异步任务队列.如果代码中定义了多个同步任务队列,一起触发执行顺序如何呢?
它们也会按照调用(call
)顺序依次执行相应的队列任务,上一个队列任务都执行完了才会开始执行下一个任务队列.如果同一份代码定义多个异步任务队列,一起触发执行顺序如何呢?
异步任务队列并不会按照同步任务队列那样按照顺序先后执行,异步任务队列与异步任务队列之间会并行执行.
自定义插件
上面介绍完了预备知识,plugin
的开发流程就很容易理解了.首先创建一个js
文件,输入下面代码.
plugin
本质上是一个对外导出的class
,类中包含一个固定方法名apply
.
apply
函数的第一个参数就是compiler
,我们编写的插件逻辑就是在apply
函数下面进行编写.
既然程序中已经获取了compiler
参数,理论上我们就可以在compiler
的各个钩子函数中绑定监听事件.比如下面代码会在emit
阶段绑定一个监听事件.
主程序一旦执行到emit
阶段,绑定的回调函数就会触发.通过上面的介绍可知,主程序处于emit
阶段时,compilation
已经将代码编译构建完了,下一步会将内容输出到文件系统.
此时compilation.assets
存放着即将输出到文件系统的内容,如果这时候我们操作compilation.assets
数据,势必会影响最终打包的结果.
下面代码直接在compilation.assets
上新增一个属性名copyright.txt
,并定义好了文件内容和长度.
这里需要引起注意,由于程序中使用tapAsync
(异步序列)绑定监听事件,那么回调函数的最后一个参数会是next
,异步任务执行完成后需要调用next
,主程序才能进入到下一个任务队列.
最终打包后的目标文件夹下会多出一个copyright.txt
文件,里面存放着字符串this is my copyright
.
介绍完了插件的编写,插件的使用也同样简单.
首先在webpack
配置文件引入插件,然后在plugins
数组中new
一下引入的插件,即完成了plugin
的注入.此后webpack
再执行打包,运行到了相应的事件节点就会执行plugin
定义的监听函数.
//copyRight.js
class CopyRightPlugin {
apply(compiler){
compiler.hooks.emit.tapAsync("CopyRightPlugin",(compilation,next)=>{
setTimeout(()=>{ // 模拟ajax获取版权信息
compilation.assets['copyright.txt'] = {
source:function(){
return "this is my copyright"; // //文件内容
},
size:function(){
return 20; // 文件大小
}
}
next();
},1000)
})
}
}
module.exports = CopyRightPlugin;