背景
插件机制可谓是前端开发中的常客,从基础的 JS 调用 DOM 到上层的 React 框架,再到构建工具 Webpack、Rulp,都可以看见其神出鬼没的影子。而在最近提前实习经历中,所经手最多的,也是某个系统插件的开发,那么如今,咱就来一起探讨一下插件机制,做到心中有数,以便在日后的需求开发中,可以设计出更加通用更加安全的代码。
什么是插件机制
对于插件的定义,我在网上找了很多资料,大家说法不一,大多是以应用程序的维度进行说明,其中比较系统的概括如下: 插件是一种可以把某些能力或特性添加到某个已有主体程序的程序,它通常遵循一定的编写规范,并只能运行在特定的主体程序中
但是这里也有不足,为什么呢,因为插件更多的是一种设计形态,具有非常多的展示形式,而非固定地依赖于某一已有主题程序的程序,比如 JS 中的 document.on("focus", callback)
,往细了说,这是一种依赖于事件提供插件开发的形式,当然这是后话。一千个读者眼中有一千个哈姆雷特,让我姑且先给插件机制下一个定义:
插件机制是由实现了核心模块与功能的程序,通过约定式对外暴露自身的参数与生命周期,从而可供外方调用的应用组织形式
大多数的插件机制,从使用方式反推回去,其核心都是两点,一是在特定时机向外暴露自身的生命周期,并往里注入核心参数,用户则可以在特定的生命周期中,按照自己的意图编写需要的代码;二是通过观察者模式或是其他设计模式,以解耦性强的方式去完成事件的绑定与触发,这里称之为事件机制。
比如在 ubuild 中,我们编写一个插件的流程往往如下,以客户端为例:
javascript
import { Plugin } from '@nocobase/client';
export class PluginSampleHelloClient extends Plugin {
async afterAdd() {}
async beforeLoad() {
this.app.i18n.loadSource()
}
async load() {}
}
export default PluginSampleHelloClient;
在上述例子中,形如afterAdd
、beforeLoad
、load
就是 ubuild 暴露给外界的生命周期,this.app
就是暴露给外界的核心参数,以便在 lifeCycle 中可以访问。
在 Webpack 中更是如此,其依赖于 tapable,实现了更为巧妙的事件机制。
插件机制的形式
主要来说有下面几种插件化形式
约定/注入插件
根据特定规范进行插件设计,这一规范通常要求使用一个指定的文件作为插件的入口点,该文件可以是 `.json` 或 `.ts` 等格式之一。只要从该文件导出的对象遵循了规定的命名规则,它就能被正确加载,并且能够访问到一定的上下文信息。 这种基于"约定优于配置"原则的插件机制常见于多种现代开发框架或工具内,例如 Umi、Next.js 和 Webpack。以 Webpack 为例,在使用 Webpack 构建项目时,开发者可以通过添加自定义的插件 (plugins) 来扩展其功能,以便处理更加复杂多样的资源转换需求。在配置这些组件时,遵循既定的规范即可实现无缝集成,从而增强构建流程的功能性和灵活性。开发者只需要按照约定将 HtmlWebpackPlugin 加入到 plugins 数组中,无需指定具体的注入点或方式,Webpack 就会自动完成这些任务。
javascript
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
// 其他配置...
plugins: [
new HtmlWebpackPlugin({
template: "./src/template.html",
}),
],
};
更进一步,当我们使用 Next.js 时,不同目录对应不同的生命周期。例如,pages
目录下的文件夹会被自动转换为路由。当需要在 pages
目录下获取静态页面的参数或路径以执行特定逻辑时,应遵循预定义的函数命名规范,如 getStaticProps
和 getStaticPaths
:
javascript
export const getStaticProps: GetStaticProps = async ({ params }) => {
const res = await fetch(`${BASEURL}article/info?id=${params!.id}`);
const results = await res.json();
const post = results.data;
return {
props: {
post,
},
revalidate: 60,
};
};
约定式插件和注入式插件可以说是一码事,但是又有细微的不同,当我们没有清晰的代码入口时,就比较适合采用注入式插件的形式,这类插件的表现形式大多是一个函数或者对象,需要使用核心程序提供的 API 或者生命周期来实现,比如我们上述提到的 ubuild 插件,它的插件形式就是一个对象,并在构造函数中接受了全局的 app 参数。
事件插件
通过事件机制提供插件开发能力是一种常见的设计模式。例如,在 DOM,可以通过 `document.on("focus", callback)` 这样的方式来注册事件处理器。这种机制可以理解为在特定阶段暴露钩子,使用户能够扩展和定制整个框架的生命周期。不解的话,可以将其与 Node.js 中的 `EventEmitter` 类进行类比:两者都允许开发者利用预定义的事件来编写回调函数,并在适当的时候触发这些事件。这种设计模式不仅增强了系统的灵活性和可扩展性,还为开发者提供了丰富的自定义选项。
再进一步,说到事件机制,就不得不提一提 Koa,它的中间件洋葱模型非常有名,通过调用 next(),可以让中间件从外到内再从内到外依次执行,也就是当我们想把执行时机放在所有中间件执行完毕时,把代码放在 next() 之后即可:
插槽插件
这种插件通常是对 UI 元素的扩展,最经典的代表就是 React 和 Vue 了,它们的组件化其实就是插件的另一种表现。比如在 React 中,插槽插件化的概念可以通过组件的 children 属性或使用特定的插槽来实现。这种模式允许开发者定义一个组件框架,其中一些部分可以通过传入的子组件来填充,从而实现自定义内容的注入。这类似于 Vue 中的插槽功能,但在 React 中,它通过 props.children 或通过特定的 props 来传递组件来实现。
插件机制的实现
一般来说,其具体流程如下:
- 确定插件加载形式:根据上述内容选择一个合适的插件使用方式即可
- 确定插件注册方式
- 通过npm注册:比如在 umi 中,只要 npm 包的名称使用
@umijs
或者umi-plugin
开头就会自动加载插件 - 通过文件名注册:比如在 ubuild 中,插件相关代码都在 plugins 目录下,ubuild 也会自动去收集此目录下各个插件的入口文件
- 通过代码注册:这个就比较简单了,就是通过 require 进行加载就行
- 通过描述注册:比如在
package.json
或者对应的配置文件中表明要使用的插件
- 通过npm注册:比如在 umi 中,只要 npm 包的名称使用
- 确定生命周期:至少会包含一到三个周期,
load
,beforeLoad
,afterLoad
- 插件对生命周期的拦截:一般通过事件,回调函数的形式进行拦截,常见的例子就是
document.on('click', callback)
- 插件之间的依赖与通信
- 依赖关系定义在业务项目中
比如 Webpack 的配置,通常我们是这样的
javascript
{
"use": ["babel-loader", "ts-loader"]
}
配置好之后,按照框架的依赖关系定义,执行顺序就是 "ts-loader" ------> "babel-loader"
markdown
2. 依赖关系定义在插件中
一种插件依赖于另一个插件,还是以 Webpack 为例,当我们对 css 文件进行处理时,通常要使用两个 loader:css-loader
与style-loader
,而 style-loader
就依赖于 css-loader
执行。
如何手撸一个超级简单的插件系统
React-Generate
大约六个月前,笔者认领了一个来自"开源之夏"项目的课题------实现 [React DSL 代码生成与预览功能](https://summer-ospp.ac.cn/org/prodetail/2436e0061?list=org\&navpage=org),并于十月底顺利完成了该项目,相关成果已合并至 tiny-engine 代码库中。该项目的核心目标是根据特定规则将Json Schema 转换成相应的 React 组件代码。在开发过程中,我便采用了插件机制来增强此功能的灵活性和可扩展性。本文将通过这个具体案例,重新审视并详细阐述如何构建一个简易版的插件机制。 首先,在设计插件机制时,面临的问题是如何使得该机制能够无缝集成到各种不同结构的应用程序中。鉴于此,我们决定采用注入式的插件模式,即在构造函数阶段将全局上下文对象传递给每个插件实例,以确保其能够访问必要的上下文信息。 接下来,关于插件的注册策略,我们选择了基于文件名的注册机制。也就是将与插件相关的源代码存放于 `plugins`子目录下,这样当应用程序启动时即可自动识别并加载这些插件。 最后,便是定义插件机制的生命周期。在 React-Generate 中,我将整个处理流程划分为三个关键阶段:
- transformStart:发生在实际转换操作之前,这一阶段主要用于解析输入的 Json Schema 并执行必要的预处理步骤。
- transform:这是执行核心逻辑的地方,负责将解析后的 Schema 数据转换为 React 组件代码,包括但不限于JSX模板、路由配置等。
- transformEnd:位于转换过程的尾声,此阶段的任务是对生成的代码进行最终优化或格式调整,确保输出结果符合预期标准。
其具体代码如下两段:
第一段主要用来注册插件,分为两块:即内置的插件以及后续可能会添加的插件
typescript
// 插件注入
export function generateApp(config = {}) {
const defaultPlugins = {
template: genTemplatePlugin(config.pluginConfig?.template || {}),
block: genBlockPlugin(config.pluginConfig?.block || {}),
page: genPagePlugin(config.pluginConfig?.page || {}),
dataSource: genDataSourcePlugin(config.pluginConfig?.dataSource || {}),
dependencies: genDependenciesPlugin(config.pluginConfig?.dependencies || {}),
globalState: genGlobalState(config.pluginConfig?.globalState || {}),
i18n: genI18nPlugin(config.pluginConfig?.i18n || {}),
router: genRouterPlugin(config.pluginConfig?.router || {}),
utils: genUtilsPlugin(config.pluginConfig?.utils || {}),
formatCode: formatCodePlugin(config.pluginConfig?.formatCode || {}),
parseSchema: parseSchemaPlugin(config.pluginConfig?.parseSchema || {})
}
const { customPlugins = {} } = config
const {
template,
block,
page,
dataSource,
dependencies,
i18n,
router,
utils,
formatCode,
parseSchema,
globalState,
transformStart = [],
transform = [],
transformEnd = []
} = customPlugins
const mergeWithDefaultPlugin = {
template: template || defaultPlugins.template,
block: block || defaultPlugins.block,
page: page || defaultPlugins.page,
dataSource: dataSource || defaultPlugins.dataSource,
dependencies: dependencies || defaultPlugins.dependencies,
i18n: i18n || defaultPlugins.i18n,
router: router || defaultPlugins.router,
utils: utils || defaultPlugins.utils,
globalState: globalState || defaultPlugins.globalState
}
const codeGenInstance = new CodeGenerator({
plugins: {
transformStart: [parseSchema || defaultPlugins.parseSchema, ...transformStart],
transform: [...Object.values(mergeWithDefaultPlugin), ...transform],
transformEnd: [formatCode || defaultPlugins.formatCode, ...transformEnd]
},
context: config?.customContext || {}
})
return codeGenInstance
}
第二段代码是整个插件机制的核心部分。在构造函数中,首先获取全局上下文和插件信息,然后在后续的三个生命周期阶段中分别执行这些插件。需要注意的是,插件的编写需遵循特定格式,即每个插件应返回一个名为 run
的函数。
typescript
class CodeGenerator {
config = {}
genResult = []
plugins = []
genLogs = []
schema = {}
context = {}
// 是否允许插件报错
tolerateError = true
error = []
contextApi = {
addLog: this.addLog.bind(this),
addFile: this.addFile.bind(this),
getFile: this.getFile.bind(this),
replaceFile: this.replaceFile.bind(this)
}
constructor(config) {
this.config = config
this.plugins = config.plugins
this.context = {
...this.context,
...(this.config.context || {})
}
if (typeof config.tolerateError === 'boolean') {
this.tolerateError = config.tolerateError
}
}
getContext() {
return {
config: this.config,
genResult: this.genResult,
genLogs: this.genLogs,
error: this.error,
...this.context
}
}
async generate(schema) {
this.schema = this.parseSchema(schema)
this.error = []
this.genResult = []
this.genLogs = []
let curHookName = ''
try {
await this.transformStart()
await this.transform()
} catch (error) {
this.error.push(error)
if (!this.tolerateError) {
throw new Error(
`[codeGenerator][generate] get error when running hook: ${curHookName}. error message: ${JSON.stringify(
error
)}`
)
}
} finally {
await this.transformEnd()
}
return {
errors: this.error,
genResult: this.genResult,
genLogs: this.genLogs
}
}
/**
* 转换开始的钩子,在正式开始转换前,用户可以做一些预处理的动作
* @param {*} plugins
*/
async transformStart() {
for (const pluginItem of this.plugins.transformStart) {
if (typeof pluginItem.run !== 'function') {
continue
}
try {
await pluginItem.run.apply(this.contextApi, [this.schema, this.getContext()])
} catch (error) {
const err = { message: error.message, stack: error.stack, plugin: pluginItem.name }
this.error.push(err)
if (!this.tolerateError) {
throw new Error(`[${pluginItem.name}] throws error`, { cause: error })
}
}
}
}
async transform() {
for (const pluginItem of this.plugins.transform) {
if (typeof pluginItem.run !== 'function') {
continue
}
try {
const transformRes = await pluginItem.run.apply(this.contextApi, [this.schema, this.getContext()])
if (!transformRes) {
continue
}
if (Array.isArray(transformRes)) {
this.genResult.push(...transformRes)
} else {
this.genResult.push(transformRes)
}
} catch (error) {
const err = { message: error.message, stack: error.stack, plugin: pluginItem.name }
this.error.push(err)
if (!this.tolerateError) {
throw new Error(`[${pluginItem.name}] throws error`, { cause: error })
}
}
}
}
async transformEnd() {
for (const pluginItem of this.plugins.transformEnd) {
if (typeof pluginItem.run !== 'function') {
continue
}
try {
await pluginItem.run.apply(this.contextApi, [this.schema, this.getContext()])
} catch (error) {
const err = { message: error.message, stack: error.stack, plugin: pluginItem.name }
this.error.push(err)
if (!this.tolerateError) {
throw new Error(`[${pluginItem.name}] throws error`, { cause: error })
}
}
}
}
parseSchema(schema) {
if (!schema) {
throw new Error(
'[codeGenerator][generate] parseSchema error, schema is not valid, should be json object or json string.'
)
}
try {
return typeof schema === 'string' ? JSON.parse(schema) : schema
} catch (error) {
throw new Error(
'[codeGenerator][generate] parseSchema error, schema is not valid, please check the input params.'
)
}
}
}
export default CodeGenerator
细心的朋友们或许已经注意到,上述的插件机制似乎尚缺乏一个关键组件------即事件机制的设计。那么,为何在此项目中不需要这一机制呢?大家可以先输出一下自己的看法。实际上,原因相当直接:即外部不需要调用 React-Generate 中的生命周期,暂无使用层面上的需求,代码转换并不需要在不同生命周期执行定制化的逻辑。当然,如果要加上也是十分简单,我们可以先写一个十分简陋的事件机制(订阅发布):
typescript
type Listener = (...args: any[]) => void;
class EventEmitter {
private events: { [eventName: string]: Listener[] } = {};
on(eventName: string, listener: Listener): void {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(listener);
}
emit(eventName: string, ...args: any[]): void {
const listeners = this.events[eventName];
if (listeners) {
listeners.forEach(listener => {
listener(...args);
});
}
}
off(eventName: string, listener: Listener): void {
const listeners = this.events[eventName];
if (listeners) {
this.events[eventName] = listeners.filter(l => l !== listener);
}
}
}
以 transformStart 为例,首先,在 constructor 中与生命周期被调用的代码中分别加上:
typescript
constructor(config) {
this.eventEmitter = new EventEmitter();
this.plugins = config.plugins
this.plugins.forEach((plugin) => plugin.apply(this.eventEmitter))
}
typescript
transformStart() {
EventEmitter.emit('transFormStart', this.schema)
}
这时候,我们便可以根据我们的需要, 在外部访问内部的生命周期。比如有一个输出日志的需求,我们便可以做成一个插件,如下:
typescript
class LogPlugins {
apply(eventEmitter) {
eventEmitter.on("transformStart", (...args) =>
console.log('转换预处理') // 或者执行某些根据args产生的特定化行为
);
eventEmitter.on("transform", (...args) =>
console.log('转换中')
);
eventEmitter.on("transformEnd", (...args) =>
console.log('即将转换完成')
);
}
}
这样一个简单的插件机制就算是完成啦!
最后
插件机制在前端领域是一个老生常谈的话题了,但是在此之前,笔者却一直未系统性地整理过相关知识,只粗略记得其关键点是向外暴露生命周期与特定参数,再通过观察者或订阅发布机制进行事件的传递与触发。最近在需求开发中,为 ubuild 贡献了好几个插件,便想着深入探讨一下所谓的插件机制,希望可以抛砖引玉,引发各位读者的思考,欢迎大家在评论区指出不足,同时也希望大家可以在日常开发中使用这种插件化的理念,这样我们就可以只关注核心代码的实现,可以保证应用在功能稳定的前提下拥有更强的可拓展性。