likes
comments
collection
share

面试官:了解webpack插件?写过webpack插件吗?

作者站长头像
站长
· 阅读数 15

前言

面试官: 了解webpack插件吗?

我:有了解过一点

面试官: 那你说说你了解的webpack插件

我:就是一个类,然后有一个apply函数作为入口函数,接受一个compiler参数,然后就在这个插件内完成具体的功能

面试官: 写过webpack插件吗?

我:嗯...,这个..., 没有😅

面试官:em...

是不是很熟悉的场景,因为有时候我们确实没机会去对项目进行webpack配置或者去优化webpack配置,所以就谈不上对webpack pluginloader、整体流程有了解,那么在面试的时候当面试官一问到webpack相关问题的时候,可能就答不上来了

那么面试官为什么想要问webpack相关的问题?无外乎

  1. webpack几乎是目前前端项目构建的标配,所以webpack的掌握程度与工程化息息相关
  2. webpack提供了很多特性和功能,例如多入口、chunk提取、懒加载等,这些功能可以显著提升前端项目的性能。因此掌握webpack的各种功能能够让开发者更好地构建出高效、健壮的前端应用
  3. webpack够难,有一定的门槛

所以在面试中,会涉及到webpack的基础概念、配置、原理、性能优化等方面的问题,需要面试者有一定的实践经验和理论知识。同时,面试官也会通过对webpack相关问题的考察,来评估面试者的技术深度和解决问题的能力。

那么本篇就是帮助面试者,攻克webpack plugin相关的问题,帮助面试者对webpack plugin有一个更好的认识,不仅能够帮助我们面试,更能够帮助我们在日常的开发中更好的使用webpack

看完本篇之后,希望小伙伴面试的时候是这样回答的

面试官:了解webpack插件吗?

你会这么回答

  • 了解,webpack的大致流程是,初始化配置 => 从入口模块开始解析 => 经过loader处理 => 遍历ast => 找到依赖 => 继续解析依赖,直到所有的子模块都解析完成 => 优化chunk => 生成assets => 根据assets生成最终的产物
  • 而这个过程中webpack不能满足所有的场景,为了webpack更加灵活与拓展,设计了插件机制,webpack的插件机制,基于tapable实现,而tapable提供了多种类型的hook,比如同步串行hook,异步并行hook等
  • 然后webpack目前提供的hook大概有5类,第一类是compiler上的hook,这类hook是大流程上的节点;第二类complation上的hook,这类hook是构建模块实例、优化chunk等流程上的节点;第三类NormalModuleFactory上的hook,这类hook是模块创建、超找模块等流程上节点;第四类是JavascriptParser上的hook,这类hook就是遍历ast流程上的节点;第五类就是ContextModuleFactory上的hook与NormalModuleFactory上的hook类似,但是用的少
  • 最后一个插件以apply方法作为入口函数,入口函数会接受一个compiler参数,接下来就是根据webpack在compiler,compilation等对象上爆料的hooks上注册callback,在callback内完成拓展功能

面试官:写过webpack插件吗?

你会这么回答

  • 写过,我写过约定式路由插件(任何自己写的插件),写这个插件的目的是为了解决手写routes配置文件,做到自动生成routes文件,以高开发效率
  • 为了使生成routes文件生效,我选择在webpack编译之前的hooks内完成routes文件的生成,而编译之前的hooks有,environment、initialize等hook,我这里选择initialize hook,这一个同步串行hook
  • 最后在initialize hook上注册callback,在callback内读取目录及相关的配置,生成路由配置文件

下面开始正文,在这篇文章中,我们将探讨如何编写webpack插件。webpack是一个非常流行的JavaScript模块打包工具,使用它可以将多个JavaScript模块打包成一个或多个bundle文件。webpack有很多功能和特点,其中一项非常重要的特点就是其可扩展性,通过编写webpack插件可以实现各种自定义功能。插件就是webpack的基石。所以掌握webpack插件,能过让我们成为一个更熟练的webpack配置工程师,同时也能解决面试中碰到的webpack plugin问题

本文将从以下几个方面介绍webpack插件:

  • webpack插件是什么?帮助我们了解webpack为什么设计插件机制
  • 项目内如何使用插件?帮助我们快速搭建项目的webpack配置
  • 常用webpack插件及原理?帮助我们了解常用的webpack插件内部是怎么写的,哪些值得我们借鉴
  • 编写自己的webpack插件?根据我们的业务场景,编写一些提升开发效率的插件

插件是什么

为什么设计插件机制

webpack的设计理念是万物皆模块,然后将所有模块打包成一个或者多个bundle文件,但是这并不意味着webpack自身就能满足所有功能,比如chunk提取、代码压缩、css文件提取等等,所以为了使webpack更加了灵活与便于拓展,webpack设计了插件机制,通过插件可以实现各种拓展功能,满足业务需求

怎么定义插件

webpack规定插件必须是一个javascript对象,且对象上必须有一个apply方法,这个apply方法会在webpack进行编译的时候进行调用,插件定义如下所示

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply() {}
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

到这里我们已经已经知道怎么定义一个webpack plugin,虽然这个插件能过被执行,但是不会有任何作用,原因是插件内没有做任何处理,那么插件内怎么去介入webpack的构建流程,影响最终的构建结果呢?其实webpack在执行插件apply方法的时候,会传入一个compiler对象,这个compiler对象上会暴露webpack构建阶段不同节点的hook,然后在这个hook上允许注册对应的callback,如下所示

const pluginName = 'ConsoleLogOnBuildWebpackPlugin';

class ConsoleLogOnBuildWebpackPlugin {
  apply(compiler) {
    // compiler.hooks 包含一组节点,这些节点,都允许注册对应的callback,webpack执行过程中,会调用这个callback,然后在这个callback
    // 调用的时候传入一些参数,然后callback内借助传入的参数修改webpack构建过程中的一些内容,最终影响webpack的构建结果
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('The webpack build process is starting!');
    });
  }
}

