阅读 2954

谈谈如何设计一个插件(Plugin)体系

前言

什么是插件?插件,又称做 Plug-in 或者 addin、addon 等,是一种遵循一定规范的应用程序接口编写出来的程序,从而可以为系统扩展原本不存在的特性。同时,如果一个系统支持了插件体系,也就拥有了可以实现用户自定义化的功能。

而一个应用支持插件的主要原因如下:

  • 可以支持第三方开发者去扩展应用的能力边界。
  • 可以更简单的支持扩展新特性。
  • 通过插件设计,减小(核心)应用的代码体积。

当然除此之外,还有其他很多优势。

计算机里对插件的应用已经历史悠久。比如图形软件通过插件支持处理不同格式的图片文件;邮箱客户端通过插件来加密、解密邮件;编辑器或集成开发环境通过插件支持不同编程语言等等。

在前端里,各个框架、库对插件的应用也有许多例子,比如 webpack、vuex、dva、babel、PostCSS 等等都有它们的一套插件体系。笔者作为一个 Web 前端,今天想稍微探讨一下前端里运用插件的姿势、插件的思想原理,以及如何实现自定义插件体系。

认识插件

在分析插件设计之前,我们可以先来通过下面几个示例来认识一下插件在不同的前端流行框架、库的应用方式是怎样的?

webpack

在常见的 webpack 插件配置中,有很多我们耳熟能详的插件,比如 HtmlWebpackPlugin、DllPlugin、ExtractTextWebpackPlugin 等等,参见官方文档: plugins | webpack doc

webpack 插件配置方式十分简单,如下即配置了两个插件:

import webpack from "webpack";
import HtmlWebpackPlugin from "html-webpack-plugin";

const config: webpack.Configuration = {
	// ...
  plugins: [
    new webpack.BannerPlugin({
      banner: '(c" ತ,_ತ)'
    }),
    new HtmlWebpackPlugin()
  ]
};

export default config;
复制代码


在 webpack 中,loader 主要职责是转换其他非 JavaScript 和 JSON 类型模块为有效支持的模块,从而交给应用解析。而除此之外,plugin 负责执行了大部分的 webpack 任务,如打包优化,资源管理等等。plugin 作为 webpack 的核心支柱,基本上 loader 不能完成的任务,plugin 都可以解决,由此可见强大之处。

webpack 中 compiler 和 compilation 主要是基于 tapable 这个库构建的,而插件又在前二者的生命周期基础上构建。

比如说,像 compiler 有以下这些生命周期钩子(hook): initialize、emit、make、compile、compilation 等约 28 个。可参考文档:compiler-hooks | webpack。每个钩子都有自己的类型,异步、同步、串行、并行、保险、瀑布流等等。

而 compliation 也一样有生命周期钩子(hook): buildModule、rebuildModule、failedModule、succeedModule、seal 等约 84 个。触发逻辑也是和 complier 的钩子一样,有不同的类型。参考文档:compliation-hooks | webpack

image.png

如何写一个 webpack 插件,也有官方文档:write a plugin | webpack。这里不赘述了。插件基本形式如下:

class FirstWebpackPlugin {
  apply(compiler) {
    compiler.hooks.emit.tapAsync("FirstWebpackPlugin", (compilation, callback) => {
      // ...
      callback();
    });
  }
}
复制代码

插件通过对指定钩子使用 tap、tapAsync、tapPromise 等方式进行注册,而 webpack 内部会在不同生命周期中对特定的钩子进行 call 调用,tapable 从而执行插件注册的函数。

在 webpack 中约 80% 的代码都是插件,因此 webpack 其实就是一个事件驱动的,以插件为基础构建的编译器。

babel

相信大部分同学对 babel 不会陌生,babel 是一个通用的多用途 JavaScript 编译器,在我们的项目里经常会存在一个文件叫做 .babelrc。因为 babel 只是一个编译器,默认情况是什么也不会做的,所以才需要我们编写 .babelrc 来指示 babel 该做什么,而主要就是通过指定 presets 和 plugins 来定义的。

