超硬核|带你畅游在 Webpack 插件开发者的世界

·  阅读 7858
超硬核|带你畅游在 Webpack 插件开发者的世界

写在前边

大多数开发者对于 Webpack 也许仅仅停留在使用配置层面,仅能够熟练应用 Webpack 各种配置选项在真实项目中。

但是如果仅仅局限于配置层面去思考 Webpack 的话,在我个人看来对于一个合格的前端工程师而言这是远远不够的。

深入 Webpack 必不可少会提起他的 Plugin 机制,关于 Webpack Plugin 究竟应该如何应用在业务项目架构中或者换句话说它能为我们在不同业务场景下带来什么优势。

在我个人看来这部分知识的应用程度对于任何一位高级前端开发者而言,一定是通往架构之路必须越过的横沟

接下来,我会由浅到深一步一步带你实现两个 Plugin ,从而带你走进 Webpack 插件开发者的世界。

如果你有兴趣深入了解 Webpack ,你可以点击这里查阅我的往期 Webpack 内容专栏从原理玩转 Webpack

在开始之前,我想说的话

如果你有兴趣深入 Webpack Plugin 我强烈阅读这两篇文章作为前置知识:

关于 Tapable ,Webpack Plugin 是完全基于它来实现的。

通俗来说它就是一个类似 Node 中的 EventEmitter 发布订阅库,我们可以在 Plugin 中通过 Tapable 接受参数订阅对应事件,Webpack 在打包的不同时机来触发不同的 Tapable Hook 从而影响打包结果。

这篇文章中对于 Webpack Plugin 开发的一些概念性问题进行阐述,介绍了 Webpack 内置提供给插件开发者的 Hook 含义以及对应的执行时机。

别担心,文章中也会在提及到上述两篇文章的内容时稍加复习。如果你有兴趣更深层次了解,我建议在看完文章后你可以带着问题回到上边两篇文章中。

CompressAssetsPlugin

让我们先从一个比较简单的插件来入手,谈一谈一个插件的基础开发流程是哪些步骤。

需求分析

众所周知在使用 Webpack 打包项目时,通常我们会将所有资源打包在 dist 文件目录内,分别存放对应的 html、css 以及 js 文件。

此时,假使我需要在每次打包结束后将本次打包生成出的所有资源打包成为一个 zip 包。

之后我会将本次打包的 zip 存放到我的服务器中作为备份之类功能,当然这不重要。重要的是我们的目标有了:我需要在每次打包结束后将本次编译生成的所有资源打包成为 zip 额外输出。

const path = require('path');
const CompressAssetsPlugin = require('./plugins/CompressAssetsPlugin');

module.exports = {
  mode: 'development',
  entry: {
    main: path.resolve(__dirname, './src/entry1.js'),
  },
  devtool: false,
  output: {
    path: path.resolve(__dirname, './build'),
    filename: '[name].js',
  },
  plugins: [
    new CompressAssetsPlugin({
      output: 'result.zip',
    }),
  ],
};
复制代码

上边的 .js 文件是一份简单的 webpack 配置文件,展示了我们如何使用 CompressAssetsPlugin 。

它仅仅接受一个 output 参数,它表示生成 zip 的名称。

关于 webpack 基础配置内容这里我就不在累赘了,如果对于基础你不太清楚的可以查看这篇 React-Webpack5-TypeScript打造工程化多页面应用 。接下来我会带你去实现 CompressAssetsPlugin 这个插件。

原理分析

围绕 Webpack 打包过程中存在两个核心对象:

  • compiler

compiler 在 Webpack 启动打包时创建,保存着本次打包的所有初始化配置信息。

在每一次进行打包过程中它会创建 compilation 对象进行模块打包。关于如何理解每一次比方说我们在 watch (devServer) 模式中,每当文件内容发生变化时都会产生一个 compilation 对象进行打包,而 compiler 对象永远只有一个,除非你终止打包命令重新调用 webpack 。

  • compilation

compilation 代表这一次资源构建的过程,在 compilation 对象中我们可以通过一系列 API 访问/修改本次打包生成的 module、assets 以及 chunks 。

官网在这个链接介绍了这两个对象。

这里我们需要在每一次打包即将生成后将输出的资源文件统一打包进入 zip ,主要用到以下内容:

这是一个 JS 生成 zip 压缩包的库,我们会使用这个库来生成 Zip 内容。

compiler 对象上的 Emit Hook 会在输出 asset 到 output 目录之前执行,简单来说就是每次即将打包完成生成文件时会调用该钩子。

