乐趣区

关于前端:当我们说插件系统的时候我们在说什么

本文作者:月陌

从一个吸尘器说起

说起插件零碎,大家或者会对这个概念感到生疏,但其实不然,这个看似很形象的概念其实在咱们日常生活中有着很多很直观体现。最近我筹备购买一台吸尘器,我发现当初的吸尘器曾经越来越高端了,一个吸尘器能实现拖地,除螨等泛滥性能,而这所有,都只须要你通过更换不同的吸头,就能实现。从计算机的视角来看,这个吸尘器其实就是一个性能齐备的插件零碎,这些吸头,就是他的插件生态。

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

  • 对于用户来说:应用更为便当,本来须要同时购买很多产品能力实现的性能,当初只购买这一个吸尘器就领有了。
  • 对于厂家来说,那益处就更多了:

    • 一方面,升高了实现复杂度,更利于分工协作,外围部门能够分心研发吸尘器的根底性能,能够做到更大吸力,更小乐音,减少本人产品的竞争力,至于吸头能够交给其余部门负责。
    • 另一方面还能利用生态,让其余厂家也参加其中帮本人生产各种能力的吸头(这方面戴森就做的特地不错,网上戴森相干的三方吸头特地多),进一步扩充本人的品牌影响力。

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

当咱们打开 Umi 的官网,能够在显眼地位看到上面这段话:

Umi 以 路由为根底的 ,同时反对配置式路由和约定式路由,保障路由的性能齐备,并以此进行 性能扩大 。而后配 以生命周期欠缺的插件体系,笼罩从源码到构建产物的每个生命周期,反对各种性能扩大和业务需要。

从下面那段话咱们能够看出两个点:

  • 以路由为根底
  • 插件体系

所以 Umi 其实就是一个以路由为根底的插件零碎。它的外围性能是路由,其余的性能都是以插件的模式补充的,比方,你须要用 antd 相干的内容,能够引入 plugin-antd,如果要应用 dva,能够引入 plugin-dva,想应用封装好的申请办法,能够引入 plugin-request ……

通过下面的介绍,置信各位心中对插件零碎的曾经有了一些本人的认知了,当初让咱们来给插件零碎下个定义。

什么是插件零碎

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

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

插件必须依赖于应用程序能力施展本身性能,仅靠插件是无奈失常运行的。相同地,应用程序并不需要依赖插件就能够运行,这样一来,插件就能够加载到应用程序上并且动静更新而不会对应用程序造成任何扭转。

然而我了解的插件更多是一种设计状态,他能够有很多展现模式。最靠近我心中对插件的定义是 handling-plugins-in-php 这篇文章中写的这句:

所谓插件是一种能容许非核心代码在运行时批改应用程序的解决形式。

依据上面对插件化的一些介绍,咱们能够给插件零碎下一个定义:

插件系数是一个由 实现了插件化的外围模块 ,和其配套的 插件模块 组成的一种利用组织模式,其中外围模块能独立运行并实现某种特定的性能,插件模块须要在外围模块上运行,并能在利用程序运行时批改程序的解决形式,从而加强或改变程序的处理结果。

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

插件零碎个别由两个局部组成:外围零碎,插件模块

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

外围模块

外围模块顾名思义个别是指这个零碎的外围性能,它定义了零碎的运行形式和根本的业务逻辑。外围零碎个别不依赖于任何插件。

比方下面说的 Umi 的外围就是路由

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

Webpack 的外围零碎就是打包构建能力。

插件模块

插件模块就是遵循对应约定或规范开发的周边配套的配套设施,插件模块可能是一个 js 文件,可能是一个配置文件,也可能是更简单的一个利用零碎,这齐全取决于对应的「外围零碎」是如何约定和加载插件的。

为什么要做插件化

插件化最重要的意义就是晋升整个零碎的可扩展性,用一句话来概:插件化能将一直扩张的性能扩散在插件中,外部集中保护外围不变逻辑。

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

  1. 保护成本低:只须要关注外围零碎的稳定性就行了。
  2. 易于协同开发:因为外围零碎和插件零碎齐全是单向依赖关系,而且插件之间根本彼此独立,缩小了「沟通合作」老本,易于团队和第三方开发人员可能扩大应用程序,这能很好的利用社区生态。
  3. 升高应用程序(外围包)大小:通过不加载未应用的性能来减小应用程序的大小,大大增加了外围包适用范围。
  4. 轻松减少新性能:在工具开发之初开发者很难就想全应用程序的所有性能,如果把所有性能都写入外围包可能会带来微小的降级保护老本。然而通过插件零碎这种形式,就能够在不影响外围性能根底上疾速新增新的性能。

插件的模式

总的来说次要有上面几种插件化模式(集体整顿)

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

约定式插件

这个是最简略的,只有咱们做好约定,就能够很轻松的实现,约定式插件个别 依赖外围零碎加载本身

如果约定比较简单,只是一些配置式的约定,就齐全能够应用简略的 JSON 配置来实现。比方 cms 脚手架 中的每个模板就能够了解为一个插件。咱们通过不同的配置约定了模板的展现模式,模板地位,交互问题…… 剩下的就能够由用户齐全按本人的须要创立一个新的模板。

然而纯 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 插件)
  • 「非必须」确定插件依赖关系
  • 「非必须」确定插件和外围零碎的通信机制

插件大抵流程

一个插件零碎大抵流程如下:首先会经验解析插件的过程,次要是要找到所有须要加载的插件。而后将这些插件都绑定到特定的生命周期或事件上。最初在适合的机会解决和调用对应的插件就行了。

插件解析(引入)形式

以下列举了一些罕用的插件引入形式:

  • 通过 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 的插件,就大抵有以下几个办法:

  • resolvePlugins:也就是解析插件,是获取对应插件的具体代码,其中的次要解决逻辑是在 getPlugins 里,其中大抵流程是从配置文件和约定的地位(包含内置和用户自定义)获取对应的插件地址,而后通过 require 动静加载,造成 [id, apply, opts] 构造,不便后续对立注册加载。
  • initPlugins:就是注册插件的过程,它会调用 initPlugin 顺次把插件注册下来,它通过 Proxy 把 PluginApi,Service 上的办法,还有环境变量都注入 api 对象中,而后供插件调用。

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

  • applyPlugins:就是调用插件,在特定的生命周期,通过调用该办法能够告诉所有订阅该生命周期的函数。

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

如何撸一个超简略的插件零碎

Talk is cheap, show me the code

这里借一个计算器的例子讲一下插件零碎(点击这里能够去 codesandbox 看理论例子)。

比如说上面这个例子,这个计算器的外围性能是:领有根本设置值的能力(应该是最简略的能力了),而后咱们在此基础上提供了两个办法,自增和自减。

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) }
  ]}),

进一步,很多插件零碎都有生命周期的钩子,咱们这边也模仿一下生命周期,一般来说,生命周期能够通过观察者模式,这边写一个最简略的事件机制(真的日常开发能够思考应用 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 办法,输入一句话看看:

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!

退出移动版