1
头图
本文作者:月陌

从一个吸尘器说起

说起插件系统,大家或许会对这个概念感到陌生,但其实不然,这个看似很抽象的概念其实在我们日常生活中有着很多很直观体现。最近我准备购置一台吸尘器,我发现现在的吸尘器已经越来越高端了,一个吸尘器能实现拖地,除螨等众多功能,而这一切,都只需要你通过更换不同的吸头,就能实现。从计算机的视角来看,这个吸尘器其实就是一个功能完备的插件系统,这些吸头,就是他的插件生态。

那这样做的好处是什么呢?

  • 对于用户来说:使用更为便利,原本需要同时购买很多产品才能实现的功能,现在只购买这一个吸尘器就拥有了。
  • 对于厂家来说,那好处就更多了:

    • 一方面,降低了实现复杂度,更利于分工协作,核心部门可以专心研发吸尘器的基础功能,可以做到更大吸力,更小噪音,增加自己产品的竞争力,至于吸头可以交给其他部门负责。
    • 另一方面还能利用生态,让其他厂家也参与其中帮自己生产各种能力的吸头(这方面戴森就做的特别不错,网上戴森相关的三方吸头特别多),进一步扩大自己的品牌影响力。

正是因为有着这么多好处,所以现在大到汽车,无人机,小到吸尘器,或多或少都会有一些功能选装配件,这无一不是插件系统在生活中的体现,那回到我们的计算机世界,插件系统更是被广泛应用在各种工具中,例如:Umi,Egg,JQuery,WordPress,Babel,Webpack……

当我们翻开 Umi 的官网,可以在显眼位置看到下面这段话:

Umi 以路由为基础的,同时支持配置式路由和约定式路由,保证路由的功能完备,并以此进行功能扩展。然后配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求。

从上面那段话我们可以看出两个点:

  • 以路由为基础
  • 插件体系

所以 Umi 其实就是一个以路由为基础的插件系统。它的核心功能是路由,其他的功能都是以插件的形式补充的,比如,你需要用 antd 相关的内容,可以引入 plugin-antd, 如果要使用 dva,可以引入 plugin-dva,想使用封装好的请求方法,可以引入 plugin-request ……

通过上面的介绍,相信各位心中对插件系统的已经有了一些自己的认知了,现在让我们来给插件系统下个定义。

什么是插件系统

说起插件系统,先让我们对插件的定义做个说明,我在网上找了很多的资料,大家说法不一,大多都是以应用程序的维度说明的,根据 维基百科(wikipedia) )的解释:

在计算机技术中,插件是一种向现有计算机程序添加特定功能的软件组件。当一个程序支持插件时,它支持自定义

插件必须依赖于应用程序才能发挥自身功能,仅靠插件是无法正常运行的。相反地,应用程序并不需要依赖插件就可以运行,这样一来,插件就可以加载到应用程序上并且动态更新而不会对应用程序造成任何改变。

但是我理解的插件更多是一种设计形态,他可以有很多展示形式。最接近我心中对插件的定义是 handling-plugins-in-php 这篇文章中写的这句:

所谓插件是一种能允许非核心代码在运行时修改应用程序的处理方式。

根据上面对插件化的一些介绍,我们可以给插件系统下一个定义:

插件系数是一个由实现了插件化的核心模块,和其配套的插件模块组成的一种应用组织形式, 其中核心模块能独立运行并实现某种特定的功能,插件模块需要在核心模块上运行,并能在应用程序运行时修改程序的处理方式,从而增强或改变程序的处理结果。

其中插件化的实现大多都是从设计模式演化而来的,大概可以参考的有:观察者模式,策略模式,装饰器模式,中介模式,责任链模式等等。

插件系统一般由两个部分组成:核心系统,插件模块

img

注:有时候,我们也会称插件为:附加组件(add-on),模块(module),扩展(extension),他们从某种意义上来说就是插件。

核心模块

核心模块顾名思义一般是指这个系统的核心功能,它定义了系统的运行方式和基本的业务逻辑。核心系统一般不依赖于任何插件。

比如上面说的 Umi 的核心就是路由

babel 的核心能力就是语法分析(将 js 文件转换 AST)

Webpack 的核心系统就是打包构建能力。

插件模块

插件模块就是遵循对应约定或标准开发的周边配套的配套设施,插件模块可能是一个 js 文件,可能是一个配置文件,也可能是更复杂的一个应用系统,这完全取决于对应的「核心系统」是如何约定和加载插件的。

为什么要做插件化