module.exports = ConsoleLogOnBuildWebpackPlugin;

webpack构建流程可以简单总结如下图所示 面试官:了解webpack插件?写过webpack插件吗?

那么webpack只要在处理的过程中,在各个阶段,执行我们注册的callback,那么我们的插件就可以介入webpack构建流程,我们从webpack源码看下,webpack是怎么触发我们注册的hook callback执行的

const {
	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook
} = require("tapable");

this.hooks = Object.freeze({
  /** @type {SyncHook<[]>} */
  initialize: new SyncHook([]),
  /** @type {SyncHook<[CompilationParams]>} */
  compile: new SyncHook(["params"]),
  /** @type {AsyncParallelHook<[Compilation]>} */
  make: new AsyncParallelHook(["compilation"]),
  ...
});


compile(callback) {
  const params = this.newCompilationParams();
  // 调用beforeCompile hook,传入参数params, callback
  this.hooks.beforeCompile.callAsync(params, err => {
    if (err) return callback(err);

    // 调用compile hook,传入参数params
    this.hooks.compile.call(params);

    const compilation = this.newCompilation(params);

    // 调用compile hook 传入参数compilation, callback
    this.hooks.make.callAsync(compilation, err => {
      if (err) return callback(err);

      // 调用finishMake hook 传入参数compilation, callback
      this.hooks.finishMake.callAsync(compilation, err => {
        if (err) return callback(err);

        process.nextTick(() => {
          compilation.finish(err => {
            if (err) return callback(err);
            
            compilation.seal(err => {
              if (err) return callback(err);

              // 调用afterCompile hook 传入参数compilation, callback
              this.hooks.afterCompile.callAsync(compilation, err => {
                if (err) return callback(err);
                return callback(null, compilation);
              });
            });
          });
        });
      });
    });
  });
}

从源码我们可以看到,webpack在编译的过程中,会在各个节点调用对应的hook,从而执行对应的callback,以达到功能拓展的目的

目前webpack暴露的hook有5类

  • compilerhook
    • run
    • compiler
    • compilation
    • shouldEmit
    • emit
    • done
  • compilationhook
    • buildModule
    • succeedModule
    • finishModules
    • normalModuleLoader
  • ContextModuleFactoryhook
    • beforeResolve
    • afterResolve
    • createModule
    • module
  • NormalModuleFactoryhook
    • beforeResolve
    • afterResolve
    • createModule
    • module
  • JavascriptParserhook
    • import
    • call

更多hook直接查看文档即可compiler hooks

我们只需要每个hook代表执行的哪个阶段,并且该hook属于哪种类型的hook即可在插件中通过该hook注册callback,完成对应的逻辑,如所示 我想在编译模块之前做些事情 面试官:了解webpack插件?写过webpack插件吗?

compiler.hooks.beforeRun.tapAsync('MyPlugin', (compiler, callback) => {
  /* 处理逻辑 */
	callback()
});

比如我想在模块解析之前做些事情 面试官:了解webpack插件?写过webpack插件吗?

compiler.hooks.compilation.tap('MyPlugin', (compilation, compilationParams) => {
   compilation.hooks.finishModules.tapAsync(
      'SourceMapDevToolModuleOptionsPlugin',
      (modules, callback) => {
        // modules 就是包含所有module处理完之后的module实例
        callback()
      }
    );
});

比如我想在所有模块处理之后做一些事情 面试官:了解webpack插件?写过webpack插件吗?

compiler.hooks.compilation.tap('MyPlugin', (compilation, compilationParams) => {
   compilation.hooks.finishModules.tapAsync(
      'SourceMapDevToolModuleOptionsPlugin',
      (modules, callback) => {

        NormalModuleFactory.hooks.someHook.tap(/* ... */)
      }
    );
});

到这里我们基本上知道,应该怎么去注册callback了,但是我们仔细看的话,hook的类型有很多种,比如

  • Synchook:同步串行钩子
  • AsyncSerieshook: 异步串行钩子
  • AsyncParallelhook:异步并发钩子
  • SyncBailhook:同步熔断钩子,也就是当有一个返回非undefined的值时,会中断后续callback的执行

那为什么会有这么多种类型,不要这种类型行不行,比如我们注册callback,将这些注册的callback放到一个数组里,然后执行数组内所有的callback不行吗?伪代码如下所示

compiler.hooks.compilation.tap('MyPlugin', callback1)

compiler.hooks.compilation.tap('MyPlugin', callback2)

compiler.hooks.compilation.tap('MyPlugin', callback2)

// 用数组来介绍callback
handles = [callback1, callback2, callback2]

// 然后到执行节点的时候,按照注册顺序执行callback
handles.forEach((handle) => {
  handle(params)
})

这种简单的发布订阅方式实现的插件机制不行吗?不是不行,而是webpack场景更复杂,单纯的通过注册顺序执行无法满足所有需求,所以才设计了更多的插件执行模式

  • 比如callback串行执行
  • 比如callback并行执行
  • 比如callback串行执行,将前一个的结果,传给后一个
  • 比如callback串行执行,只要有一个返回不是undefined的值,就立马返回,中断后面的callback执行等等

webpack把这一套插件机制封装成了一个单独的npm包tapabletapable提供的hook如下所示

  • SyncHook 同步钩子
  • SyncBailHook 同步熔断钩子
  • SyncWaterfallHook 同步流水钩子
  • SyncLoopHook 同步循环钩子
  • AsyncParalleHook 异步并发钩子
  • AsyncParalleBailHook 异步并发熔断钩子
  • AsyncSeriesHook 异步串行钩子
  • AsyncSeriesBailHook 异步串行熔断钩子
  • AysncSeriesWaterfallHook 异步串行流水钩子

从功能对Hook分类

TypeFunction
Waterfall同步方法,传值下一个函数
Bail当函数有任何返回值,则在当前执行函数停止
Loop监听函数返回true则继续循环,返回undefined表示循环结束
Series串行钩子
Paralle并行钩子

