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官方文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,319评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,801评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,567评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,156评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,019评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,090评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,500评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,192评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,474评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,566评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,338评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,212评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,572评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,890评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,169评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,478评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,661评论 2 335