前言
接上篇如何设计一个应用框架。
其实如果你了解过各种框架和库,你就会发现插件是其中必不可少的一个模块,究其原因,插件为框架和库带来了极大的扩展性和灵活性,有了插件,这些框架和库才可能被广泛的应用,这里可以参考 Webpack、Rollup、Vite 这些成熟的库。插件系统是否强大,往往决定了一个库或者框架的受欢迎程度。
本篇就结合上篇提到的插件,来手搓一个插件系统。
插件的功能
对于我们的应用框架而言,插件就是一个方法,用来改变某些配置,或者是在特定时机执行一些操作,以此我们可以得到三种操作类型的插件:
- 修改配置
- 新增配置
- 事件回调
除此之外,我们的应用框架是一个微内核的设计,对于框架的核心功能,开发服务器、打包构建等命令,也需要使用插件来实现,这样可以把命令也封装到插件中,甚至可以在插件中自己注册事件,然后在框架执行的特定时期进行调用。这些方法统称为钩子 ,所以插件系统说到底就是一个发布订阅模式。
插件的核心
如何组织这些钩子方法之间的关系,协调好修改、新增以及事件之间的区别就是插件的核心。好在我们不用费劲心力重新设计出一套发布订阅模式,奉行一个"拿来主义"原则,我们直接使用 Webpack 的插件核心库tapable
,不了解 tapable 的同学可以先去这里了解一下。
tapable 提供了多种类型的钩子,我们只需用到以下两种:
- AsyncSeriesWaterfallHook
- SyncWaterfallHook
简单介绍一下这两种钩子,AsyncSeriesWaterfallHook
顾名思义,是接受异步方法作为回调,并且按照顺序(series)的瀑布流(waterfall)钩子,举个🌰:
js
const hook = new AsyncSeriesWaterfallHook(['args']);
hook.tapPromise('eventName1', async args => {
const p = await args;
console.log(p);
return {
...p,
height: 180
};
});
hook.tapPromise('eventName2', async args => {
const p = await args;
console.log(p);
return {
...p,
age: 18
};
});
hook.tapPromise('eventName3', async args => {
const p = await args;
console.log(p);
return {
...p,
sex: 'male'
}
});
hook.promise(Promise.resolve({ name: 'qiugu' })).then(res => {
console.log(res);
});
// 执行结果
// { name: 'qiugu' }
// { name: 'qiugu', height: 180 }
// { name: 'qiugu', height: 180, age: 18 }
// { name: 'qiugu', height: 180, age: 18 }
// { name: 'qiugu', height: 180, age: 18, sex: 'male' }
总结一下,AsyncSeriesWaterfallHook
是接受异步 回调函数,串行 执行,并且每个钩子返回的值会作为下一个钩子的参数。
SyncWaterfallHook
也是同理,根据名称我们就能猜测,它是一个接受同步回调,并且返回值会作为下一个钩子的参数。这里就不再举例说明了。
当然 tapable 的钩子不止这些,具体可以参考上面的链接来学习。而我们只需要这两种钩子就可以构建上面提到的三种类型的插件,接下来就来试一试。
插件对象的定义
先确定好插件的使用方式,对比一下 vite、webpack以及 babel 的插件分别如下:
js
// vite 插件导出一个方法
import { react } from '@vitejs/plugin-react-swc';
export default defineConfig({
plugins: [react()]
});
// webpack 插件导出了一个类
import HtmlWebpackPlugin from 'html-webpack-plugin';
export default {
plugins: [
new HtmlWebpackPlugin();
]
}
// babel 插件是一个 npm 包
{
plugins:
[
[
"babel-plugin-import",
{
libraryName: "antd",
libraryDirectory: "lib",
style: "css",
}
]
]
}
这里采用的方式类似第三种,也就是 babel 插件的形式,原因后续会提到。
先分析一下这种插件方式该如何实现?babel-plugin-import 作为一个 npm 包,直接使用它放入插件配置中,而不是从这个包中导出方法来使用,这么做其实就是运行时的理念,等到执行插件的时候,利用 require 加载对应的插件,由于插件可能是 TS 文件,所以在加载前需要对 TS 文件编译为 JS,然后拿到插件暴露的方法。
整个执行流程应该是这样的:
值得一提的是我们可以利用一个库 pirates
,它是用来捕获 require 操作的,也就是说上面 require(plugin)
时候,使用 esbuild
将插件编译为 JS 文件,然后再去 require 就能拿到插件的方法了。
我们插件大概是这样的:
typescript
class Plugin {
cwd: string;
path: string;
// 插件的配置,包含默认配置,以及插件的配置对象
config: { default: any; scheme: any } = {};
id: string; 插件的路径简写,作为插件的唯一id
key: string; 插件的配置对象的key
constructor(opts: { cwd: string; path: string }) {
this.cwd = opts.cwd;
this.path = path;
let ret;
this.apply = () => {
// 注册监听 require 的钩子
register({
implementor: esbuild,
ext: ['ts']
});
ret = require(this.path);
return ret.default ? ret.default : ret;
}
}
}
register 方法做了什么呢?其实就是上文提到的 require 的钩子:
ts
import { addHook } from 'pirates';
function register(opts: { implementor: any; ext: string[] }) {
const revert = addHook(
(code, filename) => {
// 使用 esbuild 编译得到的 ts 代码
}
{
ext: opts.ext
}
)
}
这样我们插件的使用方式就可以是这样的:
ts
{
plugins: [require.resolve('./path-a/path-b/index.ts')]
}
接下来就该丰富插件的使用,让插件能够完成「插件功能」中提到的那些钩子。
插件上下文
首先根据上文的内容,我们的插件看起来应该是这样的:
ts
// path-a/path-b/index.ts
export default function foo(api) {
// 拿到插件上下文做一些操作
// 注册钩子
api.register({ key, plugin, fn });
// 注册命令
api.registerCommands({ key, plugin, fn });
// 注册自定义方法
api.registerMethods({ key, plugin, fn });
}
无论是钩子、方法还是命令,都需要一个地方存储,这里就可以选择我们的 CoreService 类,这个类之前提到过,是这样的:
ts
class CoreService {
// 工作目录
cwd: string;
// 用于存储注册的插件,key 为插件id,值为Plugin
plugins: Record<string, Plugin>;
// 用户配置文件内容
userConfig: UserConfig;
/**
* 构造函数
* @param framework 用于自定义输出到终端的框架名称
* @param env 省略
* @param cwd 省略
* @param plugins 插件
* @param presets 预设
*/
constructor(opts: { framework?: string; env: string; cwd: string; plugins?: any[]; presets?: [] }) {
this.cwd = cwd;
this.opts = opts;
};
/**
* 命令执行方法
* @param opts name为执行的命令,args 为执行命令的参数
*/
run (opts: { name: string; args?: any }) {};
/**
* 解析预设,拿到预设中的所有插件
*/
initPresets (opts: { preset: Plugin; presets: Plugin[]; plugins: Plugin[] }) {};
/**
* 解析插件,执行插件
*/
initPlugins(opts: { preset: Plugin; presets: Plugin[]; plugins: Plugin[] }) {};
}
我们需要给这个类添加属性来保持插件注册的方法、钩子以及命令:
ts
class CoreService {
// 省略其他
hooks: Record<string, {}> = {};
methods: Record<string, {}> = {};
commands: Record<string, {}> = {};
}
方法其实是一种特殊的钩子,钩子可以抽象出这样一个对象
ts
class Hook {
constructor(opts: {
key: string;
plugin: Plugin;
fn: Function;
stage?: number;
before?: string;
}) {
this.key = opts.key;
this.plugin = opts.plugin;
this.fn = opts.fn;
// 这是传递给 tapable 的参数,下面会提到
this.stage = opts.stage || 0;
this.before = opts.before;
}
}
方法如果没有提供 fn 参数,就会注册成钩子,钩子就是提供了 tapable 参数传递能力的方法,而自己定义 fn,则不具备这种能力。
而对于命令来说,和 Hook 是不同的,一个命令的组成可能包含以下几个部分:
- 命令
- 命令参数
- 命令的描述信息,用于命令帮助信息
为了方便组织命令信息,同 Hook 一样,来写一个 Command 类:
ts
class Command {
constructor(
opts: {
name: string;
options?: string;
description?: string;
plugin: Plugin;
}
) {
this.name = opts.name;
this.options = opts.options;
this.description = opts.description;
this.plugin = opts.plugin;
}
}
我们来改造一下 CoreService 类:
ts
class CoreSerivce {
// 省略其他
// 钩子是个回调函数的列表,可能包含多个 hook
hooks: Record<string, Hook[]> = {};
// 方法一个 key 只对应一个 plugin 和 fn,所以不是数组
methods: Record<string, { plugin: Plugin, fn: Function }> = {};
commands: Record<string, Command> = {};
}
这样就可以分别去注册这三种类型的插件 API 了:
ts
class PluginAPI {
constructor(opts: { plugin: Plugin; service: CoreService }) {
this.plugin = opts.plugin;
this.service = opts.service;
}
register(opts: { key: string; plugin: Plugin; fn: Function }) {
this.service.hooks[opts.key] = (this.service.hooks[opts.key] || []).push(new Hook(opts));
}
registerMethods(opts: { name: string; plugin: Plugin; fn: Function }) {
if (this.service.methods[opts.name]) {
throw new Error('方法不能重复注册!');
}
// 这里匿名函数不能使用箭头函数,否则this指向就错误了
// fn 不存在,就会注册为钩子
this.service.methods[opts.name] = opts.fn || function(fn) {
this.register({
key: opts.name,
// 判断 fn 是不是纯对象,否则就以 fn 构造一个纯对象
...(isPlainObject(fn) ? fn : {fn})
});
}
}
registerCommands(opts: {
name: string;
fn: Function;
options?: string;
description?: string;
}) {
if (this.service.commands[opts.name]) {
throw new Error('命令已经注册过了!');
}
this.service.commands[opts.name] = new Command(opts);
}
}
于是这样就可以像 「插件上下文」 中所述的那样使用插件了。除了命令插件以外,钩子插件虽然注册了,但是该如何调用,这是需要思考的问题。
注册插件钩子
其实一开始我们就说了给插件定义的三种操作类型:新增,修改、事件回调。并且利用 tapable 就可以注册并调用这些钩子,这也是上面 Hook 类的作用。
我们可以定义一个方法,分别注册这三种操作:
ts
// 放入 CoreService 类中,作为类方法
function applyPlugins(opts: {
key: string; // 指定触发钩子的标识
type: 'add' | 'modify' | 'event'; // 插件操作类型
initialValue?: any; // 初始值
args?: any; // 插件传递的参数
sync?: boolean; // 是否是同步
}) {
// 拿到对应触发 key 的 hook
const hooks = this.service.hooks[opts.key];
switch(opts.type) {
case 'add': {
// 注册新增配置的钩子
break;
}
case 'modify':
// 注册修改配置的钩子
break;
case 'event':
// 注册事件回调的钩子
break;
default:
throw new Error('插件钩子类型只能是 add,modify,event');
}
}
接着利用 AsyncSeriesWaterfallHook
注册新增配置的钩子:
ts
// 接上文注册新增配置的钩子代码
const tAdd = new AsyncSeriesWaterfallHook(['memo']);
// 遍历对应名称的所有钩子
for(const hook of hooks) {
// 注册钩子
tAdd.tapPromise(
{
name: opts.key,
// stage 表示执行钩子的优先级,数字越大执行越靠后,0为默认值
stage: hook.stage || 0,
// before 是一个字符串,用于指定当前钩子,在指定名称的钩子之前执行,其实还是控制事件执行的优先级
before: hook.before
},
async memo => {
// 还记得上面的参数 args 吗,就是传入到 hook 的参数
const ret = await hook.fn(opts.args);
return memo.concat(ret);
}
);
}
// 返回执行注册的钩子们的最终结果
return tAdd.promise(opts.initialValue || []);
接着就是 modify 的注册钩子的实现:
ts
const tModify = new AsyncSeriesWaterfallHook(['memo']);
for(const hook of hooks) {
// 注册钩子
tAdd.tapPromise(
{
name: opts.key,
stage: hook.stage || 0,
before: hook.before
},
async memo => {
// modify 的钩子会注入两个参数,一个是上次的修改得到的结果,另外一个是执行钩子传入的参数
const ret = await hook.fn(memo, opts.args);
return ret;
}
);
}
return tModify.promise(opts.initialValue);
最后则是注册事件回调:
ts
// 事件回调的同步异步分开注册
if (opts.sync) {
const tEvent = new SyncWaterfallHook(['_']);
for (const hook of hooks) {
// 这里其实没有用到 waterfall 钩子类型的注入参数,是不是可以换成 SyncHook?
tEvent.tap(
{
name: opts.key,
stage: hook.stage || 0,
before: hook.before
},
() => {
hook.fn(opts.args);
}
);
}
// 使用1也只是为了做占位的参数
tEvent.call(1);
}
// 异步事件回调
const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) {
tEvent.tapPromise(
{
name: opts.key,
stage: hook.stage || 0,
before: hook.before
},
async () => {
await hook.fn(opts.args);
}
);
}
return tEvent.promise(1);
至此插件中我们就可以在插件中注册钩子,方法,然后在特定的时机去 applyPlugins
触发钩子,注意注册的命令并不在钩子的执行中。
于是梳理一下整个插件的流程:
上图还有一个蓝色的标注 proxy
的线条,表示代理,意思是将 applyPlugins
和 methods
代理到 PluginAPI 上,这样插件就可以使用它们来执行钩子或者是注册的方法了。
如何做呢?
ts
// PluginAPI 类中的静态方法
static proxyPluginAPI(opts: {
pluginAPI: PluginAPI;
service: Service;
serviceProps: string[];
staticProps: Record<string, any>;
}) {
return new Proxy(opts.pluginAPI, {
get: (target, prop: string) => {
// 这样就可以拿到 service 上的注册的 methods 方法
if (opts.service.methods[prop]) {
return opts.service.pluginMethods[prop].fn;
}
// 可以拿到 service 上的 props
if (opts.serviceProps.includes(prop)) {
const serviceProp = opts.service[prop];
return typeof serviceProp === 'function'
? serviceProp.bind(opts.service)
: serviceProp;
}
// PluginAPI 上原本的方法
return target[prop];
}
});
}
这就是代理模式的体现,在 pluginAPI 上可以访问到 CoreService 上的属性,但是上下文是如何注入插件方法中呢?继续看下去。
插件注册时机
这里需要提到一个概念:预设,所谓预设就是一系列插件的集合,所以可以将插件分为两种类型(注意插件类型和上面的插件操作类型做区分),一种就是普通插件,一种就是预设插件。
预设插件看起来是这样的:
ts
export default function foo() {
return {
plugins: [
// 这里是普通插件的数组集合
]
}
}
并且需要给 Plugin 类添加一个属性 type,来区分是插件还是预设:
ts
enum PluginType {
plugin = 'plugin',
preset = 'preset'
}
class Plugin {
type: PluginType
}
下面就来解析预设插件。通过 initPlugins 方法拿到所有的插件集合,然后把这些插件返回出去:
ts
async initPresets (opts: {
preset: Plugin;
presets: Plugin[];
plugins: Plugin[];
}) {
const { presets, plugins } = await this.initPlugins({
plugin: opts.preset,
presets: opts.presets,
plugins: opts.plugins
});
// 这里将解析出来的预设和插件都放到函数外面处理了
opts.presets.unshift(...presets);
opts.plugins.push(...plugins);
}
async initPlugins (opts: {
plugin: Plugin;
presets: Plugin[];
plugins: Plugin[];
}) {
if (this.plugins[opts.plugin.id]) {
throw new Error('插件已经注册过了');
}
this.plugins[opts.plugin.id] = preset;
const pluginAPI = new PluginAPI({
plugin: opts.plugin,
service: this
});
const proxyPluginAPI = PluginAPI.proxyAPI({
plugin: opts.plugin,
service: this,
// 这样 api 中就可以拿到了service上的属性了
serviceProps: [
'applyPlugins',
'methods',
'userConfig'
]
});
// 还记得上面的 Plugin 解析以后的 apply 属性是什么吗
const ret = await opts.plugin.apply()(proxyPluginAPI);
if (ret?.presets) {
ret.presets = ret.presets.map(preset => {
return new Plugin({
path: preset,
cwd: this.cwd
});
});
}
if (ret?.plugins) {
ret.plugins = ret.plugins.map(plugin => {
return new Plugin({
path: plugin,
cwd: this.cwd
});
});
}
return ret;
}
最后当执行命令的时候,去解析所有的插件,解析插件的时候需要先实例化 Plugin,我们可以将实例化的过程放到 Plugin 的静态方法 getPluginsAndPresets 中:
ts
// Plugin 类中
static getPluginsAndPresets(opts: {
cwd: string;
userConfig: any;
plugins?: string[];
presets?: string[];
}) {
function get(type: 'plugin' | 'preset') {
const types = `${type}s` as 'plugins' | 'presets';
return [
...(opts[types] || []),
// 拿到用户配置中的插件
...(opts.userConfig[types] || []),
].map((path) => {
// 使用 resolve npm 库解析插件的实际路径
const resolved = resolve.sync(path, {
basedir: opts.cwd,
extensions: ['.tsx', '.ts', '.mjs', '.jsx', '.js'],
});
return new Plugin({
path: resolved,
type,
cwd: opts.cwd,
});
});
}
return {
presets: get('preset'),
plugins: get('plugin'),
};
ts
// CoreService 类中的 run 方法
async run(opts: { name: string; args?: any }) {
// 获取 Plugin 实例
const { presets, plugins } = Plugin.getPluginsAndPresets({
cwd: this.cwd,
userConfig: this.userConfig,
presets: this.opts.presets,
plugins: this.opts.plugins
});
// 拿到预设解析出来的普通插件
const presetPlugins = [];
while (presets.length) {
await this.initPresets({
// 从队首开时解析预设,对应上面的 unshift 将预设添加到队首
preset: presets.shift(),
presets,
plugins: presetPlugins;
});
}
// 将预设插件注入到普通插件集中
plugins.unshift(...presetPlugins);
// 继续解析普通插件
while (plugins.length) {
await this.initPlugins({
plugin: plugins.shift(),
plugins
});
}
// 执行对应的命令插件
const command = this.commands[opts.name];
const ret = await command.fn(opts.args);
return ret;
}
这样整个插件的执行流程就对应上了。等等,如果想要给插件传参数呢?
插件参数
和 babel 不同,我们的插件融入了用户配置文件中,利用 Plugin 的 key 属性作为配置项,比如注册了一个 antd 的插件,有一个配置对象:
ts
{
import: boolean;
style: 'css' | 'less'
}
那么使用的时候,直接在配置文件中添加 antd 的 key:
ts
{
antd: {
import: true,
style: 'less'
}
}
还记得上面的插件上下文的代理对象吗,里面有一个 userConfig 属性,所以就可以在上下文中直接拿到:
ts
export default function foo(api) {
// 这样就能拿到插件配置项了
const config = api.userConfig.antd;
}
总结
以上就是 Umi 的插件架构,当然代码这里是做了简化的,主要目的是为了表达插件系统的设计思路,而不是具体的源码是如何的,源码每个人都可以看到,但是学习它的设计思路才是我们更欠缺的能力。