从零开始的Webpack原理剖析(七)——webpack插件
前言
为什么需要插件呢?
- 仅仅靠
webpack.config.js
的配置,无法满足打包需求; - 使用了插件,能够按照我们的想法,任意改变打包的最终结果;
前文也提到过,webpack
的内部其实也是用到了大量的插件来实现的,我们利用插件的特性,通过注册不同的钩子,使得自定义钩子在webpack
各种编译构建流程中被触发执行。插件比loader
更加需要了解webpack
的一些底层原理,使用了正确的钩子,才能编写出高质量的插件。
创建插件
这里我们再简单的复习一下之前提到过的,插件是如何被创建:
- 插件是一个类
- 类里边有一个定好的
apply
实例方法,并且参数为compiler
// 插件的基本结构应该是长这个样子的
class Plugin {
constructor (options) {
this.options = options
}
apply (compiler) {
}
}
module.exports = Plugin
然后,我们再写一个最简单的插件,顺便将基本的文件目录搭建:

- 首先执行
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
钩子被触发的时候,成功执行了:

编写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
命令,查看终端输出:

这里说明一下,为啥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)
或者打印其他的不理解的东西,看一看具体是什么。
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

编写一个自动外链外部库的插件
接下来,我们 实现一个相对来说比较复杂的自动外链插件。一个插件,不会平白无故产生,肯定是有需求和背景的,相信大家的项目中,一定用过jquery
或是lodash
之类的外部库,那么如果我们不做任何处理,直接在文件中使用,在打包的时候,webpack
就会把jquery
和lodash
的代码一起打包进来,造成包的体积边的非常大,我们可以试着看一下:
// 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
文件,我们可以发现jquery
和lodash
的源码全都被打包进去了,那么有什么办法,可以不把这两个外部库打包进最终生成的文件中呢?

这里我们只先提一种方法,那就是利用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:

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

所以这和我们即将要写的插件,有啥关系呢?没错,现在的需求是,只想在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
语句来引入jquery
或lodash
的地方,通过某种神奇的操作,将其设置成不打包的状态; - 2、再通过与
html-webpack-plugin
的联动,把CDN
链接通过<script>
标签的形式写入,那么就达到了我们的目的了。
如何检测import
语句呢?之前我们学loader
的时候,曾经提到过AST
这个概念,那么这里其实也类似,只不过webpack
有现成的钩子提供给我们了,通过查阅官方文档,可以发现是在这个地方:
那么问题又来了,我们怎么才能拿到这个parser
呢?只有拿到了parse
,才能使用其内部的钩子。我们继续查阅文档(这里不得不再吐槽一句,webpack
文档对于新手来说,太不友好了,没有人指点,很多东西根本找不到在哪里🤷🏻♀️),发现paser
是在NormalModuleFactory Hooks
这个钩子里边回调的返回值。仿佛已经成功了一大半。
最后的一个问题就剩下,这个NormalModuleFactory Hooks
,我们又该从哪里拿到呢?通过不懈的努力,终于在文档熟悉的地方,找到了答案,没错就是我们熟悉的Compiler Hooks
上,到此,终于找到头了。(好难从文档里找啊)
接下来,有了以上思路和准备工作,我们便可以去写自动外链这个插件啦,还有些具体的细节,我们在写插件的过程中,会按顺序标注起来,一一讲解。
// 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
命令,查看打包结果可以发现,jquery
和lodash
没有被打包到最后生成的文件,而且新生成的index.html
中,也插入了2条CDN
脚本。

至此,我们的自动外链插件就已经写好啦,如果能够一路跟下来,相信你对如何编写webpack
插件已经有一个大致的方向了,起码不会像没学过一样一问三不知,也算是入门了!
结尾
通过前文及本文的学习,我们大致上知道webpack
插件写起来,到底是个啥思路了,但是想要短时间快速学习如何写webpack
插件,基本上不可能,首先市面上的插件基本上涵盖了大多数的需求,其次,想要学会写webpack
插件,就要对webpack
的源码有一定的了解(源码都将近10w行了吧...),而且里边提供了上百种钩子,不可能一个一个的去背下来,只能遇到相关需求的时候,我们单独去查阅文档,然后一点点摸索规律;像我们这种普通的CV
工程师,只需要了解思路即可,毕竟天天写业务都写不完,就算学会了这些看似高大上的知识,能用到的地方,也不是很多,真正用到的时候,再去查再去看也来得及。
转载自:https://juejin.cn/post/7173643032997134344