从类型对Hook分类

Async*Sync*
绑定: tapAsync/tapPromise/tap绑定:tap
执行: callAsync/promise执行:call

tapable的实现原理也相当有意思,使用的是字符串拼接 + new Function的方式生成函数体,感兴趣的可以自己通过vscode断点调试的方式去看看源码

所以到这里我们可以知道,写一个webpack插件,需要

  • 插件是一个javascript对象,且该对象必须包含入口apply方法
  • webpack暴露了5类hook,我们必须要知道我们要介入的节点是哪个hook
  • 注册callback的方式,有同步与异步的方式,一定要根据文档的提示hook类型来决定

项目内如何使用插件

上面我们已经知道插件应该怎么定义,那么这一节,我们将会学习怎么在项目内使用插件,本文的案例都是围绕webpack5来讲的

假设我们写的插件如下所示 使用类定义我们的插件

class MyPlugin {
  constructor(opts) {
    this.options = opts
  }
  apply(compiler) {
    compiler.hooks.compilation.tap('MyPlugin', (compilation, compilationParams) => {
       compilation.hooks.finishModules.tapAsync(
          'MyPlugin',
          (modules, callback) => {
          	for (let item of [...modules]) {
              //	 打印每个module的路径
              console.log('item', item.resource);
            }
            callback()
          }
        );
    });
  }
}

使用function定义我们的插件

function MyPlugin2(opts) {
  this.options = opts
}

MyPlugin2.prototype.apply = function(compiler) {
  compiler.hooks.compilation.tap('MyPlugin', (compilation, compilationParams) => {
    compilation.hooks.finishModules.tapAsync(
       'MyPlugin',
       (modules, callback) => {
         for (let item of [...modules]) {
           // 打印每个module的路径
           console.log('item 2', item.resource);
         }
         callback()
       }
     );
 });
}

一份简单的webpack配置

const path = require('path');
const { ProgressPlugin } = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const Myplugin = require('./myPlugin')


const config = {
  mode: 'production',
  output: {
    path: path.join(__dirname, '../dist'),
    filename: 'js/[name].[chunkhash].js',
    chunkFilename: 'chunk/[name].[chunkhash].js',
    publicPath: './'
  },
  plugins: [
    // 使用我们自己的插件
    new Myplugin({
      test: 1
    }),
    // 使用我们自己的插件
    new Myplugin2({
      test: 2
    }),
    // 使用webpack提供的插件
    new ProgressPlugin(
      {
        percentBy: 'entries',
      }
    ),
    // 使用社区提供的插件
    new HtmlWebpackPlugin(
      {
        filename: 'index.html',
        template: path.join(__dirname, '../public/index.html'),
      }
    ),
  ],
  entry: {
    app: path.join(__dirname, '../src/app')
  },
}

module.exports = config

所以其实插件使用只要注意两点

  • 插件本身要是javascript对象,且包含apply方法
  • 插件通过webpackplugins字段传入

常用插件

react项目为例,我们一个项目可能会包含哪些插件,这些插件分别是怎么实现功能的,我们通过对这些常用插件的了解,进一步掌握webpack插件原理

基础插件

对插件原理的阅读可能需要一定的webpack基础,可选择跳过原理部分

html-webpack-plugin@5.5.1 html处理插件

使用

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  plugins: [
    new HtmlWebpackPlugin(
      {
        filename: 'index.html',
        template: path.join(__dirname, '../public/index.html'),
        minify: {
          collapseWhitespace: true,
          minifyJS: true,
          html5: true,
          minifyCSS: true,
          removeComments: true,
          removeTagWhitespace: false
        },
      }
    ),
  ]
}

原理 html-webpack-plugin主要做的事情是

  • make钩子的callback内创建child compiler,然后通过child compiler去处理传入的filename,最终child compiler处理完之后,会将asset保存到一个对象上,等待最终处理,这里需要注意下child compiler内是删除了asset,这样做的原因是,后续还需要对html进一步处理,比如插入jscss等,避免child compiler处里完之后直接赋值给了父compilerassets里面
  • 在父compilationprocessAssets钩子的callback内,对之前child compiler处理完之后生成的asset,做进一步处理,比如添加jscss、处理publicPath、处理一些变量转化等,然后最终在通过compilation.emitAsset输出最终的html文件

流程图如下图所示

面试官:了解webpack插件?写过webpack插件吗?

下面是精简过的代码

apply (compiler) {
  // 在compiler实例创建之后,注册第一个hook
  compiler.hooks.initialize.tap('HtmlWebpackPlugin', () => {

    entryOptions.forEach((instanceOptions) => {
      hookIntoCompiler(compiler, instanceOptions, this);
    });
  });
}


