webpack进阶:从0到1实现一个插件

7,903 阅读11分钟

前言

插件对应的github源码地址,npm库地址

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:异步执行,支持传入callbackpromise风格的异步回调函数,tapAsync对应callAsynctapPromise对应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的钩子依次触发:

image.png compilation实例能够访问所有的模块和它们的依赖,它会对程序的依赖图中所有模块进行编译。在编译阶段,模块会被加载(load)、封存(seal)、优化(optimzie)、分块(chunk)、哈希(hash)和重新创建(restore)。

image.png

自定义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 image.png

  • done是一个AsyncSeriesHook,所以是使用tap去注册回调函数。
class VersionWebapckPlugin {
    ...
    apply (compiler) {
        ...
        compiler.hooks.done.tap(this.pluginName, () => {
            this.fetchVersionInfo(compiler)
        })
    }
}

回调函数是一个箭头函数,嵌套了fetchVersionInfo方法。

有人会问,直接使用将fetchVersionInfo作为回调行不行呢?
可以的,这就是著名的this指向问题,我使用箭头函数就是为了避免this指向。

第三-确定version.txt的结构

最简单的结构就是参照git log image.png 我们只需要最新的那一条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 stdoutstderr参数将包含子进程的标准输出和标准错误的输出。 默认情况下,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即可看到版本信息 image.png

总结

留下一个问题: plugin的安装有顺序要求吗?请在评论区告知!

创作不易,烦请动动手指点一点赞。

楼主github, 如果喜欢请点一下star,对作者也是一种鼓励