而顾名思义 plugins 就是给 babel 的插件,而 presets 即是预设好的插件集合。

比如常见的有安装 @babel/preset-env 作为预设,我们可以编写最新的 JavaScript 语法代码,它可以根据我们的目标环境,编译转换为合适的最终代码。

然后可以安装 @babel/plugin-transform-runtime 插件,可以为我们的编译文件注入 babel 的工具助手代码,从而节省编译后的代码体积。如下 .babelrc:

{
  "presets": ["@babel/preset-env"],
  "plugins": ["@babel/plugin-transform-runtime"]
}
复制代码


然后我们就可以通过 babel 提供的 cli 工具进行解析了:

npx babel --config-file=./.babelrc source.js --out-file compiled.js
复制代码


当然,babel 存在大量的插件:babel plugins,涉及范围极广,从 ES2015 到 ES2018,以及 React、TypeScript 等等。可以说,现代化开发基本离不开 babel 和它丰富的插件。

babel 插件原理也不复杂,babel 本身会解析输入代码为抽象语法树(AST),然后再编译输出目标代码,当然它本身什么也不做,就类似于:

const babel = code => code;
复制代码

但是如果注入了插件,插件会通过 babel 提供的各种工具,比如 babylon、babel-traversal、babel-types 等等,在中间转换(Transform)过程修改、调整 AST,babel 再将被修改后的 AST 编译为最终代码。从而达到了插件的最终需求。图示如下:

image.png

babel 插件的基本格式如下:

export default function FirstBabelPlugin({ types: t }) {
  return {
    name: "plugin-demo",
    pre(state) {},
    visitor: {
      Identifier(path, state) {},
      BinaryExpression(path, state) {},
      // ...
    },
    post(state) {}
  };
};
复制代码


作为 babel 的插件,只要声明访问的 AST 节点类型,如 Identifier、BinaryExpression 等作为函数,即可有调整、修改 AST 节点的能力。同时还提供了 pre、post 的钩子函数,可以分别在插件运行前后执行。

image.png

当然,如果同学们感兴趣,可以参考这个官方指南手册:babel plugin handbook | Github,同时去这个网站了解下:AST explorer,从而熟悉 AST 节点类型,就完全可以编写自己的 babel 插件来完成特定的需求。

dva

dva 作为前端的数据流管理方案,和 webpack、babel 一样,也依赖了插件模式。在 dva 应用里安装自定义插件如下:

import { create } from "dva-core";
import createImmerPlugin from "dva-immer";

const app = create();

app.use(createImmerPlugin());
复制代码


dva 底层依赖于 dva-core。插件的设计也在其中,可参考文件:Plugin.js | dva-core

那么 dva-core 本身也设计了一些钩子,比如 onError,onReducer,onEffect,onHmr 等等。相比于 webpack,babel 而言,dva 的插件机制远远没有那么复杂。

image.png

dva 插件的编写方式如下:

export default function FirstDvaPlugin() {
  return {
    onEffect() {},
    onReducer() {},
    // ...
  };
}
复制代码

插件通过 app.use(plugin) 注册在 dva 上后,dva-core 会对所有钩子函数进行管理(这里简单理解就是每个钩子对应了一个函数数组)。然后会在某个时机通过 plugin.apply 触发特定钩子,从而遍历执行对应的函数数组。

插件的设计思想

回顾上面探讨了 webpack、babel、dva 的插件机制。可以发现,插件的设计基本上可以归纳划分为三个部分:钩子声明、钩子注册、钩子调用。如图:

image.png

钩子声明

一个框架需要明确自己内部的关键事件、生命周期节点。这个生命周期并不一定是线性的,有可能也是循环的、点状的等等。每一个生命周期钩子基本对应框架的不同场景。就比如 webpack 中 compiler 的 initialize 钩子代表着在 compiler 对象初始化完成后触发。

框架如果本身设计好了所有钩子,基本上可以从钩子的设计上看出框架本身的业务场景和需求解决方向。

