yliu

时来天地皆同力,运去英雄不自由

Webpack 插件入门


webpack 插件入门

bg

最近写了一个移动端项目,不过每次 build 的时候还需要手动上传服务器感觉很不方便,毕竟每次删除文件夹然后拖拽上传的过程太重复了,本着不重复造轮子的原则去 Github 翻了一下,发现 Upload上传插件还是蛮多的,不过距离自己的要求还是有些差异,很多插件只是只是单一职责,只负责上传这件事情。

而如果只负责上传文件不做删除会导致服务器文件越来越多,占用额外的储存成本,WebPack 在 build 过程中会检测相关依赖是否变更,如果变更相关文件的 hash 也是发生变更,这样就会导致新的文件上传到服务器,而旧资源却不会被覆盖替换掉。

基本概念

WebPack 的插件是基于 Tapable 实现的,它是一种发布订阅的实现,作用就是将插件的各个生命周期钩子广播出去,然后在合适的时机执行。同时只让插件关注自身的订阅,保证插件组合起来有序进行。

Tapable暴露了三个方法:

  • tap: 可以注册同步钩子和异步钩子
  • tapAsync: 回调形式注册异步钩子
  • tapPromise: Promise 形式注册异步钩子

在编写插件时 WebPack 显示要求我们有 apply 方法,这样做的原因是 WebPack 执行期间会执行 apply 方法,并且注入compiler,之后在compiler上订阅钩子事件,在合适时间触发已订阅的 apply 方法

再看一下官方给出的示例代码

// A JavaScript class.
class MyExampleWebpackPlugin {
  // Define `apply` as its prototype method which is supplied with compiler as its argument
  apply(compiler) {
    // Specify the event hook to attach to
    compiler.hooks.emit.tapAsync('MyExampleWebpackPlugin', (compilation, callback) => {
      console.log('This is an example plugin!');
      console.log(
        'Here’s the `compilation` object which represents a single build of assets:',
        compilation
      );

      // Manipulate the build using the plugin API provided by webpack
      compilation.addModule(/* ... */);

      callback();
    });
  }
}

上面插件在compiler中订阅了 emit 的异步钩子,然后做了一些操作之后,执行 callback() 回调

这里稍微说下,对于 tapAsync 的钩子,callback 必须执行,否则程序会一致在等待,而 callback 左侧的 compilation 是用来访问这一次的资源构建信息,例如一些输出的资源,相互依赖的关系等。

了解了上面的信息,我们找一下 compiler 钩子 有没有我们需要的,文档中列举的钩子很多:

  • environment
  • afterEnvironment
  • entryOption
  • ...

翻到最后会看到一个 done 的钩子,它在 compilation 完成时执行。

这里我们需要的前置基本准备齐全了,下面要做的就是在 done 触发时

  • 连接 ssh 服务器,执行rm-rf xx的操作
  • 上传 build 后的资源到 xx 目录下

插件开发准备

之后的内容采用 TypeScript 作为开发,如果你没有相关经验直接跳过类型注释即可

为了方便解耦和复用文件,我们创建了一个 utils.ts 文件

// utils.ts
import { NodeSSH } from 'node-ssh';

import { Option } from './typings';

export const isObject = (obj: any): obj is Object => typeof obj === 'object' && obj;

// 删除文件夹
export const removeDir = async (option: Option) => {
  const ssh = new NodeSSH();
  await ssh.connect(option);
  await ssh.execCommand(`rm -rf ${option.to}`);
  await ssh.dispose();
};

// 上传文件夹
export const uploadDir = async (option: Option) => {
  const ssh = new NodeSSH();
  await ssh.connect(option);
  await ssh.putDirectory(option.src!, option.to, {
    recursive: true,
  });
  await ssh.dispose();
};

它暴露三个方法,删除文件夹和上传文件夹还有一个判断 object 的方法,上面的删除和上传文件夹基于 node-ssh 封装而来,如果你有兴趣了解可以去阅读一下文档

插件开发

剩下的插件开发,就是获取用户填写一些必要字段,例如密码、上传的服务器路径、host 等信息,结合上面的 utils 和钩子,完成这个上传过程

// upload-plugin.ts
import { Compiler, Stats } from 'webpack';
import { isObject, uploadDir, removeDir } from './utils';
import { Option } from './typings';

class UploadPlugin {
  public stats: Stats;

  public option: Option & Record<string, any>;

  public removeDir: boolean;

  constructor(option: Option, remove = true) {
    this.stats = null as unknown as Stats;
    this.option = option;
    this.removeDir = remove;
    this.init();
  }

  init() {
    this.checkOption();
    this.setOption();
  }

  // 检验参数
  checkOption(option = this.option) {
    if (!isObject(option)) {
      throw new Error('option Must be an object!');
    }
    const result = ['to', 'host'].filter((f) => !option[f]);
    if (result.length) {
      throw new Error(`The ${result.join(',')} parameter is required!`);
    }
    if (!option.password && !option.privateKey) {
      throw new Error('password and privateKey must have one entry!');
    }
  }

  // 初始化默认值
  setOption() {
    const option = {
      port: 22,
      username: 'root',
    };
    this.option = {
      ...option,
      ...this.option,
    };
  }

  apply(compiler: Compiler) {
    compiler.hooks.done.tap('upload-plugin', async (stats) => {
      console.time('time');
      // 获取默认的信息,如果src不存在直接使用webpack的配置
      const src = stats.compilation.outputOptions.path;
      this.option.src = this.option.src ?? src;
      if (this.removeDir) {
        await removeDir(this.option);
      }
      await uploadDir(this.option);
      console.timeEnd('time');
    });
  }
}

export default UploadPlugin;

整体代码还是很简洁的,去除参数校验部分还有赋值默认值参数,剩下的就是根据参数来是否删除远程文件夹,之后执行上传方法。

你可能很好奇 Option 的定义是啥,这个是结合 node-ssh 的连接信息加上自定义扩展的一些字段而来的

// typings.d.ts
export interface Option {
  src?: string;
  to: string;
  port?: number;
  host: string;
  username?: string;
  password?: string;
  privateKey?: string;
}

最后

完整代码已经上传了Github 仓库,如果你有兴趣可以具体看下更具体的一些信息,如果对你有帮助也欢迎 star