前言
在上篇文章 webpack loader实战——手撕8个常用loader 中,我们主要介绍了 loader
的实现。 loader
主要做的事情是针对某一类型的文件进行特定的处理,在 webpack
中, plugin
主要做的事情围绕着整一个构建的过程。基于 tapable
的钩子机制,开发者可以在众多构建环节中注册相关的事件,依托于 webpack
提供的构建上下文,来对打包结果进行一些处理。
plugin简要介绍
每一个 plugin
都是一个类,主要关注这个类的两个方法,一个是构造函数 constructor
,还有一个是 apply
方法。在 constructor
中可以获得配置 plugin
时传入的参数,而 apply
方法则是 webpack
会调用的方法。每个插件都有两个重要的钩子,一个是compiler钩子,还有一个是compilation钩子。
Compiler
模块是 webpack 的主要引擎,它通过 CLI 或者 Node API 传递的所有选项创建出一个compilation
实例。
Compilation
模块会被Compiler
用来创建新的compilation
对象(或新的build
对象)。compilation
实例能够访问所有的模块和它们的依赖(大部分是循环依赖)。 它会对应用程序的依赖图中所有模块, 进行字面上的编译(literal compilation
)。 在编译阶段,模块会被加载(load
)、封存(seal
)、优化(optimize
)、 分块(chunk
)、哈希(hash
)和重新创建(restore
)。
简单的理解是, complier
是 webpack
构建启动时产生的,只有一个,它可以访问构建的各种配置等等。 compilation
是对资源的一次构建,可以有多个,它可以访问构建过程中的资源。下面以 complier
为例,介绍钩子事件时如何注册的。
从时序的角度来看, complier
钩子有三种类型,分别是同步钩子、异步钩子、异步 promise
钩子。注册的写法分别如下:
// 同步插件 class TestWebpackPlugin { apply(compiler) { compiler.hooks.xxx.tap('TestWebpackPlugin',()=>{ console.log('TestWebpackPlugin') }) } }
//异步插件 class TestWebpackPlugin { apply(compiler) { compiler.hooks.xxx.tap('TestWebpackPlugin', (complication, callback) => { console.log('TestWebpackPlugin') callback() }) } }
//异步promise插件 class TestWebpackPlugin { apply(compiler) { compiler.hooks.xxx.tapPromise('TestWebpackPlugin', (complication) => { return new Promise((resolve) => { resolve() }) }) } }
从调用顺序的角度来看,分为串行钩子( AsyncSeriesHook
)和并行钩子( AsyncParallelHook
)
比如 emit 就是一个串行钩子,我们如下注册:
class TestWebpackPlugin { apply(compiler) { compiler.hooks.emit.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(1) callback() },3000) }) compiler.hooks.emit.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(2) callback() },2000) }) compiler.hooks.emit.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(3) callback() },1000) }) } } module.exports = TestWebpackPlugin
可以在控制台上看到他是顺序输出的
而 make 是一个并行钩子,我们如下注册:
class TestWebpackPlugin { apply(compiler) { compiler.hooks.make.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(1) callback() },3000) }) compiler.hooks.make.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(2) callback() },2000) }) compiler.hooks.make.tapAsync('TestWebpackPlugin', (compilation,callback) => { setTimeout(()=>{ console.log(3) callback() },1000) }) } } module.exports = TestWebpackPlugin
结果如下,可以看到它并没有等待上面一个钩子的完成才触发,而是并行触发。
断点调试
plugin
相对 loader
来说, webpack
提供的参数复杂很多,死记硬背显然不是一个好的方法。这里介绍一个断点调试的方法,首先 package.json
中加一条命令:"debug": "node --inspect-brk ./node_modules/webpack-cli/bin/cli.js"
,它的意思大概是在我执行 webpack
的时候,在第一行打上一个断点,然后我们在相关的插件流程上打上 debugger
,如下:
class TestWebpackPlugin { apply(compiler) { debugger compiler.hooks.make.tapAsync('TestWebpackPlugin', (compilation,callback) => { debugger }) } } module.exports = TestWebpackPlugin
然后执行 npm run debug
,打开浏览器任意页面的控制台,找到这里个图标,点击进去
就可以进行快乐的调试了,对于某一个参数的具体内容,可以到时候按需的断点调试来获取
hello plugin
那么话不多说,先直接来开发一个我们自己的 plugin
。个人觉得开发一个 plugin
,首先要想清楚的问题是,这个插件是在什么时候执行的。在我学习以及开发的过程中, emit
(资源输出前)和 afterEmit
(资源输出后)这两个钩子是最经常用到的。当然我的意思不是这两个钩子就是开发 plugin
最常用到的两个钩子,而是想说我们想开发一个 plugin
,必须先想好执行的时机,选择好注册事件的钩子。
这个钩子要做的是,对于我们输出的资源,希望打上一些注释。我们上次在 loader
也做过同样的事情,但是 loader
处理过后的资源可能会被合并、压缩。对于注释可能会被删掉。所以我们选择钩子来重新做一遍,而这个钩子的执行时机应该是要所有压缩混淆的工作做完之后再执行,所以注册 emit
钩子是一个不错的选择。
这个插件主要做下面几件事情:
- 注册
emit
钩子 - 获取即将输出的资源,筛选出
js
、css
文件 - 把注释内容添加到文件中
- 输出资源
下面可以配合代码,大概来看一下:
class BannerWebpackPlugin { // 获取参数 constructor(options = {}) { this.options = options } apply(compiler) { compiler.hooks.emit.tap('BannerWebpackPlugin', (compilation) => { // 获取即将输出的资源 const { assets } = compilation //过滤文件,只处理css、js文件 const files = Object.keys(assets).filter(filename => { const exts = ['js', 'css'] const arr = filename.split('.') const fileExt = arr[arr.length - 1] return exts.includes(fileExt) }) // 生成注释 const prefix = `/* * Author:${this.options.name} */` files.forEach(file => { // 获取资源内容 const source = assets[file].source() const newContent = prefix + source // 重写资源对象的source和size方法 assets[file] = { source() { return newContent }, size() { return newContent.length } } }) }) } } module.exports = BannerWebpackPlugin
通过 constructor
方法的参数可以获取到配置插件的参数,以及可以看到对资源进行变更主要是修改资源对象的 source
和 size
方法,来看看打包结果:
/* * Author:webpack *//******/ (() => { // webpackBootstrap /******/ "use strict"; var __webpack_exports__ = {}; /******/ })() ;
analyze-webpack-plugin
下面我们来实现一个分析打包输出后的资源大小的插件,并将结果输出到一个 markdown
文件中。思路如下:
- 注册
emit
钩子,获取所有即将输出的资源文件 - 计算每个文件的大小
- 输出一个
markdown
文件
其实计算文件大小输出文件,在 loader
一样可以做,但是还是那句话, loader
处理完后,资源可能被 webpack
压缩混淆合并,所以在 loader
去实现是不准确的。具体代码如下:
class AnalyzeWebpackPlugin { apply(compiler) { // markdown表格的头部 let content = `| filename | size | | --- | --- | ` // 注册emit钩子 compiler.hooks.emit.tap('AnalyzeWebpackPlugin', (compliaction) => { const arr = [] // 获取所有即将输出的资源 Object.keys(compliaction.assets).forEach(filename => { const file = compliaction.assets[filename] // 资源大小转换为kb const obj = { filename, size: Math.ceil(file.size() / 1024) } arr.push(obj) }) // 降序 arr.sort((a, b) => b.size - a.size) arr.forEach(item => { const { filename, size } = item const str = `| ${filename} | ${size}kb |` content += str + "\n" }) // 输出markdown文件 compliaction.assets['analyze.md'] = { source() { return content }, size() { return content.length } } }) } } module.exports = AnalyzeWebpackPlugin
可以看到dist目录下多出来了一个 analyze.md
文件,内容如下:
| filename | size | | --- | --- | | assets/img-168kb.14f34915c85a5834e8d02002d36c9c7c.jpeg | 118kb | | js/main.a84b2f6d6d7f7c338677.js | 2kb | | index.html | 1kb |
之后就可以大概根据这个文件里面的内容,去分析打包后的结果以及做一些优化
clean-webpack-plugin
这个插件也是用的比较多的插件,每次打包之后,应该把上一次打包的结果删除,虽然在 webpack5
之后这个插件被内置了,只需要一句 clean:true
即可生效,不过我们这里还是自己实现一遍吧,思路如下:
- 注册
emit
钩子 - 删除旧打包的文件,如果是文件夹则递归
代码实现如下:
class CleanWebpackPlugin { apply(compiler) { // 获取输出路径 const outputPath = compiler.options.output.path // webpack提供的文件操作 const fs = compiler.outputFileSystem compiler.hooks.emit.tap('emit', (compilation) => { this.removeFiles(fs, outputPath) }) } removeFiles(fs, filePath) { // 读取目录下的内容,包括文件和文件夹 const files = fs.readdirSync(filePath) files.forEach(file => { const path = `${filePath}/${file}` const fileStat = fs.statSync(path) // 判断是否为文件夹,如果是,则递归 if (fileStat.isDirectory()) { this.removeFiles(fs, path) } else { //是文件,则删除 fs.unlinkSync(path) } }) } } module.exports = CleanWebpackPlugin
copy-webpack-plugin
这个也是平时用的比较多的插件,它的作用主要是把某个文件或者文件夹拷贝到打包后的目录里。比如模版文件 index.html
中的 icon
图标,模版文件中的资源是不参与打包过程的,那么打包完成之后如果没对这个图标进行相关的处理的话,打包后的模版文件是找不到这个图标的。所以就需要这么一个插件,将文件拷贝一份。实现大概如下:
const fs = require('fs') const childProcess = require('child_process') class CopyWebpackPlugin { constructor(options = {}) { const { from, to } = options this.from = from this.to = to } apply(compiler) { compiler.hooks.afterEmit.tap('CopyWebpackPlugin', (compliaction) => { const { from, to } = this if(!fs.existsSync(from)) { throw new Error('from is not found') } childProcess.execSync(`cp -r ${from} ${to}`) }) } } module.exports = CopyWebpackPlugin
//webpack.config.js new CopyWebpackPlugin({ from: resolve(__dirname, './public/static'), to: resolve(__dirname, './dist/static') })
这里是用到了 afterEmit
这个钩子,表示为在打包资源输出之后执行,其实使用 emit
钩子也可以。通过插件使用时配置的参数,拿到 from
跟 to
路径,通过 shell
命令的方式去拷贝资源。通过资源的拷贝,打包生成后的模版文件也能正常引入 icon
文件了。
define-webpack-plugin
通过不同环境,给代码注入不同的变量也是一个常见的需求。比如测试环境的接口地址是 api.dev.com
,生产环境的接口地址是 api.pro.com
。实现思路如下:
- 在
emit
钩子注册,找到所有的入口js
文件 - 在入口
js
文件中注入配置中填写的变量 - 输出文件
大概代码实现如下
class DefineWebpackPlugin { constructor(options = {}) { this.options = options } // 注入变量 genDefine() { const {options} = this let str = '' Object.keys(options).forEach(key=>{ const value = options[key] str += `window.${key} = ${JSON.stringify(value)};` }) return str } apply(compiler) { compiler.hooks.emit.tap('DefineWebpackPlugin', (compilation) => { // 找到所有入口 const entrypoints = compilation.entrypoints for(let entrypoint of entrypoints) { // 找到相关的chunk const chunks = entrypoint[1].chunks chunks.forEach(chunk=>{ // 找到相关的文件 const files = chunk.files files.forEach(file=>{ const assets = compilation.assets // 获取文件的内容 const content = assets[file].source() const define = this.genDefine() // 用新内容去替换 const newContent = `${define}${content}` assets[file] = { source() { return newContent }, size() { return newContent.length } } }) }) } }) } } module.exports = DefineWebpackPlugin
在配置文件中填入如下代码
new DefineWebpackPlugin({ BASE_URL: 'http://api.pro.com', ENV: 'production' })
打包的效果如下:
window.BASE_URL = "http://api.pro.com";window.ENV = "production";/* * Author:webpack *//******/ (() => { // webpackBootstrap /******/ "use strict"; var __webpack_exports__ = {}; /******/ })() ;
html-webpack-plugin
这是一个大多数项目都会用到的插件,它主要会帮你生成一个模版 html
文件,并把入口的文件导入到这个 html
文件中。我们这里实现的只是一个简版的插件,想要了解更多的可以去 html-webpack-plugin 这个仓库看看。我们这个插件主要实现如下内容:
- 支持使用配置提供的模版,也可以使用插件内置的模版
- 支持配置标题
实现思路具体如下:
- 生成一个
html
文件 - 在
emit
钩子注册事件,找到入口文件,把入口文件通过标签的方式插入到html
文件中 - 将这个
html
文件吐出到资源目录
这里为了方便操作 html
字符串,引用了一个库——jsdom,它提供了很多类似 dom
的方法,让我们可以很便捷的在 node
环境下操作 html
文件。插件具体实现如下:
const { resolve } = require('path') const fs = require('fs') const jsdom = require('jsdom') const { JSDOM } = jsdom class HtmlWebpackPlugin { constructor(options = {}) { // 获取模版文件的地址 this.template = options.template || resolve(__dirname, './template.html') this.title = options.title || 'Document' } apply(compiler) { compiler.hooks.emit.tap('HtmlWebpackPlugin', (compilation) => { // 找到入口文件 this.entryFiles = this.getEntryFiles(compilation) // 可以通过这种方式给资源输出目录增加一个文件 compilation.assets['index.html'] = this.genTemplate() }) } genTemplate() { const { template, title, entryFiles } = this // 读取模版文件 let content = fs.readFileSync(template, { encoding: 'utf8' }) // 生成类dom对象 const dom = new JSDOM(content) // 获取文档对象 const document = dom.window.document // 设置文档标题 document.title = title // 创建标签并插入 entryFiles.forEach(file => { const script = document.createElement('script') script.src = file script.setAttribute('defer',true) document.querySelector('head').appendChild(script) }) // 生成新的内容字符串 content = `<!DOCTYPE html>\n`+document.querySelector('html').outerHTML return { source() { return content }, size() { return content.length } } } // 获取所有入口文件 getEntryFiles(compilation) { const entrypoints = compilation.entrypoints const entryFiles = [] for (let entrypoint of entrypoints) { const chunks = entrypoint[1].chunks chunks.forEach(chunk => { const files = chunk.files files.forEach(file => { entryFiles.push(file) }) }) } return entryFiles } } module.exports = HtmlWebpackPlugin
md2html-webpack-plugin
在写一些文档的时候,我们常常使用 markdown
的形式去写,但是 markdown
直接看起来并不是很好看,所以通常会被转成富文本,也就是 html
文档,再配上一些样式,可读性就会变得很高。当你在搭建一个静态文档站点的时候,不妨试一下这种方式。
所以我们需要实现这么一个插件——开发过程中只需要写 markdown
文档,在打包的时候会帮我们生成 html
文档。也就是说核心的是 markdown
转 html
的功能,这里是用到了marked这个库来帮我们实现。
有了上面几个插件的内容做铺垫,我想大家都很容易的想出如下的实现思路:
- 从配置拿到需要转换的
markdown
文档 - 调用
marked
转换成html
字符串 - 将
html
字符串配合一些样式文件输出到html
文件中 - 输出文件到
dist
目录
配置如下:
new Md2htmlWebpackPlugin({ from: resolve(__dirname, './src/docs'), to: resolve(__dirname, './dist/docs') })
具体代码实现如下:
<!-- template.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>#title#</title> </head> <body> #content# </body> </html>
/* theme.css */ /* theme.css */ h1 { font-size: 40px; font-weight: 700; } h2 { font-size: 28px; font-weight: 400; } table,td,tr,th{ border: 1px solid grey; border-collapse: collapse; }
const fs = require('fs') const { marked } = require('marked') const { resolve } = require('path') const jsdom = require('jsdom') const { JSDOM } = jsdom class Md2HtmlWebpackPlugin { constructor({ from, to } = {}) { this.from = from this.to = to } apply(compiler) { compiler.hooks.emit.tap('Md2HtmlWebpackPlugin', (compilation) => { const { from, to } = this if (!fs.existsSync(from)) { throw new Error('from is not found') } const files = fs.readdirSync(from) files.forEach(file => { const path = `${from}/${file}` // 调用marked转换 const html = marked(fs.readFileSync(path, { encoding: 'utf8' })) let template = fs.readFileSync(resolve(__dirname, './template.html'), { encoding: 'utf8' }) // 替换模版文件的内容 template = template.replace('#title#', file).replace('#content#', html) const theme = fs.readFileSync(resolve(__dirname, './theme.css'), { encoding: 'utf8' }) const dom = new JSDOM(template) const document = dom.window.document const style = document.createElement('style') // 内联的方式插入样式 style.innerHTML = `${theme}` document.querySelector('head').appendChild(style) template = `<!DOCTYPE html>${document.querySelector('html').outerHTML}` const arr = to.split('/') const toName = arr[arr.length - 1] // 输出资源 compilation.assets[`${toName}/${file.replace('.md', '.html')}`] = { source() { return template }, size() { return template.length } } }) }) } } module.exports = Md2HtmlWebpackPlugin
可以看到打包目录下已经多了一个docs文件夹:
这个 main.html
其实就是我们的 main.md
生成的,由内容
# 前端框架 - vue - react ## webpack 1. loader 2. plugin | filename | size | | --- | --- | | js/main.7ae99cbb2f3759f5dda8.js | 1kb | | index.html | 1kb |
生成了如下的 html
文件:
最后
上面我们实现了几个在开发过程中常见的 webpack
插件,在了解了 webpack
插件大概能干什么事情、在什么阶段干什么事情之后,或许我们在以后遇到一些问题时,能多一种解法。我觉得这就是学习的意义吧,不断为自己的武器库增添新的武器,在遇到不同的敌人时能有不同的武器去快速地、高效地应对。