Skip to content

插件化概念

TODO: 当我们说插件系统的时候,我们在说什么

🤔 插件化/组件化(楼层异步组件)/中间件/函数式编程(组合)?

  • 前端打包工具的插件化 🔌
    • webpack的各种css、vue的 plugin
    • babel的各种需尝鲜版新ES语法编译插件
    • rollup
    • vite
  • 前端框架的插件化 🔌
    • Vuejs渐进式的router、vuex等
    • React
    • UmiJS、Eggjs、DvaJS等
  • 代码编辑器
    • VSCode的各种插件: prettier、主题皮肤等
  • 浏览器 🔌
    • 谷歌浏览器的插件市场
    • url获取到的网页可以视为插件,浏览器窗口则是主体程序

优点:

  • 减少主体的复杂度,精简核心
    • webpack 、babel 大部分代码都是插件,主体主要是软件生命周期调配,状态流转等,以及少量核心能力的实现
  • 渐进式,按需使用
    • Vuejs,插件更多是对其能力上的一个补充
  • 独立于主体,新增功能不需要发布升级
  • 可扩展性,借助社区提升生命力

设计思路:

  • 哪些是易变的,哪些是相对稳定的
    • 易变的部分应暴露出相应的能力由插件来完成
  • 插件如何影响主体程序
    • 通常会以扩展行为,修改状态,变更展示的方式体现

先整理已知待解决的问题 将这些问题作为样本尝试分类和提取共性 从而形成一套抽象模式

设计一个插件化计算器

普通计算器

js
// 计算器
class Calculator {
  construct(initVal) {
    this.num = initVal;
  }
  // 加法
  add(num) {
    this.num = this.num + num;
    return this;
  }
  // 减法
  subtract(num) {
    this.num = this.num - num;
    return this;
  }
  // 结果
  result() {
    return this.num;
  }
}

const myCalculator = new Calculator(5);
myCalculator.add(5).subtract(2).result(); // 8

👆 只有加减法

插件计算器-插件定义

按照设计思路:

  • 主体是运算:当前值和一个新值的运算过程,这部分是稳定的
  • 支持的运算逻辑(加/减法🔧工具函数)是可扩展的,适合做成插件

👇 运算插件提供计算逻辑作为回调函数

ts
// 插件类型声明
interface Plugin {
  name: string;
  calculate(num: number) => this;
}

// 加法插件
class AddPlugin implements Plugin {
  name: 'add',
  calculate(num) {
    this.num = this.num + num;
    return this;
  }
}

// 减法插件
class SubtractPlugin implements Plugin {
  name: 'subtract',
  calculate(num) {
    this.num = this.num - num;
    return this;
  }
}

👆 name 用于调用方指定使用哪个插件计算逻辑,主体程序调用相应插件提供的回调

插件计算器-主体程序

  • 主体程序调用相应的回调函数存储结果,用于下一次计算
js
// 程序主体,定义程序核心逻辑是增加计算器运算能力
class Calculator {
  plugins = [];
  construct(initVal) {
    this.num = initVal;
  }

  // 安装插件
  use(plugin) {
    this.plugins.push(plugin);
    this[plugin.name] = plugin.calculate.bind(this);
  }

  // 结果
  result() {
    return this.num;
  }

  // 输出插件信息,根据插件信息了解支持什么运算
  help() {
    return `support ${this.plugins.map(plugin => plugin.name).join(',')}`;
  }
}

插件计算器-使用

js
// 初始化计算器
const myCalculator = new Calculator(5);
// 安装插件
myCalculator.use(new AddPlugin());
myCalculator.use(new SubtractPlugin());
 
myCalculator.add(5).subtract(2).result(); // 8

插件化系统

插件化系统组成

  • Program: 主体程序,有着一定顺序的生命周期 Hooks,可以在不同时期调用插件
  • Plugin Loader: 插件管理逻辑,对应👆插件化计算器的 use,最基础的是安装、调用逻辑,可以有复杂的管理逻辑,如卸载插件等
  • Plugins: 插件列表
  • Plugin Impl: 插件实现(Plugin Implementation),就是插件内容
  • Plugin Interface: 插件接口声明,对应👆插件化计算器的类型声明,用于给开发者了解一个插件的基本属性
    • 也可以看作是主体程序,注入给插件的各种数据状态,或特殊回调