插件化最重要的意义就是提升整个系统的可扩展性,用一句话来概:插件化能将不断扩张的功能分散在插件中,内部集中维护核心不变逻辑。

它有以下几个显著的好处:

  1. 维护成本低:只需要关注核心系统的稳定性就行了。
  2. 易于协同开发:由于核心系统和插件系统完全是单向依赖关系,而且插件之间基本彼此独立,减少了「沟通协作」成本,易于团队和第三方开发人员能够扩展应用程序,这能很好的利用社区生态。
  3. 降低应用程序(核心包)大小:通过不加载未使用的功能来减小应用程序的大小,大大增加了核心包适用范围。
  4. 轻松增加新功能:在工具开发之初开发者很难就想全应用程序的所有功能,如果把所有功能都写入核心包可能会带来巨大的升级维护成本。但是通过插件系统这种方式,就可以在不影响核心功能基础上快速新增新的功能。

插件的形式

总的来说主要有下面几种插件化形式(个人整理)

  • 约定式插件
  • 注入式插件
  • 事件式插件
  • 插槽式插件

约定式插件

这个是最简单的,只要我们做好约定,就可以很轻松的实现,约定式插件一般依赖核心系统加载自身

如果约定比较简单,只是一些配置式的约定,就完全可以使用简单的 JSON 配置来实现。比如 cms 脚手架 中的每个模板就可以理解为一个插件。我们通过不同的配置约定了模板的展示形式,模板位置,交互问题…… 剩下的就可以由用户完全按自己的需要创建一个新的模板。

image-20210822214015009

但是纯 JSON 能表达的信息量还是有限的。所以通常为了实现更复杂的插件能力,我们也会通常会需要使用函数,比如我们约定一个插件结构是 {name, action},action 可以指定一个 js 函数

module.exports = {
  "name": "increase",
  "action": (data) => data.value + 1
}

再更进一步,通过约定的目录结构来区分功能,比较有代表性的就是Egg,它通过目录结构区分出controllermiddlewareschedule……,不同的目录结构天然对应着不同的生命周期。比如在schedule目录下定义的文件就会自动当作定时任务执行,其中scheduletask方法的结构都是约定好的。

module.exports = {
  schedule: {
    interval: '1m', // 1 分钟间隔
    type: 'all', // 指定所有的 worker 都需要执行
  },
  async task(ctx) {
    const res = await ctx.curl('http://www.api.com/cache', {
      dataType: 'json',
    });
    ctx.app.cache = res.data;
  },
};
举例:Egg

注入式插件

这类插件通常是需要使用核心系统提供的API生命周期,这类插件通常就是一个函数,该函数会接收一个 API 集合,比如Umi,它就是很标准的注入式插件,它的插件形式是一个函数,接收一个 api 集合:

export default (api) => {
  // your plugin code here
};

跟约定式插件不同的是,这类插件,通常会主动调用相关 API 方法把自己的函数或能力注入。

export default function (api: IApi) {
  api.logger.info('use plugin');

  api.modifyHTML(($) => {
    $('body').prepend(`<h1>hello Umi plugin</h1>`);
    return $;
  });

}
举例:webpack, egg, babel

事件插件化

顾名思义,通过事件的方式提供插件开发的能力,最常见比如 dom 事件:

document.on("focus", callback);

虽然只是普通的业务代码,但这本质上就是插件机制:

  • 可拓展:可以重复定义 N 个 focus 事件相互独立。
  • 事件相互独立:每个 callback 之间互相不受影响。

也可以解释为,事件机制就是在一些阶段放出钩子,允许用户代码拓展整体框架的生命周期。

service worker 就更明显,业务代码几乎完全由一堆时间监听构成,比如 install 时机,随时可以新增一个监听,将 install 时机进行 delay,而不需要侵入其他代码。

举例:service workerdom events

插槽插件化

这种插件通常是对 UI 元素的扩展,最经典的代表就是 React 和 Vue 了,它们的组件化其实就是插件的另一种表现。

While React itself is a plugin system in a way, it focuses on the abstraction of the UI.

一个带插槽的组件就可以理解为一个核心系统,而插槽就是提供出的插件入口。这样的好处是实现了 UI 解耦,父元素就不需要知道子元素的具体实例,它只用提供合适的插槽位置就行。

function Menu({ plugins }) {
    return <div clssName="my-menu">
        {plugins.map(p => <div clssName="my-menuitem" style={p.style}>{p.name}</div>)}
    <div>
}

这种方式最常见的使用领域就是 CMS 系统,静态页面生成器……