在打包过程中我们需要获取本次打包即将生成的资源,可以使用 compilation.getAssets() 方法首先获得原始打包生成的资源文件内容以及通过 compilation.emitAssets() 输出生成的 zip 到打包结果中去。

动手实现

上边我们介绍了基本的基本原理,接下里就让我们一起来动手实现这个插件。

首先任何一个 Webpack Plugin 都是一个模块,这个模块导出了一个类或者说函数,该函数必须存在一个名为 apply 的原型方法。

在调用 webpack() 方法开始打包时,会将 compiler 对象传递给每一个插件的 apply 方法并且调用他们注册对应的 Hook 。

让我们先来实现一下基础内容,让我们在 ./plugins 目录下创建一个 CompressAssetsPlugin.js文件:

const pluginName = 'CompressAssetsPlugin';

class CompressAssetsPlugin {
  // 在配置文件中传入的参数会保存在插件实例中
  constructor({ output }) {
    // 接受外部传入的 output 参数
    this.output = output;
  }

  apply(compiler) {
    // 注册函数 在webpack即将输出打包文件内容时执行
    compiler.hooks.emit.tapAsync(pluginName, (compilation,callback) => {
        // dosomething
    })
  }
}

module.exports = CompressAssetsPlugin;
复制代码

上边我们简单搭建了插件的基础结构,我们在 apply 方法中通过 compiler.hooks.emit.tapAsync 注册了一个事件函数,这个事件函数会在每次即将打包完成生成文件时会调用该函数。

我们通过 tapAsync 注册的事件函数中接受两个参数:

  • 第一个参数为 compilation 对象表示本次构建的相关对象

  • callback 参数对应我们通过 tapAsync 注册的异步事件函数,当调用 callback() 时表示注册事件执行完成。

上边我们已经在打包即将输出文件时注册了一个事件函数,之后让我们来为函数填充更加具体的业务逻辑--获得本次编译的资源利用 JSZip 生成压缩包输出到最终目录中

const JSZip = require('jszip');
const { RawSource } = require('webpack-sources');
/* 
  将本次打包的资源都打包成为一个压缩包
  需求:获取所有打包后的资源
*/
 
const pluginName = 'CompressAssetsPlugin';

class CompressAssetsPlugin {
  constructor({ output }) {
    this.output = output;
  }

  apply(compiler) {
    // AsyncSeriesHook 将 assets 输出到 output 目录之前调用该钩子
    compiler.hooks.emit.tapAsync(pluginName, (compilation, callback) => {
      // 创建zip对象
      const zip = new JSZip();
      // 获取本次打包生成所有的assets资源
      const assets = compilation.getAssets();
      // 循环每一个资源
      assets.forEach(({ name, source }) => {
        // 调用source()方法获得对应的源代码 这是一个源代码的字符串
        const sourceCode = source.source();
        // 往 zip 对象中添加资源名称和源代码内容
        zip.file(name, sourceCode);
      });
      // 调用 zip.generateAsync 生成 zip 压缩包
      zip.generateAsync({ type: 'nodebuffer' }).then((result) => {
        // 通过 new RawSource 创建压缩包
        // 并且同时通过 compilation.emitAsset 方法将生成的 Zip 压缩包输出到 this.output
        compilation.emitAsset(this.output, new RawSource(result));
        // 调用 callback 表示本次事件函数结束
        callback();
      });
    });
  }
}

module.exports = CompressAssetsPlugin;
复制代码

上边的代码中每一行我都进行了注释,主要思路还是通过操作 Webpack Compilation Api 配合 Compiler Hook 控制打包结果。

其中有两个地方我想刻意强调下:

  • 关于 compilation.getAssets() 返回的参数,我们通过 asset.source.source() 方法获得即将生成的模块源代码。

也许你会好奇 compilation.getAssets() 究竟会返回什么。在之前如果没有深入 webpack 源码你很难清楚的掌握 Webpack 中各种 Api 应该如何利用。

但是 TypeScript 的出现改变了这个问题,当你临时需要查阅某个对象或者方法时,你可以通过 types.d.ts 快速的查阅对应方法和属性。

image.png

我们可以清楚的看到 Asset 资源上存在 source 属性,我们可以通过 source.source() 获得对应的资源模块内容,它存储的是一个 string | buffer 。

当然如果有兴趣我强烈建议你可以抽空顺着 Webpack 的辅助文章去阅读部分源码,关于 Webpack 中的源码流程你也可以查阅这篇 Webapck5 核心打包原理全流程解析

这个库是一个 webpack 内置库,它的内部包含了 Source 等一系列基于 Source 的子类对象。