function hookIntoCompiler (compiler, options, plugin) {
  compiler.hooks.thisCompilation.tap('HtmlWebpackPlugin',
    (compilation) => {
      // 使用子编译器处理入口html
      compiler.hooks.make.tapAsync(
      'PersistentChildCompilerSingletonPlugin',
      (mainCompilation, callback) => {
        // 创建子compiler,处理html文件,这里使用子编译器的目的是,html内可能还需要处理src等静态资源路径等
        const childCompiler = mainCompilation.createChildCompiler(compilerName, outputOptions, [
          new webpack.library.EnableLibraryPlugin('var')
        ]);

        childCompiler.hooks.thisCompilation.tap('HtmlWebpackPlugin', (compilation) => {
          compilation.hooks.processAssets.tap(
            {
              name: 'HtmlWebpackPlugin',
              stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS
            },
            (assets) => {
              temporaryTemplateNames.forEach((temporaryTemplateName) => {
                if (assets[temporaryTemplateName]) {
                  // 用extractedAssets保存html经过webpack处理之后的内容
                  extractedAssets.push(assets[temporaryTemplateName]);
                  // 删除子compiler上的asset,避免赋值到父compiler的asset上
                  compilation.deleteAsset(temporaryTemplateName);
                }
              });
            }
          );
        });
      })
      
      // webpack处理assets时注册callback
      compilation.hooks.processAssets.tapAsync(
        {
          name: 'HtmlWebpackPlugin',
          stage:
          // 开始优化assets的时候执行
          webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE
        },
        (compilationAssets, callback) => {
          // compilationAssets包含所有最终要生成的文件,html-webpack-plugin在这个callback
          // 内回去拿入口文件,以及处理publicpath等事情,还有html本身提供的一些替换等逻辑,保证最终生成html的时候,html内有准确的js、css地址
          const entryNames = Array.from(compilation.entrypoints.keys());
          const filteredEntryNames = filterChunks(entryNames, options.chunks, options.excludeChunks);
          const sortedEntryNames = sortEntryChunks(filteredEntryNames, options.chunksSortMode, compilation);

          const htmlPublicPath = getPublicPath(compilation, options.filename, options.publicPath);

          const assets = htmlWebpackPluginAssets(compilation, sortedEntryNames, htmlPublicPath);
          const emitHtmlPromise = injectedHtmlPromise
            .then(html => {
              const filename = options.filename.replace(/\[templatehash([^\]]*)\]/g, require('util').deprecate(
                (match, options) => `[contenthash${options}]`,
                '[templatehash] is now [contenthash]')
              );

              // 将最终的html文件输出的assets中去
              compilation.emitAsset(replacedFilename.path, new webpack.sources.RawSource(html, false), replacedFilename.info);
              return replacedFilename.path;
            }).then(() => null));

          emitHtmlPromise.then(() => {
            callback();
          });
        });
    });
}

其实简单总结就是,创建一个child compiler处理html,然后在父compiler处理assets的时候,在将child compiler处理的html内容经过一系列处理之后,通过compilation.emitAsset输出到最终的assets里面

mini-css-extract-plugin@2.7.5 css提取插件

使用

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
  module: {
    rules: [
      {
        test: /\.(css|less|s[a|c]ss)(\?.*)?$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader
          }
        ]
      },
    ]
  }
  plugins: [
    new MiniCssExtractPlugin(
      {
        filename: 'css/[name].[contenthash].css',
        chunkFilename: 'css/[name].[contenthash].css'
      }
    )
  ]
}

原理 mini-css-extract-plugin插件主要做的事情是

  • loader钩子的callback内向loaderContext添加属性,用来配合MinicssExtractPlugin.loader是否需要使用importModule方法
  • thisCompilation钩子注册callback,完成cssModuleFactorycssDependencyTemplate的注册,便于正确解析css模块与生成最终的css内容
  • MinicssExtractPlugin.loader内如果支持importModule方法,则会用importModule方法处理css模块,如果不支持importModule则会创建child compiler,然后通过child compiler去处理css文件,child compiler处理完之后,删除child compiler内的asset,然后最终在父compiler统一处理所有的css module
  • renderManifest钩子注册callback,目的是构造合并chunkcss modulemanifest,即将所有的css module合并到对应的css asset里面,创建出css asset

更多内容可以查看面试官:生产环境构建时为什么要提取css文件?

css-minimizer-webpack-plugin@5.0.0 css压缩插件

使用

const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new CssMinimizerPlugin(),
    ]
  },
}

原理 cssMinimizerPlugin插件的核心原理就是通过注册processAssets钩子的callback,然后在callback内对asstes内的css文件调用对应的压缩插件进行压缩,压缩结束之后,在调用compilation.updateAsset更新asset资源,保证最终生成的文件是压缩后的文件

流程图如下图所示

面试官:了解webpack插件?写过webpack插件吗?

精简后的代码

class CssMinimizerPlugin {

  constructor(options) {
  	// 传入使用的压缩插件,默认使用cssnano插件进行压缩,里面还有csso、esbuild、swc、lightningCss等压缩方式
    const {
      minify = cssnanoMinify,
    } = options || {};

    this.options = {
      minimizer: {
        implementation: /** @type {MinimizerImplementation<T>} */minify,
      }
    };
  }


  async optimize(compiler, compilation, assets, optimizeOptions) {
    const cache = compilation.getCache("CssMinimizerWebpackPlugin");
    let numberOfAssetsForMinify = 0;
    // 遍历assets,过滤已压缩过与不需要压缩的asset
    const assetsForMinify = await Promise.all(Object.keys(typeof assets === "undefined" ? compilation.assets : assets).filter(name => {
			return true;
    }).map(async name => {
      const {
        info,
        source
      } = /** @type {Asset} */
      compilation.getAsset(name);
      return {
        name,
        info,
        inputSource: source,
        output,
        cacheItem
      };
    }));

  	// 借助webpack的RawSource生成最终的source
    const {
      SourceMapSource,
      RawSource
    } = compiler.webpack.sources;
    const scheduledTasks = [];
    for (const asset of assetsForMinify) {
      scheduledTasks.push(async () => {
        const {
          name,
          inputSource,
          cacheItem
        } = asset;
        let {
          output
        } = asset;
        if (!output) {
          let input;
          /** @type {RawSourceMap | undefined} */
          let inputSourceMap;
          const {
            source: sourceFromInputSource,
            map
          } = inputSource.sourceAndMap();
          input = sourceFromInputSource;

          const options = {};
          let result;
          try {
            // 调用压缩方法,比如teser等
            result = await minifyWorker(options);
          } catch (error) {
            
            compilation.errors.push( /** @type {WebpackError} */
            
            return;
          }
          
          for (const item of result.outputs) {
            // 将压缩后的结果,传入RawSource构造函数
            output.source = new RawSource(item.code);
          }
          await cacheItem.storePromise({});
        }

        const newInfo = {
          minimized: true
        };
        const {
          source
        } = output;
        // 最终通过compilation.updateAsset方法更新asset内容
        compilation.updateAsset(name, source, newInfo);
      });
    }
    const limit = getWorker && numberOfAssetsForMinify > 0 ? /** @type {number} */numberOfWorkers : scheduledTasks.length;
    await throttleAll(limit, scheduledTasks);

  }

  apply(compiler) {
    const pluginName = this.constructor.name;
    const availableNumberOfCores = CssMinimizerPlugin.getAvailableNumberOfCores(this.options.parallel);
    compiler.hooks.compilation.tap(pluginName, compilation => {
      // 在processAssets hook注册callback
      compilation.hooks.processAssets.tapPromise({
        name: pluginName,
        stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
        additionalAssets: true
      }, assets => this.optimize(compiler, compilation, assets, {
        availableNumberOfCores
      }));
      
      compilation.hooks.statsPrinter.tap(pluginName, stats => {
        stats.hooks.print.for("asset.info.minimized").tap("css-minimizer-webpack-plugin", (minimized, {
          green,
          formatFlag
        }) =>
        // eslint-disable-next-line no-undefined
        minimized ? /** @type {Function} */green( /** @type {Function} */formatFlag("minimized")) : "");
      });
    });
  }
}

terser-webpack-plugin@5.3.7 js压缩插件

使用

const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        parallel: false,
        terserOptions: {
          // https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions
        },
      }),
    ]
  },
}