钩子调用

当然,钩子一定会得到调用,不被调用的钩子理论上是不会有存在的价值的。钩子的调用时机与地点,决定了钩子的特性需求。

比如如果有个钩子 onAppInit 代表应用初始化钩子。那么它调用的时机应该是应用初始化时调用,调用地点很有可能是在应用代码主入口。

同时针对不同场景,可以设计此钩子是异步还是同步,是并行还是串行等。假设 onAppInit 钩子会允许对接方可以阻塞应用,在 A、B、C 三个业务特定请求完成,才可以继续运行,那么此钩子应设计成为异步并行钩子,并在应用初始化入口调用。

钩子注册

针对不同场景的具体插件,会注册特定的钩子(大于等于一个钩子)。插件组合使用不同特性的钩子,往其中插入具体业务代码。框架加载插件后,即会自动注册钩子。当运行到指定钩子的时候,相应的也会执行具体插件钩入的业务代码。

概括

钩子的设计、注册、调用,其实就是从代码设计上隔离了稳定与不稳定。框架内核因为与业务相关性弱、或者不相干,所以是相对稳定的。而插件实现了具体的业务代码,本质上随业务需求变化会随时产生改动,因此是相对不稳定的。同时插件与插件之间彼此没有关联,又相当于代码上的模块解耦。

image.png

因此,只要能满足上述关系,本质上插件的设计不受限于任何具体的实现形式。

实现插件体系

说了这么多,接下来我们会亲自实践如何编写代码,实现钩子的声明、调用、注册,从而构建一个插件体系。笔者主要会从两种方案:原生实现、和使用 webpack 的核心依赖 tapable 库实现,分别来带大家认识插件设计。

原生实现

这里代码主要参考 dva-core 的源码实现:

import invariant from 'invariant';

type Hook = (...args: any) => void;
type IKernelPlugin<T extends string | symbol> = Record<T, Hook[]>;
type IPlugin<T extends string | symbol> = Partial<Record<T, Hook | Hook[]>>;

class PluginSystem<K extends string> {
  private hooks: IKernelPlugin<K>;

  constructor(hooks: K[] = []) {
    invariant(hooks.length, `plugin.hooks cannot be empty`);

    this.hooks = hooks.reduce((memo, key) => {
      memo[key] = [];
      return memo;
    }, {} as IKernelPlugin<K>);
  }

  use(plugin: IPlugin<K>) {
    const { hooks } = this;
    for (let key in plugin) {
      if (Object.prototype.hasOwnProperty.call(plugin, key)) {
        invariant(hooks[key], `plugin.use: unknown plugin property: ${key}`);
        hooks[key] = hooks[key].concat(plugin[key]);
      }
    }
  }

  apply(key: K, defaultHandler: Hook = () => {}) {
    const { hooks } = this;
    const fns = hooks[key];

    return (...args: any) => {
      if (fns.length) {
        for (const fn of fns) {
          fn(...args);
        }
      } else {
        defaultHandler(...args);
      }
    };
  }

  get(key: K) {
    const { hooks } = this;
    invariant(key in hooks, `plugin.get: hook ${key} cannot be got`);

    return hooks[key];
  }
}

export default PluginSystem;
复制代码

上述代码,本质上原理十分简单,让不同钩子对应一个函数数组。框架可以通过 PluginSystem 的实例中的 use 方法来注册插件(即会更新对应钩子的函数数组),通过 apply 方法来运行钩子。

上述 apply 方法只支持运行同步函数,但是可以稍微修改一下,即可支持运行异步函数。

当然,有了插件系统实现后,我们可以根据对应插件系统的接口设计,编写与业务需求相关的两个插件:

function createLoggerPlugin(appId: string) {
  return {
    onAction(action: { type: string; params: any }) {
      log(`Log from appId: ${appId}`, action.type, action.params);
    }
  };
}

function createAppInitPlugin() {
  return {
    onInit() {
      log(`App init, do something`);
    }
  };
}
复制代码