这里我们通过 new RawSource() 创建了一个不需要 sourceMap 映射的资源文件(Source)对象之后通过 compilation. emitAsset(name,source) 输出对应资源。

至此我们的 CompressAssetsPlugin 就实现了,让我们运行一次打包命令来验证下结果:

image.png

可以看到打包结束后在 output 配置的目录中输出了一个 result.zip 。

此时恭喜大家目前已经可以实现一些比较简单的 Webapck Plugin 了,之后趁热打铁我会带你深入插件机制实现一个更加复杂的 Plugin 。

ExternalWebpackPlugin

接下来我会带你开发一个稍微复杂的 Plugin ,这之中会涉及部分 AST(抽象语法树) 的知识。不过它并不是重点,即使对于它不是很了解也无关紧要。

背景介绍

Webpack 中存在一个 externals 的配置选项:

module.exports = {
  //...
  externals: {
    jquery: 'jQuery',
  },
};
复制代码

所谓 externals 即是说「从输出的 bundle 中排除依赖」。

比如使用上方的配置 Webpack 在进行模块编译时如果发现依赖模块 jqery 时,此时并不会将 jquery 打包进入模块依赖中,而是当作外部模块依赖使用全局对象上的 jQuery 赋值给 jquery 模块。

关于 externals 更加详尽的配置你可以看这里

也许首次接触 externals 的朋友仍然还不是很明白,让我们来举个例子说明一下吧,

比如使用上方配置文件,代码中存在这样的模块依赖:

import $ from 'jquery'
复制代码

当 Webpack 碰到 jquery 的模块引入时,并不会将 jquery 这个模块依赖代码打包进入业务代码,而是会根据 externals 配置将 jquery 作为外部模块去名为 jQuery 的变量上去寻找。

image.png

这是上述引入语句 development 模式下打包后的代码,我们可以看到针对于 jquery 模块 Webpack 将它处理成为了 module.exports = jQuery

通常 externals 配置在使用 webpack 打包一些 Library 时特别有用,在明白了它的用法之后让我们继续进行 ExternalWebpackPlugin 的需求分析。

需求分析

此时让我们先来谈一谈 ExternalWebpackPlugin 需要实现的需求内容。

源生 externals 配置方式

通常如果在业务代码中,如果我们需要将某些内部依赖模块不进行打包而是使用 externals 形式作为 CDN 进行引入,我们需要经历一下二个步骤:

  • webpack 配置中进行 externals 配置。

比如我们代码中如果使用到了 Vue 和 lodash 这两个库,此时我们并不想在业务代码中打包这两个库而是希望通过 CDN 的形式在生成的 html 文件中引入,需要这样做:

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
  mode: 'development',
  entry: {
    main: path.resolve(__dirname, './src/entry1.js'),
  },
  devtool: false,
  output: {
    path: path.resolve(__dirname, './build'),
    filename: '[name].js',
  },
  externals: {
    vue: 'Vue',
    lodash: '_',
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: '../public/index.html',
    }),
  ],
}
复制代码

我们在 webpack.config.js 中配置了 externals 选项告诉 webpack 在打包时如果遇到引入 vue 或者 lodash 模块时不需要将这两个模块的内容打包到最终输出的代码中。

而在在将全局环境下的 Vue 变量赋值给 vue 模块,将 _ 赋值给 lodash 模块。

此时我们已经完成了 externals 的配置,但这还远远不够。因为此时我们打包编译后的代码中并不存在 Vue 和 _ 这两个全局变量,我们需要在最终生成的 html 文件中添加这两个模块对应的 CDN 链接。

  • 生成的 html 文件中注入 externals 中的 CDN 配置外部链接。

上边的配置中我们使用了 HtmlWebpackPlugin 指定了生成的 html 文件的模板,接下来让我们来看看这个 html 文件 public/index.html


<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Webpack App</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <!-- 手动引入对应的模块CDN -->
  <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
</head>

<body>
</body>

</html>
复制代码

如果我们存在希望在业务代码中将某些模块以 CDN 的形式进行引入,通常需要经历上述必不可少的两个阶段。

存在的问题

这样做的话我个人看来主要存在以下两个不合理的地方:

  • 首先,配置步骤在我看来应该尽量的简单化。

我们使用需要将依赖模块转变为 CDN 形式的话每次都要在 externals 和生成的 html 文件中进行同步修改,这无疑增加了步骤的繁琐。

  • 其次,可能会存在 CDN 冗余加载的问题。

同一个项目内如果使用到了 lodash 的话,我希望将项目内使用到的 lodash 模块作为外部依赖进行打包。

