自定义一个WebPack的插件

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代码的真正的含义才是我们今天要讲到的,这个插件最主要作用是 去除注释后的文本。

  1. 第一步,我们使用 compiler.hooks.emit 钩子函数。在生成资源并输出到目录之前触发该函数,也就是说将编译好的代码发射到指定的stream中就会触发,然后我们从回调函数返回的 compilation 对象上可以拿到编译好的 stream.

  2. 访问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(),
      ]
    }
    

webpack4.0之后用compiler.hooks注册,之前用comliler.plugin注册,但为了保险起见,还是两种办法都写,这样能应付服务器无webpack4版本,详见:这篇文章

最后扩展:简单看了几个插件之后,是不是可以考虑做些其他的事情,比如删除console.log、或者构建完成时做一些文件上传cdn的操作,马上动手吧~