如何设计插件系统

前言

接上篇如何设计一个应用框架

其实如果你了解过各种框架和库,你就会发现插件是其中必不可少的一个模块,究其原因,插件为框架和库带来了极大的扩展性和灵活性,有了插件,这些框架和库才可能被广泛的应用,这里可以参考 Webpack、Rollup、Vite 这些成熟的库。插件系统是否强大,往往决定了一个库或者框架的受欢迎程度。

本篇就结合上篇提到的插件,来手搓一个插件系统。

插件的功能

对于我们的应用框架而言,插件就是一个方法,用来改变某些配置,或者是在特定时机执行一些操作,以此我们可以得到三种操作类型的插件:

  • 修改配置
  • 新增配置
  • 事件回调

除此之外,我们的应用框架是一个微内核的设计,对于框架的核心功能,开发服务器、打包构建等命令,也需要使用插件来实现,这样可以把命令也封装到插件中,甚至可以在插件中自己注册事件,然后在框架执行的特定时期进行调用。这些方法统称为钩子 ,所以插件系统说到底就是一个发布订阅模式

插件的核心

如何组织这些钩子方法之间的关系,协调好修改、新增以及事件之间的区别就是插件的核心。好在我们不用费劲心力重新设计出一套发布订阅模式,奉行一个"拿来主义"原则,我们直接使用 Webpack 的插件核心库tapable,不了解 tapable 的同学可以先去这里了解一下。

tapable 提供了多种类型的钩子,我们只需用到以下两种:

  1. AsyncSeriesWaterfallHook
  2. SyncWaterfallHook

简单介绍一下这两种钩子,AsyncSeriesWaterfallHook 顾名思义,是接受异步方法作为回调,并且按照顺序(series)的瀑布流(waterfall)钩子,举个🌰:

js 复制代码
const hook = new AsyncSeriesWaterfallHook(['args']);

hook.tapPromise('eventName1', async args => {
    const p = await args;
    console.log(p);
    return {
        ...p,
        height: 180
    };
});
hook.tapPromise('eventName2', async args => {
    const p = await args;
    console.log(p);
    return {
        ...p,
        age: 18
    };
});
hook.tapPromise('eventName3', async args => {
    const p = await args;
    console.log(p);
    return {
        ...p,
        sex: 'male'
    }
});

hook.promise(Promise.resolve({ name: 'qiugu' })).then(res => {
    console.log(res);
});
// 执行结果
// { name: 'qiugu' }
// { name: 'qiugu', height: 180 }
// { name: 'qiugu', height: 180, age: 18 }
// { name: 'qiugu', height: 180, age: 18 }
// { name: 'qiugu', height: 180, age: 18, sex: 'male' }

总结一下,AsyncSeriesWaterfallHook 是接受异步 回调函数,串行 执行,并且每个钩子返回的值会作为下一个钩子的参数

SyncWaterfallHook 也是同理,根据名称我们就能猜测,它是一个接受同步回调,并且返回值会作为下一个钩子的参数。这里就不再举例说明了。

当然 tapable 的钩子不止这些,具体可以参考上面的链接来学习。而我们只需要这两种钩子就可以构建上面提到的三种类型的插件,接下来就来试一试。

插件对象的定义

先确定好插件的使用方式,对比一下 vite、webpack以及 babel 的插件分别如下:

js 复制代码
// vite 插件导出一个方法
import { react } from '@vitejs/plugin-react-swc';

export default defineConfig({
    plugins: [react()]
});

// webpack 插件导出了一个类
import HtmlWebpackPlugin from 'html-webpack-plugin';

export default {
    plugins: [
        new HtmlWebpackPlugin();
    ]
}

// babel 插件是一个 npm 包
{ 
  plugins:
    [ 
      [
        "babel-plugin-import",
        {
            libraryName: "antd",
            libraryDirectory: "lib",
            style: "css",
        }
      ]
    ] 
}

这里采用的方式类似第三种,也就是 babel 插件的形式,原因后续会提到。

先分析一下这种插件方式该如何实现?babel-plugin-import 作为一个 npm 包,直接使用它放入插件配置中,而不是从这个包中导出方法来使用,这么做其实就是运行时的理念,等到执行插件的时候,利用 require 加载对应的插件,由于插件可能是 TS 文件,所以在加载前需要对 TS 文件编译为 JS,然后拿到插件暴露的方法。

整个执行流程应该是这样的:

