概述
插件作为非常常见的软件系统的一部分,存在于我们日常开发的各种场景中。无论是前后端的开发框架,亦或是我们使用的IDE、浏览器,或者是我们日常接触到的其他各种软件系统,都有插件的影子。插件为我们的软件系统提供了丰富的拓展能力,也在某些场景下将系统功能拆分为松耦合的子模块分而治之。
- 你一定会很好奇chrome是怎么支持这么多拓展插件的?
- 亦或想知道vscode是怎么能够提供这么多神奇的功能的?
- 或者是想知道写一个webpack一样的可拓展的插件系统应该怎么入手? 这篇文章将会尝试为解答这些问题提供一个参考方向。
调研
1、Chrome Extension
每一个chrome插件都有一个类似于package.json的核心配置引导文件叫做 manifest.json 。一个最基本的插件甚至只需要manifest.json。这里我们主要看 background 和 content_scripts这两个配置字段。
- background 提供了一个常驻chrome后台的脚本入口
- content_scripts 则提供了一个向页面中注入脚本的入口 除了这两个,还有其他的脚本入口,因为我们主要想看看chome插件系统的设计,所以着重看下这两个就好。
// 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"
}
],
// ...
}
首先来看下background。background 中,chrome提供了一个chrome的全局变量,上面挂载了很多的生命周期钩子,有运行时的钩子,也有在chrome中打开书签的钩子等等。
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的运行环境
- 通过事件钩子异步的通知消息
- 在事件的回调中实现数据通信 所以,我们发现一个插件系统的核心是制定一套消息通信机制,同时将系统运行时的上下文进行封装,按照不同的场景需求暴露给插件。通过消息通信机制将系统和插件隔离,保证插件不会侵入原系统,通过暴露封装后的上下文内容,安全可靠的将系统资源提供给插件调用。
看到这里,你可能会纳闷,为啥已经有了background,还需要 content_scripts 呢?在上面的background的代码中,我们可以看到,虽然会有chrome.runtime.XXX和chrome.bookmarks.XXX的区分,但是如果你想细粒度的控制他们的调用权限,只有一个运行时环境似乎有点难办到。我们知道,chrome除了主web页面外,还有我们开发中经常用的的chrome devtools,它也是支持插件的。如果我们把所有chrome资源都暴露给一个运行时环境(虽然理论上是OK的),就会让插件拥有过大的权限&造成一些未知的风险。所以,当一个软件系统有很多子功能模块的时候,插件系统设计中还需要做到权限区分&分模块的资源隔离。
下表就列出了chrome中集中常见的插件入口下的权限差别。
JS种类 | 可访问的API | DOM访问情况 | JS访问情况 |
---|---|---|---|
background js | 可访问绝大部分API,除了devtools系列 | 不可直接访问 | 不可以 |
content script | 只能访问 extension、runtime等部分API | 可以访问 | 不可以 |
popup js | 可访问绝大部分API,除了devtools系列 | 不可直接访问 | 不可以 |
devtools js | 只能访问 devtools、extension、runtime等部分API | 可以 | 可以 |
2、VSCode Extension
比起Chrome插件,VSCode作为一个基于electron开发的IDE,因为有nodejs的运行时环境,所以相对应的,提供了一套更加复杂的插件系统。具体的能力可以戳code.visualstudio.com/api/extensi… 看。 VSCode官方提供了方便插件开发的脚手架,想要做一个插件的话,只需要简单的执行下面的命令就可以快速开始。
$ npm install -g yo generator-code // 安装 Yeoman 和 对应vscode插件的generator
$ yo code // 会进入一个命令行交互界面,按照需求对应选择就可以快速创建一个vscode插件工程模板
进入生成的项目,你会发现和你熟悉的普通项目几乎没有什么区别,同样的,类似于chrome插件的manifest,这个vscode插件的主入口就是package.json。下面是三个主要的地方
{
// 入口文件
"main": "./src/extension",
// 贡献点,vscode插件大部分功能配置都在这里
"contributes": {
"commands": [
{
"command": "extension.sayHello",
"title": "Hello World"
}
]
},
// 扩展的激活事件
"activationEvents": [
"onCommand:extension.sayHello"
]
}
main 定义了主入口,contributes声明了想要去拓展的vscode的功能(详细的contributes可以看code.visualstudio.com/api/referen…),activationEvents则是告诉vscode什么时候去运行这个插件(详细的activationEvents列表可以看code.visualstudio.com/api/referen…)。 深入到插件实现中看一下:
'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到代码实现,大致有下面几步
- package.json 的 contributes字段中声明要使用commands拓展一个名为 extension.sayHello 的命令插件
- 插件active的时候在vscode.commond上注册并push到订阅器subscriptions中
- package.json 的activationEvents中声明extension.sayHello 的 调用时机为 onCommand 看到这里,各位看官应该就会发现,vscode除了上面提到的消息通信机制和提供上下文外,还解耦了事件监听和插件加载两个环节,从而可以提供更好的插件运行机制。
实现一个插件系统
看到这里,我们已经简单的看了一下chrome和vscode的插件机制,总结一下一个健壮的插件系统应该具备下面几点核心特性:
- 控制插件的加载
- 对插件暴露合适范围的上下文,并对不同场景的上下文做隔离
- 有一套可插拔的消息通信机制,订阅&监听 直接实现一个VS Code或者Chrome有点困难,所以本文以实现一个简单的CLI插件系统为例,讲解如何实现一个简单的插件系统。
1、初始化工程
// 目录
.
├── index.js
└── package.json
// package.json
{
// ...
"bin": {
"plugin": "index.js"
}
}
// index.js
#!/usr/bin/env node
console.log("前端深水区")
然后我们npm link一下,在命令行输入我们定义的plugin,就可以打印出我们的专栏名。这个时候就表示初始化成功了。
2、首先来模拟一下主函数的生命周期
// index.js
#!/usr/bin/env node
function onCreate() {
console.log('onCreate');
}
function onStart() {
console.log('onStart');
}
function main() {
onCreate();
onStart();
}
main();
3、有了前面的准备后,我们首先来写一下插件加载。这里约定一个简单的规则,文件目录下所有plugin开头的文件会被当做插件加载。
// 目录
.
├── index.js
├── package.json
├── plugin-1.js
└── plugin-2.js
// plugin-1.js
console.log('plugin-1 loaded');
// plugin-2.js
console.log('plugin-2 loaded');
// index.js
...
function loadPlugin() {
fs.readdirSync(__dirname)
.filter(item => /^plugin/.test(item))
.forEach(file => require(require.resolve(`${__dirname}/${file}`)));
}
function main() {
loadPlugin();
onCreate();
onStart();
}
...
cli运行一下
$ plugin
plugin-1 loaded
plugin-2 loaded
onCreate
onStart
4、实现了插件的加载后,下面就需要实现最核心的部分了,主文件和插件的通信。这部分较多,直接结合代码说了
// hooks.js,通过这个hook建立一个hash map,相当于一个插件注册中心。每个key代表一个类型的钩子
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
};
通过上面简单的几步,我们已经实现了一个简易的插件系统。完整代码可以在github.com/yvshuo/exte… 看到。
一个插件系统的核心大概就是上面这些东西,基于这个基础,后面可以再拓展出各种各样的插件功能。 其实业界已经有很成熟的插件包,譬如 github.com/webpack/tap… 。它包含很多种不同的hook type:
- Basic hook (without “Waterfall”, “Bail” or “Loop” in its name). This hook simply calls every function it tapped in a row. 最基本的钩子,会连续的去call。
- Waterfall. A waterfall hook also calls each tapped function in a row. Unlike the basic hook, it passes a return value from each function to the next function. 管道式的钩子。
- Bail. A bail hook allows exiting early. When any of the tapped function returns anything, the bail hook will stop executing the remaining ones. 竞速钩子,类比promise.race。
- Sync. A sync hook can only be tapped with synchronous functions (using myHook.tap()). 同步钩子,只能被同步函数调用。
- AsyncSeries. An async-series hook can be tapped with synchronous, callback-based and promise-based functions (using myHook.tap(), myHook.tapAsync() and myHook.tapPromise()). They call each async method in a row. 异步钩子,串行的去call。
- AsyncParallel. An async-parallel hook can also be tapped with synchronous, callback-based and promise-based functions (using myHook.tap(), myHook.tapAsync() and myHook.tapPromise()). However, they run each async method in parallel. 异步钩子,并行的去call。 它的核心思想就也是上面这些~感兴趣的同学可以戳进去详细了解,这里就不再赘述。
总结
一个插件系统的核心有以下几点:
- 控制插件的加载
- 对插件暴露合适范围的上下文,并对不同场景的上下文做隔离
- 有一套可插拔的消息通信机制,订阅&监听
实现一个插件系统的步骤:
- 制定一套加载插件的机制和规则(配置 or 约定 or 注册 等等)
- 提供一个存放插件的仓库
- 统一插件入口,暴露上下文,通过回调等手段实现消息通信
参考资料
1、developer.chrome.com/extensions/…