原理 TerserPlugin插件的核心原理就是通过注册processAssets钩子的callback,然后在callback内对asstes内的js文件调用对应的压缩插件进行压缩,压缩结束之后,在调用compilation.updateAsset更新asset资源,保证最终生成的文件是压缩后的文件

流程图如下所示

面试官:了解webpack插件?写过webpack插件吗?

精简后的代码如下所示

class TerserPlugin {
  constructor(options) {

    // 选择压缩的插件,默认时terser,里面还有uglifyJs、swc、esbuild,可以通过传入的参数控制
    const {
      minify = /** @type {MinimizerImplementation<T>} */terserMinify,
    } = options || {};

    this.options = {
      minimizer: {
        implementation: minify,
        options: terserOptions
      }
    };
  }

  async optimize(compiler, compilation, assets, optimizeOptions) {
    const cache = compilation.getCache("TerserWebpackPlugin");
    let numberOfAssets = 0;
    // 遍历assets,然后返回一个新数组
    const assetsForMinify = await Promise.all(Object.keys(assets).filter(name => {
      return true;
    }).map(async name => {
      const {
        info,
        source
      } = compilation.getAsset(name);
      return {
        name,
        info,
        inputSource: source,
        output,
        cacheItem
      };
    }));

    const {
      SourceMapSource,
      ConcatSource,
      RawSource
    } = compiler.webpack.sources;

    const scheduledTasks = [];
    for (const asset of assetsForMinify) {
      scheduledTasks.push(async () => {
        const {
          name,
          inputSource,
          info,
          cacheItem
        } = asset;
        let {
          output
        } = asset;
        // 表示没有被压缩过
        if (!output) {
          let input;
          let inputSourceMap;
          const {
            source: sourceFromInputSource,
            map
          } = inputSource.sourceAndMap();
          input = sourceFromInputSource;

          const options = {};

          try {
            // 调用压缩插件进行压缩
            output = await minify(options);
          } catch (error) {

            compilation.errors.push( /** @type {WebpackError} */
            return;
          }

          // 使用webpack提供的RawSource构造函数
          output.source = new RawSource(output.code);
        
          await cacheItem.storePromise({});
        }
        

        /** @type {Record<string, any>} */
        const newInfo = {
          minimized: true
        };
        const {
          source,
          extractedCommentsSource
        } = output;

        // 更新压缩后的内容
        compilation.updateAsset(name, source, newInfo);
      });
    }
    const limit = getWorker && numberOfAssets > 0 ? /** @type {number} */numberOfWorkers : scheduledTasks.length;
    await throttleAll(limit, scheduledTasks);

  }

  apply(compiler) {
    const pluginName = this.constructor.name;
    const availableNumberOfCores = TerserPlugin.getAvailableNumberOfCores(this.options.parallel);
    compiler.hooks.compilation.tap(pluginName, compilation => {
      const hooks = compiler.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation);
      const data = serialize({
        minimizer: typeof this.options.minimizer.implementation.getMinimizerVersion !== "undefined" ? this.options.minimizer.implementation.getMinimizerVersion() || "0.0.0" : "0.0.0",
        options: this.options.minimizer.options
      });

      // 注册processAssets钩子的callback,在这里压缩assets
      compilation.hooks.processAssets.tapPromise({
        name: pluginName,
        stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
        additionalAssets: true
      }, assets => this.optimize(compiler, compilation, assets, {
        availableNumberOfCores
      }));

      // 注册statsPrinter钩子的callback,优化控制台输出
      compilation.hooks.statsPrinter.tap(pluginName, stats => {
        stats.hooks.print.for("asset.info.minimized").tap("terser-webpack-plugin", (minimized, {
          green,
          formatFlag
        }) => minimized ? /** @type {Function} */green( /** @type {Function} */formatFlag("minimized")) : "");
      });
    });
  }
}

看完cssMinimizerPluginTerserPlugin插件之后,发现两个压缩插件实现基本上是一样的

辅助插件

speed-measure-webpack-plugin@1.5.0 耗时统计插件

使用

const SpeedMeasurePlugin = require('speed-measure-webpack-plugin')

const smp = new SpeedMeasurePlugin();

module.exports = smp.wrap({
  plugins: []
})

原理 speed-measure-webpack-plugin注册compiledone钩子的callback统计webpack本次构建耗时,注册build-modulesucceed-module钩子的callback,统计loader链耗时

精简代码如下所示

apply(compiler) {
    tap(compiler, "compile", () => {
      this.addTimeEvent("misc", "compile", "start", { watch: false });
    });
    tap(compiler, "done", () => {
      clear();
      this.addTimeEvent("misc", "compile", "end", { fillLast: true });
    });
}