此时我可能我并没有使用 lodash 但是并没法保证该项目内其他开发者有没有使用 lodash ,当我在 externals 中配置 lodash 时就必须在 html 文件中引入 lodash 的CDN 。

但其实有可能最终项目内并没有使用 lodash ,但是我们在 html 中仍然冗余的引入了它的 CDN 。当然这一步可以在上线前或者和小组内成员进行口头约定,但如果可以更加智能化岂不是更好?

针对上边两个存在的问题,我们来构思一款插件来解决这两个问题。

设计插件

首先,我们先来看看我们需要书写插件的使用方式:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExternalsWebpackPlugin = require('./plugins/externals-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: {
    main: path.resolve(__dirname, './src/entry1.js'),
  },
  devtool: false,
  output: {
    path: path.resolve(__dirname, './build'),
    filename: '[name].js',
  },
  externals: {
    vue: 'Vue',
    lodash: '_',
  },
  plugins: [
    new HtmlWebpackPlugin(),
    new ExternalsWebpackPlugin({
      lodash: {
        // cdn链接
        src: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
        // 替代模块变量名
        variableName: '_',
      },
      vue: {
        src: 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js',
        variableName: 'vue',
      },
    }),
  ],
};
复制代码

这里我们从结果来推过程,针对上述两个问题我们构思了一款 ExternalsWebpackPlugin 来解决。

参数设计

首先我们先来聊聊插件的参数:

{
      lodash: {
        // cdn链接
        src: 'https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js',
        // 替代模块变量名
        variableName: '_',
      },
      vue: {
        src: 'https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js',
        variableName: 'Vue',
      },
}
复制代码

我们支持传入一个 Object 作为参数,这个对象每个属性名为代码中需要处理为外部依赖模块的模块名。

比如上述传入对象中的 lodash 属性,它表示如果我们代码中引入用名为 lodash 的依赖的话,此时我会将 lodash 依赖作为外部依赖模块。

同时会使用 variableName 属性来替换 lodash 模块,就类似于 externals: {lodash: '_'}

最后它还会帮我们在生成的 html 文件中动态注入对象中的 src 属性值生成 CDN 链接。

插件需要解决的问题

  • 配置步骤简单化。

针对于上述每次使用 CDN 引入外链原本需要两个步骤实现我们可以设计一款插件通过在插件中传递参数简化这个步骤。

  • CDN 冗余加载。

针对于源生 CDN 方式可能引起的冗余,我们会在 ExternalsWebpackPlugin 中通过 AST 抽象抽象语法树的分析保存仅仅在代码中使用到的外部依赖模块,在生成 html 文件时仅注入这些使用到的模块 CDN 链接

原理分析

接下来我会带你稍微探讨一些关于 ExternalWebpackPlugin 使用到的 webpack 原理部分的内容。

  • NormalModuleFactory Hook

NormalModuleFactory 模块是于生成模块息息相关的一个模块,compiler 对象正是通过这个模块来处理编译模块请求。

针对于模块请求的处理,我们可以通过 NormalModuleFactory 中的一系列 Hook 进行事件注册从而改变引入模块时的逻辑。

import vue from 'vue' ,简单来说这样的一句 import 语句就属于模块请求。

我们需要通过 NormalModuleFactory Hook 注册事件函数,当 webpack 处理模块内部的依赖模块引入时会触发对应的 hook 从而判断:如果即将引入的模块匹配插件传入需要作为外部依赖模块的话,那么此时就不行编译直接当作外部模块处理。

  • JavaScriptParser Hook

上边说到 NormalModuleFactory 针对于模块处理存在一些 Hook,同样在 NormalModuleFactory Hook 对象中存在一个 parser 属性的 HookMap。

这里我会稍微给你解释一下 parser 属性,compiler 对象上针对于模块处理使用 NormalModuleFactory 模块处理。

既然是模块引入,在识别到引入新的需要编译的模块时 webpack 内部实质上对于新引入的模块会进行一个深度优先的过程,这就是 Parser 的作用。

当编译引入模块时难免会涉及 AST 抽象语法树的转化,NormalModuleFactory 属性上的 parser hook 即是针对于编译每一个 module 生成 AST 时提供给插件开发者的 hook 列表。

这里插件内部会使用 JavaScriptParser Hook 分析引入模块的依赖模块引入语句,在生成 AST 时进行判断,保存使用到的外部依赖模块。

所谓保存仅使用到的外部依赖模块的意思就是说,比如我们代码中没有使用 lodash 而插件参数中传入了 lodash 的 CDN 配置,那么我们正是通过 AST 分析代码,如果没有碰到 import _ from 'lodash'/const _ = require('lodash') 之类的模块引入语句,那么之后我并不会生成对应的 CDN 链接在 html 文件中。