可以看到,这个插件很简单,其实只是通过函数返回来一个钩子对象。

有了插件系统、和插件后,可以在应用中注册对应的插件,示例如下:

import { log } from './util';
import Plugin from './Plugin';

type Hook = 'onInit' | 'onAction';

/**
 * 基于原生插件构建的 App 示例
 */
(async function App() {
  /**
   * 初始化插件机制 - 钩子声明
   */
  const system = new PluginSystem<Hook>(['onInit', 'onAction']);
  const APP_ID = 'a57e41';

  const appInitPlugin = createAppInitPlugin();
  const loggerPlugin = createLoggerPlugin(APP_ID);
  /**
   * 插件声明、注册
   */
  system.use(appInitPlugin);
  system.use(loggerPlugin);

  /**
   * 插件钩子任意时机调用
   */
  system.apply('onInit')();
  system.apply('onAction')({
    type: 'SET_AUTHOR_INFO',
    params: { name: 'sulirc', email: 'ygj2awww@gmail.com' }
  });
})();
复制代码


至此,框架钩子、业务插件达成了和谐的统一的生态系统。

使用 tapable 实现

当然上述的原生实现可能较为简单,满足不了更为复杂的场景,比如前文提到的异步、并行、瀑布流、保险等钩子类型,如果用原生实现,的确成本和风险都会较大。

好在,我们有 webpack 的核心依赖库:tapable。它提供了丰富的钩子类型,比如 SyncHook 代表着同步钩子,AsyncParallelHook 代表异步并行钩子,AsyncSeriesHook 代表异步串行钩子等等。想具体了解钩子类型含义的话,可以去看 tapable 的 README。

由于 tapable 已经极其强大,再基于此做多余的封装显得多此一举,因此假设框架声明了三个钩子 onError、onAction、onInit 如下,如代码示例:

import invariant from 'invariant';
import {
  SyncHook,
  SyncBailHook,
  SyncWaterfallHook,
  SyncLoopHook,
  AsyncParallelHook,
  AsyncParallelBailHook,
  AsyncSeriesHook,
  AsyncSeriesBailHook,
  AsyncSeriesWaterfallHook,
  HookMap
} from 'tapable';

export interface IPluginHooks {
  onError: SyncHook;
  onAction: AsyncParallelHook;
  onInit: AsyncSeriesHook;
}

interface IPlugin {
  apply: (hooks: IPluginHooks) => void;
}

class TapablePluginSystem {
  hooks: IPluginHooks;

  constructor(plugins: IPlugin[] = []) {
    /**
     * 钩子声明、注册
     */
    this.hooks = {
      onError: new SyncHook(['errMsg']),
      onAction: new AsyncParallelHook(['action']),
      onInit: new AsyncSeriesHook()
    };

    if (~plugins.length) {
      plugins.forEach(plugin => this.use(plugin));
    }
  }

  use(plugin: IPlugin) {
    invariant(plugin.apply, 'plugin.apply cannot be undefined');

    plugin.apply(this.hooks);
  }
}

export default TapablePluginSystem;
复制代码

框架实现完成后,同样,此时声明两个插件如下:

class LoggerPlugin {
  private appId: string;

  constructor(appId: string) {
    this.appId = appId;
  }

  apply(hooks: IPluginHooks) {
    const PluginType = 'LoggerPlugin';
    hooks.onInit.tapPromise(PluginType, () => {
      return fetch('LOGGER_INIT');
    });
    hooks.onAction.tapAsync(PluginType, (action, callback) => {
      report(`Log action from appId: ${this.appId}`, action.type, action.params);
      fetch('APP_INFO')
        .then(() => callback())
        .catch(err => callback(err));
    });
  }
}

class ReportPlugin {
  private appId: string;

  constructor(appId: string) {
    this.appId = appId;
  }

