从Webpack本质开始(三):手写plugin

这个是探究 Webpack 本质的系列文章,会详细讲解如何手写一些源码如 Webpack, loader, plugin 等等,本文主要讲解的是如何手写 plugin

webpack 流程

在手写 webpack plugin 之前,需要了解一下的是 webpack 整个流程是怎么样的,为了方便起见,这里用几张图来概括下

  • 整体的流程图
  • compilation 对象

为什么只是用这两张图呢,因为这是 webpack 运行流程中最重要的两个部分。我们对 webpack 的印象是什么,基本上 webpack 是以下这么个大致的流程

  1. 整合配置 options
  2. 实例化 compiler 对象
  3. 实例化所有的插件(这里包括内置的以及自己配置的),调用其 apply 方法
  4. 返回 compiler 对象
  5. 执行 complier 对象的 run 方法

而在 webpack 的内部(这里主要说它的 compiler 对象做了啥事),它会经历大致如下的流程(以 SingleEntryPlugin 为例)

  1. 调用 beforeRun 钩子
  2. 调用 run 钩子
  3. 调用 compile 方法(调用 beforeCompiler 钩子 -> 调用 compile 钩子 -> 实例化 compilation 对象 -> 调用 thisCompilation 以及 compliation 钩子 -> 执行 make 钩子 -> seal 封装 -> 调用 afterCompile 钩子)
  4. 编译结束后会 run 方法会执行 onCompiled 回调,在这个回调中会先调用 shouldEmit 钩子做判断,成功执行 emitAssets 然后调用 emit 钩子输出文件,失败则调用 done 钩子结束

再细一点,我们深入到 compilation 里,看看在实例化 compilation 对象里面做了啥

  1. 调用 addEntry 钩子,调用完毕后执行其回调通知 make 钩子插件编译完成
  2. 调用 addEntry 钩子时会执行 _addModuleChain 方法(根据 dep 拿到 moduleFactory -> 调用 moduleFactory.create 拿到 module -> 调用 buildModule 方法构建模块依赖 -> 调用 afterBuild 方法递归用模块工厂创建依赖的模块)

从以上的描述我们可以看到,webpack 的一个基本流程就是 读取配置, 实例化 compiler 时会调用各种钩子以及回调函数, 最后再去挂载相应的插件

所以插件是怎么回事

webpack 中,如果要写自己的插件,其实就是利用 发布订阅模式来写的。因为在 webpack 里面有很多的事件,我们通过 “监听” 这些事件后去执行相应的回调就好,这样说是不是很像前端页面中的交互逻辑呢。是的,基本上原理是相似的。所以插件一般都是这么写

class TestPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('TestPlugin', function (compilation) {
console.log('compilation', compilation);
})
}
}

// 导出 Plugin
module.exports = TestPlugin;

在 webpack 使用的钩子中,很大部分都是依赖 tapable 这个库,而这个库的作用就是为 plugin 创建钩子用的,它里面会有类似 XXXHook 类型的钩子,有同步的也有异步的。里面的 tap 方法就是用于 订阅事件。代码 compiler.hooks.compilation.tap() 就是当 compilation 这个钩子被触发时就会去执行 tap 里面的回调。

手写文件列表插件

这个插件主要就是将 webpack 最后 emit 时输出到终端的内容做一个 “拦截”,然后将其输出到一个设置的 filename 文件中。

注意这里还要安装 html-webpack-plugin 插件

class FileListPlugin {
constructor({ filename }) {
this.filename = filename
}

apply(compiler) {
// 在发射文件这个时候截取
compiler.hooks.emit.tap('FileListPlugin', (compilation) => {
// 当前打包的资源会放在 assets 属性上
const { assets } = compilation
let content = `## 文件名 大小 \r\n`
for (const [filename, stat] of Object.entries(assets)) {
content += `- ${filename} ${stat.size()}\r\n`
}
// 为 assets 添加一个 filename 的字段,在 webpack 接下来的处理中为输出相应的文件做准备
assets[this.filename] = {
source() { return content },
size() { return content.length }
}
})
}
}

module.exports = FileListPlugin

这里我们就知道了为什么要知道 webpack 的流程才能写插件了,如果要做一个文件列表的插件,那么就应该是在 文件发射之前做,而不是在 webpack 调用其他的钩子的时候做

手写内联 webpack 插件

这个所谓的内联插件实际上的功能就是将外链的标签变成内联的,主要体现在

  • link 标签变成 style 标签,然后里面填充的是引入的 css 的内容
  • script 标签填充引入的 script 文件内容
  • 删除掉已经生成的没必要引入的文件

首先还需要安装三个插件 css-loadermini-css-extract-plugin 以及 html-webpack-plugin/next

yarn add css-loader mini-css-extract-plugin html-webpack-plugin/next