值得一提的是我们可以利用一个库 pirates,它是用来捕获 require 操作的,也就是说上面 require(plugin) 时候,使用 esbuild 将插件编译为 JS 文件,然后再去 require 就能拿到插件的方法了。

我们插件大概是这样的:

typescript 复制代码
class Plugin {
    cwd: string;
    path: string;
    // 插件的配置,包含默认配置,以及插件的配置对象
    config: { default: any; scheme: any } = {};
    id: string; 插件的路径简写,作为插件的唯一id
    key: string; 插件的配置对象的key
    
    constructor(opts: { cwd: string; path: string }) {
        this.cwd = opts.cwd;
        this.path = path;
        let ret;
        this.apply = () => {
            // 注册监听 require 的钩子
            register({
                implementor: esbuild,
                ext: ['ts']
            });
            ret = require(this.path);
            return ret.default ? ret.default : ret;
        }
    }
}

register 方法做了什么呢?其实就是上文提到的 require 的钩子:

ts 复制代码
import { addHook } from 'pirates';

function register(opts: { implementor: any; ext: string[] }) {
    const revert = addHook(
        (code, filename) => {
            // 使用 esbuild 编译得到的 ts 代码
        }
        {
             ext: opts.ext
        }
    )
}

这样我们插件的使用方式就可以是这样的:

ts 复制代码
{
    plugins: [require.resolve('./path-a/path-b/index.ts')]
}

接下来就该丰富插件的使用,让插件能够完成「插件功能」中提到的那些钩子。

插件上下文

首先根据上文的内容,我们的插件看起来应该是这样的:

ts 复制代码
// path-a/path-b/index.ts
export default function foo(api) {
    // 拿到插件上下文做一些操作
    // 注册钩子
    api.register({ key, plugin, fn });
    // 注册命令
    api.registerCommands({ key, plugin, fn });
    // 注册自定义方法
    api.registerMethods({ key, plugin, fn });
}

无论是钩子、方法还是命令,都需要一个地方存储,这里就可以选择我们的 CoreService 类,这个类之前提到过,是这样的:

ts 复制代码
class CoreService {
    // 工作目录
    cwd: string;
    // 用于存储注册的插件,key 为插件id,值为Plugin
    plugins: Record<string, Plugin>;
    // 用户配置文件内容
    userConfig: UserConfig;
    /**
     * 构造函数
     * @param framework 用于自定义输出到终端的框架名称
     * @param env 省略
     * @param cwd 省略
     * @param plugins 插件 
     * @param presets 预设
     */
    constructor(opts: { framework?: string; env: string; cwd: string; plugins?: any[]; presets?: [] }) {
        this.cwd = cwd;
        this.opts = opts;
    };
    /**
     * 命令执行方法
     * @param opts name为执行的命令,args 为执行命令的参数
     */
    run (opts: { name: string; args?: any }) {};
    /**
     * 解析预设,拿到预设中的所有插件
     */
    initPresets (opts: { preset: Plugin; presets: Plugin[]; plugins: Plugin[] }) {};
    /**
     * 解析插件,执行插件
     */
    initPlugins(opts: { preset: Plugin; presets: Plugin[]; plugins: Plugin[] }) {};
  }

我们需要给这个类添加属性来保持插件注册的方法、钩子以及命令:

ts 复制代码
class CoreService {
    // 省略其他
    hooks: Record<string, {}> = {};
    methods: Record<string, {}> = {};
    commands: Record<string, {}> = {};
}

方法其实是一种特殊的钩子,钩子可以抽象出这样一个对象

ts 复制代码
class Hook {
    constructor(opts: {
        key: string;
        plugin: Plugin;
        fn: Function;
        stage?: number;
        before?: string;
    }) {
        this.key = opts.key;
        this.plugin = opts.plugin;
        this.fn = opts.fn;
        // 这是传递给 tapable 的参数,下面会提到
        this.stage = opts.stage || 0;
        this.before = opts.before;
    }
}

方法如果没有提供 fn 参数,就会注册成钩子,钩子就是提供了 tapable 参数传递能力的方法,而自己定义 fn,则不具备这种能力。

而对于命令来说,和 Hook 是不同的,一个命令的组成可能包含以下几个部分:

  • 命令
  • 命令参数
  • 命令的描述信息,用于命令帮助信息

为了方便组织命令信息,同 Hook 一样,来写一个 Command 类:

ts 复制代码
class Command {
    constructor(
        opts: {
            name: string;
            options?: string;
            description?: string;
            plugin: Plugin;
        }
    ) {
        this.name = opts.name;
        this.options = opts.options;
        this.description = opts.description;
        this.plugin = opts.plugin;
    }
}

我们来改造一下 CoreService 类:

ts 复制代码
class CoreSerivce {
    // 省略其他
    // 钩子是个回调函数的列表,可能包含多个 hook
    hooks: Record<string, Hook[]> = {};
    // 方法一个 key 只对应一个 plugin 和 fn,所以不是数组
    methods: Record<string, { plugin: Plugin, fn: Function }> = {};
    commands: Record<string, Command> = {};
}

这样就可以分别去注册这三种类型的插件 API 了:

ts 复制代码
class PluginAPI {
    constructor(opts: { plugin: Plugin; service: CoreService }) {
        this.plugin = opts.plugin;
        this.service = opts.service;
    }
    register(opts: { key: string; plugin: Plugin; fn: Function }) {
        this.service.hooks[opts.key] = (this.service.hooks[opts.key] || []).push(new Hook(opts));
    }
    registerMethods(opts: { name: string; plugin: Plugin; fn: Function }) {
        if (this.service.methods[opts.name]) {
            throw new Error('方法不能重复注册!');
        }
        // 这里匿名函数不能使用箭头函数,否则this指向就错误了
        // fn 不存在,就会注册为钩子
        this.service.methods[opts.name] = opts.fn || function(fn) {
            this.register({
                key: opts.name,
                // 判断 fn 是不是纯对象,否则就以 fn 构造一个纯对象
                ...(isPlainObject(fn) ? fn : {fn})
            });
        }
    }
    registerCommands(opts: {
        name: string;
        fn: Function; 
        options?: string;
        description?: string;
     }) {
         if (this.service.commands[opts.name]) {
             throw new Error('命令已经注册过了!');
         }
         this.service.commands[opts.name] = new Command(opts);
     }
}

于是这样就可以像 「插件上下文」 中所述的那样使用插件了。除了命令插件以外,钩子插件虽然注册了,但是该如何调用,这是需要思考的问题。

注册插件钩子

其实一开始我们就说了给插件定义的三种操作类型:新增,修改、事件回调。并且利用 tapable 就可以注册并调用这些钩子,这也是上面 Hook 类的作用。

我们可以定义一个方法,分别注册这三种操作:

ts 复制代码
// 放入 CoreService 类中,作为类方法
function applyPlugins(opts: {
    key: string; // 指定触发钩子的标识
    type: 'add' | 'modify' | 'event'; // 插件操作类型
    initialValue?: any; // 初始值
    args?: any; // 插件传递的参数
    sync?: boolean; // 是否是同步
}) {
    // 拿到对应触发 key 的 hook
    const hooks = this.service.hooks[opts.key];
    switch(opts.type) {
        case 'add': {
            // 注册新增配置的钩子
            break;
        }
        case 'modify':
            // 注册修改配置的钩子
            break;
        case 'event':
            // 注册事件回调的钩子
            break;
        default:
            throw new Error('插件钩子类型只能是 add,modify,event');
    }
}

接着利用 AsyncSeriesWaterfallHook 注册新增配置的钩子:

ts 复制代码
// 接上文注册新增配置的钩子代码
const tAdd = new AsyncSeriesWaterfallHook(['memo']);
// 遍历对应名称的所有钩子
for(const hook of hooks) {
    // 注册钩子
    tAdd.tapPromise(
        {
            name: opts.key,
            // stage 表示执行钩子的优先级,数字越大执行越靠后,0为默认值
            stage: hook.stage || 0,
            // before 是一个字符串,用于指定当前钩子,在指定名称的钩子之前执行,其实还是控制事件执行的优先级
            before: hook.before
        },
        async memo => {
            // 还记得上面的参数 args 吗,就是传入到 hook 的参数
            const ret = await hook.fn(opts.args);
            
            return memo.concat(ret);
        }
    );
}
// 返回执行注册的钩子们的最终结果
return tAdd.promise(opts.initialValue || []);

接着就是 modify 的注册钩子的实现:

ts 复制代码
const tModify = new AsyncSeriesWaterfallHook(['memo']);
for(const hook of hooks) {
    // 注册钩子
    tAdd.tapPromise(
        {
            name: opts.key,
            stage: hook.stage || 0,
            before: hook.before
        },
        async memo => {
            // modify 的钩子会注入两个参数,一个是上次的修改得到的结果,另外一个是执行钩子传入的参数
            const ret = await hook.fn(memo, opts.args);
            
            return ret;
        }
    );
}
return tModify.promise(opts.initialValue);

最后则是注册事件回调:

ts 复制代码
// 事件回调的同步异步分开注册
if (opts.sync) {
    const tEvent = new SyncWaterfallHook(['_']);
    for (const hook of hooks) {
        // 这里其实没有用到 waterfall 钩子类型的注入参数,是不是可以换成 SyncHook?
        tEvent.tap(
            {
                name: opts.key,
                stage: hook.stage || 0,
                before: hook.before
            }, 
            () => {
                hook.fn(opts.args);
            }
        );
    }  
    // 使用1也只是为了做占位的参数
    tEvent.call(1);
}
// 异步事件回调
const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) {
    tEvent.tapPromise(
        {
            name: opts.key,
            stage: hook.stage || 0,
            before: hook.before
        },
        async () => {
            await hook.fn(opts.args);
        }
    );
}
return tEvent.promise(1);

至此插件中我们就可以在插件中注册钩子,方法,然后在特定的时机去 applyPlugins 触发钩子,注意注册的命令并不在钩子的执行中。

于是梳理一下整个插件的流程:

上图还有一个蓝色的标注 proxy 的线条,表示代理,意思是将 applyPluginsmethods 代理到 PluginAPI 上,这样插件就可以使用它们来执行钩子或者是注册的方法了。

如何做呢?

ts 复制代码
// PluginAPI 类中的静态方法
static proxyPluginAPI(opts: {
    pluginAPI: PluginAPI;
    service: Service;
    serviceProps: string[];
    staticProps: Record<string, any>;
}) {
    return new Proxy(opts.pluginAPI, {
        get: (target, prop: string) => {
            // 这样就可以拿到 service 上的注册的 methods 方法
            if (opts.service.methods[prop]) {
                return opts.service.pluginMethods[prop].fn;
            }
            // 可以拿到 service 上的 props
            if (opts.serviceProps.includes(prop)) {
                const serviceProp = opts.service[prop];
                return typeof serviceProp === 'function'
                    ? serviceProp.bind(opts.service)
                    : serviceProp;
            }
            // PluginAPI 上原本的方法
            return target[prop];
        }
    });
  }

这就是代理模式的体现,在 pluginAPI 上可以访问到 CoreService 上的属性,但是上下文是如何注入插件方法中呢?继续看下去。

插件注册时机

这里需要提到一个概念:预设,所谓预设就是一系列插件的集合,所以可以将插件分为两种类型(注意插件类型和上面的插件操作类型做区分),一种就是普通插件,一种就是预设插件。

预设插件看起来是这样的:

ts 复制代码
export default function foo() {
    return {
        plugins: [
            // 这里是普通插件的数组集合
        ]
    }
}

并且需要给 Plugin 类添加一个属性 type,来区分是插件还是预设:

ts 复制代码
enum PluginType {
    plugin = 'plugin',
    preset = 'preset'
}
class Plugin {
    type: PluginType
}

下面就来解析预设插件。通过 initPlugins 方法拿到所有的插件集合,然后把这些插件返回出去:

ts 复制代码
async initPresets (opts: {
    preset: Plugin;
    presets: Plugin[];
    plugins: Plugin[];
}) {
    const { presets, plugins } = await this.initPlugins({
        plugin: opts.preset,
        presets: opts.presets,
        plugins: opts.plugins
    });
    // 这里将解析出来的预设和插件都放到函数外面处理了
    opts.presets.unshift(...presets);
    opts.plugins.push(...plugins);
}

async initPlugins (opts: {
    plugin: Plugin;
    presets: Plugin[];
    plugins: Plugin[];
}) {
    if (this.plugins[opts.plugin.id]) {
        throw new Error('插件已经注册过了');
    }
    this.plugins[opts.plugin.id] = preset;
    const pluginAPI = new PluginAPI({
        plugin: opts.plugin,
        service: this
    });
    const proxyPluginAPI = PluginAPI.proxyAPI({
        plugin: opts.plugin,
        service: this,
        // 这样 api 中就可以拿到了service上的属性了
        serviceProps: [
            'applyPlugins',
            'methods',
            'userConfig'
        ]
    });
    // 还记得上面的 Plugin 解析以后的 apply 属性是什么吗
    const ret = await opts.plugin.apply()(proxyPluginAPI);
    
    if (ret?.presets) {
        ret.presets = ret.presets.map(preset => {
            return new Plugin({
                path: preset,
                cwd: this.cwd
            });
        });
    }
    
    if (ret?.plugins) {
        ret.plugins = ret.plugins.map(plugin => {
            return new Plugin({
                path: plugin,
                cwd: this.cwd
            });
        });
    }
    
    return ret;
}