插件注入(挂载)、配置、初始化

如何让主体程序感知到插件的存在

👇 插件注入(主程序挂载/匹配插件) 注入的方式一般可以分为

  • 声明式(babel)
    • 通过配置信息,告诉主体程序应该去哪里去取什么插件
    • 主体程序运行时会按照约定与配置去加载对应的插件
    • 如 Babel,通过在babel配置文件中填写插件名称,运行时去 modules 目录下去查找对应的插件并加载(!==下载),如果没有安装则会报错
    • 适合自己单独启动不用接入另一个软件系统的场景,这种情况一般使用编程式进行定制的话成本会比较高,但是相对的,对于插件命名和发布渠道都会有一些限制
  • 编程式(webpack、vue)
    • 主体程序提供某种注册 API,开发者通过将插件传入 API 中来完成注册
    • 适合于需要在开发中被引入一个外部系统的情况

👇 插件配置 配置的主要目的是实现插件的可定制 因为一个插件在不同使用场景下,可能对于其行为需要做一些微调,这时候如果每个场景都去做一个单独的插件那就有点小题大作了 配置如何生效就和👇插件初始化的有点关联

👇 插件初始化 插件配置一般在注入时一起传入,很少会支持注入后再进行重新配置

  • 初始化插件方式
    • 工厂模式
      • 一个插件暴露出来的是一个工厂函数,由调用者传入插件配置信息生成插件实例或者主体程序来传入插件配置信息,生成插件实例
      • babel 是由主体程序根据配置信息实例化插件对象
    • 运行时传入
      • 主体程序在调度插件时会通过约定的上下文把插件配置信息给到插件
  • 初始化插件时机
    • 注入插件时初始化
    • 统一初始化
    • 运行时初始化

💡 类似函数组合,有时候某些插件组合是捆绑的

依赖许多插件组合来完成一件复杂的事情 可以提供一种 Preset 的概念,去打包多个插件及其配置 使用者只需要引入 Preset 即可,不用关心里面有哪些插件

如 Babel 转译 react 语法时,其实要引入

  • syntax-jsx
  • transform-react-jsx
  • transform-react-display-name
  • transform-react-pure-annotationsd
  • 等多个插件

最终给到的是 preset-react这样一个包


插件如何影响主体程序(通信)(plugin interface)

插件影响👇主体程序

  • 行为逻辑
  • 交互
  • 展示UI

主体程序要提供插件需要用到的相关回调或是API 👇 VSCode 插件大致覆盖了这些与主体程序的通信

主体程序的插件调度机制

👇 主体程序直接调用插件 主体程序需要处理好插件调度,这段逻辑可能增加复杂度

  • VSCode 直接调用插件的 activate 和 deactivate

👇 主体程序通过中转的统一调度机制(webpack的Hooks),触发相应插件 钩子机制适合注入点多 松耦合需求高的插件场景 能够减少整个主体程序中插件调度的复杂度

  • webpack

👇 使用者运行时调用插件 本质就是将插件提供的能力,统一作为系统的额外能力对外透出,最后又系统的开发使用者决定什么时候调用

  • JQuery 的插件会注册 fn 中的额外行为
  • Egg 的插件可以向上下文中注册额外的接口能力

这种模式比较适合又需要定制更多对外能力,又需要对能力的出口做收口的场景 如果希望用户通过统一的模式调用你的能力,那大可尝试一下(🤔 设计模式-代理?) 可以尝试使用新的 Proxy 特性来实现这种模式

插件接口(plugin interface)(API签名)

主体程序提供给插件的

  • 纯工具🔧方法:不影响主体程序状态
  • 获取当前系统状态
  • 修改当前系统状态
  • API 形式注入功能:例如注册 UI,注册事件等

为了减少插件破坏系统的可能性 一般建议提供尽可能少的主体程序能力给插件 也可以考虑给插件创造沙箱环境

主体程序的某个时机允许挂载多个插件

👇 覆盖式 只执行最新注册的插件逻辑,跳过原始逻辑

👇 管道式 输入输出相互衔接,一般输入输出是同一个数据类型。

👇洋葱圈式 在管道式的基础上,如果系统核心逻辑处于中间,插件同时关注进与出的逻辑,则可以使用洋葱圈模型。 👆 参考 koa的中间件调度中心