webpack 内部使用 acron 语法进行抽象语法树的解析与处理。

如果你不是很明白 HookMap 的概念你可以点击这里查看

同时关于 AST 生成的规则你也可以点击这个在线网站进行查看。

  • HtmLWebpackPlugin Hook

在编译最终输出 html 文件我们需要依赖于 HtmlWebapckPlugin

HtmlWebpackPlugin 通过 HtmlWebpackPlugin.getHooks(compilation) 方法拓展了一些列 hook 方便别的插件开发者在生成 html 文件中注入逻辑,具体 Hook 以及时机你可以查看这张图:

flow.png

图片来源于 HtmlWebapckPlugin NPM 地址

这里,我们会使用到这个插件提供的 Hook 从而在生成的 html 文件中实现自动注入外部模块的 CDN 。

NormalModuleFactory 与 JavaScriptParser

关于 NormalModuleFactory Hook 与 JavaScriptParser Hook 有的朋友可能首次接触并不是很了解,在这里我还是想稍微和大家啰嗦下。

我尽量简单来和大家聊一聊这对象 hook 的触发时机,比如我们需要进行的打包代码的入口文件如下:

// index.js 入口文件
import module1 from './module1'
import module2 from './module2'
复制代码

首先 webpack 会进入入口文件,在此时首先会涉及到 NormalModuleFactory hook 上注册的相关 hook ,它是针对于模块资源请求的处理 hook 。

只有在进入入口文件后,通过 NormalModuleFactory hook 该依赖文件需要进行编译时,才会进入 JavascriptParser Hooks 通过 AST 来分析模块内容。

如果在 NormalModuleFactory hook 开头判断该模块不需要编译那么自然也不会进入依赖模块的 parser 阶段。

以上方的为例:

  • 在运行编译命令时首先分析入口文件 index.js 模块请求,调用 NormalModuleFactory Hook 部分钩子。

  • 之后会编译 index.js 文件(它也是一个 module ),会进行 AST 分析此时就是 Parser 实例对象的作用,接下来分析该模块(index.js)时会触发一系列 JavascriptParser Hooks 。

在碰到 import module1 from './module1' 时 同样会重复循环上述两个步骤。

NormalModuleFactory 的作用是生成 module ,而生成编译模块时 AST 处理当前模块是必不可少的部分,所以我们可以通过 NormalModuleFactory.hooks.parser.somehook 注册 AST 处理时的事件函数,你可以这样简单理解它。

或者说它们的关系是父集与子集的关系,这样你理解起来会不会更好一些呢。

动手实现

铺垫了这么多,接下来让我们顺着思路一步一步来实现吧!

初始化

首先,我们先来看看插件的初始化阶段:

const pluginName = 'ExternalsWebpackPlugin'


class ExternalsWebpackPlugin {
  constructor(options) {
    // 保存参数
    this.options = options
    // 保存参数传入的所有需要转化CDN外部externals的库名称
    this.transformLibrary = Object.keys(options)
    // 分析依赖引入 保存代码中使用到需要转化为外部CDN的库
    this.usedLibrary = new Set()
    
  }

  apply(compiler) {
    // do something
  }
}

module.exports = ExternalsWebpackPlugin;
复制代码

在 ExternalsWebpackPlugin 的开头,我们在插件的构建函数内我们初始化了插件需要使用到的参数:

  • this.options

这个自然不用多说,保存外部传入的配置对象。

  • this.transformLibrary

保存代码中哪些依赖库需要转化为 CDN 形式的依赖库的名称,这里转化后为 ['lodash','vue']

  • this.usedLibrary

它是一个 Set 对象,存储我们代码中使用到的外部依赖库。比如,如果我们插件参数传入了 lodash 和 vue 但是代码中并没有使用 lodash 仅仅使用和 vue ,那么这个对象中只会存储一个 vue 。

转化外部依赖

接下来我们需要做的即是处理我们模块中的请求,针对于打包时每一个模块中的请求语句。

如果 this.transformLibrary 包含该模块的话,我们需要将引入的模块跳过编译,转化为外部依赖。

让我们先来看一看代码:

const { ExternalModule } = require('webpack');

const pluginName = 'ExternalsWebpackPlugin';

class ExternalsWebpackPlugin {
  constructor(options) {
    // 保存参数
    this.options = options;
    // 保存参数传入的所有需要转化CDN外部externals的库名称
    this.transformLibrary = Object.keys(options);
    // 分析依赖引入 保存代码中使用到需要转化为外部CDN的库
    this.usedLibrary = new Set();
  }

