我应该如何去理解Webpack Plugin

311 阅读6分钟

前言

在学习Webpack Plugin之前,我们需要清楚Webpack的整体构建流程。

image.png 1、首先会接收并且合并配置文件里的和命令行传入的参数。

2、调用compiler方法,生成一个compiler对象,接着在这个对象中注册各个Webpack Plugin。

3、根据配置参数中的entry参数,从入口文件开始,调用compiler.run方法进行编译。

4、对入口模块及其依赖的模块进行递归分析,将无法识别的模块类型调用loader进行转译。

5、整理模块之间的依赖关系,同时将处理完成的文件打包到dist目录。

💡Plugin是什么及其构成?

在Webpack调用compiler方法之后,会生成一个compiler对象,这个对象在初始化的时候,会根据打包过程的各个生命周期,并且基于Tapable库的同时,生成各类的Hooks。Plugin实则上就是一个个注册在不同hooks上的执行方法。当webpack在编译到某个阶段时,compiler对象上的对应的hooks就会被调用,进而去执行相对应的Plugin逻辑,从而影响最终的编译结果。 构成:

  • Plugin应该是一个Class或者一个函数
  • Plugin原型对象上应该存在一个apply方法,compiler对象在初始化时,会调用各个Plugin实例上的apply方法,并且传入compiler对象作为参数。
  • Plugin需要绑定在Compiler对象上的某一个hook。
// 监听compiler对象上的done事件。
class CustomPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('myFirstPlugin', () => {
      cosnole.log("This is my first plugin")
    })
  }
}

module.exports = CustomPlugin

💡Tapable是什么?

Tapable是一个工具库,他提供了一系列事件的发布订阅API,我们可以通过Tapable去注册一些事件,然后在不同的时机去执行,简单来说就是Tapable为我们实现了一个发布订阅机制。 官方根据不同的类型和功能,划分了10种钩子(Hook)

exports.SyncHook = require("./SyncHook");
exports.SyncBailHook = require("./SyncBailHook");
exports.SyncWaterfallHook = require("./SyncWaterfallHook");
exports.SyncLoopHook = require("./SyncLoopHook");
exports.AsyncParallelHook = require("./AsyncParallelHook");
exports.AsyncParallelBailHook = require("./AsyncParallelBailHook");
exports.AsyncSeriesHook = require("./AsyncSeriesHook");
exports.AsyncSeriesBailHook = require("./AsyncSeriesBailHook");
exports.AsyncSeriesLoopHook = require("./AsyncSeriesLoopHook");
exports.AsyncSeriesWaterfallHook = require("./AsyncSeriesWaterfallHook");

Tapable的hook的使用过程大致分为3步:

  • 生成对应种类Hook的实例
  • 在实例上注册方法
  • 通过call触发注册的方法。
// 生成实例
const hook = new SyncHook[{"a", "b", "c"}];

// 注册事件
hook.tap("tap1", (args1, args2, args3) => {
  console.log("tap1:", args1, args2, args3)
})

// 触发事件
hook.call('a','b','c')

// 打印结果
tap1: a b c

如果按照执行机制去划分的话,大致可以将上述十种钩子划分为:

  • Basic Hook: 基本类型的Hook,仅执行注册的事件,不关心函数调用值
  • Waterfall: 瀑布流类型的Hook, 在执行注册事件的同时,会将非undefined的返回值传递给下一个事件作为入参。
  • Bail: 保险类型的Hook,在执行事件时,如果事件函数执行后返回了非undefined的值是,则中断执行后续的事件。
  • Loop: 循环类型的Hook,如果任意一个事件的返回值非 undefeind ,那么会立即重头开始重新执行所有的事件,直到所有被注册的事件函数都返回 undefined。

💡如何去实现一个Plugin?

当我们需要手动实现一个Plugin的时候,我们首先得清楚,我们需要在Webpack的哪一个环节,利用哪一些Webpack提供给我们的方法或者参数,去做哪一些事情? 而如果需要清晰了解这些,我们必须要去了解Webpack在编译打包过程中的两个核心对象:

  • compiler compiler会在每次启动webpack的时候生成,并且从始至终都只会有一个。compiler对象里面不仅保存着本次打包所有的初始化配置信息,而且根据生命周期注册了各类的Hooks,供我们的Plugins根据不同的需要进行注册。

  • complicaiton

     compiler在每一次进行打包的过程中,它都会创建一个compilation对象,在这个对象中我们可以通过它提供的Api,        访问到本次打包的module,assests和chunks。
    

👉Example MapPlugin

倘若我们需要在打包出文件之前,针对所有的文件生成一个额外的Map文件。

const { RawSource } = require("webpack-sources")

