umi 如何实现插件 api (附实践)

lxf2023-03-15 13:13:01

tapable

从官方渠道发布的信息或者 umi 的源码我们很容易知道 umi 的插件机制是使用了 tapable 库。tapable 是 webpack 实现各种钩子函数的底层库,官方说明是 “Just a little module for plugins.” ,文档我能找到的也只有仓库的 readme,感兴趣的朋友可以去看看,简单的说就是 webpack 插件机制的实现,保证了插件内定义的钩子能够按照正确的顺序以尽可能快的速度执行。这不是我们这个文章的重点,后面有机会我再写文章详细说明。

umi 如何使用 tapable

如果你去查看 umi 的源码,你会觉得有点绕,而且没那么容易懂的代码是如何运行的。 下面我把关键步骤简化提取出来,希望能够帮助你理解。

比如,我们现在有这样的顺序(也就是我们常说的生命周期)

// 通过插件加载,把 hooks 整理起来
const hooks = {
  onStart: [
    {
      plugin: "version",
      fn: () => {
        console.log("开始:执行了 version 插件");
      },
    },
    {
      plugin: "other",
      fn: () => {
        console.log("开始:执行了 other 插件");
      },
    },
  ],
  onEnd: [
    {
      plugin: "version",
      fn: () => {
        console.log("结束:执行了 version 插件");
      },
    },
    {
      plugin: "other",
      fn: () => {
        console.log("结束:执行了 other 插件");
      },
    },
  ],
};
// 执行插件中的 onStart 钩子
applyPlugins({ key:'onStart' })

// 做点别的什么事情
console.log('konos@1.0.0');

// 执行插件中的 onEnd 钩子
applyPlugins({ key: 'onEnd' })

实现 applyPlugins

import { AsyncSeriesWaterfallHook } from "tapable";

// hooks 详见上方代码
const hooks = {...}

const applyPlugins = (opts: { key: string; })=>{
    const hooks = hooks[opts.key] || [];
    const tEvent = new AsyncSeriesWaterfallHook(["_"]);
    for (const hook of hooks) {
      tEvent.tapPromise(
        {
          name: hook.plugin,
        },
        async () => {
          await hook.fn();
        }
      );
    }
    return tEvent.promise(1) as Promise<T>;
}

从上面的代码我们可以比较清晰的看到整个过程,其实就是声明一个 AsyncSeriesWaterfallHook 钩子,然后按照插件注册顺序(for (const hook of hooks)),调用各个插件中声明的函数。

实践

我们在 如何手写 umi 的核心插件模块 的基础上来完善插件机制。如果你没有根据这个文章的步骤实践过,那你可以从这里开始源码归档。

扩展插件 api

在 《如何手写 umi 的核心插件模块》 中,我们实现了插件的注册机制,现在我们来补充一下钩子函数的注册机制。 其实你可以简单的理解,各个钩子函数,其实就是我们之前聊到的 PluginAPI 对象中的一个函数,有点类似我们之前写的 registerCommand,只不过各个钩子函数可能是通过其他插件声明的。所以我们可以使用 Proxy 实现一个 proxyPluginAPI 函数。

const pluginMethods = {
    a:1,
    b:2
}
const proxyPluginAPI = (opts: { pluginApi: PluginAPI; }) => {
  return new Proxy(opts.pluginApi, {
    get: (target, prop: string) => {
      if (pluginMethods[prop]) {
        return some[prop];
      }
      return target[prop];
    },
  });
};

const pluginApi = {
    c:3
}
const proxyApi = proxyPluginAPI({pluginApi});

console.log(proxyApi.a);
// 1

大致的过程如上述的伪代码,Proxy 看起来非常的简单,但是在现代前端框架中有很重要的实践,后面我会再写文章介绍。如果你不懂它是什么,你应该也能从上面的代码中看懂它的作用,大致的效果就是

let proxyApi.a  = pluginApi.a || pluginMethods.a;

当然如果你根据自己的约定和条件,可以让这个过程变得更加灵活复杂,但在这里,这简单逻辑我们就够用了。

