Webpack编写自定义插件

2021-01-06 15:04:41 浏览数 (1)

一、webpack 核心概念

1. Entry(入口)

指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

2. Output(输出)

告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件。

3. Module(模块)

在 Webpack 里一切皆模块,一个模块对应着一个文件。

4. Chunk(代码块)

一个 Chunk 由多个模块组合而成,用于代码合并与分割。

5. Loader

webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。

6. Plugin

插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。

二、webpack 构建流程

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

三、插件简易示例

1. Webpack 常用构建阶段的钩子

Webpack 提供钩子有很多,这里简单介绍几个,完整具体可参考文档《Compiler Hooks》:

钩子

说明

参数

类型

entryOption

在 webpack 选项中的 entry 配置项 处理过之后

context,entry

同步

afterPlugins

设置完初始插件之后

compiler

同步

compile

创建compilation对象之前

compilationParams

同步

compilation

编译对象创建之后,生成文件之前

compilation

同步

emit

资源生成完成,输出之前

compilation

异步

afterEmit

资源输出到目录完成

compilation

异步

done

完成编译

stats

同步

2. Webpack 提供的三种触发钩子方法 (在 compiler.hooks 下指定事件钩子函数,便会触发钩子时,执行回调函数):

  • tap :以同步方式触发钩子;
  • tapAsync :以异步方式触发钩子;
  • tapPromise :以异步方式触发钩子,返回 Promise;

Tapable

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

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

tap

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

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

代码语言:javascript复制
compiler.hooks.compile.tap('MyWebpackPlugin', params => {
  console.log('我是同步钩子')
});

tapAsync

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

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

代码语言:javascript复制
compiler.hooks.emit.tapAsync('MyWebpackPlugin', (compilation, callback) => {
  setTimeout(()=>{
    console.log('文件列表', Object.keys(compilation.assets).join(','));
    callback();
  }, 1000);
});

tapPromise

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

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

代码语言:javascript复制
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方法中插入钩子的一般形式如下:

代码语言:javascript复制
compileer.hooks.阶段.tap函数('插件名称', (阶段回调参数) => {
  
});

3. 一个典型的Webpack插件示例

代码语言:javascript复制
// 插件代码
class MyWebpackPlugin {
  constructor(options) {
  }
  
  apply(compiler) {
    // 插入钩子函数
    compiler.hooks.emit.tap('MyWebpackPlugin', (compilation) => {});
  }
}

module.exports = MyWebpackPlugin;

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

代码语言:javascript复制
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可以访问到本次编译过程中的模块、依赖、文件内容等信息。

四、常用API

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

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

代码语言:javascript复制
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对象,我们可以添加、删除、更改最终输出的资源。

代码语言:javascript复制
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):

代码语言:javascript复制
|----src
		|----main.js
|----plugins
		|----my-webpack-plugin.js
|----package.json
|----webpack.config.js

相关文件的内容如下:

代码语言:javascript复制
// src/main.js
console.log('Hello World');
代码语言:javascript复制
// package.json
{
  "scripts":{
    "build":"webpack"
  }
}
代码语言:javascript复制
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实现。

代码语言:javascript复制
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,内容如下:

代码语言:javascript复制
{"main.js":956}

构建结果上传到七牛CDN

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

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

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

代码语言:javascript复制
npm install qiniu --save-dev

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

代码语言:javascript复制
https://developer.qiniu.com/kodo/sdk/1289/nodejs

开始编写插件代码:

代码语言:javascript复制
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中需要传递给该插件传递相关配置:

代码语言:javascript复制
Copymodule.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即可。

五、插件案例

1. 背景介绍

在项目打包遇到问题:“当项目托管到 CDN 平台,希望实现项目中的 index.js 不被缓存”。因为我们需要修改 index.js 中的内容,不想用户被缓存。

解决方案:需要在 index.html 生成之前,修改 js 文件的路径,并添加时间戳。

2 Webpack 插件组成

  • 一个具名 JavaScript 函数;
  • 在它的原型上定义 apply 方法;
  • 指定一个触发到 Webpack 本身的事件钩子;
  • 操作 Webpack 内部的实例特定数据;
  • 在实现功能后调用 Webpack 提供的 callback。

3. Webpack 插件基本架构

插件由一个构造函数实例化出来。构造函数定义 apply 方法,在安装插件时,apply 方法会被 Webpack compiler 调用一次。apply 方法可以接收一个 Webpack compiler对象的引用,从而可以在回调函数中访问到 compiler 对象。

代码语言:javascript复制
class HelloWorldPlugin {
    apply(compiler) {
        compiler.hooks.done.tap('Hello World Plugin', (stats) => {
            console.log('hello world');
        })
    }
}

4. HtmlWebpackPlugin 介绍

4.1 插件两个主要作用:

  • 为 HTML 文件引入外部资源(如 script / link )动态添加每次编译后的 hash,防止引用文件的缓存问题;
  • 动态创建 HTML 入口文件,如单页应用的 index.html文件。

4.2 插件原理介绍:

  • 读取 Webpack 中 entry 配置的相关入口 chunkextract-text-webpack-plugin 插件抽取的 CSS 样式;
  • 将样式插入到插件提供的 templatetemplateContent 配置指定的模版文件中;
  • 插入方式是:通过 link 标签引入样式,通过 script 标签引入脚本文件。

5. 实现流程

5.1 实现的原理

通过HtmlWebpackPlugin生成 HTML 文件前,将模版文件预留位置替换成脚本,脚本中执行自动添加时间戳来引用脚本文件。

5.2 初始化插件文件

代码语言:javascript复制
// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  apply(compiler) {
    compiler.hooks.done.tap('SetScriptTimestampPlugin',
     (compilation, callback) => {
      console.log('SetScriptTimestampPlugin!');
    });
  }
}
module.exports = SetScriptTimestampPlugin;

5.3 选择插件触发时机

插件应该是要在 HTML 输出之前,动态添加script标签,所以我们选择钩入compilation阶段,

由于compilationSyncHook同步钩子,所以采用tap触发方式,

代码修改如下:

代码语言:javascript复制
// SetScriptTimestampPlugin.js

class SetScriptTimestampPlugin {
  apply(compiler) {
-   compiler.hooks.done.tap('SetScriptTimestampPlugin',
    compiler.hooks.compilation.tap('SetScriptTimestampPlugin', 
      (compilation, callback) => {
      console.log('SetScriptTimestampPlugin!');
    });
  }
}
module.exports = SetScriptTimestampPlugin;

5.4 添加插件替换入口

在模版文件 template.html 中添加 <!--SetScriptTimestampPlugin inset script--> 作为标识替换入口:

代码语言:javascript复制
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Webpack 插件开发入门</title>
</head>
<body>
  	<!-- other code -->
    <!--SetScriptTimestampPlugin inset script-->
</body>
</html>

5.5 编写插件逻辑

0 人点赞