当然有些情况看似是例外,比如 Tree 的查询功能,就依赖子元素 TreeNode 的配合。但它依赖的是基于某个约定的子元素,而不是具体子元素的实例,父级只需要与子元素约定接口即可。真正需要关心物理结构的恰恰是子元素,比如插入到 Tree 子元素节点的 TreeNode 必须实现某些方法,如果不满足这个功能,就不要把组件放在 Tree 下面;而 Tree 的实现就无需顾及啦,只需要默认子元素有哪些约定即可。

举例:React, gaea-editor。

如何实现插件化?

一般来说,要实现一个插件化能力,核心系统需要提供以下能力:

  • 「必须」确定插件注册加载方式
  • 「必须」 确定核心系统的生命周期和相关相关暴露 API
  • 「非必须」对插件暴露合适范围的上下文,并对不同场景的上下文做隔离(通常是更复杂的插件系统,比如 vscode,chrome 插件)
  • 「非必须」确定插件依赖关系
  • 「非必须」确定插件和核心系统的通信机制

插件大致流程

一个插件系统大致流程如下:首先会经历解析插件的过程,主要是要找到所有需要加载的插件。然后将这些插件都绑定到特定的生命周期或事件上。最后在合适的时机处理和调用对应的插件就行了。

pluginsystem-Page-2

插件解析(引入)方式

以下列举了一些常用的插件引入方式:

  • 通过 npm 名:比如只要 npm 包符合某个前缀,就会自动注册为插件,例:Umi 约定,只要 npm 包的名称使用 @umijs 或者 umi-plugin 开头就会自动加载成插件。
  • 通过文件名:比如项目中存在 xx.plugin.ts 会自动做到插件引用,这一般作为辅助方案使用。
  • 通过代码:这个很基础,就是通过代码 require 就行,比如 babel-polyfill,不过这个要求插件执行逻辑正好要在浏览器运行,场景比较受限。
  • 通过描述文件:这是比较常用的方式,几乎所有的插件系统都会提供一个入口描述文件,比如在 package.json或者对应的配置文件中描述一个属性,表明了要加载的插件,比如 .babelrc:
{
  "plugins": ["babel-plugin-myPlugin", "@babel/plugin-transform-runtime"]
}

Umi 的插件机制

比如 Umi 的插件,就大致有以下几个方法:

image-20210822183457082

  • resolvePlugins:也就是解析插件,是获取对应插件的具体代码,其中的主要处理逻辑是在 getPlugins 里,其中大致流程是从配置文件和约定的位置(包括内置和用户自定义)获取对应的插件地址,然后通过 require 动态加载,形成 [id, apply, opts] 结构,方便后续统一注册加载。

  • initPlugins:就是注册插件的过程,它会调用 initPlugin 依次把插件注册上去,它通过 Proxy 把 PluginApi,Service 上的方法,还有环境变量都注入 api 对象中,然后供插件调用。

image-20210825100823415

这里其实就用到了观察者模式,插件在调用其中特定的方法(api.xxx)的时候,其实就是就会把对应的函数注册到该方法的钩子上。

image-20210825101327467

  • applyPlugins:就是调用插件,在特定的生命周期,通过调用该方法可以通知所有订阅该生命周期的函数。

image-20210822190648195

可以从上述步骤中看出,Umi这一套流程也遵循之前我们说的:解析插件 ——> 注册插件 ——> 调用插件 这么几个过程。

如何撸一个超简单的插件系统

Talk is cheap, show me the code

这里借一个计算器的例子讲一下插件系统(点击这里可以去 codesandbox 看实际例子)。

比如说下面这个例子,这个计算器的核心功能是:拥有基本设置值的能力(应该是最简单的能力了),然后我们在此基础上提供了两个方法,自增和自减。

image-20210824210221437

import React, { useState } from "react";
import "antd/dist/antd.css";
import "./index.css";
import { Button } from "antd";

export default function Calculator(props) {
  const { initalValue } = props;
  const [value, setValue] = useState(initalValue || 0);

  const handleInc = () => setValue(value + 1);

  const handleDec = () => setValue(value - 1);


  return (
    <div>
      <div>{value}</div>
      <Button onClick={handleInc}>inc</Button>
      <Button onClick={handleDec}>dec</Button>
    </div>
  );
}

这时候,如果我们想要继续扩展它的能力,不使用插件化的思想,我们可能会直接在上面扩展函数:

export default function Calculator(props) {
  const [value, setValue] = useState(initalValue || 0);

  const handleInc = () => setValue(value + 1);

  const handleDec = () => setValue(value - 1);
  // 新增能力
  const handleSquared = () => setValue(value * value);
  
  return (
    <div>
      <div>{value}</div>
      <Button onClick={handleInc}>inc</Button>
      <Button onClick={handleDec}>dec</Button>
      <Button onClick={handleSquared}>squared</Button>
    </div>
  );
}

