Skip to content

增加plugins 实际就是发布订阅,使用了tapable

使用tapable增加插件。

插件其实就是打包的时候,在各个过程中,穿插的一些钩子。

使用tapable正好可以实现

前置工作

安装tapable

在webpack开始编译时,new Compiler时,会立即执行构造方法,在这个过程中,会先发布一些任务,待会在编译的过程中,在将这些任务穿插到各个环节中。

安装tapable

shell
yarn add tapable@^1.1.3 -D

写几个不同过程中需要执行的插件

emit钩子时执行

js
// webpack\webpack-dev-3\plugin\P1-entryOption.js
class P {
    // compiler是webpack中的编译器,上面有很多钩子
    // 规定插件都有一个apply方法
    apply(compiler) {
        // 假如,在发射文件的时候,我想做什么事
        compiler.hooks.emit.tap('emit', () => {
            console.log('emit -----');
        })
        // tap 同步注册任务
    }
}

module.exports = P

compile钩子时执行

js
// webpack\webpack-dev-3\plugin\P2-compile.js
class P {
    // compiler是webpack中的编译器,上面有很多钩子
    // 规定插件都有一个apply方法
    apply(compiler) {
        // 假如,在发射文件的时候,我想做什么事
        compiler.hooks.compile.tap('compile', () => {
            console.log('compile -----');
        })
        // tap 同步注册任务
    }
}

module.exports = P

afterCompile钩子时执行

js
// webpack\webpack-dev-3\plugin\P3-afterCompile.js
class P {
    // compiler是webpack中的编译器,上面有很多钩子
    // 规定插件都有一个apply方法
    apply(compiler) {
        // 假如,在发射文件的时候,我想做什么事
        compiler.hooks.afterCompile.tap('afterCompile', () => {
            console.log('afterCompile -----');
        })
        // tap 同步注册任务
    }
}

module.exports = P

修改配置文件

js
// webpack\webpack-dev-3\webpack.config.js
let path = require('path')

// 引入自定义插件
let P1 = require('./plugin/P1-entryOption.js')
let P2 = require('./plugin/P2-compile.js')
let P3 = require('./plugin/P3-afterCompile.js')

module.exports = {
    mode: 'development',

    entry: './src/index.js',

    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist')
    },

    module: {
        rules: [
            {
                test: /\.less$/,
                // 引入自己写的loader
                use: [
                    path.resolve(__dirname, 'loader', 'style-loader'),
                    path.resolve(__dirname, 'loader', 'less-loader')
                ]
            }
        ]
    },

    // 增加插件
    plugins: [
        new P1(),
        new P2(),
        new P3(),
    ]
}

修改自定义webpack代码,配置钩子

修改compiler代码

js
// webpack\cheny-pack\lib\Complier.js

let path = require('path')
let fs = require('fs')

let babylon = require('babylon') // 把源码转换为ast语法树
let t = require('@babel/types') // 生成ast节点,可以生成各种类型
let traverse = require('@babel/traverse').default // 遍历ast语法树,替换节点
let generator = require('@babel/generator').default // 根据ast语法树,生成源代码

let ejs = require('ejs') // 模板引擎

// 为了方便 就全部用同步钩子
let { SyncHook } = require('tapable') // 定义一些钩子 同步或异步钩子

class Complier {
    constructor(config) {
        // 将配置文件挂载到实例上,所有实例都能拿到了
        // webpack.config.js
        this.config = config

        // 1. 保存入口文件的路径
        this.entryId; // './src/index.js'

        // 2. 需要保存所有模块的依赖
        // 解析文件的依赖,变成webpack打包传递的参数,key value的形式
        // key 是路径名,value就是模块代码
        this.modules = {}

        this.entry = config.entry // 入口路径
        this.root = process.cwd() // 工作路径 运行 npx cheny-pack 时候的路径

        // 声明实例时 增加一些钩子
        this.hooks = {
            entryOption: new SyncHook(), // 获取完配置文件,配置文件传参给compiler编译器,有了实例之后执行
            compile: new SyncHook(), // 开始编译之前的钩子,还没有开始编译
            afterCompile: new SyncHook(),
            afterPlugins: new SyncHook(),
            run: new SyncHook(),
            emit: new SyncHook(),
            done: new SyncHook()
        }

        // 如果配置文件需要加载一些插件,就先把这些任务注册一下
        let plugins = this.config.plugins
        if (Array.isArray(plugins)) { // 如果配置的有插件,就依次执行插件的apply方法
            plugins.forEach(plugin => {
                plugin.apply(this) // 传递的参数 是这个编译器 this
                // 通过这个this 就可以拿到各种钩子,然后在写插件的时候,想让在哪个环节执行,就调用对应的钩子
            })
        }

        // 所有的插件都已经注册完成的钩子
        this.hooks.afterPlugins.call()
    }