按上面的逻辑,我们要实现的就是如何获取到 pluginMethods

我们现在 service 中增加一个对象用来保存公用数据。

export class Service {
  commands: any = {};
  opts = {};
+ pluginMethods: Record<string, { plugin: string; fn: Function }> = {};
  constructor(opts?: any) {
    this.opts = opts;
  }
  ...
}

由于我们的hook需要区分是从那个插件来的,因此我们在 PluginAPI 中保存下 plugin 供其他方法使用

class PluginAPI {
  service: Service;
+  plugin: string;
  constructor(opts: { service: Service; plugin: string }) {
    this.service = opts.service;
+    this.plugin = opts.plugin;
  }
...
}

然后在 PluginAPI 中实现一个 pluginMethods 的注册方法。

registerMethod(opts: { name: string; }) {
    this.service.pluginMethods[opts.name] = {
      plugin: this.plugin,
      fn:
        function (fn: Function | Object) {
          // 在这里注册 hooks
        },
    };

我们就可以通过 api.registerMethod 声明一个新的插件 api 了

比如,我们写一个 onstart 插件,新建文件 src/onstart.ts

export default (api: any) => {
  api.registerMethod({
    name: "onStart",
  });
};

在 src/cli 中使用这个插件

    await new Service({
      plugins: [
+       require.resolve("./onstart"),
        require.resolve("./version"),
      ],
    })

修改 proxyPluginAPI,从 service 的 pluginMethods 中取到其他的插件 api

const proxyPluginAPI = (opts: { pluginApi: PluginAPI; service: Service }) => {
  return new Proxy(opts.pluginApi, {
    get: (target, prop: string) => {
      if (opts.service.pluginMethods[prop]) {
        return opts.service.pluginMethods[prop].fn;
      }
      // @ts-ignore
      return target[prop];
    },
  });
};

然后修改插件初始化函数 initPlugin

async initPlugin(opts: { plugin: any }) {
    const ret = await this.getPlugin(opts.plugin);
-   const pluginApi = new PluginAPI({ service: this });
+   const pluginApi = new PluginAPI({ service: this, plugin: opts.plugin });
+   const proxyAPI = proxyPluginAPI({
+     pluginApi,
+     service: this,
+   });
+   ret(proxyAPI);
-   ret(pluginApi);
}

这样我们就可以在其他插件中使用 api.onStart api 了 比如,我们在 version 插件中使用。

export default (api: any) => {
+  api.onStart(() => {
+    console.log("开始:执行了 version 插件");
+  });
...
}

我们可以测试一下,执行 pnpm build 构建代码,然后执行 pnpm test,你会发现程序可以正确执行,并不会报错,找不到 onStart 函数。

插件钩子注册机制

我们在扩展插件 api 方法中,还少了 注册 hooks,也就是虽然插件注册了,但是插件中的钩子并没有被记录。

registerMethod(opts: { name: string; }) {
    this.service.pluginMethods[opts.name] = {
      plugin: this.plugin,
      fn:
        function (fn: Function | Object) {
          // 在这里注册 hooks
        },
    };

修改为

  registerMethod(opts: { name: string }) {
    this.service.pluginMethods[opts.name] = {
      plugin: this.plugin,
      fn: function (fn: Function | Object) {
        // @ts-ignore
        this.register({
          key: opts.name,
          fn,
        });
      },
    };

同样的我们在 service 中增加一个对象用来保存 hooks。

export class Service {
  commands: any = {};
  opts = {};
  pluginMethods: Record<string, { plugin: string; fn: Function }> = {};
+ hooks: Record<string, Hook[]> = {};
  constructor(opts?: any) {
    this.opts = opts;
  }
  ...
}

然后在 PluginAPI 中实现一个 hooks 的注册方法。

  register(opts: Omit<IHookOpts, "plugin">) {
    this.service.hooks[opts.key] ||= [];
    this.service.hooks[opts.key].push(
      new Hook({ ...opts, plugin: this.plugin })
    );
  }

Hook 做的事情也很简单,就是把这个 hook 来自那个插件,绑定了什么函数记录下来。

export interface IHookOpts {
  key: string;
  plugin: string;
  fn: Function;
}

export class Hook {
  key: string;
  fn: Function;
  plugin: string;
  constructor(opts: IHookOpts) {
    this.key = opts.key;
    this.fn = opts.fn;
    this.plugin = opts.plugin;
  }
}

可以在这里测试一下,在 service 的 run 函数中,增加一个日志,打印一下 this.hooks

执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:

{
  onStart: [
    Hook {
      key: 'onStart',
      fn: [Function (anonymous)],
      plugin: '/Users/congxiaochen/Documents/konos-core/dist/version.js'
    }
  ]
}

执行钩子函数

onStart 生命周期函数,就是在命令执行之前,所以我们在 run 函数中增加对他的调用。

  async run(opts: { name: string; args?: any }) {
    const { plugins = [] } = this.opts as any;
    while (plugins.length) {
      await this.initPlugin({ plugin: plugins.shift()! });
    }
    const { name, args = {} } = opts;
    const command = this.commands[name];
    if (!command) {
      throw Error(`命令 ${name} 执行失败,因为它没有定义。`);
    }
    // console.log(this.hooks);
+    await this.applyPlugins({
+      key: "onStart",
+    });
    let ret = await command.fn({ args });
    return ret;
  }

实现 applyPlugins

上方我们已经实现过 applyPlugins 了,我们只要简单的修改一下 hooks 的取值,将它改成从 service 取即可。

  applyPlugins<T>(opts: { key: string; args?: any }): Promise<T> | T {
    const hooks = this.hooks[opts.key] || [];
    const tEvent = new AsyncSeriesWaterfallHook(["_"]);
    for (const hook of hooks) {
      tEvent.tapPromise(
        {
          name: hook.plugin,
        },
        async () => {
          await hook.fn(opts.args);
        }
      );
    }
    return tEvent.promise(1) as Promise<T>;
  }

执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:

{ _: [ 'version' ] }
开始:执行了 version 插件
konos@1.0.0

测试

同样的我们在增加一个 onEnd 的 api

新建文件 src/onend.ts

export default (api: any) => {
  api.registerMethod({
    name: "onEnd",
  });
};

新建一个测试插件,用来验证hook执行顺序,新建文件 src/other.ts

export default (api: any) => {
  api.onStart(() => {
    console.log("开始:执行了 other 插件");
  });
  api.onEnd(() => {
    console.log("结束:执行了 other 插件");
  });
};

在 src/cli 中使用这两个插件

    await new Service({
      plugins: [
        require.resolve("./onstart"),
+       require.resolve("./onend"),
        require.resolve("./version"),
+       require.resolve("./other"),
      ],
    })

注意顺序,提供插件 api 的插件,需要在前面,hooks 执行顺序,按插件注册顺序执行,比如 other 就比 version 插件要慢执行。

在 version 插件中使用 onEnd api

export default (api: any) => {
  api.onStart(() => {
    console.log("开始:执行了 version 插件");
  });
+  api.onEnd(() => {
+    console.log("结束:执行了 version 插件");
+  });
  api.registerCommand({
    name: "version",
    alias: "v",
    description: "show konos version",
    fn({}) {
      const version = require("../package.json").version;
      console.log(`konos@${version}`);
      return version;
    },
  });
};

执行 pnpm build 构建代码,然后执行 pnpm test,你将会看到类似如下的日志:

{ _: [ 'version' ] }
开始:执行了 version 插件
开始:执行了 other 插件
konos@1.0.0
结束:执行了 version 插件
结束:执行了 other 插件

如果你在窗口中,看到类似的日志,说明你的所有操作都正确了,恭喜你,你实践了一次 tapable 库的使用,并且对 umi 源码的理解更进一步了。这个系列越写感觉越可以归档到 umi 插件开发了。

本次代码变更记录

源码归档