如果我们用插件化的写法,会怎么做呢,首先,我们会把一些通用的结构抽离出来,约定一个插件的结构:

{
    name, // 按钮名
    exec, // 按下按钮的执行方法
}

然后写该插件被注册上去的通用方法,比如这里我们的每个插件就是一个按钮

  const buttons = plugins.map((v) => (
    <Button onClick={() => v.exec(value, setValue)}>{v.name}</Button>
  ));

  return (
    <div>
      <div>{value}</div>
      {buttons}
    </div>
  );

这里,我们通过一个函数包裹一下,把插件逻辑和渲染逻辑拆分一下,然后把核心插件(按钮)也按这个格式补充上:

export default function showCalculator({ initalValue, plugins }) {
  const corePlugins = [
    { name: "inc", exec: (val, setVal) => setVal(val + 1) },
    { name: "dec", exec: (val, setVal) => setVal(val - 1) }
  ];

  const newPlugins = [...corePlugins, ...plugins];

  return <Calculator initalValue={initalValue} plugins={newPlugins} />;
}

现在就有了最简单一版插件化的计算器,我们可以扩展一个平方插件:

 showCalculator({ initalValue: 1, plugins: [
    { name: "square", exec: (val, setVal) => setVal(val * val) }
  ]}),

image-20210824210403484

进一步,很多插件系统都有生命周期的钩子,我们这边也模拟一下生命周期,一般来说,生命周期可以通过观察者模式,这边写一个最简单的事件机制(真的日常开发可以考虑使用 Tapable

const event = {
  eventList: {},
  listen: function (key, fn) {
    if (!this.eventList[key]) {
      this.eventList[key] = [];
    }
    this.eventList[key].push(fn);
  },
  trigger: function (...args) {
    const key = args.splice(0, 1);
    const fns = this.eventList[key];
    if (!fns || fns.length === 0) {
      return false;
    }
    for (let i = 0, len = fns.length; i < len; i++) {
      const fn = fns[i];
      fn.apply(this, args);
    }
  }
};

export default event;

我们主要就是在注册插件的时候把对应的生命周期事件都注册上,这里我默认所有 on 开头的都是生命周期钩子。

newPlugins.forEach(p => {
    // 把所有on开头的都注册一下
    Object.keys(p)
    .filter(key => key.indexOf('on') === 0 && typeof p[key] === 'function')
    .forEach(key => event.listen(key, p[key]))
  });

然后我们这边开放两个生命周期:onMount 和 onUnMount

 // 这里就简单定义两个生命周期
  const handleMount = () => event.trigger('onMount');

  const handleUnMount = () => event.trigger('onUnMount');

他们的触发条件也很简单,就是在对应的组件中写个 useEffect

  useEffect(() => {
    onMount();
    return () => {
      onUnMount()
    }
  }, []);

这时候,我们在插件中补充上对应的 onMount 方法,输出一句话看看:

image-20210824210943619

OK,这样一个简单的插件系统算是就完成了。

最后

其实讲了这么多,主要想给大家传达的一个插件化的理念,在做设计的时候可以多思考一下应用的最核心能力,专注核心代码的编写,通过插件化的方式扩展其他能力。这样你只用关注核心功能的实现是否可靠,由插件开发者负责其他功能的扩展和可靠性。这样就能保证自己应用在功能稳定的前提下拥有更强的可扩展性。同时这样可以尽量避免写特别复杂且难以维护的代码。

著名的 Javascript 工程师 Nicholas Zakas(JavaScript 高级程序设计高性能 JavaScript 作者,Eslint 作者)曾说过这么一段话:

一个好的框架或一个好的架构很难做错事,你的工作是确保最简单的事情是正确的。一旦你明白了这一点,整个系统就会变得更易于维护。

Nicholas Zakas,Javascript Jabber 075 - 可维护的 Javascript

参考资料

https://en.wikipedia.org/wiki/Plug-in_(computing)

webpack 插件

Babel 插件手册

umi 插件开发

精读《插件化思维》

designing-a-javascript-plugin-system

how-i-created-my-first-plugin-system

Handling Plugins In PHP

Plugin architecture in JavaScript and Node.js with Plug and Play

https://www.bryanbraun.com/2015/02/16/on-designing-great-syst...

本文发布自网易云音乐技术团队,文章未经授权禁止任何形式的转载。我们常年招收各类技术岗位,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!

云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队