class MapPlugin {
  // 在配置文件中传入的参数会保存在插件实例中
  constructor(options = {}) {
    this.options = options;
  }
  // 注册函数 
  apply(compiler) {
    // 在打包完成后,输出文件之前执行的钩子
    // 由于注册的是异步钩子, 需要通过调用 callback 表示本次事件函数结束
    compiler.hooks.emit.tapAsync('MapPlugin', (compilation, callback) => {
      // 通过getAssets方法获取本次打包的assets资源
      const assets = compilation.getAssets();
      let map = '# File Map:\n\n'

      // 对assets资源进行遍历
      assets.forEach(({ name, source }) => {
        map += name + '\n'
      });

      // 通过emitAsset方法生成文件
      compilation.emitAsset("map.md", new RawSource(map));
      callback()
    })
  }
}

module.exports = CustomPlugin

打包后的结果: image.png

👉Example ExternalPlugin 当我们需要在打包过程中,将特定的模块从打包打代码中剔除出去,变成外部依赖引入时,我们可以利用Webpack的Externals字段:

module.exports = {
  //...
  externals: {
    jquery: 'jQuery',
  },
};

那如果我们利用Webpack Plugins去实现这个功能的时候,我们又该怎么做呢?

  • 第一步,接收一个对象参数,对象里的每一个属性名都是我们在打包的时候需要剔除出去的模块名。同时,src属性为需要带入Html的外部依赖链接,name属性为替换外置模块的名字。
module.exports = {
  //...
	plugins: [
    new ExternalsPlugin({
      lodash: {
        src: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
        name: "_"
      },
      vue: {
        src: "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js",
        name: "Vue"
      }
    })
  ]
};
  • 第二步,在webpack进行依赖分析时,分析所依赖的模块是否在需要剔除的模块的范围内,其次,对代码转化的Ast语法树进行分析,如果该依赖确实被用到了,才会进行cdn的引入。
  • 第三步,根据第二步的分析的结果,结合HtmlWebpackPlugin,将cdn插入到最终的Html模版文件中。
const { ExternalModule } = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const pluginName = "ExtranalPlugin";
class ExternalPlugin {
  constructor(options) {
    this.options = options;
    // 保存需要外部依赖的依赖信息
    this.outLibrary = Object.keys(options);
    // 需要转化为cdn引入的依赖信息
    this.cdnLibrary = new Set();
  }
  apply(compiler) {
    /**
     * compiler对象通过 NormalModuleFactory 来对模块请求进行处理,
     * NormalModuleFactory内部暴露了一些hooks供开发者在webpack解析模块时注册事件。
     * 这里我们需要先注册一个事件,当NormalModuleFactory被创建后触发,
     * 然后在NormalModuleFactory解析模块之前,判断需要解析的模块的名字是否在我们的outLibrary 里面,
     * 如果在,则按照外部依赖处理,如果不在,则什么都不做,按照原webpack的编译逻辑。
     */
    compiler.hooks.normalModuleFactory.tap(
      pluginName,
      (normalModuleFactory) => {
        // 在解析模块前调用
        normalModuleFactory.hooks.factorize.tapAsync(pluginName, (resolveData, callback) => {
          // 获取引入模块的名称
          const requireModuleName = resolveData.request;
          if (this.outLibrary.includes(requireModuleName)) {
            // 获得当前模块需要转位成为的变量名
            const externalModuleName =
              this.options[requireModuleName].variableName;

            // 调用webpack内置的处理外部依赖的方法
            callback(
              null,
              new ExternalModule(
                externalModuleName,
                'window',
                externalModuleName
              )
            );
          } else {
            callback()
          }
        });

        // 在解析时,将代码转化为Ast后调用
        normalModuleFactory.hooks.parser.for("javascript/auto").tap(pluginName, (parser) => {

          // 当解析碰到import语句时
          parser.hooks.import.tap(pluginName, (statement, source) => {
            if (this.outLibrary.includes(source)) {
              // 如果source是在outLibrary名单里的,则代表需要加入到cdn里
              this.cdnLibrary.add(source);
            }
          });
          
            // 解析当前模块中的require语句
          parser.hooks.call.for('require').tap(pluginName, (expression) => {
            const moduleName = expression.arguments[0].value;
            if (this.outLibrary.includes(moduleName)) {
              this.cdnLibrary.add(moduleName);
            }
          });

        })
      }
    );

    // 利用HtmlWebpackPlugin提供的alterAssetTags钩子,我们能拿到输出到Html上的script标签,然后加入我们需要添加的cdn链接
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tap(pluginName, (data) => {
        const scriptTag = data.assetTags.scripts
        this.cdnLibrary.forEach((library) => {
          scriptTag.unshift({
            tagName: 'script',
            voidTag: false,
            meta: { plugin: pluginName },
            attributes: {
              defer: true,
              type: undefined,
              src: this.options[library].src,
            },
          });
        });
      })
    })
  }
}

module.exports = ExternalPlugin;

  • 代码基本完成,只需要在webpack中添加我们的Plugin,并且在入口文件中添加lodash的依赖,再打包就可以看到效果了。

image.png image.png