从零开始的Webpack原理剖析(七)——webpack插件


theme: orange
highlight: gruvbox-dark

前言

为什么需要插件呢?

  • 仅仅靠webpack.config.js的配置,无法满足打包需求;
  • 使用了插件,能够按照我们的想法,任意改变打包的最终结果;

前文也提到过,webpack的内部其实也是用到了大量的插件来实现的,我们利用插件的特性,通过注册不同的钩子,使得自定义钩子在webpack各种编译构建流程中被触发执行。插件比loader更加需要了解webpack的一些底层原理,使用了正确的钩子,才能编写出高质量的插件。

创建插件

这里我们再简单的复习一下之前提到过的,插件是如何被创建:

  • 插件是一个类
  • 类里边有一个定好的apply实例方法,并且参数为compiler
// 插件的基本结构应该是长这个样子的
class Plugin {
  constructor (options) {
    this.options = options
  }
  apply (compiler) {
  
  }
}
module.exports = Plugin

然后,我们再写一个最简单的插件,顺便将基本的文件目录搭建:

image.png

  • 首先执行npm init -y初始化package.json文件,配置build: "webpack"命令;
  • 然后执行npm install webpack webpack-cli -D安装webpack和命令行工具;
  • 各个文件内容如下:
// plugin/RunPlugin.js
class RunPlugin {
  constructor(options) {
    // opitons为配置文件中传入的options
    this.options = options
  }
  apply(compiler) {
    compiler.hooks.run.tap('runName', () => {
      console.log('runPluginStart', this.options.name)
    })
  }
}
module.exports = RunPlugin
// src/index.js
const a = 'zhangsan'
console.log(a)
// webpack.config.js
const path = require('path')
const RunPlugin = require('./plugin/RunPlugin')
module.exports = {
  entry: './src/index.js',
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },
  plugins: [new RunPlugin({ name: 'my-run-plugin' })]
}

执行npm run build,可以在终端看到,在执行打包命令的时候,我们的自定义的插件,在run钩子被触发的时候,成功执行了:

image.png

编写Compilation插件

最简单的打印名称插件

我们上边写的RunPlugin,用到的是Compiler上边提供的钩子,之前我们也提到过Compilation对象,在每次文件变化的时候,都会创建一个新的Compilation对象,从而进行新的一组编译,同样Compilation也提供了很多的钩子。

我们来写一个插件,作用很简单,是打印当前打包chunk的名字,和打包后文件的名字:

// plugin/WebpackLogAssetNamePlugin
class WebpackLogAssetNamePlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    // 每次webpack进行编译的时候,就会创建一个新的compilation,此时compilation钩子就会被触发
    compiler.hooks.compilation.tap('WebpackLogAssetNamePlugin', (compilation) => {
      //chunkAsset钩子:每次根据chunk创建一个新的文件后,都会被触发一次
      compilation.hooks.chunkAsset.tap('WebpackLogAssetNamePlugin', (chunk, filename) => {
        console.log('chunkName:', chunk.name, ';', 'filename:', filename)
      })
    })
  }
}
module.exports = WebpackLogAssetNamePlugin

之后再webpack.config.js文件中引入,并且实例化,然后我们运行npm run build命令,查看终端输出:

image.png

这里说明一下,为啥chunk的名字是main呢?其实,在webpack.config.js文件中,我们在配置entry: './src/index.js'这种写法是一种简写的语法糖,实际上相当于entry: { main: './src/index.js'},有一个默认的名字叫做main,我们手动修改这个名字,那么再运行npm run build打包命令,打印出来的名字也就会随之发生变化了。

编写一个将输出目录dist中的内容,全部压缩成zip文件的插件

首先要安装jszip webpack-sources这两个包;
我们先在官方文档查询,需要在哪个钩子里进行编码,发现是processAsset这个钩子,那么这个钩子回调函数的参数,文档上有说明,是assets对象,包含了pathname和相对应的代码对象,那么如何获取代码,可以点击红框标注的这个链接,发现其实就是利用webpack-sources这个包里边提供的方法,来获取源代码,如果这里不理解的话,在下边的案例里可以console.log(source)或者打印其他的不理解的东西,看一看具体是什么。

image.png

const jszip = require('jszip')
const { RawSource } = require('webpack-sources')
class WebpackArchivePlugin {
  constructor(options) {
    this.options = options
  }
  apply(compiler) {
    compiler.hooks.compilation.tap('WebpackArchivePlugin', compilation => {
      // 处理每个文件资源的是时候触发的钩子
      compilation.hooks.processAssets.tapPromise({ name: 'WebpackArchivePlugin' }, assets => {
        const zip = new jszip()
        for (const filename in assets) {
          // 把输出的文件,打包在zip压缩包中
          const source = assets[filename]
          const sourceCode = source.source()
          zip.file(filename, sourceCode)
          // 如果只想输出压缩包,不想输出打包后的文件,可以放开下边一行的代码
          // delete assets[filename]
        }
        return zip.generateAsync({ type: 'nodebuffer' }).then(zipContent => {
          // 在assets中添加zip压缩包的filename,为接下来生成实际的zip文件做准备
          assets[`archive_${Date.now()}.zip`] = new RawSource(zipContent)
        })
      })
    })
  }
}
module.exports = WebpackArchivePlugin

