插件系统 其实见的并不少了,Chrome, Visual Studio Code等等都有自己的一套插件系统,这不是自己也在写一个博客系统嘛,想着实现插件系统会好玩点,本文主要是通过阅读各路大佬的文章,最终总结出来的一些东西

为什么要做一个插件系统?

作为一个系统开发者,开发系统考虑的事情有所局限性,其实现的「features」或者对于一些人来说并不够,他们需要更多的额外功能,但是由于整一个系统的项目结构较复杂,其他人来维护的成本相对来讲会较高。那这个时候就需要使用一个插件来实现那些额外的功能。

当然插件也可以将系统功能拆分为松耦合的子模块,分而治之。

设计插件&系统?

插件有什么

如 VSCode 和 Chrome 的插件,他们内部都自带一个核心配置引导文件:package.jsonmanifest.json

Chrome

比如在 Chrome 中的插件主要有两个配置字段:background, content_scripts

这个是 配置文件

// https://developer.chrome.com/extensions/manifest
{
    // ...
    // 会一直常驻的后台JS或后台页面
    "background": {
      "persistent": false,
      "scripts": ["background_script.js"]
    },
    // 需要直接注入页面的JS
    "content_scripts": 
    [
        {
            // "<all_urls>" 表示匹配所有地址
            "matches": ["<all_urls>"],
            // 多个JS按顺序注入
            "js": ["js/content-script.js"]
            // 代码注入的时间,可选值: "document_start", "document_end", or "document_idle",最后一个表示页面空闲时,默认document_idle
            "run_at": "document_start"
        }
    ],
    // ...
}

在 Chrome 中 它提供了一个全局变量,上面有很多的生命周期钩子,比如说 runTime, onMount, onInstall

chrome.runtime.onInstalled.addListener(function() {
    chrome.contextMenus.create({
      "id": "sampleContextMenu",
      "title": "Sample Context Menu",
      "contexts": ["selection"]
    });
  });
  chrome.bookmarks.onCreated.addListener(function() {
    // do something
  });
  chrome.runtime.onMessage.addListener(function(message, callback) {
    if (message.data == “setAlarm”) {
      chrome.alarms.create({delayInMinutes: 5})
    } else if (message.data == “runLogic”) {
      chrome.tabs.executeScript({file: 'logic.js'});
    } else if (message.data == “changeColor”) {
      chrome.tabs.executeScript(
          {code: 'document.body.style.backgroundColor="orange"'});
    };
  });

总结一下,background中提供的功能有:

  • 提供一个暴露了插件API的运行环境
  • 通过事件钩子异步的通知消息
  • 在事件的回调中实现数据通信

所以,我们发现一个插件系统的核心是制定一套消息通信机制,同时将系统运行时的上下文进行封装,按照不同的场景需求暴露给插件。通过消息通信机制将系统和插件隔离,保证插件不会侵入原系统,通过暴露封装后的上下文内容,安全可靠的将系统资源提供给插件调用

Zhihu.潜语.插件系统的设计 https://zhuanlan.zhihu.com/p/106183037

当一个软件系统有很多子功能模块的时候,插件系统设计中还需要做到权限区分&分模块的资源隔离,因此才有了 content_scripts 这个东西,但用 background 仅能做到运行时环境的权限管控,无法做到细粒度控制

TypeAccessible APIDOM AccessJS Access
Background除 devtools不能直接访问不可以
Content_script只能访问 extension, runtime可以不可以
Popup除 devtools不能直接访问不可以
DevToolsdevtools, extension, runtime 中的一部分可以可以

VSCode

VSCode的技术栈和我的技术栈十分的接近(其实也就只是同 TypeScript 啦)因为有nodejs的运行时环境,所以相对应的,提供了一套更加复杂的插件系统。

通过 Yeoman 初始化插件项目,你在package.json中可以得到以下内容:

{
    // 入口文件
    "main": "./src/extension",
    // 贡献点,vscode插件大部分功能配置都在这里
    "contributes": {
        "commands": [
            {
                "command": "extension.sayHello",
                "title": "Hello World"
            }
        ]
    },
  // 扩展的激活事件
    "activationEvents": [
        "onCommand:extension.sayHello"
    ]
}

