webpack进阶:从0到1实现一个插件
前言
webpack的工作流程就好像是一条生产流水线,源文件需要经过一系列处理流程进行处理后才能转换为输出结果。流水线上的每一个处理流程都是单一职责的,而且流程与流程之间是存在依赖关系的,只有完成当前流程后才能到下一个流程进行处理。
而插件,简单来说就是额外插入到流水线上的一个处理流程,这个插入的位置是有讲究的。这条流水线能做到有条不紊的进行离不开一个核心功能-tapable
,webpack通过它来组织流水线。webpack会在运行过程中的某些特定时机会广播一些事件,插件只需监听这些事件就能加入到流水线中。
plugin
首先来看看,我们需要的plugin长什么样子:
class HelloCompilationPlugin {
apply(compiler) {
// 指定一个挂载到 compilation 的钩子,回调函数的参数为 compilation 。
compiler.hooks.compilation.tap('HelloCompilationPlugin', (compilation) => {
// 现在可以通过 compilation 对象绑定各种钩子
compilation.hooks.optimize.tap('HelloCompilationPlugin', () => {
console.log('资源已经优化完毕。');
});
});
}
}
module.exports = HelloCompilationPlugin;
这是一个官方的例子。
- 从上面来看,plugin是一个JavaScript的类
- 而且有一个
apply
实例方法,apply
执行具体的插件方法 - 该插件就是简单的在
done
这个hook
上注册了一个同步的打印日志的方法 - apply方法接收一个
compiler
实例 - hook回调方法注入了
compilation
实例 其中compiler
对象包含了webpack环境所有的配置信息,包含options、loaders、plugins等信息,该对象在webapck启动时被实例化,而且是全局唯一的,可以简单的理解为webpack实例。
compilation
则是包含了当前的模块资源、编译生成资源、变化的文件等。当webpack以开发模式运行时,每当检测到一个文件变化,一次新的compilation将被创建。该实例提供了很多事件回调供插件作扩展,而且通过它还能访问到compiler对象。
tapable
前面说到apply方法在运行时会被注入compiler实例,该实例可以调用hooks对象注册各种钩子,比如上面例子的compilation,这里的compilation是钩子的名称,tap定义了钩子的调用方法。webpack的插件系统就是基于这种模式构建而成的。
钩子的核心逻辑来自于tapable,tapable仓库暴露出很多hook类,可以用来为插件创建钩子
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
不同类型的钩子根据其同步异步、是否并行等性质的不同会导致调用方式也会有所不同,插件开发者需要根据这些特性,编写不同的交互逻辑。
所有hook构造函数都接收一个可选参数,该参数是一个字符串列表。
const hook = new SyncHook(["arg1", "arg2", "arg3"]);
最佳实践是在hooks属性中公开类的所有钩子:
class Car {
constructor() {
this.hooks = {
accelerate: new SyncHook(["newSpeed"]),
brake: new SyncHook(),
calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"])
};
}
}
使用如下:
const myCar = new Car();
myCar.hooks.brake.tap("WarningLampPlugin", () => warningLamp.on());
基本性质
上面的例子在webpack中经常见到,而tapable其实没有那么复杂。tapable在本质上就是基于发布-订阅模式下叠加各种功能逻辑。
所以tapable是会遵循发布-订阅模式的使用规则:
- 第一步创建钩子实例
- 第二步调用订阅接口来注册回调
- 第三步调用发布接口来触发回调
const { SyncHook } = require('tapable');
// 创建钩子实例
const print = new SyncHook();
// 调用订阅接口来注册回调
print.tap('error', () => {
console.log('发生错误');
})
// 调用发布接口来触发回调
print.call(); // '发生错误'
上面的十种hook类中,可以按两种方式来区分: 1、按回调类型
- 基本类型:名称不带Waterfall、bail和Loop关键字;该类型按钩子注册顺序,依次调用回调
Waterfall
类型:简单来说,前一个回调的返回值会注入下一个回调中Bail
类型:依次调用回调,若有任何一个回调返回非undefined值,则停止后续调用的执行Loop
类型:依次循环调用,直到所有回调函数都返回undefined 2、按回调的方式sync
:同步执行,依次执行回调,支持call/tap
调用async
:异步执行,支持传入callback
或promise
风格的异步回调函数,tapAsync
对应callAsync
、tapPromise
对应promise
webpack运行机制
了解完webpack的基本结构后,还需要了解一个非常重要的问题:何时会触发钩子。前面也说到webpack会在运行过程中的某些特定时机会广播一些事件,插件系统监听这些事件从而插入到运行流程中。
比如上面官网例子的compiler.hooks.compilation
:
- 时机:启动编译创建出compilation对象后触发
- 参数:当前编译的compilation对象 了解到上面这两个要素,我们就能对流程做一个插入处理。
其中触发时机与webpack运行流程息息相关:
- 首先会将
config
文件和命令行参数等,合并为options
; - 将
options
作为参数传入Compiler
构造方法,实例化compiler
,并执行构造函数初始化hook方法; - 执行
compiler
实例的run
等hook方法; - 调用
Compilation
构造方法实例化出compilation
,它是负责管理所有模块和对应的依赖,之后会触发名为make
的钩子; - 执行
compilation.addEntry()
,addEntry
用于对入口文件递归解析。并调用NormalModuleFactory
方法,为每个依赖生成一个Module
实例。期间会触发beforeResolve
等hook; - 将生产的
module
实例作为入参,执行Compilation.addModule()
和Compilation.buildModule()
方法递归创建模块对象和依赖对象; - 调用
seal
方法生成代码,整理输出主文件和chunk
。 compiler的钩子依次触发:
compilation实例能够访问所有的模块和它们的依赖,它会对程序的依赖图中所有模块进行编译。在编译阶段,模块会被加载(load)、封存(seal)、优化(optimzie)、分块(chunk)、哈希(hash)和重新创建(restore)。
自定义Plugin
古人云:
纸上得来终觉浅,绝知此事要躬行
理论说再多都是浅的,接下来动手开发一个Plugin。
VersionWebpackPlugin
首先明确需求,需要开发什么功能的plugin。这个plugin要解决的问题是项目版本:
- 一个产品应用,是不断迭代更新的。
- 但应用在项目上却是某个确定的版本。 基于以上这两个前提,那么就会出现一种情况就是:项目上的版本有bug修复或者增加一些定制功能,但在项目上使用的是1.x的版本,而产品已经是3.x的版本了。由于某种原因(没给够钱,或者不需要这么多功能)而不能进行全量升级。这时就需要确定项目版本的准确信息,某个commit或者某个tag,基于这个信息切分支进行修复或者开发。
这是非常常见的项目开发流程,所以应用程序上包含版本信息是非常有必要的。
自定义开发流程
平时使用webpack,都是捣鼓webpack.config.js
类似的配置文件(配置工程师)。而需要自定义构建或者开发流程时,都是推荐使用webpack的Node接口,因为所有的报告和错误处理都必须自行实现,webpack仅仅负责编译的部分。所以stats配置选项不会在webpack调用中生效。
应该有不少人没接触过webpack的Node接口,所以在这多啰嗦几句:
首先: 在js文件中,引用wepback模块
const wepback = require('webpack')
第二: 调用wepback 将配置对象(也就是经常捣鼓的那个配置文件导出的对象)传给wepback,如果同时传入回调函数,那么该回调将会在compiler运行时被执行。
webpack({
// 配置对象
}, (err, stats) => {
if (err || stats.hasErrors()) {
// 处理错误
}
// 运行回调
})
其中err对象只包含wepback相关的问题,比如配置错误等。它是不包含编译错误,所以必须使用stats.hasErrors()进行单独处理。
第三: 调用返回的Complier实例 如果不传入回调函数,那么调用webpack()之后会返回一个Compiler实例。Compiler实例基本上只会执行最低限度的功能,以维持生命周期运行的功能。它将所有的加载、打包和写入工作都委托到注册过的插件上。
Compiler实例上的hook属性会被用于将一个插件注册到Compiler的生命周期中所有钩子的事件上,正如上述所说的一样。
Compiler实例提供两个方法:
run(callback)
watch(watchOptions, handler)
使用run方法启动所有编译工作。完成之后,执行传入的callback函数。最终记录下来的stats和err,都可以在callback函数中获取。
const compiler = webpack(config);
compiler.run((err, stats) => {});
使用watch方法会触发webpack执行,但之后会监听变更,一旦webpack检测到文件变更,就会重新执行编译。
const watching = compiler.watch({
aggregateTimeout: 300,
poll: undefined
}, (err, stats) => {
console.log(stats);
});
watch方法返回一个Watching实例,该实例会暴露一个close方法,用于结束监听。
watching.close((closeErr) => {
console.log('Watching Ended.');
});
测试用例
回归正题,首先在开发这个plugin前,先使用上面介绍的方法创建一个测试用例。
const path = require('path');
const webpack = require('webpack');
const VersionWebpackPlugin = require('../src');
const compiler = webpack({
entry: path.resolve(__dirname, 'main.js'),
output: {
path: path.resolve(__dirname, '../dist')
},
mode: 'production',
plugins: [
new VersionWebpackPlugin()
]
})
compiler.run();
main.js简单写两句就行
function versionWebpackPlugin () {
console.log('欢迎使用--VersionWebpackPlugin')
}
versionWebpackPlugin();
测试用例非常简单,一看就懂。目标就是在dist目录中除了打包出来的bundle.js之外,还需要一个包含版本信息的version.txt
文件
plugin开发
首先-创建一个插件模版
class VersionWebapckPlugin {
apply (compiler) {}
}
该插件只在生产环境下使用的,有两种常用的方法验证环境
- 使用
process.env.NODE_ENV
变量 - 使用
compiler.options.mode
变量 第一、变量的设置是通过配置webpack.config.js文件中的mode
属性; 第二、变量则是向插件传入mode
属性。
class VersionWebapckPlugin {
constructor (options = {}) {
this.options = Object.assign(options);
this.pluginName = 'Version-Webpack-Plugin';
}
apply (compiler) {
const isProd = comiler.options.mode === 'production' || process.env.NODE_ENV === 'production';
if (!isProd) return;
}
}
第二-确定触发时机
接下来就是决定触发时机了。该插件并没有改变webpack运行流程,只是增加一个version.txt文件而已。所以最佳的触发时机应该是webpack执行完成时触发,查阅官方文档后决定使用done
- done是一个
AsyncSeriesHook
,所以是使用tap
去注册回调函数。
class VersionWebapckPlugin {
...
apply (compiler) {
...
compiler.hooks.done.tap(this.pluginName, () => {
this.fetchVersionInfo(compiler)
})
}
}
回调函数是一个箭头函数,嵌套了fetchVersionInfo
方法。
有人会问,直接使用将fetchVersionInfo
作为回调行不行呢?
可以的,这就是著名的this
指向问题,我使用箭头函数就是为了避免this指向。
第三-确定version.txt的结构
最简单的结构就是参照git log
我们只需要最新的那一条commit记录就行,所以需要获取到最新的commitId,再通过
git show commitId
获取即可
使用child_process
在Node.js中可以使用child_process
创建子进程来执行git命令来获取执行结果。这次用到的是child_process.exec()
方法:
child_process.exec(command[, options][, callback])
该方法会衍生shell,然后在该shell中执行command
,缓冲任何生成的输出。接收三个参数:
- command-要运行的命令,参数以空格分隔。
- options
- callback-当进程终止时使用输出调用。
- error
- stdout
- stderr
stdout
和stderr
参数将包含子进程的标准输出和标准错误的输出。 默认情况下,Node.js会将输出解码为UTF-8并将字符串传给回调。
const exec = require('child_process').exec;
class VersionWebapckPlugin {
fetchCommitInfo (command) {
return new Promise((resolve, reject) => {
exec(command, (err, stdout, stderr) => {
if (err) {
resolve('');
} else {
resolve(stdout.replace(/^[\s\n\r]+|[\s\n\r]+$/, ''))
}
})
})
}
}
因为要执行多条命令,所以将其封装为一个实例方法,结果去除开头结尾的空格、换行。
获取git信息
- 首先使用
git rev-parse --short HEAD
获取最新的commitId或者说sha的简短结果。 - 然后使用
git symbolic-ref --short -q HEAD
当前的git分支名称。 - 最后使用
git show commitId --quiet
获取指定commitId的详细内容。
输出
- 使用
fs.writeFileSync()
输出到config指定output的文件夹中。
fs.writeFileSync(path.resolve(compiler.options.output.path, 'version.txt'), result)
最终结果
启动应用后,访问ip:port/version.txt
即可看到版本信息
总结
留下一个问题: plugin的安装有顺序要求吗?请在评论区告知!
创作不易,烦请动动手指点一点赞。
楼主github, 如果喜欢请点一下star,对作者也是一种鼓励
转载自:https://juejin.cn/post/7098526687427559431