webpack系列文章
写在前面
在之前的webpack的系列课程中,我们已经介绍了:
- webpack的核心打包原理,并手动实现了一个简易的模块打包器。详见:实现一个简易的模块打包器。
- webpack的核心之loader,并实现了自定义的loader。详见:由浅及深实现一个自定义loader。
- 粗劣地查看了webpack的源码,了解了webpack的整个工作流程及各个阶段的常见钩子。详见:webpack源码阅读一:webpack流程以及重要钩子。
- webpack的核心之tapable机制,详细介绍了核心库tapable,并简单地实现了这个库的核心类。详见:webpack的核心之tapable机制。
接下来,只剩下webpack的最后一个核心Plugin,之所以将Plugin放到最后,是因为Plugin的部分依赖于webpack的整个编译流程,以及tapable的钩子。也就是说我们需要有这两部分的前置知识。同理,这篇文章与之前的文章一样,都是希望通过由浅及深的方式向大家讲述Plugin,同时实现Plugin插件。
什么是Plugin?
我们在日常开发中,可能会用到很多插件(Plugin),但是我们可能很少去仔细地思考什么是插件。用官方的话来说:插件(Plugin)是webpack的支柱功能,webpack自身就是构建于插件之上(说了等于没说)。平常我们的理解就是插件是用于处理webpack在编译过程中的某个特定任务的功能模块。更加通俗地理解:Plugin就是在webpack编译的某个阶段专注于实现某个功能。这就涉及到Plugin的两个重点:
- **阶段。**也就是说它需要插入webpack的某个阶段。
- **实现某个功能。**也就是说它需要再这个阶段做一些事情。而这个事情是我们编写插件的人确定的。你想要实现某个功能就去实现一个插件。 这样的话,大家可能就更加容易理解插件(Plugin)了。
webpack的编译阶段
在上面的介绍中,我们知道插件需要插入到webpack编译的某个阶段,那到底有多少个编译阶段了,这就需要用到我们在之前的阅读源码的文章中了解到的webpack的常见阶段。
插件原则上是可以作用于webpack编译的整个阶段。但是通常我们会在以下几个重要阶段进行插入:
-
编译阶段:
钩子 说明 compile 编译启动 compilation 编译(常用) make 正式开始编译,编译的核心过程 afterCompile 结束编译 -
输出阶段
钩子 说明 emit 输出编译后的文件(常用) afterEmit 输出完成 done webpack所有过程完成(常用)
其中,compilation
,emit
和done
又是最常用的三个阶段。
Plugin实现功能
我们在上面的介绍中提到了:Plugin的另外一个重要特点是需要实现特定的功能。提到实现某个功能,我们第一次想法就是它是一个函数或者说一个类。事实上一个插件就是一个函数或者说一个类,我们更常用的还是一个类。因此,接下来我们的核心其实就是去实现一个类。
如何实现一个Plugin?
在webpack官网中writting a Plugin描述了如何去实现一个类:
- 编写一个具名的函数或者类。
- 在函数或者类身上定义一个apply方法。
- 注入一个事件钩子。
- 处理webpack内部实例身上的特定数据。(compilation)。
- 功能完成之后,调用wbepack提供的回调。
接下来我们就按照它的描述一步一步由浅及深地去实现:
步骤一:创建一个类或者函数
正如我们在上面所理解的那样,Plugin需要实现特定的功能,因此它可能是一个函数或者类。这里我们使用类来创建Plugin。
class MyPlugin{
// 插件内容
}
步骤二:在类身上实现一个apply方法
Plugin比较奇特的一点是必须创建一个apply方法,Plugin插件的核心实现都在这个apply方法身上。我们可以从源码中去查看为什么一定要实现一个apply方法。我们可以看下webpack
中源码中Plugin部分,如下所示:
if (Array.isArray(plugins)) {
for (const plugin of plugins) {
plugin.apply(childCompiler); // 调用apply方法
}
}
我们可以看到,如果plugins是一个数组(这就是为什么我们在webpack.config.js
中需要定义plugins成一个数组),然后会遍历这个数组,对数组的每个元素,也就是每个插件,调用它的apply方法并传入compiler对象。这就是为什么所有编写的插件都必须有一个apply方法,而且传入了compiler对象作为apply方法的参数。因此,我们也需要定义一个apply方法。
class MyPlugin{
apply(compiler){
// 功能实现
}
}
步骤三:注入一个事件钩子
我们反复提到Plugin需要在webpack编译的某个阶段进行功能实现,因此需要注入一个钩子,用于在特定阶段进行监听。这里的所有阶段都可以通过apply
的compiler
参数获取到。
class MyPlugin{
apply(compiler){
console.log(Object.keys(compiler.hooks));
}
}
通过打印compiler.hooks
,我们可以知道我们能够在哪些阶段注入钩子。
[
'initialize',
'shouldEmit',
'done',
'afterDone',
'additionalPass',
'beforeRun',
'run',
'emit',
'assetEmitted',
'afterEmit',
'thisCompilation',
'compilation',
'normalModuleFactory',
'contextModuleFactory',
'beforeCompile',
'compile',
'make',
'finishMake',
'afterCompile',
'watchRun',
'failed',
'invalid',
'watchClose',
'infrastructureLog',
'environment',
'afterEnvironment',
'afterPlugins',
'afterResolvers',
'entryOption'
]
因此,apply
的方法实现大致应该是这样,这里我们以done阶段为例:
class MyPlugin{
apply(compiler){
compiler.hooks.done.tap("xxx",(stat) => {
})
}
}
其中每个阶段可能是同步的,也可能是异步的,都可以通过打印compiler.hooks.钩子名称
来获取到,如果是同步的,那么只有一个参数stat
,如果是异步的,那么除了有一个参数compilation
之外,还应该有一个回调函数callback
。也就是说:
同步Plugin的apply方法大致是这样:
class MyPlugin{
apply(compiler){
compiler.hooks.done.tap("xxx",(stat) => {
console.log("stat:",stat)
})
}
}
异步Plugin的apply方法大致是这样:
class MyPlugin{
apply(compiler){
compiler.hooks.emit.tapAsync("xxx",(compilation,callback) => {
// 其他实现:
callback();
})
}
}
**好了,其实到目前为止,我们已经实现了一个最简单的Plugin。**虽然这个Plugin并没有什么功能,因为我们并没有去实现第四步处理webpack内部实例身上的特定数据。但是我们基本上已经知道了如何去创建最简单的Plugin。至于第四步需要使用实际的例子来进行阐述。因此,看下一部分。
实现一个简单的Plugin之获取文件列表
在上面的介绍中,我们已经能够实现最简单的插件了,但是我们并没有实现特定的功能,因此也就没有去操作webpack内部实例身上的特定数据,但是在实际开发插件过程中,我们要实现特定功能,肯定需要去操作数据,那么如何去操作数据了,这也是官方的第四步。我们以webpack
官网的例子为例,获取打包后的文件列表,并将其写入一个README文档中。
class MyFileListPlugin{
constructor({filename}){
this.filename = filename;
}
apply(compiler){
compiler.hooks.emit.tapAsync("MyFileListPlugin",(compilation,callback) => {
const assets = compilation.assets; // 看这里,看这里
let content = `## 文件名 资源大小`;
Object.entries(assets).forEach(([filename,statObj]) => {
content += `\n ${filename} ${statObj.size()}`;
})
assets[this.filename] = { // 看这里,看这里
source:() => {
return content;
},
size(){
return content.length;
}
}
callback();
})
}
}
在之前,我们介绍过,在apply方法定义时,注册事件有一个回调函数,回到函数的参数是compilation
:
class MyPlugin{
apply(compiler){
compiler.hooks.emit.tapAsync("xxx",(compilation,callback) => {
// 其他实现:
console.log("compilation:",compilation)
callback();
})
}
}
这个compilation
就是我们可以操作的数据,它能够获取到这个阶段最常见的数据。其中最常用的就是compilation.assets
。看过我之前的文章实现一个简易的模块打包器的同学应该知道,assets实际上就是打包后的文件,它的组成一般是一个key:value。key值是路径,value值是每一个模块的内容。这里的compilation.assets
下的每一个模块也是差不多:只不过它是两个函数source:文件内容和size:文件大写。
assets[xxxx] = {
source:() => {
return content;
},
size(){
return content.length;
}
}
我们经常会根据xxx路径去获取到对象的source即文件内容,然后进行操作。以上面的获取文件列表为例,其核心功能实现如下:
const assets = compilation.assets; // 看这里,看这里
let content = `## 文件名 资源大小`;
Object.entries(assets).forEach(([filename,statObj]) => {
content += `\n ${filename} ${statObj.size()}`;
})
assets[this.filename] = { // 看这里,看这里
source:() => {
return content;
},
size(){
return content.length;
}
}
实际上就是:
- 通过
compilaiton.assets
获取到所有的文件的内容和大小 - 创建一个新的文件
assets[this.filename]
,这个文件也包括source和size。其中source就是要写入的文件内容,size就是文件的尺寸。
我们可以发现:其实我们的所有操作都是围绕compilation.assets
,我们在实际的开发过程中其实也是这样,就是去操作compilation.assets
,当然如果有更多复杂的功能,那么可能需要操作compilation
下面的更多数据,比如path
,output
等。
实现一个复杂的Plugin之内联Plugin
在上面的例子中,我们实现了一个简单的获取文件列表的Plugin,但是这个Plugin比较简单基本上不涉及到较多的操作,但是实际开发中我们经常可能需要做一些复杂的操作,这里有一条希望大家记住:大部分的插件涉及到的功能,其他开发者基本上已经实现了,因此我们更多的还是去找相关的插件,而不是自己开发;如果没有找到完全符合的,而必须自己开发的,那么你通常需要找功能最相关的插件,然后在他们的功能基础上进行实现,也就是说复杂插件的实现,通常是需要使用其他的插件的或者其他npm包。
这里我们以实现一个内联Plugin为例:它的功能就是将link中css的内容,从href引入变成style引入,将script中的js内容,也从src变成直接写入script标签。这个功能的实现我们首先想到:肯定需要操作index.html
中的link标签
和script
标签。我们都知道index.html
这个模板文件通常都是通过html-webpack-plugin
这个插件生成的,那么我们能否在这个插件的功能基础上进行实现,我们查看它的官网,可以发现,它提供了各种各样的钩子,可以帮助我们进行操作。也就是说我们能够在实现我们自己的钩子的过程中,调用它的钩子来实现功能。实现的详细过程就不进行描述了,最终的代码如下:
const HtmlWebpackPlugin = require('html-webpack-plugin');
class MyInlineSourcePlugin {
constructor({ match}){
this.match = match;
}
processTags(data,compilation){
let headTags = [];
let bodyTags = [];
data.headTags.forEach((headTag) => {
headTags.push(this.processTag(headTag, compilation))
});
data.bodyTags.forEach((bodyTag) => {
bodyTags.push(this.processTag(bodyTag, compilation))
});
return {
...data,
headTags,
bodyTags
}
}
//处理每个link和script标签。
processTag(tag,compilation){
let newTag ,url;
if (tag.tagName === "link" && this.match.test(tag.attributes.href)){
newTag = {
tagName:"style",
attributes:{
type:"text/css"
}
}
url = tag.attributes.href;
}
if (tag.tagName === "script" && this.match.test(tag.attributes.src)){
newTag = {
tagName: "script",
attributes: {
type: "application/javascript"
}
}
url = tag.attributes.src;
};
if(url){
// 通过compilation.assets[文件地址]可以获取到每个文件的内容。
newTag.innerHTML = compilation.assets[url].source();
delete compilation.assets[url]; // 删除原来的资源,不让生成文件
return newTag;
}
return tag;
}
apply(compiler) {
console.log("compiler:",compiler.hooks)
// 要通过webpack-plugin来实现这个功能
compiler.hooks.compilation.tap("MyInlineSourcePlugin", (compilation) => {
// 看这里看这里,调用html-webpack-plugin的内部的钩子alterAssetTagGroups。
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
'MyInlineSourcePlugin', (data, callback) => {
data = this.processTags(data,compilation);
callback(null, data)
}
)
})
}
}
总结
到目前为止,这篇文章我们介绍了:
- 什么是Plugin?Plugin就是在webpack编译的某个阶段专注于实现某个功能。因此需要牢记常见的阶段以及实现你想要的功能。
- 如何实现一个Plugin。从零开始,一步一步地教你如何实现一个Plugin
- 实现了一个简单的Plugin。带你初步了解如何去操作
webpack
的数据。主要是compilation.assets
。 - 实现了一个复杂的Plugin。带你去实现一个复杂的Plugin。对于复杂的Plugin,我们通常会借助其他的一些已有的Plugin,或者借助一些已有的npm包,主要还是借助这些已有的功能进行实现。
通过这篇文章,相信你再也不会因为对Plugin不熟悉,而感到恐惧了。所有的难都只是因为不了解,当你真正去了解一个东西时,你就会发现一切没有你想象中那么难。
本文的代码可以在webpack/plugin中进行查看,欢迎star。