webpack 配置如下

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const InlineSourcePlugin = require('./plugins/inlineSource-plugin')
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: 'main.css'
}),
new InlineSourcePlugin({
test: /\.(js|css)/
}),
new HtmlWebpackPlugin({
template: './src/index.html'
}),
]
}

然后是关键的 InlineSourcePlugin 插件

// 因为最后是改变 html 的 tag 然后插入到 html 中的,所以这里会使用到
// html-webpack-plugin 提供的一些 hooks 来供我们使用
// 在 html-webpack-plugin 的基础上开发插件
const HtmlWebpackPlugin = require('html-webpack-plugin')

class InlineSourcePlugin {
constructor({ test }) {
// 用于匹配文件的正则,这里主要寻找以 js 或 css 结尾的文件
this.reg = test
}

// 处理一个 tag 的数据
processTag(tag, compilation) {
let newTag, url
const { tagName, attributes } = tag

if (tagName === 'link' && this.reg.test(attributes.href)) {
newTag = {
tagName: 'style',
attributes: { type: 'text/css' }
}
url = attributes.href
}

if (tagName === 'script' && this.reg.test(attributes.src)) {
newTag = {
tagName: 'script',
attributes: { type: 'application/javascript' }
}
url = attributes.src
}

if (url) {
// 标签里面插入对应文件的源码
newTag.innerHTML = compilation.assets[url].source()
// 既然都把源码怼 html 上了,就应该删除对应的文件
delete compilation.assets[url]
return newTag
}

return tag
}

// 处理引入 tags 的数据
processTags(data, compilation) {
const headTags = []
data.headTags.forEach(headTag => {
// 处理引入 css 的 link 标签
headTags.push(this.processTag(headTag, compilation))
})

const bodyTags = []
data.bodyTags.forEach(bodyTag => {
// 处理引入 script 标签
bodyTags.push(this.processTag(bodyTag, compilation))
})

return { ...data, headTags, bodyTags }
}

apply (compiler) {
compiler.hooks.compilation.tap('InlineSourcePlugin', (compilation) => {
console.log('The compiler is starting a new compilation...')

// 静态插件接口 | compilation | HOOK NAME | register listener
// 使用 alterAssetTagGroups 这个 hooks
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(
'alterPlugin', // 为堆栈取名
(data, cb) => {
// 处理 html 的某些 tags, 这里需要做处理的是 css 和 js
const newData = this.processTags(data, compilation)
// 返回 data
cb(null, newData)
}
)
})
}
}

module.exports = InlineSourcePlugin

其实这里整体的逻辑很简单,就是找出 index.html 中的 link 标签以及 script 标签,将其的 innerHTML 替换为对应文件的源码而已。从 compliation 这个对象里面找对应的字段然后做对应的修改即可。但是这里的重点是,应该在哪里或者说是哪个钩子里面去用?这里就用到了 html-webpack-plugin 这个插件了,众所周知这个插件的作用是 简化 html 文件的创建的,而且这个插件本身也提供了一些钩子给开发者在基于这个插件的基础上再开发插件。去看 html-webpack-plugin 的官方文档,在 plugin.js 这个小标题下可以找到对应插件的写法,上面就是它说明的提供的一些钩子。不过这里不是用 beforeEmit 这个钩子,因为我们想要的是要修改标签,理所当然的就是跟 tag 相关的 hooks 了。这里有两个 tag,一个是 alterAssetTags,另一个是 alterAssetTagGroups,这里我们选择的是 alterAssetTagGroups,因为我们重点是在 head 以及 body 上找标签,而且为了之后能修改更多的标签,选这个比较靠谱。选择了之后将 beforeEmit 改为 alterAssetTagGroups 就可以整活了, 后面就是根据 data 的属性去做相应的修改, processTags 完成之后调用 cb 回调,代码修改执行完毕。

这里还有一张 html-webpack-plugin hooks 图,以供参考

手写打包自动发布插件

这个插件的主要功能就是将打包后的文件(就是 dist 目录下的)放到 cdn 服务器上。然后页面中就直接引用 cdn 的文件就可以了。这里使用免费的 七牛云 来做

首先需要安装 qiniu

yarn add qiniu

webpack 配置文件设置 outputpublicPath

output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: 'http://test.strugglebak.com' // 在你的七牛云的 bucket 上设置的外链域名
},

然后写一个 upload-plugin.jsplugin 目录,webpack 配置文件引入和使用

const UploadPlugin = require('./plugins/upload-plugin')
...
module.exports = {
...
plugins: [
new UploadPlugin({
bucket: 'ststatic', // 你的七牛云对象存储中创建的 bucket 名字
domain: 'test.strugglebak.com' // 在你的七牛云的 bucket 上设置的外链域名
})
]
}

然后是 upload-plugin.js 文件

