如何编写一个WebPack的插件原理及实践


一:webpack插件的基本原理
  • 二:理解 Compiler对象 和 Compilation 对象
  • 三:插件中常用的API
  • 四:编写插件实战
  • 回到顶部官网地址

    从官网得知:编写一个webpack插件需要由以下组成:

    1. 一个javascript命名函数。
    2. 在插件函数的prototype上定义一个 apply 方法。
    3. 指定一个绑定到webpack自身的钩子函数。
    4. 处理webpack内部实列的特定数据。
    5. 功能完成后调用webpack提供的回调函数。

    一个最基础的插件代码像如下这个样子:

    // 一个javascript命名函数
    function MyExampleWebpackPlugin() {
      
    };
    // 在插件函数的prototype上定义一个 apply 方法
    MyExampleWebpackPlugin.prototype.apply = function(compiler) {
      // 指定一个挂载到webpack自身的事件钩子。
      compiler.plugin('webpacksEventHook', function(compilation, callback) {
        console.log('这是一个插件demo');
    
        // 功能完成后调用 webpack 提供的回调
        callback();
      })
    }
    
    // 导出plugin
    module.exports = MyExampleWebpackPlugin;

    在我们使用该plugin的时候,相关调用及配置代码如下:

    const MyExampleWebpackPlugin = require('./MyExampleWebpackPlugin');
    module.exports = {
      plugins: [
        new MyExampleWebpackPlugin(options)
      ]
    };

    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来理解效果会更好)。

    回到顶部查看.  官网可以看这里。

    如上我们可以看到,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 事件发生时,函数就会执行。
    */
    
    compiler.hooks.myPlugin-name.tap('myPlugin-name', function(params) {
      
    });
    回到顶部回到顶部官网

    如上面代码,我们使用两个钩子事件,分别是 compiler.hooks.emit 和 compiler.hooks.done, compiler.hooks.emit 钩子事件的含义是: 在生成资源并输出到目录之前。这个事件就会发生。 compiler.hooks.done 的含义是:编译完成,该事件就会发生。因此上面截图我们可以看到先触发 emit事件,因此会打印 'done 事件发生啦,成功构建完成~', 然后会触发 done事件,因此会打印 "emit 事件发生啦,所有模块的转换和代码块对应的文件已经生成好~" 执行这个回调函数。
    github代码查看

    2. 编写去除生成 bundle.js 中多余的注释的插件

    项目结构如下:

    |--- webpack-plugin-demo
    | |--- node_modules
    | |--- public
    | | |--- js
    | | | |--- main.js                     # 入口文件             
    | | |--- plugins                       # 存放所有的webpack插件
    | | | |--- AsyncPlugin.js 
    | | | |--- AutoExternalPlugin.js
    | | | |--- DonePlugin.js
    | | | |--- FileListPlugin.js
    | | | |--- MyPlugin.js
    | | | |--- OptimizePlugin.js
    | | |--- styles                        # 存放css样式文件
    | | |--- index.html                    # index.html模板
    | |--- package.json        
    | |--- webpack.config.js 

    项目结构如上所示;上面在 public/plugins 中一共有6个插件,我们分别来看下6个插件的代码:

    1. public/plugins/AsyncPlugin.js 代码如下:

    class AsyncPlugin {
      constructor() {
    
      }
      apply(compiler) {
        // 监听emit事件,编译完成后,文件内容输出到硬盘上 触发该事件
        compiler.hooks.emit.tapAsync('AsyncPlugin', (compilation, callback) => {
          setTimeout(() => {
            console.log('文件将要被写入到硬盘中');
            callback();
          }, 2000)
        })
      }
    }
    
    module.exports = AsyncPlugin;

    如上该插件代码没有什么实际作用,无非就是监听 emit 异步事件钩子,emit事件钩子我们从官网 

    上可以看到具体的含义为:'在生成资源并输出到目录之前',会执行该事件钩子中函数代码,这边无非就是在控制台中打印一些提示信息的,没有什么实际作用的。
    2. public/plugins/DonePlugin.js 代码如下:

    class DonePlugin {
      constructor() {
    
      }
      apply(compiler) {
        compiler.hooks.done.tapAsync('DonePlugin', (name, callback) => {
          console.log('全部编译完成');
          callback();
        })
      }
    }
    
    module.exports = DonePlugin;

    如上代码也是一个意思,当编译完成后,就会执行 done的事件钩子的回调函数,也是在命令中提示作用的。

    3. public/plugins/OptimizePlugin.js 代码如下:

    class OptimizePlugin {
      constructor() {
    
      }
      apply(compiler) {
        // 监听 compilation 事件
        compiler.hooks.compilation.tap('OptimizePlugin', (compilation) => {
          compilation.hooks.optimize.tap('OptimizePlugin', () => {
            console.log('compilation 完成,正在优化,准备输出');
          });
        });
      }
    }
    
    module.exports = OptimizePlugin;

    也是一样监听 compilation 事件的,每当检测到一个文件发生改变的时候,那么一次新的 Compilation将会被创建。从而生成一组新的编译资源。

    4. public/plugins/FileListPlugin.js 代码如下:

    class FileListPlugin {
      constructor() {
    
      }
      apply(compiler) {
        compiler.hooks.compilation.tap('FileListPlugin', (compilation) => {
          compiler.hooks.emit.tap('FileListPlugin', () => {
            let content = '生成的文件列表\r\n';
            content = Object.keys(compilation.assets).reduce((current, prev) => current + '- ' + prev + '\r\n', content);
            console.log(content);
            compilation.assets['README.md'] = {
              source() {
                return content;
              },
              size() {
                return content.length;
              }
            }
          })
        })
      }
    }
    module.exports = FileListPlugin;

    生成文件列表的时候,就会触发该文件的代码。

    5. public/plugins/AutoExternalPlugin.js 代码如下:

    const ExternalModules = require('webpack/lib/ExternalModule');
    
    class AutoExternalPlugin {
      constructor(options) {
        this.options = options;
        this.externalModules = {};
      }
      apply(compiler) {
        compiler.hooks.normalModuleFactory.tap('AutoExternalPlugin', (normalModuleFactory) => {
          // parser 将代码转换为语法书 判断有无 import
          normalModuleFactory.hooks.parser.for('javascript/auto').tap('AutoExternalPlugin', (parser, parserOptions) => {
            parser.hooks.import.tap('AutoExternalPlugin', (statement, source) => {
              if (this.options[source]) {
                this.externalModules[source] = true;
              }
            })
          })
          // factory 是创建模块的方法
          // data 是创建模块的参数
          normalModuleFactory.hooks.factory.tap('AutoExternalPlugin', factory => (data, callback) => {
            const dependencies = data.dependencies;
            const value = dependencies[0].request; // jquery
            if (this.externalModules[value]) {
              const varName = this.options[value].varName;
              callback(null, new ExternalModules(varName, 'window'));
            } else {
              factory(data, callback);
            }
          })
        });
        compiler.hooks.compilation.tap('InlinePlugin', (compilation) => {
          compilation.hooks.htmlWebpackPluginAlterAssetTags.tapAsync('AutoExternalPlugin', (htmlPluginData, callback) => {
            Object.keys(this.options).forEach(key => {
              this.externalModules[key] = this.options[key];
              htmlPluginData.body.unshift(this.processTags(compilation, htmlPluginData, this.options[key]))
            });
            callback(null, htmlPluginData); 
          });
        });
      }
      processTags(compilation, htmlPluginData, value) {
        var tag;
        return tag = {
          tagName: 'script',
          closeTag: true,
          attributes: {
            type: 'text/javascript',
            src: value.url
          }
        }
      }
    }
    
    module.exports = AutoExternalPlugin;

    如上该插件的代码的作用是可以解决外部的js引用,比如我在webpack中如下使用该插件:

    const AutoExternalPlugin = require('./public/plugins/AutoExternalPlugin');
    module.exports = {
      plugins:[
        new AutoExternalPlugin({
          jquery:{
            varName:'jQuery',
            url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
          }
        })
      ]
    }

    这样我就可以在页面中使用jquery插件了;如下代码所示:

    import $ from 'jquery';
    console.log($);

    然后在我们的页面中引入的是 该 jquery库文件,它会把该库文件自动生成到 index.html 上去,如下index.html 代码变成如下了:

    <html lang="en">
    <head>
      <meta charset="UTF-8">
      <title>title>
      <link rel="manifest" href="/public/manifest.json" />
    <link href="main.css" rel="stylesheet">head>
    <body>
      <div id="app">222226666div>
    <script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.1.0/jquery.js">script><script type="text/javascript" src="bundle.js">script>body>
    html>

    我们可以来简单的分析下 AutoExternalPlugin.js 的代码:

    在apply方法内部会生成一个 compiler 实列,然后我们监听 normalModuleFactory 事件,该事件的作用我们可以看下官网就知道了。

    compiler.hooks.normalModuleFactory.tap('AutoExternalPlugin', (normalModuleFactory) => {
      // parser 将代码转换为语法书 判断有无 import
      normalModuleFactory.hooks.parser.for('javascript/auto').tap('AutoExternalPlugin', (parser, parserOptions) => {
        parser.hooks.import.tap('AutoExternalPlugin', (statement, source) => {
          if (this.options[source]) {
            this.externalModules[source] = true;
          }
        })
      })
    }

    如上 parser 实例,是用来解析由 webpack 处理过的每个模块。parser 也是扩展自 tapable 的 webpack 类,并且提供多种 tapable 钩子,插件作者可以使用它来自定义解析过程。官网解释可以看这里

    如上代码,我们调用 parser.hooks.import 钩子函数, 然后返回的 source 就是我们的在 我们的main.js 中调用插件名。如main.js 代码如下:

    import $ from 'jquery';

    因此在我们的webpack.config.js 中会如下初始化插件 

    new AutoExternalPlugin({
      jquery:{
        varName:'jQuery',
        url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
      }
    });

    因此 source 返回的值 就是 'jquery'; 其他的代码可以自己稍微看看就行了。这里暂时先不讲了,由于时间问题。

    6. public/plugins/MyPlugin.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);
    });

    如下图所示:

    1) assets 数组对象中的key是资源名。在如上代码,我们通过 Object.key()方法拿到了。如下所示:

    main.css
    bundle.js
    index.html

    2) 然后我们调用 compilation.assets[data].source(); 可以获取资源的内容。

    3) 使用正则,去掉注释,如下代码:

    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;
      });
    });

    4) 更新 compilation.assets[data] 对象,如下代码:

    compilation.assets[data] = {
      source() {
        return content;
      },
      size() {
        return content.length;
      }
    }

    然后我们就可以在webpack中引入该所有的插件:

    const DonePlugin = require('./public/plugins/DonePlugin');
    const OptimizePlugin = require('./public/plugins/OptimizePlugin');
    const AsyncPlugin = require('./public/plugins/AsyncPlugin');
    const FileListPlugin = require('./public/plugins/FileListPlugin');
    const AutoExternalPlugin = require('./public/plugins/AutoExternalPlugin');
    const MyPlugin = require('./public/plugins/MyPlugin');

    调用方式如下:

    module.exports = {
      plugins:[
        new DonePlugin(),
        new OptimizePlugin(),
        new AsyncPlugin(),
        new FileListPlugin(),
        new MyPlugin(),
        new AutoExternalPlugin({
          jquery:{
            varName:'jQuery',
            url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
          }
        })
      ]
    }

    然后我们进行打包运行效果如下所示:

    github源码查看