插件化架构(Plug-in Architecture),也被称为微内核架构(Microkernel Architecture),是一种面向功能进行拆分的可扩展性架构,在如今的许多前端主流框架中都能看到它的身影。今天我们以 umi 框架为主,来看看插件化架构的实现思路,同时对比一下不同框架中插件化实现思路的异同。
二话不说先上结论。
触发方式 | 插件 API | 插件功能 | |
---|---|---|---|
umi | 基于 tapable 的发布订阅模式 | 10 种核心方法,50 种扩展方法,9 个核心属性 | 在路由、生成文件、构建打包、HTML 操作、命令等方面提供能力 |
babel | 基于 visitor 的访问者模式 | 基于@babel/types | 对于 AST 的操作等 |
rollup | 基于 hook 的回调模式 | 构建钩子、输出钩子、监听钩子 | 定制构建和打包阶段的能力 |
webpack | 基于 tapable 的发布订阅模式 | 主要为 compolier 和 compilation 提供一系列的钩子 | loader 不能实现的都靠它 |
vue-cli | 基于 hook 的回调模式 | 生成阶段为 Generator API,运行阶段为 chainWebpack 等更改 webpack 配置为主的 api | 在生成项目、项目运行和 vue ui 阶段提供能力 |
一个完整的插件系统应该包括三个部分:
插件内核(plugiCore):用于管理插件;
插件接口(pluginApi):用于提供 api 给插件使用;
插件(plugin):功能模块,不同的插件实现不同的功能。
因此我们也从这三部分入手去分析 umi 的插件化。
我们先从最简单的开始,认识一个umi 插件长什么样。我们以插件集preset(@umijs/preset-built-in)中的一个内置插件umiInfo(packages/preset-built-in/src/plugins/features/umiInfo.ts)为例,来认识一下 umi 插件。
import { IApi } from '@umijs/types';
export default (api: IApi) => {
// 调用扩展方法addHTMLHeadScripts在 HTML 头部添加脚本
api.addHTMLHeadScripts(() => [
{
content: `//! umi version: ${process.env.UMI_VERSION}`,
},
]);
// 调用扩展方法addEntryCode在入口文件最后添加代码
api.addEntryCode(
() => `
window.g_umi = {
version: '${process.env.UMI_VERSION}',
};
`,
);
};
可以看到 umi 插件导出了一个函数,函数内部为调用传参 api 上的两个方法属性,主要实现了两个功能,一个是在 html 文件头部添加脚本,另一个是在入口文件最后添加代码。其中,preset是一系列插件的合集。代码非常简单,就是 require 了一系列的plugin。插件集preset(packages/preset-built-in/src/index.ts)如下:
export default function () {
return {
plugins: [
// 注册方法插件
require.resolve('./plugins/registerMethods'),
// 路由插件
require.resolve('./plugins/routes'),
// 生成文件相关插件
require.resolve('./plugins/generateFiles/core/history'),
……
// 打包配置相关插件
require.resolve('./plugins/features/404'),
……
// html操作相关插件
require.resolve('./plugins/features/html/favicon'),
……
// 命令相关插件
require.resolve('./plugins/commands/build/build'),
……
}
这些plugin主要包括一个注册方法插件(packages/preset-built-in/src/plugins/registerMethods.ts),一个路由插件(packages/preset-built-in/src/plugins/routes.ts),一些生成文件相关插件(packages/preset-built-in/src/plugins/generateFiles/*),一些打包配置相关插件(packages/preset-built-in/src/plugins/features/*),一些html 操作相关插件(packages/preset-built-in/src/plugins/features/html/*)以及一些命令相关插件(packages/preset-built-in/src/plugins/commands/*)。
在注册方法插件registerMethods(packages/preset-built-in/src/plugins/registerMethods.ts)中,umi集中注册了几十个方法,这些方法就是umi文档中插件 api 的扩展方法。
export default function (api: IApi) {
// 集中注册扩展方法
[
'onGenerateFiles',
'onBuildComplete',
'onExit',
……
].forEach((name) => {
api.registerMethod({ name });
});
// 单独注册writeTmpFile方法,并传参fn,方便其他扩展方法使用
api.registerMethod({
name: 'writeTmpFile',
fn({
path,
content,
skipTSCheck = true,
}: {
path: string;
content: string;
skipTSCheck?: boolean;
}) {
assert(
api.stage >= api.ServiceStage.pluginReady,
`api.writeTmpFile() should not execute in register stage.`,
);
const absPath = join(api.paths.absTmpPath!, path);
api.utils.mkdirp.sync(dirname(absPath));
if (isTSFile(path) && skipTSCheck) {
// write @ts-nocheck into first line
content = `// @ts-nocheck${EOL}${content}`;
}
if (!existsSync(absPath) || readFileSync(absPath, 'utf-8') !== content) {
writeFileSync(absPath, content, 'utf-8');
}
},
});
}
当我们在控制台umi路径下键入命令npx umi dev后,就启动了 umi 命令,附带 dev 参数,经过一系列的操作后实例化Service对象(路径:packages/umi/src/ServiceWithBuiltIn.ts),
import { IServiceOpts, Service as CoreService } from '@umijs/core';
import { dirname } from 'path';
class Service extends CoreService {
constructor(opts: IServiceOpts) {
process.env.UMI_VERSION = require('../package').version;
process.env.UMI_DIR = dirname(require.resolve('../package'));
super({
...opts,
presets: [
// 配置内置默认插件集
require.resolve('@umijs/preset-built-in'),
...(opts.presets || []),
],
plugins: [require.resolve('./plugins/umiAlias'), ...(opts.plugins || [])],
});
}
}
export { Service };
在Service的构造函数中就传入了上面提到的默认插件集preset(@umijs/preset-built-in),供umi使用。至此我们介绍了以默认插件集preset为代表的umi插件。
Service对象(packages/core/src/Service/Service.ts)中的getPluginAPI方法为插件提供了插件接口。getPluginAPI接口就是整个插件系统的桥梁。它使用代理模式将umi插件核心方法、初始化过程hook 节点api、Service 对象方法属性和通过@umijs/preset-built-in 注册到 service 对象上的扩展方法组织在了一起,供插件调用。
getPluginAPI(opts: any) {
//实例化PluginAPI对象,PluginAPI对象包含describe,register,registerCommand,registerPresets,registerPlugins,registerMethod,skipPlugins七个核心插件方法
const pluginAPI = new PluginAPI(opts);
// 注册umi服务初始化过程中的hook节点
[
'onPluginReady', // 插件初始化完毕
'modifyPaths', // 修改路径
'onStart', // 启动umi
'modifyDefaultConfig', // 修改默认配置
'modifyConfig', // 修改配置
].forEach((name) => {
pluginAPI.registerMethod({ name, exitsError: false });
});
return new Proxy(pluginAPI, {
get: (target, prop: string) => {
// 由于 pluginMethods 需要在 register 阶段可用
// 必须通过 proxy 的方式动态获取最新,以实现边注册边使用的效果
if (this.pluginMethods[prop]) return this.pluginMethods[prop];
// 注册umi service对象上的属性和核心方法
if (
[
'applyPlugins',
'ApplyPluginsType',
'EnableBy',
'ConfigChangeType',
'babelRegister',
'stage',
……
].includes(prop)
) {
return typeof this[prop] === 'function'
? this[prop].bind(this)
: this[prop];
}
return target[prop];
},
});
}
上面讲到启动umi后会实例化Service对象(路径:packages/umi/src/ServiceWithBuiltIn.ts),并传入preset插件集(@umijs/preset-built-in)。该对象继承自CoreServeice(packages/core/src/Service/Service.ts)。CoreServeice在实例化的过程中会在构造函数中初始化插件集和插件:
// 初始化 Presets 和 plugins, 来源于四处
// 1. 构造 Service 传参
// 2. process.env 中指定
// 3. package.json 中 devDependencies 指定
// 4. 用户在 .umirc.ts 文件中配置
this.initialPresets = resolvePresets({
...baseOpts,
presets: opts.presets || [],
userConfigPresets: this.userConfig.presets || [],
});
this.initialPlugins = resolvePlugins({
...baseOpts,
plugins: opts.plugins || [],
userConfigPlugins: this.userConfig.plugins || [],
});
经过转换处理,一个插件在umi系统中最终会表示为如下格式的一个对象:
{
id, // @umijs/plugin-xxx,插件名称
key, // xxx,插件唯一的key
path: winPath(path), // 路径
apply() {
// 延迟加载插件
try {
const ret = require(path);
// use the default member for es modules
return compatESModuleRequire(ret);
} catch (e) {
throw new Error(`Register ${type} ${path} failed, since ${e.message}`);
}
},
defaultConfig: null, // 默认配置
};
umi实例化Service对象后会调用Service对象的run方法。插件的初始化就是在run方法中完成的。初始化preset和plugin的过程大同小异,我们重点看初始化plugin的过程。
// 初始化插件
async initPlugin(plugin: IPlugin) {
// 在第一步初始化插件配置后,插件在umi系统中就变成了一个个的对象,这里导出了id, key和延迟加载函数apply
const { id, key, apply } = plugin;
// 获取插件系统的桥梁插件接口PluginApi
const api = this.getPluginAPI({ id, key, service: this });
// 注册插件
this.registerPlugin(plugin);
// 执行插件代码
await this.applyAPI({ api, apply });
}
这里我们要重点看一下在最开始preset集中第一个注册方法插件中注册扩展方法时曾提到的registerMethod方法。
registerMethod({
name,
fn,
exitsError = true,
}: {
name: string;
fn?: Function;
exitsError?: boolean;
}) {
// 注册的方法已经存在的情况的处理
if (this.service.pluginMethods[name]) {
if (exitsError) {
throw new Error(
`api.registerMethod() failed, method ${name} is already exist.`,
);
} else {
return;
}
}
// 这里分为两种情况:第一种注册方法时传入了fn参数,则注册的方法就是fn方法;第二种情况未传入fn,则返回一个函数,函数会将传入的fn参数转换为hook钩子并注册,挂载到service的hooksByPluginId属性下
this.service.pluginMethods[name] =
fn || function (fn: Function | Object) {
const hook = {
key: name,
...(utils.lodash.isPlainObject(fn) ? fn : { fn }),
};
// @ts-ignore
this.register(hook);
};
}
因此当执行插件代码时,如果是核心方法则直接执行,如果是扩展方法则除了writeTmpFile,其余都是在hooksByPluginId下注册了hook。到这里Service完成了插件的初始化,执行了插件调用的核心方法和扩展方法。
通过下述代码,Service将以插件名称为维度配置的hook,转换为以hook名称为维度配置的回调集。
Object.keys(this.hooksByPluginId).forEach((id) => {
const hooks = this.hooksByPluginId[id];
hooks.forEach((hook) => {
const { key } = hook;
hook.pluginId = id;
this.hooks[key] = (this.hooks[key] || []).concat(hook);
});
});
以addHTMLHeadScripts扩展方法为例 转换前:
'./node_modules/@@/features/devScripts': [
{ key: 'addBeforeMiddlewares', fn: [Function (anonymous)] },
{ key: 'addHTMLHeadScripts', fn: [Function (anonymous)] },
……
],
'./node_modules/@@/features/umiInfo': [
{ key: 'addHTMLHeadScripts', fn: [Function (anonymous)] },
{ key: 'addEntryCode', fn: [Function (anonymous)] }
],
'./node_modules/@@/features/html/headScripts': [ { key: 'addHTMLHeadScripts', fn: [Function (anonymous)] } ],
转换之后:
addHTMLHeadScripts: [
{
key: 'addHTMLHeadScripts',
fn: [Function (anonymous)],
pluginId: './node_modules/@@/features/devScripts'
},
{
key: 'addHTMLHeadScripts',
fn: [Function (anonymous)],
pluginId: './node_modules/@@/features/umiInfo'
},
{
key: 'addHTMLHeadScripts',
fn: [Function (anonymous)],
pluginId: './node_modules/@@/features/html/headScripts'
}
],
至此插件系统就绪达到pluginReady状态。
在程序达到 pluginReady 状态后,Service 立即执行了一次触发 hook 操作。
await this.applyPlugins({
key: 'onPluginReady',
type: ApplyPluginsType.event,
});
那么是如何触发的呢?我们来详细看一下applyPlugins的代码实现:
async applyPlugins(opts: {
key: string;
type: ApplyPluginsType;
initialValue?: any;
args?: any;
}) {
// 找到对应需要触发的hook会调集,这里的hooks就是上面以插件名称为维度配置的hook转换为以hook名称为维度配置的回调集
const hooks = this.hooks[opts.key] || [];
// 判断事件类型,umi将回调事件分为add、modify和event三种
switch (opts.type) {
case ApplyPluginsType.add:
if ('initialValue' in opts) {
assert(
Array.isArray(opts.initialValue),
`applyPlugins failed, opts.initialValue must be Array if opts.type is add.`,
);
}
// 事件管理基于webpack的Tapable库,只用到了AsyncSeriesWaterfallHook一种事件控制方式,既异步串行瀑布流回调方式:异步,所有的钩子都是异步处理;串行,依次执行;瀑布流,上一个钩子的结果是下一个钩子的参数。
const tAdd = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tAdd.tapPromise(
{
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before,
},
//与其他两种事件类型不同,add类型会返回所有钩子的结果
async (memo: any[]) => {
const items = await hook.fn(opts.args);
return memo.concat(items);
},
);
}
return await tAdd.promise(opts.initialValue || []);
case ApplyPluginsType.modify:
const tModify = new AsyncSeriesWaterfallHook(['memo']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tModify.tapPromise(
{
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before,
},
// 与其他两种钩子不同,modify类型会返回最终的钩子结果
async (memo: any) => {
return await hook.fn(memo, opts.args);
},
);
}
return await tModify.promise(opts.initialValue);
case ApplyPluginsType.event:
const tEvent = new AsyncSeriesWaterfallHook(['_']);
for (const hook of hooks) {
if (!this.isPluginEnable(hook.pluginId!)) {
continue;
}
tEvent.tapPromise(
{
name: hook.pluginId!,
stage: hook.stage || 0,
// @ts-ignore
before: hook.before,
},
// event类型,只执行钩子,不返回结果
async () => {
await hook.fn(opts.args);
},
);
}
return await tEvent.promise();
default:
throw new Error(
`applyPlugin failed, type is not defined or is not matched, got ${opts.type}.`,
);
}
}
至此,umi的整体插件工作流程介绍完毕,后续代码就是umi根据流程需要不断触发各类的hook从而完成整个umi的各项功能。除了umi,其他的一些框架也都应用了插件模式,下面做简单介绍对比。
babel主要的作用就是语法转换,babel的整个过程分为三个部分:解析,将代码转换为抽象语法树(AST);转换,遍历 AST 中的节点进行语法转换操作;生成,根据最新的 AST 生成目标代码。其中在转换的过程中就是依据babel配置的各个插件去完成的。
const createPlugin = (name) => {
return {
name,
visitor: {
FunctionDeclaration(path, state) {},
ReturnStatement(path, state) {},
}
};
};
可以看到babel的插件也是返回一个函数,和umi的很相似。但是babel插件的运行却并不是基于发布订阅的事件驱动模式,而是采用访问者模式。babel会通过一个访问者visitor统一遍历节点,提供方法及维护节点关系,插件只需要在visitor中注册自己关心的节点类型,当visitor遍历到相关节点时就会调用插件在visitor上注册的方法并执行。
webpack整体基于两大支柱功能:一个是loader,用于对模块的源码进行转换,基于管道模式;另一个就是plugin,用于解决 loader 无法解决的问题,顾名思义,plugin 就是基于插件机制的。来看一个典型的webpack插件:
const pluginName = 'ConsoleLogOnBuildWebpackPlugin';
class ConsoleLogOnBuildWebpackPlugin {
apply(compiler) {
compiler.hooks.run.tap(pluginName, (compilation) => {
console.log('webpack 构建正在启动!');
});
}
}
module.exports = ConsoleLogOnBuildWebpackPlugin;
webpack在初始化时会统一执行插件的apply方法。插件通过注册Compiler和compilation的钩子函数,在整个编译生命周期都可以访问compiler对象,完成插件功能。同时整个事件驱动的功能都是基于 webpack 的核心工具Tapable。Tapable同样也是umi的事件驱动工具。可以看到umi和webpack的整体思路是很相似的。
rollup也是模块打包工具,与 webpack 相比rollup更适合打包纯 js 的类库。同样rollup也具有插件机制。一个典型的rollup插件:
export default function myExample() {
return {
name: 'my-example',
resolveId(source) {},
load(id) {},
};
}
rollup 插件维护了一套同步/异步、串行/并行、熔断/传参的事件回调机制,不过这部分并没有单独抽出类库,而是在 rollup 项目中维护的。通过插件控制器(src/utils/PluginDriver.ts)、插件上下文(src/utils/PluginContext.ts)、插件缓存(src/utils/PluginCache.ts),完成了提供插件 api 和插件内核的能力。
vue-cli的插件与其他相比稍有特点,就是将插件分为几种情况,一种项目生成阶段,插件未安装需要安装插件;另一种是项目运行阶段,启动插件;还有一种是UI插件,在运行vue ui时会用到。
vue-cli插件的包目录结构
├── generator.js # generator(可选)
├── index.js # service 插件
├── package.json
└── prompts.js # prompt 文件(可选)
└── ui.js # ui 文件(可选)
其中generator.js和prompts.js在安装插件的情况下执行,index 则在运行阶段执行。generator 示例:
module.exports = (api, options) => {
// 扩展package.json字段
api.extendPackage({
dependencies: {
'vue-router-layout': '^0.1.2'
}
})
// afterAnyInvoke钩子 函数会被反复执行
api.afterAnyInvoke(() => {
// 文件操作
})
// afterInvoke钩子,这个钩子将在文件被写入硬盘之后被调用
api.afterInvoke(() => {})
}
prompts 会在安装期间与用户交互,获取插件的选项配置并在 generator.js 调用时作为参数存入。
在项目生成阶段通过 packages/@vue/cli/lib/GeneratorAPI.js 提供插件 api;在 packages/@vue/cli/lib/Generator.js 中初始化插件,执行插件注册的 api,在 packages/@vue/cli/lib/Creator.js 中运行插件注册的钩子函数,最终完成插件功能的调用。
vue-cli运行阶段插件:
const VueAutoRoutingPlugin = require('vue-auto-routing/lib/webpack-plugin')
module.exports = (api, options) => {
api.chainWebpack(webpackConfig => {
webpackConfig
.plugin('vue-auto-routing')
.use(VueAutoRoutingPlugin, [
{
pages: 'src/pages',
nested: true
}
])
})
}
在项目运行阶段的插件主要用来修改webpack的配置,创建或者修改命令。由 packages/@vue/cli-service/lib/PluginAPI.js 提供pluginapi,packages/@vue/cli-service/lib/Service.js 完成插件的初始化和运行。而vue-cli插件的运行主要是基于回调函数的模式来管理的。
通过以上介绍,可以发现插件机制是现代前端项目工程化框架中必不可少的一部分,插件的实现形式多种多样,但总的结构是大体一致的,既由插件(plugin)、插件 api(pluginApi)、插件核心(pluginCore)三部分组成。其中通过插件核心去注册和管理插件,完成插件的初始化和运行工作,插件 api 是插件和系统之间的桥梁,使插件完成特定功能,再通过不同插件的组合形成了一套功能完整的前端框架系统。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/d26VVM5wOI6ZK5odin7-0A
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。