webpack插件编写及原理解析

作为先进最为流行的前端构建工具之一,webpack成为了前端开发必须掌握的技能。其诸多的插件为我们的工作带来了大大的便利,本文将对webpack plugin的基本原理以及编写方式做一个介绍。编写插件需要对webpack的底层特性有一定的了解,本文中也会对这些内容做一些基本介绍。

后文的介绍和样例代码编写所对应的webpack版本号为4.35.0

创建一个最基础的Plugin

首先我们不来扯别的原理,先来看看一个最为基本的webpack plugin结构。

// 声明一个js函数
function ExamplePlugin(option) {
  this.option = option
}
// 在函数的原型上声明一个apply方法
ExamplePlugin.prototype.apply = function(compiler) {}

你也可以采用ES6来进行编写

// 采用ES6
class ExamplePlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {}
}

以上就是一个最为基本的plugin结构。webpack plugin最为核心的便是这个apply方法。
webpack执行时,先生成了插件的实例对象,之后会调用插件上的apply方法,并将compiler对象(webpack实例对象,包含了webpack的各种配置信息...)作为参数传递给apply。
之后我们便可以在apply方法中使用compiler对象去监听webpack在不同时刻触发的各种事件来进行我们想要的操作了。

接下来看一个简单的示例

class plugin1 {
  constructor(option) {
    this.option = option
    console.log(option.name + '初始化')
  }
  apply(compiler) {
    console.log(this.option.name + ' apply被调用')

    //在webpack的emit生命周期上添加一个方法
    compiler.hooks.emit.tap('plugin1', (compilation) => {
      console.log('生成资源到 output 目录之前执行的生命周期')
    })
  }
}

class plugin2 {
  constructor(option) {
    this.option = option
    console.log(option.name + '初始化')
  }
  apply(compiler) {
    console.log(this.option.name + ' apply被调用')

    //在webpack的afterPlugins生命周期上添加一个方法
    compiler.hooks.afterPlugins.tap('plugin2', (compilation) => {
      console.log('webpack设置完初始插件之后执行的生命周期')
    })
  }
}

module.exports = {
  entry: './index.js',
  output: {
    filename: 'bundle.js'
  },
  plugins: [
    new plugin1({ name: 'plugin1' }),
    new plugin2({ name: 'plugin2' })
  ]
}

//执行webpack命令后输出结果如下:
/*
plugin1初始化
plugin2初始化
plugin1 apply被调用
plugin2 apply被调用
webpack设置完初始插件之后执行的生命周期
生成资源到 output 目录之前执行的生命周期
*/

首先webpack会按顺序实例化plugin对象,之后再依次调用plugin对象上的apply方法。
也就是对应输出 plugin1初始化plugin2初始化plugin1 apply被调用plugin2 apply被调用
webpack源代码中我们也可以看到这么一行,options.plugins便是配置文件中的被实例化的plugin数组。

插件中的apply被调用,对应源码目录lib/webpack.js

之前我们也提到了,webpack在运行过程中会触发各种事件,而在apply方法中我们能接收一个compiler对象,我们可以通过这个对象监听到webpack触发各种事件的时刻,然后执行对应的操作函数。这套机制类似于Node.js的EventEmitter,总的来说就是一个发布订阅模式。

compiler.hooks中定义了各式各样的事件钩子,这些钩子会在不同的时机被执行。而上文中的compiler.hooks.emitcompiler.hooks.afterPlugin这两个生命周期钩子,分别对应了设置完初始插件以及生成资源到 output 目录之前这两个时间节点,afterPlugin是在emit之前被触发的,所以输出顺序更靠前。

compiler对象上具体的钩子也可以查看官方文档 compiler钩子

在继续记下来的内容之前,我们先来对compilercompilation做一个更为详细的介绍。

compiler和compilation介绍

webpack的compiler模块是其核心部分。其包含了webpack配置文件传递的所有选项,包含了诸如loader、plugins等信息。

我们可以看看Compiler类中定义的一些核心方法。

//继承自Tapable类,使得自身拥有发布订阅的能力
class Compiler extends Tapable {
  //构造函数,context实际传入值为process.cwd(),代表当前的工作目录
  constructor(context) {
    super();
    // 定义了一系列的事件钩子,分别在不同的时刻触发
    this.hooks = {
      shouldEmit: new SyncBailHook(["compilation"]),
      done: new AsyncSeriesHook(["stats"]),
      //....更多钩子
    };
    this.running = true;
    //其他一些变量声明
  }