    run() {
        // 开始执行编译的钩子
        this.hooks.run.call()

        // 开始编译之前执行的钩子
        this.hooks.compile.call()

        // 执行,并且创建模块的依赖关系
        // 从入口开始执行
        this.buildModule(path.resolve(this.root, this.entry), true) // true 表示解析的是主模块


        // 编译之后的钩子
        this.hooks.afterCompile.call()

        // 发射一个文件 打包后的文件
        this.emitFile()

        // 发射文件之后的钩子
        this.hooks.emit.call()

        // 发射文件结束的钩子
        this.hooks.done.call()
    }

    // 创建模块的依赖关系
    buildModule(modulePath, isEntry) {
        // 模块的key是相对路径 value是模块中的代码,不过需要做一些替换
        // require都变成了webpack_require等

        let source = this.getSource(modulePath) // 模块内容
        // 模块id是一个相对路径 总路径 减去 rootPath
        // 总路径modulePath F:\code\note_code\webpack\webpack-dev-3\src\index.js
        // 工作路径this.root F:\code\note_code\webpack\webpack-dev-3
        let moduleName = './' + path.relative(this.root, modulePath) // ./src/index.js  模块id

        if (isEntry) { // 如果是主入口的话,记录一下主入口的id
            this.entryId = moduleName // 保存入口的名字
        }

        // path.dirname(moduleName)获取父路径 ./src/index.js -> ./src
        // 解析模块代码,替换相应的内容
        let { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName))


        // 把相对路径和模块中的内容对应起来
        this.modules[moduleName] = sourceCode

        // 在这里需要递归继续解析依赖项
        dependencies.forEach(dep => { // 加载附模块
            // 参数是一个绝对路径
            this.buildModule(path.join(this.root, dep), false) // false 表示不是主入口文件
        })


    }
    // 解析文件
    // 需要把source源码进行改造 返回一个依赖列表
    // 因为每一个模块里面还可能会引另外的模块,所以需要一个依赖列表
    parse(source, parentPath) { // AST 解析语法树

        let ast = babylon.parse(source) // 源码先解析成ast语法树
        // https://astexplorer.net/

        let dependencies = [] // 存放当源代码依赖的数组,即当前文件引入的第三方模块

        // 遍历ast语法树
        traverse(ast, {
            CallExpression(p) { // 调用表达式,比如 a() require() ...
                let node = p.node // 拿到对应的节点
                // 如果是require 就改为 __webpack_require__
                if (node.callee.name === 'require') {
                    node.callee.name = '__webpack_require__'

                    let moduleName = node.arguments[0].value // 取到的就是模块的引用名字 let a = require('./a') -> a
                    // 有时候我们引用的时候,没有带后缀,我们先把后缀加上
                    // path.extname('./a') === ''  path.extname('./a.js') === '.js' 
                    moduleName = moduleName + (path.extname(moduleName) ? '' : '.js') // ./a.js
                    // webpack的模板所有的路径都需要加上 ./src 所以需要把 ./a.js 变成 ./src/a.js
                    moduleName = './' + path.join(parentPath, moduleName) // ./src\a.js

                    // 把依赖的第三方模块 塞进依赖数组中
                    dependencies.push(moduleName)

                    // 替换原来的ast节点
                    node.arguments = [t.stringLiteral(moduleName)]
                }
            }
        })

        // 生成新的代码
        let sourceCode = generator(ast).code


        return { sourceCode, dependencies }
    }

    // 发射文件
    emitFile() {
        // 用转换后的数据,渲染模板
        // 拿到输出路径
        let main = path.join(this.config.output.path, this.config.output.filename)
        // 获取到模板资源
        let templateStr = this.getSource(path.join(__dirname, 'main.ejs'))

        // 根据处理过的模块 渲染新代码
        let code = ejs.render(templateStr, {
            entryId: this.entryId,
            modules: this.modules
        })

        // 如果是多入口打包,所以声明一个assets
        this.assets = {}
        this.assets[main] = code

        // 生成打包结果
        this.writeFile(main, this.assets[main])

    }

    // 生成打包结果
    writeFile(filePath, code) {
        // filePath: F:\code\note_code\webpack\webpack-dev-3\dist\bundle.js
        // 先判断文件夹是否存在,不存在的话先创建文件夹
        // path.dirname(filePath) F:\code\note_code\webpack\webpack-dev-3\dist
        let outputFolder = path.dirname(filePath)
        if (!fs.existsSync(outputFolder)) {
            fs.mkdirSync(outputFolder) // 没有文件夹先创建文件夹
        }
        // 输出打包文件
        fs.writeFileSync(filePath, code)
    }

    // 获取模块代码
    // 我们是通过路径来读取代码的,实际上可能这个代码会匹配到自己配置的loader
    // 比如 .less .js 等
    getSource(modulePath) {
        let content = fs.readFileSync(modulePath, 'utf8') // 读取到的源文件
        // 看看是否需要用自定义的loader处理
        let rules = this.config.module.rules // 拿到配置的所有loader
        // 如果路径匹配上了,就用自己的loader去加载文件

        for (let i = 0; i < rules.length; i++) {
            let rule = rules[i] // 配置的每一种规则 比如 /\.js$/ /\.css$/ /\.less$/ 等
            let { test, use } = rule // 将规则和配置的loader解构出来
            // loader是倒着执行,拿到最后的索引
            let count = use.length - 1

            if (test.test(modulePath)) { // 匹配到当前这个模块需要使用loader来处理
                // 倒着依次执行loader
                function normalLoader() {
                    let loader = require(use[count]) // 配置loader时使用的是绝对路径
                    // 递归调用loader
                    content = loader(content)
                    count-- // 倒着挨个执行loader

                    if (count >= 0) {
                        normalLoader()
                    }
                }

                // 刚进来 先执行一次,然后就递归执行loader
                normalLoader()
            }
        }


        return content
    }
}