最后当执行命令的时候,去解析所有的插件,解析插件的时候需要先实例化 Plugin,我们可以将实例化的过程放到 Plugin 的静态方法 getPluginsAndPresets 中:

ts 复制代码
// Plugin 类中
static getPluginsAndPresets(opts: {
  cwd: string;
  userConfig: any;
  plugins?: string[];
  presets?: string[];
}) {
  function get(type: 'plugin' | 'preset') {
    const types = `${type}s` as 'plugins' | 'presets';
    return [
      ...(opts[types] || []),
      // 拿到用户配置中的插件
      ...(opts.userConfig[types] || []),
    ].map((path) => {
      // 使用 resolve npm 库解析插件的实际路径
      const resolved = resolve.sync(path, {
        basedir: opts.cwd,
        extensions: ['.tsx', '.ts', '.mjs', '.jsx', '.js'],
      });

      return new Plugin({
        path: resolved,
        type,
        cwd: opts.cwd,
      });
    });
  }

  return {
    presets: get('preset'),
    plugins: get('plugin'),
  };
ts 复制代码
// CoreService 类中的 run 方法
async run(opts: { name: string; args?: any }) {
    // 获取 Plugin 实例
    const { presets, plugins } = Plugin.getPluginsAndPresets({
        cwd: this.cwd,
        userConfig: this.userConfig,
        presets: this.opts.presets,
        plugins: this.opts.plugins
    });
    
    // 拿到预设解析出来的普通插件
    const presetPlugins = [];
    while (presets.length) {
        await this.initPresets({
            // 从队首开时解析预设,对应上面的 unshift 将预设添加到队首
            preset: presets.shift(),
            presets,
            plugins: presetPlugins;
        });
    }
    // 将预设插件注入到普通插件集中
    plugins.unshift(...presetPlugins);
    // 继续解析普通插件
    while (plugins.length) {
        await this.initPlugins({
            plugin: plugins.shift(),
            plugins
        });
    }
    // 执行对应的命令插件
    const command = this.commands[opts.name];
    const ret = await command.fn(opts.args);
    
    return ret;
}

这样整个插件的执行流程就对应上了。等等,如果想要给插件传参数呢?

插件参数

和 babel 不同,我们的插件融入了用户配置文件中,利用 Plugin 的 key 属性作为配置项,比如注册了一个 antd 的插件,有一个配置对象:

ts 复制代码
{
    import: boolean;
    style: 'css' | 'less'
}

那么使用的时候,直接在配置文件中添加 antd 的 key:

ts 复制代码
{
    antd: {
        import: true,
        style: 'less'
    }
}

还记得上面的插件上下文的代理对象吗,里面有一个 userConfig 属性,所以就可以在上下文中直接拿到:

ts 复制代码
export default function foo(api) {
    // 这样就能拿到插件配置项了
    const config = api.userConfig.antd;
}

总结

以上就是 Umi 的插件架构,当然代码这里是做了简化的,主要目的是为了表达插件系统的设计思路,而不是具体的源码是如何的,源码每个人都可以看到,但是学习它的设计思路才是我们更欠缺的能力。

相关推荐
Devil枫几秒前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
尚梦34 分钟前
uni-app 封装刘海状态栏(适用小程序, h5, 头条小程序)
前端·小程序·uni-app
GIS程序媛—椰子1 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
前端青山2 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
毕业设计制作和分享2 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
清灵xmf4 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询
大佩梨4 小时前
VUE+Vite之环境文件配置及使用环境变量
前端
GDAL4 小时前
npm入门教程1:npm简介
前端·npm·node.js
小白白一枚1115 小时前
css实现div被图片撑开
前端·css
薛一半5 小时前
PC端查看历史消息,鼠标向上滚动加载数据时页面停留在上次查看的位置
前端·javascript·vue.js