1、webpack插件的基本原理
我们知道,webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。
webpack 通过 Tapable 来组织这条复杂的生产线。 webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。
官网提供的自定义插件文档
tapable 是一个类似于 Node.js 中的 EventEmitter的库,但更专注于自定义事件的触发和处理。webpack 通过 tapable 将实现与流程解耦,所有具体实现通过插件的形式存在。
- tapable作为webpack的主模块,需要单独抽一篇出来讲。需要注意的是,如果你要写sdk需要向外层吐露数据的话,建议还是用EventEmitter库,不建议用tapable,因为它销毁全部注册事件不是很容易。
好了,写webpack插件必须要了解tapable,这里我们先简单介绍一下它的几个常用的钩子,其他的可以网上查,这里有篇写的还不错tapable详解
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require('tapable');
tapable通过tap注册一个事件,通过call执行该钩子注册的所有事件。tapable的每个hooks都tap一个或多个事件。tapAsync/callAsync、tapPromise/Promise用于注册同步执行的异步事件,callAsync用在并行执行的异步钩子完成后再执行该函数。
具体使用举个例子(比如SyncHook,依次执行注册事件,无法中断)
const hook = new SyncHook(['name', 'sex'])
/*
tap(options,function):
options是事件描述,可以为一个字符串,也可以为一个对象,为对象时必须包含name属性,描述该插件名称。
function:回调函数
*/
// 打印我的名字
hook.tap('printName', (name) => {
console.log('my name is ' + name);
})
hook.tap('printSex', (name, sex) => {
console.log('I’m a ' + sex);
})
// call(arg1,arg2,...)
hook.call('张三', 'man');
执行结果:
my name is 张三
I’m a man
好了,说了一堆tapable了,该说webpack插件了。
webpack 插件由以下组成:
- 一个 JavaScript 命名函数。
- 在插件函数的 prototype 上定义一个 apply 方法。
- 指定一个绑定到 webpack 自身的事件钩子。
- 处理 webpack 内部实例的特定数据。
- 功能完成后调用 webpack 提供的回调。
下面是官网的简单例子:
// 一个 JavaScript 命名函数。
function MyExampleWebpackPlugin() {
};
// 在插件函数的 prototype 上定义一个 `apply` 方法。
MyExampleWebpackPlugin.prototype.apply = function(compiler) {
// 指定一个挂载到 webpack 自身的事件钩子。
compiler.plugin('webpacksEventHook', function(compilation /* 处理 webpack 内部实例的特定数据。*/, callback) {
console.log("This is an example plugin!!!");
// 功能完成后调用 webpack 提供的回调。
callback();
});
};
在我们使用该plugin的时候,相关调用及配置代码如下:
const MyExampleWebpackPlugin = require('./MyExampleWebpackPlugin');
module.exports = {
plugins: [
new MyExampleWebpackPlugin(options)
]
};
我开始说了一大堆tapable,其实就是为了说明白webpack插件的原理
用代码说明吧,一个compiler.js,一个main.js
compiler.js
// 需要做的事情如下:
// 1. 定义一个 Compiler 类,接收一个options对象参数,该参数是从main.js中的MyPlugin类的实列对象。该对象下有 apply函数。
// 2. 在该类中我们定义了run方法,我们在main.js 中执行该run函数就可以自动执行对应的插件了。
const { SyncHook, AsyncParallelHook } = require('tapable');
class Compiler {
constructor(options) {
this.hooks = {
kzSyncHook: new SyncHook(['name', 'age']),
kzAsyncHook: new AsyncParallelHook(['name', 'age'])
};
let plugins = options.plugins;
if (plugins && plugins.length > 0) {
plugins.forEach(plugin => plugin.apply(this));
}
}
run() {
console.log('开始执行了---------');
this.kzSyncHook('我是小明', 81);
this.kzAsyncHook('我是小红', 91);
}
kzSyncHook(name, age) {
this.hooks.kzSyncHook.call(name, age);
}
kzAsyncHook(name, age) {
this.hooks.kzAsyncHook.callAsync(name, age);
}
}
module.exports = Compiler;
main.js
// 需要做的事情如下:
// 1. 引入 compiler.js 文件。
// 2. 定义一个自己的插件,比如叫 MyPlugin 类,该类下有 apply 函数。该函数有一个 compiler 参数,该参数就是我们的 compiler.js 中的实列对象。然后我们会使用 compiler 实列对象去调用 compiler.js 里面的函数。因此就可以自动执行了。
const Compiler = require('./compiler');
class MyPlugin {
constructor() {
}
apply(compiler) {
compiler.hooks.kzSyncHook.tap("eventName1", (name, age) => {
console.log(`同步事件eventName1: ${name} this year ${age} 周岁了, 可是还是单身`);
});
compiler.hooks.kzAsyncHook.tapAsync('eventName2', (name, age) => {
setTimeout(() => {
console.log(`异步事件eventName2: ${name} this year ${age}周岁了,可是还是单身`);
}, 1000)
});
}
}
const myPlugin = new MyPlugin();
const options = {
plugins: [myPlugin]
};
const compiler = new Compiler(options);
compiler.run();
看到没,这种使用方式是不是和官网的使用方式很相似~这回知道原理了吧~
现在看官网的简单例子,webpack启动后,在读取配置的过程中会先执行 new MyExampleWebpackPlugin(options) 初始化MyExampleWebpackPlugin来获得一个实例。
然后我们会把该实例当做参数传递给我们的Compiler对象,然后会实例化 Compiler类(这个逻辑可以结合看我们上面实现了一个简单的demo中 的main.js和compiler.js的代码结合起来理解)。在Compiler类中,我们会获取到options的这个参数,该参数是一个对象,该对象下有一个 plugins 这个属性。
然后遍历该属性,然后依次执行 某项插件中的apply方法,即:myExampleWebpackPlugin.apply(compiler); 给插件传递compiler对象。插件实例获取该compiler对象后,就可以通过 compiler.plugin('事件名称', '回调函数'); 监听到webpack广播出来的事件.(这个地方我们可以看我们上面的main.js中的如下代码可以看到, 在我们的main.js代码中有这样代码:compiler.hooks.kzSyncHook.tap("eventName1", (name, age) => {}));
如上就是一个简单的Plugin的插件原理(切记:结合上面的demo中main.js和compiller.js来理解效果会更好)。
2、Compiler 和 Compilation
在开发Plugin时我们最常用的两个对象就是 Compiler 和 Compilation, 他们是Plugin和webpack之间的桥梁。
compiler 对象代表了完整的 webpack 环境配置。这个对象在启动 webpack 时被一次性建立,并配置好所有可操作的设置,包括 options,loader 和 plugin。当在 webpack 环境中应用一个插件时,插件将收到此 compiler 对象的引用。可以使用它来访问 webpack 的主环境。
compilation 对象代表了一次资源版本构建。当运行 webpack 开发环境中间件时,每当检测到一个文件变化,就会创建一个新的 compilation,从而生成一组新的编译资源。一个 compilation 对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。compilation 对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用。
Compiler对象
Compiler对象包含了Webpack环境所有的配置信息,包含options,loaders, plugins这些项,这个对象在webpack启动时候被实例化,它是全局唯一的。我们可以把它理解为webpack的实例。
基本源码可以看如下:
// webpack/lib/webpack.js
const Compiler = require("./Compiler")
const webpack = (options, callback) => {
...
// 初始化 webpack 各配置参数
options = new WebpackOptionsDefaulter().process(options);
// 初始化 compiler 对象,这里 options.context 为 process.cwd()
let compiler = new Compiler(options.context);
compiler.options = options // 往 compiler 添加初始化参数
new NodeEnvironmentPlugin().apply(compiler) // 往 compiler 添加 Node 环境相关方法
for (const plugin of options.plugins) {
plugin.apply(compiler);
}
...
}
如上我们可以看到,Compiler对象包含了所有的webpack可配置的内容。开发插件时,我们可以从 compiler 对象中拿到所有和 webpack 主环境相关的内容。
compilation对象
compilation 对象包含了当前的模块资源、编译生成资源、文件的变化等。当webpack在开发模式下运行时,每当检测到一个文件发生改变的时候,那么一次新的 Compilation将会被创建。从而生成一组新的编译资源。
Compiler对象 与 Compilation 对象 的区别是:Compiler代表了是整个webpack从启动到关闭的生命周期。Compilation 对象只代表了一次新的编译。
Compiler对象的事件钩子:
钩子 作用 参数 类型
after-plugins 设置完一组初始化插件之后 compiler sync
after-resolvers 设置完 resolvers 之后 compiler sync
run 在读取记录之前 compiler async
compile 在创建新 compilation之前 compilationParams sync
compilation compilation 创建完成 compilation sync
emit 在生成资源并输出到目录之前 compilation async
after-emit 在生成资源并输出到目录之后 compilation async
done 完成编译 stats sync
理解webpack中的事件流
我们可以把webpack理解为一条生产线,需要经过一系列处理流程后才能将源文件转换成输出结果。
这条生产线上的每个处理流程的职责都是单一的,多个流程之间会存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。
我们的插件就像一个插入到生产线中的一个功能,在特定的时机对生产线上的资源会做处理。webpack它是通过 Tapable来组织这条复杂的生产线的。
webpack在运行的过程中会广播事件,插件只需要关心监听它的事件,就能加入到这条生产线中。然后会执行相关的操作。
webpack的事件流机制它能保证了插件的有序性,使整个系统的扩展性好。事件流机制使用了观察者模式来实现的。比如如下代码:
/*
* 广播事件
* myPlugin-name 为事件名称
* params 为附带的参数
*/
compiler.apply('myPlugin-name', params); // myPlugin-name随便写,就是一个名字
/*
* 监听名称为 'myPlugin-name' 的事件,当 myPlugin-name 事件发生时,函数就会执行。
*/
compiler.hooks.myPlugin-name.tap('myPlugin-name', function(params) {
});
插件中常用的API
compiler生命周期钩子
Compiler 支持可以监控文件系统的监听(watching)机制,并且在文件修改时重新编译。当处于监听模式(watch mode)时,compiler 会触发诸如 watchRun, watchClose 和 invalid 等额外的事件。通常用于开发环境中使用,也常常会在 webpack-dev-server 这些工具的底层之下调用,由此开发人员无须每次都使用手动方式重新编译。还可以通过 CLI 进入监听模式。
相关钩子:
以下生命周期钩子函数,是由 compiler 暴露,可以通过如下方式访问:
compiler.hooks.someHook.tap(...)
取决于不同的钩子类型,也可以在某些钩子上访问 tapAsync 和 tapPromise。
生命周期 | 对应hooks | 执行时机 | 参数 |
---|---|---|---|
entryOption | SyncBailHook | 在 entry 配置项处理过之后,执行插件。 | |
afterPlugins | SyncHook | 设置完初始插件之后,执行插件。 | compiler |
afterResolvers | SyncHook | resolver 安装完成之后,执行插件。 | compiler |
environment | SyncHook | environment 准备好之后,执行插件。 | |
afterEnvironment | SyncHook | environment 安装完成之后,执行插件。 | |
beforeRun | AsyncSeriesHook | compiler.run() 执行之前,添加一个钩子。 | compiler |
run | AsyncSeriesHook | 开始读取 records 之前,钩入(hook into) compiler。 | compiler |
watchRun | AsyncSeriesHook | 监听模式下,一个新的编译(compilation)触发之后,执行一个插件,但是是在实际编译开始之前。 | compiler |
normalModuleFactory | SyncHook | NormalModuleFactory 创建之后,执行插件。 | normalModuleFactory |
contextModuleFactory | ContextModuleFactory 创建之后,执行插件。 | contextModuleFactory | |
beforeCompile | AsyncSeriesHook | 编译(compilation)参数创建之后,执行插件。 | compilationParams |
compile | SyncHook | 一个新的编译(compilation)创建之后,钩入(hook into) compiler。 | compilationParams |
thisCompilation | SyncHook | 触发 compilation 事件之前执行(查看下面的 compilation) | compilation |
compilation | SyncHook | 编译(compilation)创建之后,执行插件 | compilation |
make | AsyncParallelHook | compilation | |
afterCompile | AsyncSeriesHook | compilation | |
shouldEmit | SyncBailHook | 此时返回 true/false。 | compilation |
needAdditionalPass | SyncBailHook | ||
emit | AsyncSeriesHook | 生成资源到 output 目录之前。 | compilation |
afterEmit | AsyncSeriesHook | 生成资源到 output 目录之后。 | compilation |
done | SyncHook | 编译(compilation)完成 | stats |
failed | SyncHook | 编译(compilation)失败 | error |
invalid | SyncHook | 监听模式下,编译无效时 | fileName, changeTime |
watchClose | SyncHook | 监听模式停止 |
读取输出资源、模块及依赖
在我们的emit钩子事件发生时,表示的含义是:源文件的转换和组装已经完成了,在这里事件钩子里面我们可以读取到最终将输出的资源、代码块、模块及对应的依赖文件。并且我们还可以输出资源文件的内容。比如插件代码如下:
class MyPlugin {
apply(compiler) {
compiler.plugin('emit', function(compilation, callback) {
// compilation.chunks 是存放了所有的代码块,是一个数组,我们需要遍历
compilation.chunks.forEach(function(chunk) {
/*
* chunk 代表一个代码块,代码块它是由多个模块组成的。
* 我们可以通过 chunk.forEachModule 能读取组成代码块的每个模块
*/
chunk.forEachModule(function(module) {
// module 代表一个模块。
// module.fileDependencies 存放当前模块的所有依赖的文件路径,它是一个数组
module.fileDependencies.forEach(function(filepath) {
console.log(filepath);
});
});
/*
webpack 会根据chunk去生成输出的文件资源,每个chunk都对应一个及以上的输出文件。
比如在 Chunk中包含了css 模块并且使用了 ExtractTextPlugin 时,
那么该Chunk 就会生成 .js 和 .css 两个文件
*/
chunk.files.forEach(function(filename) {
// compilation.assets 是存放当前所有即将输出的资源。
// 调用一个输出资源的 source() 方法能获取到输出资源的内容
const source = compilation.assets[filename].source();
});
});
/*
该事件是异步事件,因此要调用 callback 来通知本次的 webpack事件监听结束。
如果我们没有调用callback(); 那么webpack就会一直卡在这里不会往后执行。
*/
callback();
})
}
}
监听文件变化
webpack读取文件的时候,它会从入口模块去读取,然后依次找出所有的依赖模块。当入口模块或依赖的模块发生改变的时候,那么就会触发一次新的 Compilation。
在我们开发插件的时候,我们需要知道是那个文件发生改变,导致了新的Compilation, 我们可以添加如下代码进行监听。
// 当依赖的文件发生改变的时候 会触发 watch-run 事件
class MyPlugin {
apply(compiler) {
compiler.plugin('watch-run', (watching, callback) => {
// 获取发生变换的文件列表
const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
// changedFiles 格式为键值对的形式,当键为发生变化的文件路径
if (changedFiles[filePath] !== undefined) {
// 对应的文件就发生了变化了
}
callback();
});
/*
默认情况下Webpack只会监听入口文件或其依赖的模块是否发生变化,但是在有些情况下比如html文件发生改变的时候,那么webpack
就会去监听html文件的变化。因此就不会重新触发新的 Compilation。因此为了监听html文件的变化,我们需要把html文件加入到
依赖列表中。因此我们需要添加如下代码:
*/
compiler.plugin('after-compile', (compilation, callback) => {
/*
如下的参数filePath是html文件路径,我们把HTML文件添加到文件依赖表中,然后我们的webpack会去监听html模块文件,
html模板文件发生改变的时候,会重新启动下重新编译一个新的 Compilation.
*/
compilation.fileDependencies.push(filePath);
callback();
})
}
}
修改输出资源
我们在第一点说过:在我们的emit钩子事件发生时,表示的含义是:源文件的转换和组装已经完成了,在这里事件钩子里面我们可以读取到最终将输出的资源、代码块、模块及对应的依赖文件。因此如果我们现在要修改输出资源的内容的话,我们可以在emit事件中去做修改。那么所有输出的资源会存放在 compilation.assets中,compilation.assets是一个键值对,键为需要输出的文件名,值为文件对应的内容。如下代码:
class MyPlugin {
apply(compiler) {
compiler.plugin('emit', (compilation, callback) => {
// 设置名称为 fileName 的输出资源
compilation.assets[fileName] = {
// 返回文件内容
source: () => {
// fileContent 即可以代表文本文件的字符串,也可以是代表二进制文件的buffer
return fileContent;
},
// 返回文件大小
size: () => {
return Buffer.byteLength(fileContent, 'utf8');
}
};
callback();
});
// 读取 compilation.assets 代码如下:
compiler.plugin('emit', (compilation, callback) => {
// 读取名称为 fileName 的输出资源
const asset = compilation.assets[fileName];
// 获取输出资源的内容
asset.source();
// 获取输出资源的文件大小
asset.size();
callback();
});
}
}
判断webpack使用了哪些插件
在我们开发一个插件的时候,我们需要根据当前配置是否使用了其他某个插件,我们可以通过读取webpack某个插件配置的情况,比如来判断我们当前是否使用了 HtmlWebpackPlugin 插件。代码如下:
/*
判断当前配置使用了 HtmlWebpackPlugin 插件。
compiler参数即为 webpack 在 apply(compiler) 中传入的参数
*/
function hasHtmlWebpackPlugin(compiler) {
// 获取当前配置下所有的插件列表
const plugins = compiler.options.plugins;
// 去plugins中寻找有没有 HtmlWebpackPlugin 的实列
return plugins.find(plugin => plugin.__proto__.constructor === HtmlWebpackPlugin) !== null;
}
实战
实现一个打印日志的LogWebpackPlugin插件
// 这个文件为了观看更直观,先放到webpack.config.js中,真正使用时可以将你的自定义webpack插件封装到你们的前端组件库中。
class LogWebpackPlugin {
constructor(doneCallback, emitCallback) {
this.emitCallback = emitCallback
this.doneCallback = doneCallback
}
apply(compiler) {
compiler.hooks.emit.tap('LogWebpackPlugin', () => {
// 在 emit 事件中回调 emitCallback
this.emitCallback();
});
compiler.hooks.done.tap('LogWebpackPlugin', (err) => {
// 在 done 事件中回调 doneCallback
this.doneCallback();
});
compiler.hooks.compilation.tap('LogWebpackPlugin', () => {
// compilation('编译器'对'编译ing'这个事件的监听)
console.log("The compiler is starting a new compilation...")
});
compiler.hooks.compile.tap('LogWebpackPlugin', () => {
// compile('编译器'对'开始编译'这个事件的监听)
console.log("The compiler is starting to compile...")
});
}
}
// 使用
module.exports = {
plugins: [
new LogWebpackPlugin(() => {
// Webpack 模块完成转换成功
console.log('emit 事件发生啦,所有模块的转换和代码块对应的文件已经生成好~')
} , () => {
// Webpack 构建成功,并且文件输出了后会执行到这里,在这里可以做发布文件操作
console.log('done 事件发生啦,成功构建完成~')
})
]
}
编写去除生成 bundle.js 中多余的注释的插件
class MyPlugin {
constructor(options) {
this.options = options;
this.externalModules = {};
}
apply(compiler) {
var reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g;
compiler.hooks.emit.tap('CodeBeautify', (compilation) => {
Object.keys(compilation.assets).forEach((data) => {
console.log(data);
let content = compilation.assets[data].source(); // 获取处理的文本
content = content.replace(reg, function (word) { // 去除注释后的文本
return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word;
});
compilation.assets[data] = {
source() {
return content;
},
size() {
return content.length;
}
}
});
});
}
}
module.exports = MyPlugin;
这个js代码的真正的含义才是我们今天要讲到的,这个插件最主要作用是 去除注释后的文本。
-
第一步,我们使用 compiler.hooks.emit 钩子函数。在生成资源并输出到目录之前触发该函数,也就是说将编译好的代码发射到指定的stream中就会触发,然后我们从回调函数返回的 compilation 对象上可以拿到编译好的 stream.
-
访问compilation对象,compilation内部会返回很多内部对象,这边先不打印了,因为打印的话直接会卡死掉,要等很长时间才会打印出来,你们自己可以试试;然后我们遍历 assets.
Object.keys(compilation.assets).forEach((data) => {
console.log(compilation.assets);
console.log(8888)
console.log(data);
});
如下图所示:
- assets 数组对象中的key是资源名。在如上代码,我们通过 Object.key()方法拿到了。如下所示:
main.css bundle.js index.html
- 然后我们调用 compilation.assets[data].source(); 可以获取资源的内容。
- 使用正则,去掉注释,如下代码:
Object.keys(compilation.assets).forEach((data) => { let content = compilation.assets[data].source(); // 获取处理的文本 content = content.replace(reg, function (word) { // 去除注释后的文本 return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word; }); });
- 更新 compilation.assets[data] 对象,如下代码:
compilation.assets[data] = { source() { return content; }, size() { return content.length; } }
- 最后使用
module.exports = { plugins:[ new MyPlugin(), ] }