tap(compilation, "build-module", (module) => {
  // 获取模块的userRequest
  const name = getModuleName(module);
  if (name) {
    this.addTimeEvent("loaders", "build", "start", {
      name,
      fillLast: true,
      loaders: getLoaderNames(module.loaders), //获取处理当前module的loaders数组,用于最终的分组统计与展示
    });
  }
});

tap(compilation, "succeed-module", (module) => {
  const name = getModuleName(module);
  if (name) {
    this.addTimeEvent("loaders", "build", "end", {
      name,
      fillLast: true,
    });
  }
});

更详细的分析,可以查看5分钟教你快速定位webpack构建性能瓶颈

webpack-bundle-analyzer@4.8.0 产物大小分析插件

使用

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

原理 bundleAnalyzerPlugin注册done钩子的callback,然后在callback内通过stats获取最终的信息,然后起服务,分析stats内的信息,并最终呈现出来

精简后的代码如下所示

class BundleAnalyzerPlugin {
  constructor(opts = {}) {
    this.opts = {};
    this.server = null;
    this.logger = new Logger(this.opts.logLevel);
  }

  apply(compiler) {
    this.compiler = compiler;

    const done = (stats, callback) => {
      callback = callback || (() => {});

      const actions = [];

      if (this.opts.generateStatsFile) {
        // 是否生成stats文件
        actions.push(() => this.generateStatsFile(stats.toJson(this.opts.statsOptions)));
      } // Handling deprecated `startAnalyzer` flag


      if (this.opts.analyzerMode === 'server' && !this.opts.startAnalyzer) {
        this.opts.analyzerMode = 'disabled';
      }

      if (this.opts.analyzerMode === 'server') {
        // 是否起本地服务方式分析
        actions.push(() => this.startAnalyzerServer(stats.toJson()));
      } else if (this.opts.analyzerMode === 'static') {
        // 是否以静态文件的方式分析
        actions.push(() => this.generateStaticReport(stats.toJson()));
      } else if (this.opts.analyzerMode === 'json') {
        // 是否以生成json的方式分析
        actions.push(() => this.generateJSONReport(stats.toJson()));
      }

      if (actions.length) {
        // Making analyzer logs to be after all webpack logs in the console
        setImmediate(async () => {
          try {
            await Promise.all(actions.map(action => action()));
            callback();
          } catch (e) {
            callback(e);
          }
        });
      } else {
        callback();
      }
    };

    if (compiler.hooks) {
      compiler.hooks.done.tapAsync('webpack-bundle-analyzer', done);
    } else {
      compiler.plugin('done', done);
    }
  }
}

@soda/friendly-errors-webpack-plugin@1.8.1 美化错误插件

使用

const FriendlyErrorsWebpackPlugin = require('@soda/friendly-errors-webpack-plugin')

module.exports = {
  plugins: [
    new FriendlyErrorsWebpackPlugin(),
  ]
}

原理 FriendlyErrorswebpackPlugin插件在注册doneinvalid钩子上注册callback,然后在done钩子对应的callback内根据stats获取错误及警告,然后在进行对应的美化打印;在invalid钩子注册的callback内处理错误

精简后的代码如下所示

class FriendlyErrorsWebpackPlugin {

  constructor(options) {}

  apply(compiler) {

    const doneFn = stats => {
      this.clearConsole();

      const hasErrors = stats.hasErrors();
      const hasWarnings = stats.hasWarnings();

      if (!hasErrors && !hasWarnings) {
        this.displaySuccess(stats);
        return;
      }

      if (hasErrors) {
        this.displayErrors(extractErrorsFromStats(stats, 'errors'), 'error');
        return;
      }

      if (hasWarnings) {
        this.displayErrors(extractErrorsFromStats(stats, 'warnings'), 'warning');
      }
    };

    const invalidFn = () => {
      this.clearConsole();
      output.title('info', 'WAIT', 'Compiling...');
    };

    if (compiler.hooks) {
      const plugin = { name: 'FriendlyErrorsWebpackPlugin' };

      compiler.hooks.done.tap(plugin, doneFn);
      compiler.hooks.invalid.tap(plugin, invalidFn);
    } else {
      compiler.plugin('done', doneFn);
      compiler.plugin('invalid', invalidFn);
    }
  }
}

编写自己的webpack插件

了解了上面的常用插件原理之后,我们知道,写一个webpack插件,最关键的点就是需要知道webpack大致的构建流程,webpack流程中暴露了哪些hook,而我们的真实场景又是需要在哪个阶段介入,比如上面我们看到的

  • html-webpack-plugin插件主要目的是,根据传入的模版html文件,生成最终带js、css等静态资源的html文件,那么html-webpack-plugin就在编译开始阶段的make hook上注册callback,然后在callback内创建child compiler完成对html文件的编译,然后又在生成asset阶段的hook processAssets上注册callback,在这个callback内获取已经生成的assets,然后插入到html
  • terser-webpack-plugin插件的主要目的是压缩js代码,那么要压缩肯定也是编译结束生成assets之后,然后对assets内的js进行压缩,所以是在assets生成阶段的processAssets hook上注册callback,然后在callback内对js文件进行压缩

是以我们要编写自己的webpack插件

  1. 先确定自己的目的
  2. 根据目的选择介入的webpack阶段
  3. 然后在该阶段内,找webpack暴露的hook
  4. 然后注册对应hookcallback,在callback内完成对应的目的
  5. 有些复杂场景可能会涉及到多个阶段的不同hook,那就需要自己多翻翻webpack文档

下面用几个具体的例子带领大家一起写webpack插件

约定式路由插件

我们不想要在项目内些routes配置,而是通过一些目录约定自动帮助我们生成路由配置文件,我们只需要加载这个路由文件即可

根据我们的目的来确定怎么写这个插件

目的:自动生成routes配置

webpack阶段:肯定要在编译之前,不然webpack会构建二次