main 定义了主入口,contributes声明了想要去拓展的vscode的功能(详细的contributes可以看https://code.visualstudio.com/api/references/contribution-points),`activationEvents`则是告诉vscode什么时候去运行这个插件(详细的activationEvents列表可以看https://code.visualstudio.com/api/references/activation-events)

看看插件实现:

'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
// vscode 模块提供了VS Code 插件拓展的API
import * as vscode from 'vscode';
// this method is called when your extension is activated
// your extension is activated the very first time the command is executed
// 这个方法可以理解为VS Code插件的主入口
export function activate(context: vscode.ExtensionContext) {
    // 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
    console.log('Congratulations, your extension "hello-world" is now active!');
    // 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
    // 重点看这个注册机制!!!
    let disposable = vscode.commands.registerCommand('extension.sayHello', () => {
        // The code you place here will be executed every time your command is executed
        // Display a message box to the user
        vscode.window.showInformationMessage('Hello World!');
    });
    context.subscriptions.push(disposable);
}
// this method is called when your extension is deactivated
export function deactivate() {
}
  • package.json 的 contributes字段中声明要使用commands拓展一个名为 extension.sayHello 的命令插件
  • 插件active的时候在vscode.commond上注册并push到订阅器subscriptions中
  • package.json 的activationEvents中声明extension.sayHello 的 调用时机为 onCommand 看到这里,各位看官应该就会发现,vscode除了上面提到的消息通信机制提供上下文外,还解耦了事件监听和插件加载两个环节,从而可以提供更好的插件运行机制。

最为简单的方法 实现一个插件系统

根据 Chrome 和 VSCode 的插件系统,所需要做到的插件系统就需要做到:

  • 控制插件的加载
  • 对插件暴露合适范围的上下文,并对不同场景的上下文做隔离
  • 有一套可插拔的消息通信机制,订阅&监听

首先先在index.js模拟插件生命周期

#!/usr/bin/env node
function onCreate() {
  console.log('onCreate');
}
function onStart() {
  console.log('onStart');
}
function main() {
  onCreate();
  onStart();
}
main();

之后可以在同级目录下 创建两个文件作为插件:plugin-*.js 里面就最简单地写个 console.log()

之后 我们在 index.js 中要将插件注入

// ...
function loadPlugin() {
  fs.readdirSync(__dirname)
    .filter(item => /^plugin/.test(item))
    .forEach(file => require(require.resolve(`${__dirname}/${file}`)));
}
// ...

通过运行它,你就可以收到返回了

plugin-1 loaded
plugin-2 loaded
onCreate
onStart

实现了插件的加载后,下面就需要实现最核心的部分了,主文件和插件的通信

在这个地方我们需要有一个插件注册中心(hash map)每个key代表一个类型的钩子,我们新建一个 hook.js 来实现

// hook.js
class Hooks {
  constructor() {
    this.hooks = new Map();
  }
  add(name, fn) {
    const hooks = this.get(name);
    hooks.add(fn);
    this.hooks.set(name, hooks);
  }
  get(name) {
    return this.hooks.get(name) || new Set();
  }
  invoke(name, ...args) {
    for (const hook of this.get(name)) {
      hook(...args);
    }
  }
  async invokePromise(name, ...args) {
    for (const hook of this.get(name)) {
      await hook(...args);
    }
  }
}
module.exports = new Hooks();
// index.js
#!/usr/bin/env node
const fs = require('fs');
const hookBus = require('./hooks');
function onCreate() {
  console.log('onCreate');
  hookBus.invoke('onCreate',{a: 1,b: 2}); // 这里增加了主生命周期钩子的注册,可以将主流程中的上下文变量传过去
}
async function onStart() {
  console.log('onStart');
  await hookBus.invokePromise('onStart', {a: 3, b: 4}); // 这里是一个主生命周期异步钩子的注册
}
// 这个方法传给plugin,提供给插件来调用钩子
function hook(name, fn) {
  hookBus.add(name, fn);
}
function loadPlugin() {
  fs.readdirSync(__dirname)
    .filter(item => /^plugin/.test(item))
    .forEach(file =>
      require(require.resolve(`${__dirname}/${file}`)).apply(hook) // 这里统一向钩子暴露了apply方法,作为插件主入口
    );
}
function main() {
  loadPlugin();
  onCreate();
  onStart();
}
main();
// plugin-1.js
console.log('plugin-1 loaded');
function apply(hook) {
  hook('onCreate', function(ctx) {
    console.log('plugin-1 onCreate');
    console.log(ctx);
  });
  hook('onStart', function(ctx) {
    console.log('plugin-1 onStart');
    console.log(ctx);
  });
}
module.exports = {
  apply
};
// plugin-2.js
console.log('plugin-2 loaded');
function apply(hook) {
  hook('onCreate', function(ctx) {
    console.log('plugin-2 onCreate');
    console.log(ctx);
  });
}
module.exports = {
  apply
};

通过上面简单的几步,我们已经实现了一个简易的插件系统

总结

一个插件系统的核心有以下几点:

  • 控制插件的加载
  • 对插件暴露合适范围的上下文,并对不同场景的上下文做隔离
  • 有一套可插拔的消息通信机制,订阅&监听

实现一个插件系统的步骤:

  • 制定一套加载插件的机制和规则(配置 or 约定 or 注册 等等)
  • 提供一个存放插件的仓库
  • 统一插件入口,暴露上下文,通过回调等手段实现消息通信

Reference

Zhihu.潜语.插件系统的设计 https://zhuanlan.zhihu.com/p/106183037

Juejin.末日沙兔.设计一个node.js插件式系统 https://juejin.cn/post/7077202852694720525