  //调用该方法之后会监听文件变更,一旦变更则重新执行编译
  watch(watchOptions, handler) {
    this.running = true;
    return new Watching(this, watchOptions, handler)
  }
  
  //用于触发编译时所有的工作
  run(callback) {
    //编译之后的处理,省略了部分代码
    const onCompiled = (err, compilation) => {
      this.emitAssets(compilation, err => {...})
    }
  }

  //负责将编译输出的文件写入本地
  emitAssets(compilation, callback) {}

  //创建一个compilation对象,并将compiler自身作为参数传递
  createCompilation() {
    return new Compilation(this);
  }

  //触发编译,在内部创建compilation实例并执行相应操作
  compile() {}


  //以上核心方法中很多会通过this.hooks.someHooks.call来触发指定的事件
  
}

可以看到,compiler中设置了一系列的事件钩子和各种配置参数,并定义了webpack诸如启动编译、观测文件变动、将编译结果文件写入本地等一系列核心方法。在plugin执行的相应工作中我们肯定会需要通过compiler拿到webpack的各种信息。

接下来看看compilation

如果把compiler算作是总控制台,那么compilation则专注于编译处理这件事上。

在启用Watch模式后,webpack将会监听文件是否发生变化,每当检测到文件发生变化,将会执行一次新的编译,并同时生成新的编译资源和新的compilation对象。
compilation对象中包含了模块资源、编译生成资源以及变化的文件和被跟踪依赖的状态信息等等,以供插件工作时使用。如果我们在插件中需要完成一个自定义的编译过程,那么必然会用到这个对象。

tips: 在webpack-dev-server和webpack-dev-middleware里Watch模式默认开启

插件编写示例

首先看一个插件示例,这个插件在我们构建完相关的文件后,会输出一个记录所有构建文件名的filelist.md文件。

class myPlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {
    compiler.hooks.emit.tap('myPlugin', compilation => {
      let filelist = '构建后的文件: \n'
      for (var filename in compilation.assets) {
        filelist += '- ' + filename + '\n';
      }

      compilation.assets['filelist.md'] = {
        source: function() {
          return filelist
        },
        size: function() {
          return filelist.length
        }
      }
    })
  }
}

在webpack的emit事件被触发之后,我们的插件会执行指定的工作,并将包含了编译生成资源的compilation作为参数传入了函数。我们可以通过compilation.assets拿到生成的文件,并获取其中的filename值。

同样的,我们也可以获取到构建后的文件内容。
接下来我们编写一个插件,将编译后的.js.css文件进行gzip压缩。

const zlib = require('zlib')

class gzipPlugin {
  constructor(option) {
    this.option = option
  }
  apply(compiler) {
    compiler.hooks.emit.tap('myPlugin', compilation => {

      for (var filename in compilation.assets) {
        if (/(.js|.css)/.test(filename)) {
          const gzipFile = zlib.gzipSync(compilation.assets[filename]._value, {
            //压缩等级

            level: this.option.level || 7
          })

          compilation.assets[filename + '.gz'] = {
            source: function () {
              return gzipFile 
            },
            size: function () {
              return gzipFile.length
            }
          }
        }
      }
    })
  }
}

//webpack.config.js中调用
{
  ...
  plugins: [
    new gzipPlugin({
      //设置压缩等级
      level: 9
    })
  ]
}

在这个插件中,我们同样监听compiler的emit事件,通过compilation.assets[filename]._value拿到文件内容,之后通过node自带的zlib库便可生成gzip文件了。

压缩后结果如下:


js文件压缩结果
css文件压缩结果

关于gzip的更多实践内容,可以去这篇文章查看 gzip压缩实践

异步事件钩子
webpack有些事件钩子是支持异步的。
具体可以通过tapAsync或者tapPromise来实现,接下来看分别看一个示例。

class AsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('asyncEmit', (compilation, callback) => {
      console.log('asyncEmit')
      setTimeout(() => {
        //异步完成后调用callback函数以继续流程
        callback()
      }, 2000)
    })
  }
}

class LogPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync('log', (compilation, callback) => {
      console.log('LogPlugin')
    })
    compiler.hooks.done.tap('done', () => {
      console.log('done')
    })
  }
}