👇集散式 集散式就是每一个插件都会执行,如果有输出则最终将结果进行合并。这里的前提是存在方案,可以对执行结果进行 merge。

👆 都是按照理想的插件回调函数是同步逻辑来调度的 如果需要异步, 可以使用neo-async, 而webpack 的 tapble 已经有相应的异步处理了

VSCode

Clock in status bar 这个

假设已经安装了插件,那插件已经存在于主体程序的某个目录下

package.json 就是插件配置信息

json
"main": "./extension", // 入口文件地址
"contributes": {
  "commands": [{
    "command": "clock.insertDateTime",
    "title": "Clock: Insert date and time"
  }],
  "configuration": {
    "type": "object",
    "title": "Clock configuration",
    "properties": {
      "clock.dateFormat": {
        "type": "string",
        "default": "hh:MM TT",
        "description": "Clock: Date format according to https://github.com/felixge/node-dateformat"
      }
    }
  }
}

👇 插件内容

js
'use strict';

// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
const
  clockService = require('./clockservice'),
  ClockStatusBarItem = require('./clockstatusbaritem'),
  vscode = require('vscode');

// this method is called when your extension is activated
// your extension is activated the very first time the command is executed
function activate(context) {
  // Use the console to output diagnostic information (console.log) and errors (console.error)
  // This line of code will only be executed once when your extension is activated

  // The command has been defined in the package.json file
  // Now provide the implementation of the command with  registerCommand
  // The commandId parameter must match the command field in package.json
  context.subscriptions.push(new ClockStatusBarItem());

  context.subscriptions.push(vscode.commands.registerTextEditorCommand('clock.insertDateTime', (textEditor, edit) => {
    textEditor.selections.forEach(selection => {
      const
        start = selection.start,
        end = selection.end;

      if (start.line === end.line && start.character === end.character) {
        edit.insert(start, clockService());
      } else {
        edit.replace(selection, clockService());
      }
    });
  }));
}

exports.activate = activate;

// this method is called when your extension is deactivated
function deactivate() {
}

exports.deactivate = deactivate;

👆 关注抛出,才是主体程序注入的东西

也就是 VSCode 限制了插件需要抛出2个函数 activate deactivate

  • 插件注入: 从主体程序上看,从插件商店安装的插件,注入方式属于声明式
  • 插件配置: package.json 文件 contributes configuration
  • 插件接口: 限制插件内容结构要抛出activate deactivate,以及主体程序环境全局提供的api

webpack

webpack 核心模块(主体程序)为compiler 和 compilation,他们都有各自的生命周期钩子(Hook)

webpack 核心(主体程序)定义生命周期(或者叫事件流) 插件管理逻辑(Plugin Loader),就是在各个生命周期中调用插件在对应生命周期注册的方法

👆 相对于插件化计算器,一个插件除了定一个插件名、回调函数外 还需要把回调函数指定为不痛的webpack Hooks调用

因为这样主体程序中就涉及生命周期中插入逻辑 那么这段逻辑是

  • 同步执行/异步执行
  • 并行执行/串行执行
  • 执行结果需不需要影响之后的生命周期

因此主体程序为了适应插件,需要有完善的生命周期事件处理机制 webpack 封装了一个 Hook 的核心库 Tapablecompilercompilation 都是基于 Tapable 的实现

👇 也就是webpack的生命周期Hooks有一定的复杂逻辑在

TODO: 关于 Tapable 我们在 重学webpack 里详细讲解

这里有一个模糊的概念即可:webpack 的Hooks事件处理机制就是一个 EventEmitter(发布订阅模式等)

TODO: rollup

rollup怎么处理插件 PluginDriver

jQuery

js
(function ($) {
  $.fn.myPlugin = function () { ... }
})(jQuery)

👇 示例

  • pluginCore:主体程序通过原型链来挂载不同的插件
    • 手动获得jQuery实例(主体对象)后调用插件,而不是主体程序有生命周期Hook自动调用插件
    • 😖 更像是新增了🔧工具方法,而放置的地方不是全局变量,也不想自己引入,于是放到这个主体上
  • plugin:无限制,可以是JavaScript的类型,一般是实现具体功能的模块,比如,日期选择器等。

babel

TODO: 深入babel