const path = require('path')
const fs = require('fs')
const qiniu = require('qiniu')
class UploadPlugin {
constructor({bucket, domain}) {
// 初始化七牛云参数以及内部变量
this.init({bucket, domain})
}

apply(compiler) {
// 上传文件是多个,且返回的是 promise,所以这里用 tapPromise
compiler.hooks.afterEmit.tapPromise(
'UploadPlugin',
(compliation) => {
const { assets } = compliation
const promises = []
for (const [filename, stat] of Object.entries(assets)) {
promises.push(this.upload(filename))
}

return Promise.all(promises)
}
)
}

// 上传文件
upload(filename) {
// 代码参考在 https://developer.qiniu.com/kodo/sdk/1289/nodejs#form-upload-file
return new Promise((resovle, reject) => {
// 要上传的本地文件目录为 dist 目录下的
const localFile = path.resolve(__dirname, '../dist', filename)
// 使用表单方式上传本地文件
this.formUploader.putFile(
this.uploadToken, filename, localFile, this.putExtra,
(respErr, respBody, respInfo) => {
if (respErr) {
reject(respErr)
}
if (respInfo.statusCode == 200) {
resovle(respBody)
}
})
})
}

init({bucket, domain}) {
// 读取本地的 qiniu 的密钥文件,这样比较安全
const keyJSON = fs.readFileSync(
path.resolve(__dirname, '../', 'qiniu-key.json'),
'utf8'
)
const { accessKey, secretKey } = JSON.parse(keyJSON)
const mac = new qiniu.auth.digest.Mac(accessKey, secretKey)
const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket })
const config = new qiniu.conf.Config()

this.uploadToken = putPolicy.uploadToken(mac)
this.formUploader = new qiniu.form_up.FormUploader(config)
this.putExtra = new qiniu.form_up.PutExtra()
this.domain = domain
}
}

module.exports = UploadPlugin

这里的整体逻辑很简单,就是先初始化 qiniu 的一些参数,然后利用其 formUploader 对象进行表单方式的文件上传。而因为是在 发射文件之后 才采取的操作,所以这里就使用 afterEmit 这个 hooks,传的参数里面 compilation 对象的 assets 属性有对应的文件名和其对应的内容。拿到这个文件路径之后就将其 push 到一个 promise 数组里面,最后再整体返回(因为这里使用的是 tapPromise)。在文件上传代码的核心部分,就是利用 qiniu 表单上传代码 来做的,本质上就是返回了一个 promise。在 formUploader.putFile 这个函数中,有几个参数

  • uploadToken: upload 的 token,主要根据 accessKeysecretKey 生成的,用于身份的验证
  • filename: 这个相当于文件的 key
  • localFile: 这个要上传的本地文件的 value,就是文件路径,与上面的 filename 对应
  • putExtra: qiniu.form_up.PutExtra() 对象(意义不明)

这里主要 resolve 的是 respBody 这个数据。这样整体的逻辑就算完成。

补充(在七牛云添加 cdn)

这里可能有些小伙伴不知道如何在七牛云添加 cdn 域名。下面我来说下步骤

  • 首先你需要有一个七牛云账号,在注册完账号后,点击主页的 管理控制台 进入控制台

  • 进入 管理控制台 后,点击左边的 cdn

  • 然后点击 域名管理

  • 再点击 添加域名

  • 然后在 加速域名 那块输入要加速的域名

    这里需要注意的是,你目前的域名已经注册并且备过案了!!!,比如我的 strugglebak.com 这个域名是备过案的才能用。
    我这里输入的要加速的域名是 test.strugglebak.com,其中 test 表示 主机记录,你也可以随便取啥名

  • 再点击 创建

  • 跳回到 域名管理 上,鼠标悬浮到你刚创建的域名上(test.strugglebak.com),复制 CNAME

  • 然后在自己的服务器在对应的域名上添加 解析记录(因为我这里通常就是用腾讯云小程序搞的,所以截图是小程序的手机截图)

    点击 域名解析

    点击 添加解析记录

    然后填写 主机记录 以及 记录值

    其中 test 对应你设置的七牛云 bucket 的外链,而 记录值 就是 CNAME

  • 最后在你的七牛云 对象存储 中,在对应的 bucket 中设置外链

如果最后看到 cdn 外链的下面的图,则说明设置成功

所以当你在访问 test.strugglebak.com/xxx文件时,就相当于在访问 test-strugglebak-com-idva9dm.qiniudns.com/xxx文件,效果就是会先访问你的服务器,然后再转到七牛的服务器上访问文件。

总结

由以上的内容我们知道了,在写一个 webpack 的插件之前,最重要的是要了解一个流程是怎么走的,都是利用 webpack 的钩子或者其他插件本身的钩子来进行编写。注意 webpack 插件的本质是利用了 发布订阅 的模式。我们订阅了这个事件后,执行对应的回调,并将执行回调的结果再传回给 webpack,最终结果会体现在打包的文件上。这样 webpack 就完成了对插件的加载以及处理。

另,👉项目测试仓库戳这里

参考链接

Webpack 源码研究