module.exports = Complier

修改cheny-pack代码

js
#! /usr/bin/env node

// webpack\cheny-pack\bin\cheny-pack.js
// 最顶部的注释表示,这个文件的使用node运行

// 1. 需要找到当前执行名的路径,拿到webpack.config.js
let path = require('path')
// process.cwd() 可以拿到执行命令时的路径 npx cheny-pack
let config = require(path.resolve(process.cwd(), 'webpack.config.js'))

// 2. 声明一个类,来编译配置文件
let Compiler = require('../lib/Complier.js')
let complier = new Compiler(config)

// 调用钩子 entryOption 传参之后执行
complier.hooks.entryOption.call() // tapable 使用 call方法执行之前注册的任务

complier.run() // 标识运行编译

打包一下,看下效果 npx cheny-pack

image-20220205114653925

我们编写的三个插件,都在打包过程中执行了,成功。

总结

webpack中的插件机制,其实就是一个发布订阅。

使用tapable来实现。

每一个插件都是一个类,里面规定有一个apply方法。

在webpack开始编译时,new Compiler时,会立即执行构造方法,在这个过程中,会先发布一些任务,待会在编译的过程中,在将这些任务穿插到各个环节中。这些任务是我们自己配置的插件。

  1. 配置一些钩子,在构造编译器时,就将这些钩子挂载到了编译器的实例上,接下来的过程随时可以访问这些钩子

    js
    // webpack\cheny-pack\lib\Complier.js
    // 部分代码
    // 为了方便 就全部用同步钩子
    let { SyncHook } = require('tapable') // 定义一些钩子 同步或异步钩子
    
    class Complier {
        constructor(config) {
    
            // 声明实例时 增加一些钩子
            this.hooks = {
                entryOption: new SyncHook(), // 获取完配置文件,配置文件传参给compiler编译器,有了实例之后执行
                compile: new SyncHook(), // 开始编译之前的钩子,还没有开始编译
                afterCompile: new SyncHook(),
                afterPlugins: new SyncHook(),
                run: new SyncHook(),
                emit: new SyncHook(),
                done: new SyncHook()
            }
    
            // 如果配置文件需要加载一些插件,就先把这些任务注册一下
            let plugins = this.config.plugins
            if (Array.isArray(plugins)) { // 如果配置的有插件,就依次执行插件的apply方法
                plugins.forEach(plugin => {
                    plugin.apply(this) // 传递的参数 是这个编译器 this
                    // 通过这个this 就可以拿到各种钩子,然后在写插件的时候,想让在哪个环节执行,就调用对应的钩子
                })
            }
    
            // 所有的插件都已经注册完成的钩子
            this.hooks.afterPlugins.call()
        }
    }
    
    module.exports = Complier
  2. 将钩子穿插到编译的各个环节中

    js
    run() {
        // 开始执行编译的钩子
        this.hooks.run.call()
    
        // 开始编译之前执行的钩子
        this.hooks.compile.call()
    
        // 执行,并且创建模块的依赖关系
        // 从入口开始执行
        this.buildModule(path.resolve(this.root, this.entry), true) // true 表示解析的是主模块
    
        // 编译之后的钩子
        this.hooks.afterCompile.call()
    
        // 发射一个文件 打包后的文件
        this.emitFile()
    
        // 发射文件之后的钩子
        this.hooks.emit.call()
    
        // 发射文件结束的钩子
        this.hooks.done.call()
    }
  3. 自己写的插件实现apply方法,在需要执行的时机执行

    js
    // webpack\webpack-dev-3\plugin\P1-entryOption.js
    class P {
        // compiler是webpack中的编译器,上面有很多钩子
        // 规定插件都有一个apply方法
        apply(compiler) {
            // 假如,在发射文件的时候,我想做什么事
            compiler.hooks.emit.tap('emit', () => {
                console.log('emit -----');
            })
            // tap 同步注册任务
        }
    }
    
    module.exports = P
  4. 配置文件中加载插件

    js
    // webpack\webpack-dev-3\webpack.config.js
    // 引入自定义插件
    let P1 = require('./plugin/P1-entryOption.js')
    let P2 = require('./plugin/P2-compile.js')
    let P3 = require('./plugin/P3-afterCompile.js')
    
    module.exports = {
        // 增加插件
        plugins: [
            new P1(),
            new P2(),
            new P3(),
        ]
    }

参考

https://www.bilibili.com/video/BV1a4411e7Bz?p=39&spm_id_from=pageDriver