webpack编译之前的hook有:environmentinitialize等很多hook,我们这里选择initialize

initialize callback内逻辑:处理路由文件生成与文件监听的逻辑

代码如下所示

const pluginName = 'ReactAutoRoutePlugin'
class ReactAutoRoutePlugin extends BaseRoute {

  constructor(options: IGetRoutesOpts) {
    super(options);
    this.options = options;
    this.isWriting = false;
  }

  apply(compiler: Compiler) {
    if (process.env.NODE_ENV === 'production') {
      compiler.hooks.run.tapPromise(pluginName, async () => {
        await this.writeFile();
      })
    } else {
      compiler.hooks.initialize.tap(pluginName, async () => {
        await this.writeFile();
        this.watchAndWriteFile();
      })
    }
  }
}

最终产物如下图所示 面试官:了解webpack插件?写过webpack插件吗?

注意点:这里只需要保证callback执行在webpack处理模块之前生成路由文件,避免在webpack处理模块之后生成,导致webpack重新编译或者最终的产物不包含最新的route内容

生成zip包插件

比如混合app场景,app想要更新内容,我们使用的方式,就是在构建的时候将静态资源打包成一个zip包,然后远程通过拉取这个zip包达到资源更新的目的

根据我们的目的来确定怎么写这个插件

目的:生成zip包与一些辅助验证文件 webpack阶段:肯定要在编译之后,也就是assets生成阶段

webpack assets生成阶段的hook有:emitprocessAssetsafterProcessAssets等很多hook,我们这里选择emit

emit callback内逻辑:处理zip包压缩逻辑与创建新的asset逻辑

代码如下所示

export default class BuildZip {
  private options: Opts;
  constructor(options: Opts) {
    this.options = options;
  }
  async handlerZip(compilation: Compilation, callback: any) {
    if (compilation.compiler.isChild()) {
      return callback();
    }
    const { versionConfig, zipConfig } = this.options;
    const assetsCache = compilation.assets;
    // 将静态资源通过yazl处理成zip包
    const [zip, config] = await Promise.all([
      doZip(compilation, this.options),
      generateConfig(compilation, this.options),
    ]);

    // 兼容webpack5与webpack4,webpack5可以直接在compiler.webpack.sources上直接获取操作source相关的构造函数
    const { RawSource } = compilation.compiler.webpack
      ? compilation.compiler.webpack.sources
      : require('webpack-sources');

    // 将zip合并成一个
    const zipContent = new RawSource(Buffer.concat(zip as any) as any);
    if (zipConfig.removeBundle === true) {
      // 清空assets准备重新赋值
      compilation.assets = {};
    } else if (typeof zipConfig.removeBundle === 'function') {
      const assets = {} as { [key: string]: any };
      for (const name in compilation.assets) {
        if (compilation.assets.hasOwnProperty(name)) {
          if (!zipConfig.removeBundle(name, compilation.assets[name])) {
            assets[name] = compilation.assets[name];
          }
        }
      }

      compilation.assets = assets;
    }

    const zipFileName = zipConfig.filename.replace('.zip', '');
    const fileKeys = Object.keys(assetsCache);
    // 保留原来的js、css等静态资源
    fileKeys.map((key) => {
      compilation.assets[`${zipFileName}/${key}`] = assetsCache[key];
    });

    // 添加一个包含文件目录的txt
    compilation.assets[`${zipFileName}.txt`] = new RawSource(fileKeys.join('\n'));

    // 生成zip包
    compilation.assets[zipConfig.filename] = zipContent;

    const content = JSON.stringify(config, null, '\t');

    // 生成版本信息json文件
    compilation.assets[versionConfig.filename] = new RawSource(content);

    callback();
  }
  apply(compiler: Compiler) {
    const { pass } = this.options;
    if (!pass) {
      // webpack5注册hook
      if (compiler.hooks) {
        compiler.hooks.emit.tapAsync('BuildZipPlugin', this.handlerZip.bind(this));
      } else {
        // webpack4之前注册hook方式
        // @ts-ignore
        compiler.plugin('emit', this.handlerZip.bind(this));
      }
    }
  }
}

最终产物如下图所示

面试官:了解webpack插件?写过webpack插件吗?

tree-shaking插件

在项目内引入一些npm包,但是一些npm包没有主动声明sideEffects,这时候看了代码之后,确定是没有副作用的,那么可以通过自动插件控制这个包的sideEffects,另外就是有些包设置了sideEffects但是在一些极端场景下会导致tree-shaking不对,项目运行的时候报错

根据我们的目的来确定怎么写这个插件

目的:为一些npm包自动添加sideEffects及去掉一些npm包的sideEffects

webpack阶段:肯定要在编译过中,也就是module实例生成阶段

webpack module生成阶段的hook有:createModulemodule等很多hook,我们这里选择module

module callback内逻辑:控制module上的元数据逻辑

代码如下所示

class ControlNpmPackageSideEffectsPlugin {
  noSideEffectsPackages: string[]
  hadSideEffectsPackages: string[]
  constructor({
    noSideEffectsPkgs = [], // 传入需要处理的npm package name
    hadSideEffectsPkgs = [], // 传入需要处理的npm package name
  } = {}) {
    this.noSideEffectsPackages = noSideEffectsPkgs;
    this.hadSideEffectsPackages = hadSideEffectsPkgs;
  }
  apply(compiler: Compiler) {
    if (!this.noSideEffectsPackages.length && !this.hadSideEffectsPackages.length) return;
    const name = this.constructor.name;

    compiler.hooks.normalModuleFactory.tap(name, (normalModuleFactory) => {
      // 在module实例创建之后,通过修改module相关的meta数据信息,改变模块或者包的sideEffects配置
      normalModuleFactory.hooks.module.tap(name, (module, data) => {
        const resolveData = data.resourceResolveData;

        // 如果npm包没有设置sideEffects,且满足includePackages,就设置sideEffectFree: true,表示该模块是纯的
        if (
          this.noSideEffectsPackages.some((item) => data?.resource?.includes(item)) &&
          resolveData &&
          resolveData.descriptionFileData &&
          resolveData.descriptionFileData.sideEffects === void 0
        ) {
          // 处理npm包没有标记了sideEffects的场景
          module.factoryMeta.sideEffects = false;
        }

        if (
          this.hadSideEffectsPackages.some((item) => data?.resource?.includes(item)) &&
          resolveData &&
          resolveData.descriptionFileData &&
          resolveData.descriptionFileData.sideEffects !== void 0
        ) {
          // 处理npm包标记了sideEffects的场景
          resolveData.descriptionFileData.sideEffects = undefined;
        }
      });
    });
  }
}