  apply(compiler) {
    // normalModuleFactory 创建后会触发该事件监听函数
    compiler.hooks.normalModuleFactory.tap(
      pluginName,
      (normalModuleFactory) => {
        // 在初始化解析模块之前调用
        normalModuleFactory.hooks.factorize.tapAsync(
          pluginName,
          (resolveData, callback) => {
            // 获取引入的模块名称
            const requireModuleName = resolveData.request;
            if (this.transformLibrary.includes(requireModuleName)) {
              // 如果当前模块需要被处理为外部依赖
              // 首先获得当前模块需要转位成为的变量名
              const externalModuleName =
                this.options[requireModuleName].variableName;
              callback(
                null,
                new ExternalModule(
                  externalModuleName,
                  'window',
                  externalModuleName
                )
              );
            } else {
              // 正常编译 不需要处理为外部依赖 什么都不做
              callback();
            }
          }
        );
      }
    );
  }
}

module.exports = ExternalsWebpackPlugin;
复制代码

乍一看你可能会对上边的代码有些懵,不过没关系不懂的知识才是学习不是吗。接下来我们稍微来分析下上边的代码。

首先 compiler.hooks.normalModuleFactory.tap 首先我们注册了一个事件函数在 compiler 创建 normalModuleFactory 模块之后。

这个函数调用时 webpack 会传入 NormalModuleFactory 对象作为参数,我们就可以通过 NormalModuleFactory 上的 hook 监听 compiler 对象在处理模块时的钩子从而实现逻辑处理。

normalModuleFactory.hooks.factorize,这个 hook 会在 NormalModuleFactory 在初始化解析之前调用,它的事件监听函数会接受一个 resolveData 作为参数。

简单来说,我们在这里通过 Webapck 提供给开发者的 hook 注册了一个执行函数,这个函数会在 compiler 对象创建 NormalModuleFactory 之后并且在 NormalModuleFactory 初始化解析每一个模块之前调用

换句话说比如我们的代码中存在这样一句:

import Vue from 'vue'

此时,webpack 在解析这句代码时当碰到模块请求(vue)时,会在初始化解析之前调用我们注册的函数。

接下来我们再来它的参数 resolveData :

image.png

这是源码中 resolveData 的类型定义,有兴趣的小伙伴可以在运行时打印出来看看。

这里我们需要使用 resolveData 中的 request 属性,它表示当前需要解析的模块名称。

比如import Vue from 'vue',resolveData.request 拿到的内容即是 'vue'

接下来在来看看这段代码:

const { ExternalModule } = require('webpack');
// ...

normalModuleFactory.hooks.factorize.tapAsync(
  pluginName,
  (resolveData, callback) => {
    // 获取引入的模块名称
    const requireModuleName = resolveData.request;
    if (this.transformLibrary.includes(requireModuleName)) {
      // 如果当前模块需要被处理为外部依赖
      // 首先获得当前模块需要转位成为的变量名
      const externalModuleName = this.options[requireModuleName].variableName;
      callback(
        null,
        new ExternalModule(externalModuleName, 'window', externalModuleName)
      );
    } else {
      // 正常编译 不需要处理为外部依赖 什么都不做
      callback();
    }
  }
);

// ...
复制代码

我们在函数内部获取了需要解析的依赖模块名 requireModuleName ,此时首先判断需要解析的模块是否是需要被处理成为 externals 外部模块。

  • 如果不需要处理 externals 模块,也就是模块不在 this.transformLibrary 中。

此时我们直接调用注册函数的第二个 callback 参数,不进行任何逻辑返回表示让 compiler 对象继续处理该模块正常编译。

  • 如果需要处理为 externals 模块,也就是模块存在 this.transformLibrary 中。

此时首先我们通过 this.options[requireModuleName].variableName 获得了该模块在配置时传入的 variableName。

之后我们通过 callback(null, new ExternalModule(externalModuleName, 'window', externalModuleName) ) 返回了创建了一个外部依赖模块进行返回,告诉 webpack 这个模块不需要被编译,我为你返回了一个 ExternalModule 的实例对象,直接当作外部依赖处理。

关于第二句代码,不熟悉插件开发者的朋友可能不是很了解它的含义。这里我为大家稍微讲述下含义:

  • 首先 tapable 中的异步注册方法 tapAsync 的监听函数中,调用 callback() 表示异步监听函数执行完毕。

callback(error,result) 函数调用时接受两个参数:

如果发生错误那么第一个参数传入错误信息,很明显我们这里没有错误就传入 null 即可。

