webpack实战——手写常用plugin

1,670 阅读5分钟

前言

在上篇文章 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)。

简单的理解是, complierwebpack 构建启动时产生的,只有一个,它可以访问构建的各种配置等等。 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

可以在控制台上看到他是顺序输出的

image.png

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

结果如下,可以看到它并没有等待上面一个钩子的完成才触发,而是并行触发。

image.png

断点调试

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 ,打开浏览器任意页面的控制台,找到这里个图标,点击进去

image.png

再点击这个按钮,跳到下一个断点

image.png

就可以进行快乐的调试了,对于某一个参数的具体内容,可以到时候按需的断点调试来获取

image.png

hello plugin

那么话不多说,先直接来开发一个我们自己的 plugin 。个人觉得开发一个 plugin ,首先要想清楚的问题是,这个插件是在什么时候执行的。在我学习以及开发的过程中, emit (资源输出前)和 afterEmit (资源输出后)这两个钩子是最经常用到的。当然我的意思不是这两个钩子就是开发 plugin 最常用到的两个钩子,而是想说我们想开发一个 plugin ,必须先想好执行的时机,选择好注册事件的钩子。

这个钩子要做的是,对于我们输出的资源,希望打上一些注释。我们上次在 loader 也做过同样的事情,但是 loader 处理过后的资源可能会被合并、压缩。对于注释可能会被删掉。所以我们选择钩子来重新做一遍,而这个钩子的执行时机应该是要所有压缩混淆的工作做完之后再执行,所以注册 emit 钩子是一个不错的选择。

这个插件主要做下面几件事情:

  • 注册 emit 钩子
  • 获取即将输出的资源,筛选出 jscss 文件
  • 把注释内容添加到文件中
  • 输出资源

下面可以配合代码,大概来看一下:

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 方法的参数可以获取到配置插件的参数,以及可以看到对资源进行变更主要是修改资源对象的 sourcesize 方法,来看看打包结果:

/*            
* 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 钩子也可以。通过插件使用时配置的参数,拿到 fromto 路径,通过 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 文档。也就是说核心的是 markdownhtml 的功能,这里是用到了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文件夹:

image.png

这个 main.html 其实就是我们的 main.md 生成的,由内容

# 前端框架
 - vue
 - react

## webpack
  1. loader
  2. plugin

| filename | size |
| --- | --- |        
| js/main.7ae99cbb2f3759f5dda8.js | 1kb |
| index.html | 1kb |

生成了如下的 html 文件:

image.png

最后

上面我们实现了几个在开发过程中常见的 webpack 插件,在了解了 webpack 插件大概能干什么事情、在什么阶段干什么事情之后,或许我们在以后遇到一些问题时,能多一种解法。我觉得这就是学习的意义吧,不断为自己的武器库增添新的武器,在遇到不同的敌人时能有不同的武器去快速地、高效地应对。