babel设计思路 将一种语言的代码转化为另一种语言的代码 遇到问题

  • 无法在设计时就穷举语法类型
  • 也不确定完美转换一种新的语法

转译的三个步骤

  • parse - 分词、词义语法
  • transform - 某些语法树结构转换成相应的已知的语法树结构
  • generate

主要面向 parsetransform 提供插件方式做扩展性支持

通过一些不确定但可能未来待解决的问题来测试 是否存在无法套用的情况

👇 babel插件结构

js
function () {
  return {
    name: '插件名',
    visitor: {
      enter(nodePath) {},
      exit(nodePath) {},
      [NodeType](nodePath) {},
      // NodeType指的是babel转译AST得到的节点类型,利用这个节点类型作为回调函数的key
    }
  }
}

可以看出和 👆插件化计算器非常像,只是回调函数改为了一个对象存储限制变量名的函数属性

👇 箭头函数转译插件 babel-plugin-transform-arrow-functions

ts
import { declare } from "@babel/helper-plugin-utils";

export interface Options {
  spec?: boolean;
}

export default declare((api, options: Options) => {
  api.assertVersion(7);

  const noNewArrows = api.assumption("noNewArrows") ?? !options.spec;

  return {
    name: "transform-arrow-functions",

    visitor: {
      ArrowFunctionExpression(path) {
        // In some conversion cases, it may have already been converted to a function while this callback
        // was queued up.
        if (!path.isArrowFunctionExpression()) return;

        path.arrowFunctionToExpression({
          // While other utils may be fine inserting other arrows to make more transforms possible,
          // the arrow transform itself absolutely cannot insert new arrow functions.
          allowInsertArrow: false,
          noNewArrows,

          // TODO(Babel 8): This is only needed for backward compat with @babel/traverse <7.13.0
          specCompliant: !noNewArrows,
        });
      },
    },
  };
});
  • 第一步,执行该插件,获取到包含visitor对象
  • 第二步,AST 遍历节点,检测nodePath的type === 'ArrowFunctionExpression',寻找到vistor对象的中key为 ArrowFunctionExpression的函数
  • 第三步,将nodePath传入该函数进行调用(AST在这步被修改)

🤔思考: babel主体程序,怎么管理多个插件对同一个AST节点类型做转译回调?

合并所有使用到的babel插件的属性:

  • 通过解析babel的配置文件(或者命令行--plugins参数),获取Babel配置的所有插件的描述
  • 将插件的require进内存,获得插件函数,并执行插件函数,获取到多个包含vistor字段对象(详细逻辑:@babel/core/src/config/full.js
  • 将多个包含vistor字段对象整合成一个大的visitor源码展示(详细逻辑:@babel/core/src/transformation/index.js):
  • AST遍历时,每个节点根据 NodeType,来获取 visitor[NodeType],并依次执行
js
// @babel/traverse
const visitor = traverse.visitors.merge(
  visitors,
  passes,
  file.opts.wrapPluginVisitorMethod
)

👆 节点类型对应的转译插件逻辑变成了 Array<function(nodePath)>


vue-cli

内部源码是插件化的

React插件化组件库-DevExtreme Reactive

目前包含了 Grid / Chart / Scheduler 三个复杂组件,这三个组件都是基于一个插件化框架进行开发的。

jsx
import { PluginHost, Plugin, Template, Getter, Action } from '@devexpress/dx-react-core';

export default function PluginRoot(props) {
 const { chidren } = props;
 return <PluginHost>{children}</PluginHost>;
}

export function FeatureA() {
 return (
   <Plugin name="A">
     <Template name="...">...</Template>
     <Getter name="..." />
     <Action name="..." />
   </Plugin>
 );
}
export function FeatureB() {
 return (
   <Plugin name="B">
     <Template>...</Template>
     <Getter name="..." />
     <Action name="..." />
   </Plugin>
 );
}

👆 类似Vuejs插槽, plugin标签 内的 name标签 会插入到主体程序的 pluginhost标签 内的 name 的位置

jsx
// App.jsx
import PluginRoot, { FeatrueA, FeatureB, ... FeatureN } from './MyComponnent;'

const App = () => (
 <PluginRoot>
   <FeatureA />
   <FeatureB />
   ...
   <FeatrueN />
 </PluginRoot>
);

ReactDOM.render(<App />, rootNode);

参考资料