第二个参数表示本次事件函数的返回值,如果该事件函数存在返回值那么 webpack 在处理该模块时会以注册函数的返回值来替代模块内容。

这里我们需要修改 webpack 处理该模块的原始逻辑将它变成为一个外部依赖模块,所谓我们返回了一个 ExternalModule 的实例告诉 webpack 该模块是一个外部依赖模块。

  • 其次关于new ExternalModule(externalModuleName, 'window', externalModuleName)

我们可以通过 webpack 内置的 ExternalModule 来创建一个外部依赖模块它的构造函数分别接受三个参数:

image.png

第一个参数 request 表示创建 ExternalModule 外部依赖模块时,该外部模块生成的变量名。比如 lodash 作为外部依赖模块时,我们需要从 _ 上取到它,此时我们就传入 _ 即可。

第二个参数 type 表示创建 ExternalModule 时,第一个参数对应的变量挂载在哪个对象中。比如通常我们通过 CDN 引入 lodash 时, 我们会在 window._ 表示 lodash ,第一个参数的 _ 挂载在了 window 这个对象下,缺省的话会直接从 global 下取第一个变量。

第三个参数 userRequest 表示 webpack 在打包文件时,生成唯一 moduleId 的名称,缺省的话会自动生成。关于 moduleId 你可以简单理解成为打包生成后的模块 ID 。

如果你有兴趣深入了解打包流程可以查看我的这篇 Webapck5 核心打包原理全流程解析

我们来看看所谓 ExternalModule 在 webpack 打包后变成什么样子:

image.png

所谓返回的 new ExternalModule(externalModuleName, 'window', externalModuleName),以 lodash 举例返回模块如图所示。

当代码中使用到的 lodash 模块时,webpack 会去 window['_'] 上去寻找对应模块内容。

剔除未使用到的模块

接下来我们会完成另外一个功能:在生成 AST 时进行判断,仅保存使用到的外部依赖模块,剔除插件配置传入了但代码中未使用的模块。

为了大家更好的理解,我们将这部分代码拆成两部分来为你讲解:

 // ...

 apply(compiler) {
    // normalModuleFactory 创建后会触发该事件监听函数
    compiler.hooks.normalModuleFactory.tap(
      pluginName,
      (normalModuleFactory) => {
        // 在初始化解析模块之前调用 将匹配的模块处理成为外部 externalModule
        normalModuleFactory.hooks.factorize.tapAsync(
          pluginName,
          (resolveData, callback) => {
            // 已经完成的逻辑...
          }
        );

        // 在编译模块时触发 将模块变成为AST阶段调用
        normalModuleFactory.hooks.parser
          .for('javascript/auto')
          .tap(pluginName,(parser) => {
            // 当遇到模块引入语句 import 时
            importHandler.call(this, parser);
            // 当遇到模块引入语句 require 时
            requireHandler.call(this, parser);
          });
      }
    );
  }
  
 // ...
复制代码

上边的代码中我们同样在 normalModuleFactory 对象上监听了一个 parser hook ,不同的是 normalModuleFactory.hooks 的 parser 属性它是一个 hookMap。

我们通过 hookMap.for('javascript/auto') 方法寻找到名为 'javascript/auto' 的 hook ,关于 parser 中 HookMap 的钩子你可以在这里查阅到

关于 parser 中的 'javascript/auto' hook,简单来说这个钩子会在 complier 对象上的 Parser 编译 js 文件时执行。

所以上边我们通过 JavaScriptParser hook 注册了相应的事件函数,当 webpack 将 js 文件转化为 AST 时会调用执行注册的监听函数。

接下来我们来看看 importHandler/requireHander 这两个函数:


// ...

function importHandler(parser) {
  parser.hooks.import.tap(pluginName, (statement, source) => {
    // 解析当前模块中的import语句
    if (this.transformLibrary.includes(source)) {
      this.usedLibrary.add(source);
    }
  });
}

function requireHandler(parser) {
  // 解析当前模块中的require语句
  parser.hooks.call.for('require').tap(pluginName, (expression) => {
    const moduleName = expression.arguments[0].value;
    // 当require语句中使用到传入的模块时
    if (this.transformLibrary.includes(moduleName)) {
      this.usedLibrary.add(moduleName);
    }
  });
}

// ...
复制代码

这里会涉及部分的 AST 相关知识,不过它很简单。

首先我们先来看看 importHandler 这个函数,它接受 paser 对象作为参数,我们通过了 parser.hooks.import.tap ,这个钩子注册的事件函数会在代码解析(将代码转化 AST )过程中遇到每个 import 语句都会调用。