webpack.config.js文件中引入我们这个插件后,执行npm run build打包后,可以发现,在dist文件中多出来了一个zip压缩包,里边的内容解压出来后,正是bundle.js

image.png

编写一个自动外链外部库的插件

接下来,我们 实现一个相对来说比较复杂的自动外链插件。一个插件,不会平白无故产生,肯定是有需求和背景的,相信大家的项目中,一定用过jquery或是lodash之类的外部库,那么如果我们不做任何处理,直接在文件中使用,在打包的时候,webpack就会把jquerylodash的代码一起打包进来,造成包的体积边的非常大,我们可以试着看一下:

// npm install jquery lodash html-webpack-plugin -S
// src/index.js
import _ from 'lodash'
import $ from 'jquery'
const name = 'zhangsan'
console.log(name, $, _)
// public/index.html文件生成最基本的默认结构即可
// webpack.config.js
const path = require('path')
// 在打包结果中按照模板生成index.html文件
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: {
    main: './src/index.js'
  },
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    clean: true
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ]
}

执行npm run build命令,我们可以看到,打包出来的bundle.js文件非常大,打开bundle.js文件,我们可以发现jquerylodash的源码全都被打包进去了,那么有什么办法,可以不把这两个外部库打包进最终生成的文件中呢?

image.png

这里我们只先提一种方法,那就是利用externals这个配置项 + CDN引入外部链接:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
  entry: {
    main: './src/index.js'
  },
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    clean: true
  },
  // 使用externals配置,来标注外部库
  externals: {
    'jquery': '$',
    'lodash': '_' 
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    })
  ]
}
// public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <!-- 加上CDN链接引入外部库 -->
  <script src="https://cdn.bootcss.com/jquery/3.1.0/jquery.js"></script>
  <script src="https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.js"></script>
</body>
</html>

此时我们再执行npm run build命令,可以发现打包的bundle.js大小只有几kb:

image.png

我们再来看下打包后的bundle.js文件,找到关键的地方,这里导出的就是我们externals配置项里配置的内容,jquerylodash就没有被打包进来了,而是利用CDN进行资源加载,通过全局变量的方式进行使用

image.png

所以这和我们即将要写的插件,有啥关系呢?没错,现在的需求是,只想在webpack.config.js这个文件中,把所有的配置都一步到位改好,不想再去index.html一个一个加CDN链接了。一句话我们自定义的自动外链插件 = externals + index.html手动添加CDN链接。

我们先把插件在webpack.config.js中配好,传参写上我们想要的格式:

// webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const AutoExternalPlugin = require('./plugin/AutoExternalPlugin')
module.exports = {
  entry: {
    main: './src/index.js'
  },
  mode: 'development',
  devtool: false,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js',
    clean: true
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './public/index.html'
    }),
    // 这就是自动外链插件,里边传入外链库的全局变量名和CDN链接
    new AutoExternalPlugin({
      jquery: {
        variable: '$',
        url: 'https://cdn.bootcss.com/jquery/3.1.0/jquery.js'
      },
      lodash: {
        variable: '_',
        url: 'https://cdn.bootcdn.net/ajax/libs/lodash.js/4.17.21/lodash.js'
      }
    })
  ]
}

先做下准备工作:我们可以大致想一下思路,该如何实现这个插件。目前能想到的就是:

  • 1、检索到使用import语句来引入jquerylodash的地方,通过某种神奇的操作,将其设置成不打包的状态;
  • 2、再通过与html-webpack-plugin的联动,把CDN链接通过<script>标签的形式写入,那么就达到了我们的目的了。

如何检测import语句呢?之前我们学loader的时候,曾经提到过AST这个概念,那么这里其实也类似,只不过webpack有现成的钩子提供给我们了,通过查阅官方文档,可以发现是在这个地方:

image.png

那么问题又来了,我们怎么才能拿到这个parser呢?只有拿到了parse,才能使用其内部的钩子。我们继续查阅文档(这里不得不再吐槽一句,webpack文档对于新手来说,太不友好了,没有人指点,很多东西根本找不到在哪里🤷🏻‍♀️),发现paser是在NormalModuleFactory Hooks这个钩子里边回调的返回值。仿佛已经成功了一大半。

image.png

最后的一个问题就剩下,这个NormalModuleFactory Hooks,我们又该从哪里拿到呢?通过不懈的努力,终于在文档熟悉的地方,找到了答案,没错就是我们熟悉的Compiler Hooks上,到此,终于找到头了。(好难从文档里找啊)

image.png

接下来,有了以上思路和准备工作,我们便可以去写自动外链这个插件啦,还有些具体的细节,我们在写插件的过程中,会按顺序标注起来,一一讲解。

// plugin/AutoExternalPlugin.js
const { ExternalModule } = require('webpack')
const HtmlWebpackPlugin = require('html-webpack-plugin')