//webpack.config.js中调用
{
  //...
  plugins: [
    new AsyncPlugin(),
    new LogPlugin()
  ]
}

以上代码输出顺序如下:asyncEmit,2秒后输出LogPlugin,紧跟着输出done。

使用tapPromise也同理,只需稍稍改变一下写法即可:

class AsyncPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapPromise('asyncEmit', compilation => {
      // 返回一个 Promise,在我们的异步任务完成时 resolve……
      return new Promise((resolve, reject) => {
        setTimeout(function() {
          console.log('异步工作完成……')
          resolve()
        }, 1000);
      })
    })
  }
}

结合Tapable在插件中使用自定义事件

Tapable是一个小型的库,类似于Node.js的EventEmitter类,负责自定义事件的注册和触发。

const {SyncHook} = require('tapable')

class MainPlugin {
    apply(compiler) {
        //在hooks上自定义一个名为mainPlugin的钩子
        compiler.hooks.mainPlugin = new SyncHook(['data'])

        //在webpack的environment事件触发时,广播自定义的mainPlugin事件,并传参
        compiler.hooks.environment.tap('mainPlugin', (compilation) => {
            compiler.hooks.mainPlugin.call({
                text: 'MainPlugin Call'
            })
        })
    }
}

class ListenPlugin {
    apply(compiler) {
        //监听自定义的mainPlugin被触发后,执行对应的函数,输出data.text
        compiler.hooks.mainPlugin.tap('listenPlugin', (data) => {
            console.log(data.text)
        })
        
    }
}

//在webpack.config.js中引用

{
  // ...
  plugins: [
    new MainPlugin(),
    new ListenPlugin()
  ]
}

可以看到,借助tapable我们可以在webpack插件中自定义一些事件,用来进行特定的操作。插件之间也可以通过自定义事件互相调用部分逻辑。
webpack自身的compilercomplation类也是继承自tapable来实现自身事件的注册和触发的。

通过以上的学习,我们接下来对上面的内容进行一个小小的总结。
1. webpack插件本质上是一个函数,它的原型上存在一个名为apply函数。webpack在初始化时 (在最早触发的environment事件之前) 会执行这个函数,并将一个包含了webpack所有配置信息的compiler作为参数传递给apply函数。
2. 插件可以通过监听webpack本身触发的事件,在不同的时间阶段介入进行你想做的操作。
3. 通过获取到的compiler对象,我们可以结合tapable在插件中自定义事件并将其广播。
4. 在插件中监听一些特定的事件 (thisCompilation到afterEmit这个阶段的事件),你可以拿到一个compilation对象,里面包含了各种编译资源,你可以通过操作这个对象对生成的资源进行添加和修改等操作。

通过上面的学习,相信大家插件的编写和大致原理有了一定的了解和认识。

webpack执行流程

最后我们来对webpack本身的执行流程进行一个概述,并将其和compiler事件钩子的触发时机进行一个对照。

webpack流程

webpack首先会读取配置文件,创建compiler对象,之后调用所有插件中的apply方法,并将参数传入其中。
在完成之后会广播environment这个事件钩子。然后读取配置文件的entry属性,遍历所有入口js文件。

接下来compiler对象会调用run方法,正式开始启动各方面的工作。

webpack开始为创建compilation对象做准备工作,首先会调用一个newCompilationParams方法,创建compilation对象所需的参数,紧接着立刻广播beforeCompile和compile这两个事件。之后compilation对象被创建,并广播compilation和make事件。

webpack接下来就开始了编译相关的工作。调用loader处理各模块之间的依赖,对每一个require调用对应的loader进行加工,再将加工后的文件处理生成AST抽象语法树并遍历这颗抽象语法树,构建该模块所依赖的模块。最后再将所有模块中的require语法转换成 __webpack_require__

以上步骤完成之后webpack会触发emit事件,你可以在这个事件中通过compilation.assets拿到生成的各种资源。最后,webpack通过compiler的emitAssets方法将文件输出到对应的构建目录中,操作完成。

本文篇幅有限,对webpack流程只是进行了一个简单的介绍,但通过对流程的学习和了解,你能够更合理地运用、编写插件。

以上是这篇文章的全部内容,希望对您有所帮助。

参考文献

Webpack揭秘——走向高阶前端的必经之路
细说webpack 之流程篇
webpack官方文档