Webpack4—编写自定义插件

motioliang2021-08-29webpackwebpack

TIP

Webpack 通过 Loader 完成模块的转换工作,让“一切皆模块”成为可能。Plugin 机制则让其更加灵活,可以在 Webpack 生命周期中调用钩子完成各种任务,包括修改输出资源、输出目录等等。

构建流程

在编写插件之前,还需要了解一下 Webpack 的构建流程,以便在合适的时机插入合适的插件逻辑。Webpack 的基本构建流程如下:

  1. 校验配置文件
  2. 生成 Compiler 对象
  3. 初始化默认插件
  4. run/watch:如果运行在 watch 模式则执行 watch 方法,否则执行 run 方法
  5. compilation:创建 Compilation 对象回调 compilation 相关钩子
  6. emit:文件内容准备完成,准备生成文件,这是最后一次修改最终文件的机会
  7. afterEmit:文件已经写入磁盘完成
  8. done:完成编译

插件示例

一个典型的 Webpack 插件代码如下:

// 插件代码
class MyWebpackPlugin {
    constructor(options) {}

    apply(compiler) {
        // 在emit阶段插入钩子函数
        compiler.hooks.emit.tap('MyWebpackPlugin', compilation => {})
    }
}

module.exports = MyWebpackPlugin

接下来需要在 webpack.config.js 中引入这个插件。

module.exports = {
    plugins: [
        // 传入插件实例
        new MyWebpackPlugin({
            param: 'paramValue'
        })
    ]
}

Webpack 在启动时会实例化插件对象,在初始化 compiler 对象之后会调用插件实例的 apply 方法,传入 compiler 对象,插件实例在 apply 方法中会注册感兴趣的钩子,Webpack 在执行过程中会根据构建阶段回调相应的钩子。

Compiler && Compilation 对象

在编写 Webpack 插件过程中,最常用也是最主要的两个对象就是 Webpack 提供的 Compiler 和 Compilation,Plugin 通过访问 Compiler 和 Compilation 对象来完成工作。

  • Compiler 对象包含了当前运行 Webpack 的配置,包括 entry、output、loaders 等配置,这个对象在启动 Webpack 时被实例化,而且是全局唯一的。Plugin 可以通过该对象获取到 Webpack 的配置信息进行处理。
  • Compilation 对象可以理解编译对象,包含了模块、依赖、文件等信息。在开发模式下运行 Webpack 时,每修改一次文件都会产生一个新的 Compilation 对象,Plugin 可以访问到本次编译过程中的模块、依赖、文件内容等信息。

常见钩子

Webpack 会根据执行流程来回调对应的钩子,下面我们来看看都有哪些常见钩子,这些钩子支持的 tap 操作是什么。

钩子说明参数类型
afterPlugins启动一次新的编译compiler同步
compile创建 compilation 对象之前compilationParams同步
compilationcompilation 对象创建完成compilation同步
emit资源生成完成,输出之前compilation异步
afterEmit资源输出到目录完成compilation异步
done完成编译stats同步

Tapable

Tapable 是 Webpack 的一个核心工具,Webpack 中许多对象扩展自 Tapable 类。Tapable 类暴露了 tap、tapAsync 和 tapPromise 方法,可以根据钩子的同步/异步方式来选择一个函数注入逻辑。

  • tap 同步钩子
  • tapAsync 异步钩子,通过 callback 回调告诉 Webpack 异步执行完毕
  • tapPromise 异步钩子,返回一个 Promise 告诉 Webpack 异步执行完毕

tap

tap 是一个同步钩子,同步钩子在使用时不可以包含异步调用,因为函数返回时异步逻辑有可能未执行完毕导致问题。

下面一个在 compile 阶段插入同步钩子的示例。

compiler.hooks.compile.tap('MyWebpackPlugin', params => {
    console.log('我是同步钩子')
})

tapAsync

tapAsync 是一个异步钩子,我们可以通过 callback 告知 Webpack 异步逻辑执行完毕。

下面是一个在 emit 阶段的示例,在 1 秒后打印文件列表。

compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
    setTimeout(() => {
        console.log('文件列表', Object.keys(compilation.assets).join(','))
        callback()
    }, 1000)
})

tapPromise

tapPromise 也是也是异步钩子,和 tapAsync 的区别在于 tapPromise 是通过返回 Promise 来告知 Webpack 异步逻辑执行完毕。

下面是一个将生成结果上传到 CDN 的示例。

compiler.hooks.afterEmit.tapPromise('MyWebpackPlugin', compilation => {
    return new Promise((resolve, reject) => {
        const filelist = Object.keys(compilation.assets)
        uploadToCDN(filelist, err => {
            if (err) {
                reject(err)
                return
            }
            resolve()
        })
    })
})

apply 方法中插入钩子的一般形式如下:

compileer.hooks.阶段.tap函数('插件名称', 阶段回调参数 => {})

常用 API

读取输出资源、模块及依赖

在 emit 阶段,我们可以读取最终需要输出的资源、chunk、模块和对应的依赖,如果有需要还可以更改输出资源。

apply(compiler) {
  compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
    // compilation.chunks存放了代码块列表
    compilation.chunks.forEach(chunk => {
     // chunk包含多个模块,通过chunk.modulesIterable可以遍历模块列表
			for(const module of chunk.modulesIterable) {
        // module包含多个依赖,通过module.dependencies进行遍历
      	module.dependencies.forEach(dependency => {
          console.log(dependency);
        });
      }
    });
    callback();
  });
}

修改输出资源

通过操作 compilation.assets 对象,我们可以添加、删除、更改最终输出的资源。