class AutoExternalPlugin {
  constructor(options) {
    this.options = options
    this.externalModules = Object.keys(this.options) // ['jquery', 'lodash'] 存放着我们配置项中的模块名
    this.importedModules = new Set() // 存放着项目中使用到的外部依赖模块(如jquery, lodash)
  }
  apply (compiler) {
    /* 1、首先找到项目中所依赖的所有模块,看看哪些模块里边用到了我们这个插件里配置的依赖(如jquery, lodash)
    用到了才会去处理为外部模块,没用到就不用处理,怎么去找项目中依赖了哪些模块呢?和之前AST很类似,
    我们需要找import xxx;和require(xxx)语句 */
    compiler.hooks.normalModuleFactory.tap('AutoExternalPlugin', normalModuleFactory => {
      /* 为啥用normalModuleFactory这个实例呢?因为每种模块都会对应一个模块工厂,从名字上就能看出来
      普通模块对应普通模块工厂;.for('javascript/auto')就是普通js文件对应的标识符,文档上写的很清楚 */
      normalModuleFactory.hooks.parser.for('javascript/auto').tap('AutoExternalPlugin', parser => {
        // 模块工厂创建完模块之后,要将模块源码编译成AST语法树,然后遍历树的节点找import这种语句
        parser.hooks.import.tap('AutoExternalPlugin', (statement, source) => {
          // 这个source就是 import $ from 'jquery'中 from后边的依赖名(jquery)
          if (this.externalModules.includes(source)) {
            this.importedModules.add(source)
          }
        })
        // 因为导入模块方式还可能用require,所以还需要找require这个关键字,同样,call也是个hookMap(上节tapable提及过)
        parser.hooks.call.for('require').tap('AutoExternalPlugin', expression => {
          // 这个value就是require('lodash') 中的依赖名(lodash)
          let value = expression.arguments[0].value
          if (this.externalModules.includes(value)) {
            this.importedModules.add(value)
          }
        })
      })
      /* 2、改造模块生产的过程,如果是外链模块的话,直接产生一个外链模块返回;factorize是一个 AsyncSeriesBailHook 异步串行保险类钩子,
      也就是说,以下方代码为例,经过判断是外部模块的话,callback回调函数有了返回值,那么因为是Bail类型的钩子,所以接下来的正常模块的打包流程就会被跳过,
      也就是说jquery和lodash的代码,不会被打包进最终生成的文件中。 */
      normalModuleFactory.hooks.factorize.tapAsync('AutoExternalPlugin', (resolveData, callback) => {
        let { request } = resolveData //模块名 jquery lodash
        if (this.externalModules.includes(request)) {
          // 获取变量名
          let { variable } = this.options[request]
          /* 使用new ExternalModule生成外部模块,分别传入变量名,全局对象,模块名,webapck.config.js配置中的externales配置项,原理
          也是使用了new ExternalModule生成外部模块 */
          callback(null, new ExternalModule(variable, 'window', request))
        } else {
          callback(null) //如果是正常模块,直接向后执行。走正常的打包模块的流程
        }
      })
    })
    /* 3、向打包后的html文件,插入script的CDN脚本(就是和html-webpack-plugin进行交互,利用这个插件,向html中插入script标签)*/
    compiler.hooks.compilation.tap('AutoExternalPlugin', (compilation) => {
      // 查看html-webpack-plugin文档可以发现,在plugin章节可以看到html-webpack-plugin提供的各种钩子和获取钩子的方法
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tapAsync('AutoExternalPlugin', (htmlData, callback) => {
        // 利用html-webpack-plugin,遍历外部依赖模块set集合,插入script标签
        for (let key of this.importedModules) {
          htmlData.assetTags.scripts.unshift({
            tagName: 'script',
            voidTag: false,
            attributes: {
              defer: false,
              src: this.options[key].url
            }
          })
        }
        // alterAssetTags是一个waterfall类型的钩子,所以要在回调中传入htmlData
        callback(null, htmlData)
      })
    })
  }
}
module.exports = AutoExternalPlugin

我们此时再执行npm run build命令,查看打包结果可以发现,jquerylodash没有被打包到最后生成的文件,而且新生成的index.html中,也插入了2条CDN脚本。

image.png

至此,我们的自动外链插件就已经写好啦,如果能够一路跟下来,相信你对如何编写webpack插件已经有一个大致的方向了,起码不会像没学过一样一问三不知,也算是入门了!

结尾

通过前文及本文的学习,我们大致上知道webpack插件写起来,到底是个啥思路了,但是想要短时间快速学习如何写webpack插件,基本上不可能,首先市面上的插件基本上涵盖了大多数的需求,其次,想要学会写webpack插件,就要对webpack的源码有一定的了解(源码都将近10w行了吧…),而且里边提供了上百种钩子,不可能一个一个的去背下来,只能遇到相关需求的时候,我们单独去查阅文档,然后一点点摸索规律;像我们这种普通的CV工程师,只需要了解思路即可,毕竟天天写业务都写不完,就算学会了这些看似高大上的知识,能用到的地方,也不是很多,真正用到的时候,再去查再去看也来得及。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容