  apply(hooks: IPluginHooks) {
    const PluginType = 'ReportPlugin';
    hooks.onError.tap(PluginType, errMsg => {
      report(`Report error from appId: ${this.appId}:`, errMsg);
    });
    hooks.onAction.tapAsync(PluginType, (action, callback) => {
      report(`Report action from appId: ${this.appId}:`, action.type, action.params);
      fetch('APP_INFO')
        .then(() => callback())
        .catch(err => callback(err));
    });
  }
}
复制代码

插件风格和 webpack 插件很类似,其实原理很简单,框架内部调用插件实例的 apply 从而使插件成功注册钩子即可。

最后,应用需要在适当的时机和地点调用钩子,使用示例:

/**
 * 基于 Tapable 插件构建的 App 示例
 */
(async function TapableApp() {
  const APP_ID = 'a57e41';

  /**
   * 插件声明、注册
   */
  const plugins = [new LoggerPlugin(APP_ID), new ReportPlugin(APP_ID)];
  const system = new TapablePluginSystem(plugins);

  /**
   * 插件钩子任意时机调用
   */
  system.hooks.onInit.promise().then(() => {
    log('onInit hooks complete');
  });
  system.hooks.onAction.callAsync(
    {
      type: 'SET_AUTHOR_INFO',
      params: { name: 'sulirc', email: 'ygj2awww@gmail.com' }
    },
    (err: any) => {
      if (err) {
        console.error(err);
        return;
      }
      log('onAction hooks complete');
    }
  );
  // ...
  system.hooks.onError.call('Fake Error');
  log('onError hooks complete');
})();

复制代码

阅读完上述的示例代码后,是不是有更深刻的认识了。也可以想象到基于插件构建的框架应用的能力是十分强大的,一个具有丰富的钩子接口的复杂应用,可以基于钩子产生无数的插件,甚至产生一个社区。

思考:业务需求中如何应用?

行文至此,笔者认为自己对插件的认识还是较为肤浅、不够深入的。但是正因为写了这篇博文,笔者的确更加认识到了插件模式的魅力。

那么问题来了,如何将上述模式成功应用到实际生产环境中的话,如果光说不用,基本上也只能算是一个“大脑体操”而已。

如何在业务需求中应用插件模式? 这个我相信没有标准答案,但是笔者想给出自己的思考。

首先,一个应用在设计之初,设计者应该有一个长远的视野,能够知道应用的业务方向在哪里?未来有什么可能的发展方向?同时也需要明确能力边界。在大框架设定后,知道了应用未来业务场景,可以细化罗列出大致的业务功能(当然,我觉得也需要保留一定的想象空间,这个往往是最难的点)。

紧接下来,应该是科学的代码设计。设计什么钩子?以及钩子的触发时机、地点(代码触发时空控制问题)?钩子的设计与业务需求紧密相关,同时也与应用的生命周期相关。比如 React 应用会有 componentWillMount、componentDidMount 等系列生命周期,那么一个应用是否也存在 init、fetchData、render、update、destory 等阶段呢?每个阶段是否可以细化成钩子,并且不同阶段适合成为什么类型的钩子?都值得思考。

有了设计良好的钩子,第三方如何钩入实现插件?是通过配置加载,还是可以支持动态加载,插件是否允许动态卸载?插件是否需要权限控制?

以上,都需要在具体设计中,具体思考。

小结

高中时期,我的物理老师曾说过一句话:“一流的学生学思想,二流的学生学方法,三流的学生学题目”,这句话让我印象十分深刻。学习一类事物,学习思想往往才是捷径。思想掌握了,往往也就会举一反三,触类旁通。

希望大家也可以通过本文关于插件体系的探讨,稍微了解插件背后的设计方式、思想原理。以后无论接触到什么框架、库的插件 API,都可以从更高维度去使用、去思考。

当我们掌握了插件的思想后,如果遇到合适的场景,一定不要犹豫,针对具体业务设计实现一个插件系统,应用将会变得更具扩展性,更强大,当然,有了插件机制赋能的应用也会从“应用”升级成为“平台”。

以上,若本文可以给大家带来启示与帮助,将是笔者的荣幸~