本地服务插件

有这样的场景,开发环境没有问题,但是上到测试环境之后,运行的时候报错,这时候没有source-map,代码又是压缩过后的,不太方便排查问题,如果这个时候我们可以直接在本地build一份构建后的产物,然后又可以请求测试环境的数据就很方便,我们可以通过一个自定义插件帮助我们达成这个目的

根据我们的目的来确定怎么写这个插件

目的:为本地build之后的产物,可以直接通过服务访问,并且可以请求到测试环境的数据

webpack阶段:肯定要在编译结束之后起服务,与编译过程中替换一些html中的占位符

webpack 结束阶段的hook有:done,我们这里选择done

webpack 编译过程中的hook有:compilationthisCompilation,我们这里选择compilation done callback内逻辑:起server服务逻辑

compilation callback内逻辑:**注册html-webpack-plugin hook修改html内占位字符

代码如下所示

export default class LocalDebugSettingPlugin {
  local_debug: string | undefined;
  constructor({ userConfig }) {
    this.local_debug = process.env.LOCAL_DEBUG;
    this.userConfig = userConfig;
  }

  apply(compiler: Compiler) {
    if (this.local_debug) {
      if (envs.includes(this.local_debug)) {
        this.registerReplace(compiler);
        !process.env.LOCAL_DEBUG_NO_SERVER && this.addService(compiler);
      } else {
        console.log('当前process.env.LOCAL_DEBUG的值不是支持的类型,目前支持', envs.join(','));
        process.exit(1);
      }
    }
  }

  getHtml(html: string) {
    if (typeof html !== 'string') return html;
    const OSS_HOST = 'https://xxxx.com';
    const ENV = this.local_debug as string;
    const DEPLOY_ENV = this.local_debug as string;
    return html.replace(/__OSS_HOST__/gm, OSS_HOST).replace(/__ENV__/gm, YUNKE_ENV).replace(/__DEPLOY_ENV__/gm, DEPLOY_ENV);
  }

  replaceHtml(htmlPluginData, callback) {
    if (typeof htmlPluginData.html === 'string') {
      htmlPluginData.html = this.getHtml(htmlPluginData.html);
    }
    callback(null, htmlPluginData);
  }

  registerReplace(compiler: Compiler) {
    if (compiler.hooks) {
      compiler.hooks.compilation.tap('LocalDebugSettingPlugin', (compilation) => {
        if (compilation.hooks.htmlWebpackPluginAfterHtmlProcessing) {
          compilation.hooks.htmlWebpackPluginAfterHtmlProcessing.tapAsync(
            'EnvReplaceWebpackPlugin',
            this.replaceHtml.bind(this),
          );
        } else {
          const htmlWebpackPlugin = compiler.options.plugins.filter((plugin) => plugin.constructor.name === 'HtmlWebpackPlugin');
          if (htmlWebpackPlugin.length) {
            htmlWebpackPlugin.forEach((item) => {
              item.constructor.getHooks(compilation).beforeEmit.tapAsync('LocalDebugSettingPlugin', this.replaceHtml.bind(this));
            });
          } else {
            const HtmlWebpackPlugin = require('html-webpack-plugin');
            if (!HtmlWebpackPlugin) {
              throw new Error('Please ensure that `html-webpack-plugin` was placed before `html-replace-webpack-plugin` in your Webpack config if you were working with Webpack 4.x!');
            }
            HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
              'EnvReplaceWebpackPlugin',
              this.replaceHtml.bind(this),
            );
          }
        }
      });
    } else {
      compiler.plugin('compilation', (compilation) => {
        compilation.plugin('html-webpack-plugin-before-html-processing', this.replaceHtml.bind(this));
      });
    }
  }


  addService(compiler) {
    const { outputRoot = '/dist', devServer = {}, publicPath = '/' } = this.userConfig;
    const contentBase = `${path.join(process.cwd(), outputRoot)}`;
    const devServerOptions = Object.assign({}, {
      publicPath,
      contentBase: [contentBase],
      historyApiFallback: true,
    }, devServer, { inline: false, lazy: true, writeToDisk: true, watchContentBase: false, filename: /not-to-match/ });

    if (!compiler.outputPath) {
      compiler.outputPath = path.join(process.cwd(), outputRoot);
    }

    compiler.hooks.done.tap('LocalDebugSettingPlugin', (stats) => {
      server.listen(devServerOptions.port, devServerOptions.host, (err: Error) => {
        if (err) {
          throw err;
        }
        console.log();
        console.log('- 已开启本地生产调试模式,可以直接使用上面的链接地址进行访问');
        console.log();
      });
    });

    const server = new WebpackDevServer(compiler, devServerOptions);
  }
}

总结

webpack插件看似很难掌握,但其实只要掌握关键点,完成大部分业务需求还是问题不大,同时对于面试中问题的webpack plugin问题相信也能够有自己的准确回答 最后插件架构或者微内核架构目前是前端很常用的一种架构模式,在babelrollupesbuildvite中都能看到这种架构,只不过插件插件定义与运行略有不同,但是这种思想是想通的,所以掌握插件架构能够对我们的日常开发有一定的好处

demo项目地址

转载自:https://juejin.cn/post/7244009969534910524
评论
请登录