apply(compiler) {
  compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation) => {
    // 修改或添加资源
    compilation.assets['main.js']  = {
      source() {
        return 'modified content';
      },
      size() {
        return this.source().length;
      }
    };
    // 删除资源
    delete compilation.assets['main.js'];
  });
}

assets 对象需要定义 source 和 size 方法,source 方法返回资源的内容,支持字符串和 Node.js 的 Buffer,size 返回文件的大小字节数。

插件编写实例

接下来我们开始编写自定义插件,所有插件使用的示例项目如下(需要安装 webpack 和 webpack-cli):

|----src
		|----main.js
|----plugins
		|----my-webpack-plugin.js
|----package.json
|----webpack.config.js

相关文件的内容如下:

// src/main.js
console.log('Hello World');
// package.json
{
  "scripts":{
    "build":"webpack"
  }
}
const path = require('path');
const MyWebpackPlugin = require('my-webpack-plugin');

// webpack.config.js
module.exports = {
  entry:'./src/main',
  output:{
    path: path.resolve(__dirname, 'build'),
    filename:'[name].js',
  },
  plugins:[
    new MyWebpackPlugin()
  ]
};

生成清单文件

通过在 emit 阶段操作 compilation.assets 实现。

class MyWebpackPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
            const manifest = {}
            for (const name of Object.keys(compilation.assets)) {
                manifest[name] = compilation.assets[name].size()
                // 将生成文件的文件名和大小写入manifest对象
            }
            compilation.assets['manifest.json'] = {
                source() {
                    return JSON.stringify(manifest)
                },
                size() {
                    return this.source().length
                }
            }
            callback()
        })
    }
}

module.exports = MyWebpackPlugin

构建完成后会在 build 目录添加 manifest.json,内容如下:

{ "main.js": 956 }

构建结果上传到七牛

在实际开发中,资源文件构建完成后一般会同步到 CDN,最终前端界面使用的是 CDN 服务器上的静态资源。

下面我们编写一个 Webpack 插件,文件构建完成后上传的七牛 CDN。

我们的插件依赖 qiniu,因此需要额外安装 qiniu 模块

npm install qiniu --save-dev

七牛的 Node.js SDK 文档地址如下:

https://developer.qiniu.com/kodo/sdk/1289/nodejs

开始编写插件代码:

const qiniu = require('qiniu')
const path = require('path')

class MyWebpackPlugin {
    // 七牛SDK mac对象
    mac = null

    constructor(options) {
        // 读取传入选项
        this.options = options || {}
        // 检查选项中的参数
        this.checkQiniuConfig()
        // 初始化七牛mac对象
        this.mac = new qiniu.auth.digest.Mac(this.options.qiniu.accessKey, this.options.qiniu.secretKey)
    }
    checkQiniuConfig() {
        // 配置未传qiniu,读取环境变量中的配置
        if (!this.options.qiniu) {
            this.options.qiniu = {
                accessKey: process.env.QINIU_ACCESS_KEY,
                secretKey: process.env.QINIU_SECRET_KEY,
                bucket: process.env.QINIU_BUCKET,
                keyPrefix: process.env.QINIU_KEY_PREFIX || ''
            }
        }
        const qiniu = this.options.qiniu
        if (!qiniu.accessKey || !qiniu.secretKey || !qiniu.bucket) {
            throw new Error('invalid qiniu config')
        }
    }

    apply(compiler) {
        compiler.hooks.afterEmit.tapPromise('MyWebpackPlugin', compilation => {
            return new Promise((resolve, reject) => {
                // 总上传数量
                const uploadCount = Object.keys(compilation.assets).length
                // 已上传数量
                let currentUploadedCount = 0
                // 七牛SDK相关参数
                const putPolicy = new qiniu.rs.PutPolicy({ scope: this.options.qiniu.bucket })
                const uploadToken = putPolicy.uploadToken(this.mac)
                const config = new qiniu.conf.Config()
                config.zone = qiniu.zone.Zone_z1
                const formUploader = new qiniu.form_up.FormUploader()
                const putExtra = new qiniu.form_up.PutExtra()
                // 因为是批量上传,需要在最后将错误对象回调
                let globalError = null

                // 遍历编译资源文件
                for (const filename of Object.keys(compilation.assets)) {
                    // 开始上传
                    formUploader.putFile(
                        uploadToken,
                        this.options.qiniu.keyPrefix + filename,
                        path.resolve(compilation.outputOptions.path, filename),
                        putExtra,
                        err => {
                            console.log(`uploade ${filename} result: ${err ? `Error:${err.message}` : 'Success'}`)
                            currentUploadedCount++
                            if (err) {
                                globalError = err
                            }
                            if (currentUploadedCount === uploadCount) {
                                globalError ? reject(globalError) : resolve()
                            }
                        }
                    )
                }
            })
        })
    }
}

module.exports = MyWebpackPlugin

Webpack 中需要传递给该插件传递相关配置:

module.exports = {
    entry: './src/index',
    target: 'node',
    output: {
        path: path.resolve(__dirname, 'build'),
        filename: '[name].js',
        publicPath: 'CDN域名'
    },
    plugins: [
        new CleanWebpackPlugin(),
        new QiniuWebpackPlugin({
            qiniu: {
                accessKey: '七牛AccessKey',
                secretKey: '七牛SecretKey',
                bucket: 'static',
                keyPrefix: 'webpack-inaction/demo1/'
            }
        })
    ]
}

编译完成后资源会自动上传到七牛 CDN,这样前端只用交付 index.html 即可。

链接open in new window

最后更新时间 2021/12/16 14:55:02