比如在遇到模块内部的模块请求语句 import _ from lodash,在进行语法分析时 webpack 会将这段代码转化为这样的结构:

image.png

同时在转化完成后会调用注册的 parser.hooks.import.tap 注册的事件函数,传入:

  • statement ,上图语句中整个 ImportDeclaration 对象。

  • source ,它表示引入的模块名,比如import _ from 'lodash',它的值即是 lodash 。

函数内部的逻辑其实并不复杂,在进行模块解析时,我们注册监听函数,当解析到 import 语句时获得事件函数调用时传入的 source 值,判断当前引入模块是否存在 this.transformLibrary,如果存在,我们将它加入 this.usedLibrary 中去

requireHandler 函数和 importHandler 逻辑大同小异,不过它内部是针对于 require 引入语句的处理,达到的效果是相同的。

AST 转化的结构你可以在代码中打印出来,也可以在这个在线网站查看

在所有模块解析完毕后,this.usedLibrary 中就保存代码中使用到的外部依赖库名称了。同时不要忘记它是一个 Set 对象,所以内部是不会重复的。

注入 CDN 脚本

上边我们通过一系列模块分析时插入的逻辑,已经完成了:

  • 将匹配到的模块转化为外部依赖 externals 。

  • 仅保留代码中使用到传入的外部依赖库名称 this.usedLibrary 。

接下来让我们来完成最后一步,根据 this.usedLibrary 的内容在生成最终的 html 文件时插入对应使用到的模块的 CDN 外部链接。

我们先来看一看实现的代码:

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


  // ....
  apply() {
    // ...
    compiler.hooks.compilation.tap(pluginName, (compilation) => {
      // 获取HTMLWebpackPlugin拓展的compilation Hooks
      HtmlWebpackPlugin.getHooks(compilation).alterAssetTags.tap(
        pluginName,
        (data) => {
          // 额外添加scripts
          const scriptTag = data.assetTags.scripts
          this.usedLibrary.forEach((library) => {
            scriptTag.unshift({
              tagName: 'script',
              voidTag: false,
              meta: { plugin: pluginName },
              attributes: {
                defer: true,
                type: undefined,
                src: this.options[library].src,
              },
            });
          });
        }
      );
    });
    // ...
  }
复制代码

这里我们借助了 HtmlWebpackPlugin 提供给 compilation 对象上额外拓展的 alterAssetTags 钩子。

在最终生成 html 文件时,循环 this.usedLibrary ,循环外部依赖的 CDN 链接,添加 CDN 链接进入 html 文件中。

关于 data.assetTags.scripts

image.png

这是它的打印结果,这里目前仅保存了一个 script 脚本即是根据项目入口文件打包生成的 js 文件。

这里我们上述代码做的便是往这个对象中添加对应 CDN 标签,在打包结束后便 html 文件中就会根据 assetTags.scripts 内容生成对应 script 标签。

写到这里 ExternalsWebpackPlugin 我们已经实现了它的所有逻辑,其实它也并不是很难对吧。

收官

接下来我带你去验证一下我们的 ExternalsWebpackPlugin 。

code.png

我们使用这样一份 webpack.config.js ,在 ExtendsPlugin 中传入 vue 、lodash 两个库的配置。

同时在入口文件 src/entry1.js 中,仅仅引入 lodash 模块:

import _ from 'lodash';
复制代码

此时让我们重新来运行打包命令来看一看结果:

code.png

这是打包输出的 index.html 文件,我们在插件配置中配置了两个 CDN ,但是因为代码内部并没有使用到 vue ,所以最终的 html 文件中仅挂载了使用到的 lodash 的 CDN 链接。

code.png

这是我截取了部分 webpack 打包后生成的 js 文件内容,可以看到针对于 lodash 模块我们成功的达到了想要的效果,它并没有编译 lodash 进去最终输出结果中而是以外部依赖模块的形式去 window['_'] 上去寻找。

至此,ExternalsWebpackPlugin 大功告成!

写在结尾

首先感谢每一位可以看到这里的同学,Webapck 插件开发对于大多数前端开发者来说是陌生的。

也许首次接触插件开发的小伙伴的同学会对文章中的很多内容感到陌生,但是换个角度来说所谓学习不正是一个从无到有的过程。

在庞大的前端工程化面前,一篇短短的 Webpack 文章是远远不够的。

笔者的愿望更多的是希望大家以此为起点,投入实践中寻找业务中的优化点。

后续我在会在 从原理玩转 Webpack 专栏中为大家带来更多前端工程化的实践,有兴趣的朋友持续关注。

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改