从零构建一个插件系统(六)低代码场景的插件构建思考
不知不觉,插件系统构建之旅来到了最终章。前面几篇我们从核心概念聊起,探讨了串行、并发、缓存等机制,还学习了 Koa、Redux、Vue 3
在各自领域中插件系统的实现。现在,让我们从低代码平台这个复杂的应用场景出发,完整阐述如何构建一个强大的插件系统。
我在 2024 年的主要工作,就是深度参与一个基于 Vue 3 的低代码平台开发。这个平台功能丰富,简单概括如下:
- 多页面管理:类似于 Vue Router,支持多个路由页面。
- 组件拖拽与嵌套:多个组件可以相互嵌套,组合成一个更复杂的新组件。
- 协议驱动的属性配置:每个组件基于不同的协议,展示对应的属性配置面板。
- 事件与联动:通过配置事件,让组件能够影响自身和其他组件的状态。
- 全局历史与撤销重做:可以对大部分操作进行撤销与重做。
- 画布辅助工具:支持缩放、拖拽、标尺等功能。
- 预览与构建:可以快速预览和构建一个完整的工程。
面对这样一个平台,如果用“面向过程”一把梭,后果可想而知。这恰恰是我们需要使用插件思想来去重新架构的地方。此外,我们要做的不是写一个简单的插件系统,而是为这个平台构建一个健壮、可扩展的“操作系统”。这篇文章,便是对之前工作的回顾与总结。
推荐配合 漫谈 AI + 低代码前景 一起阅读。这是我对未来低代码平台的一个展望,以及对 AI 的一些理解。
顶层架构🔗
在深入细节之前,我们必须先确立整个系统的架构基石。面对如此复杂的、可撤销的、需要同步的状态变更,基于“命令模式”的架构是绝佳的选择。
顶层架构包含三大核心:
全局状态树🔗
一个由 Pinia 或 Vue reactive
API 管理的、全局唯一的响应式对象,它定义了应用的“一切”。
它看起来可能是这样的:
JSON{
"project": {
// 当前打开的项目,描述文档内容,需要持久化
"id": "proj-123",
"name": "我的应用",
"pages": {
"page-main": {
/* 首页的状态,包含组件树等 */
},
"page-detail": {
/* 详情页的状态 */
}
},
"apis": {
"fetchUserList": {
"name": "fetchUserList",
"url": "https://api.example.com/users",
"method": "GET",
"description": "获取用户列表"
}
}
},
"session": {
// 当前会话状态,描述用户交互,非持久化
"currentPageId": "page-main",
"selectedComponentIds": ["comp-xyz"],
"canvas": { "zoom": 1, "pan": { "x": 0, "y": 0 } }
},
"history": {
// 全局历史记录
"undoStack": [
/* Command 对象列表 */
],
"redoStack": [
/* Command 对象列表 */
]
}
}
命令 (Command)🔗
任何对平台内容(如页面、组件、API 配置)有影响、需要被记录和撤销的修改,都必须被封装成一个“命令”对象。它是状态变更的原子单元。
TS/**
* 定义所有命令都必须遵守的接口契约。
*/
interface Command {
execute(): void; // 正向执行
undo(): void; // 反向执行
}
/**
* 一个具体的命令实现:添加组件。
* 它封装了“添加一个组件”这个操作的所有信息和逻辑。
*/
class AddComponentCommand implements Command {
private pageId: string;
private componentData: any;
constructor(pageId: string, componentData: any) {
this.pageId = pageId;
this.componentData = componentData;
}
public execute(): void {
// 【核心】命令的 execute 方法直接修改全局状态树
// 假设 globalState 是全局可访问的状态树实例
globalState.project.pages[this.pageId]?.components.push(this.componentData);
console.log(`Command Executed: Added component ${this.componentData.id}`);
}
public undo(): void {
// 【核心】命令的 undo 方法以相反的方式修改全局状态树
const page = globalState.project.pages[this.pageId];
if (page) {
page.components = page.components.filter(
(c) => c.id !== this.componentData.id
);
console.log(`Command Undone: Removed component ${this.componentData.id}`);
}
}
}
中心化命令执行器🔗
一个全局唯一的 app.executeCommand()
方法,它是所有状态变更的“咽喉要道”。
TS/**
* 这是一个全局单例对象或类,提供了修改状态的唯一入口。
*/
const AppExecutor = {
/**
* 执行一个命令。这是所有状态变更的“咽喉要道”。
* @param command - 一个实现了 Command 接口的实例。
*/
executeCommand(command: Command): void {
console.log("--- Executor: Receiving a new command ---");
// 1. 调用命令的正向执行方法,真正地修改状态
command.execute();
// 2. 将命令推入撤销堆栈进行历史记录
globalState.history.undoStack.push(command);
// 3. 执行新命令后,必须清空重做堆栈
globalState.history.redoStack = [];
console.log(
`--- Executor: Command executed. Undo stack size: ${globalState.history.undoStack.length} ---`
);
},
/**
* 执行撤销操作。
*/
undo(): void {
const command = globalState.history.undoStack.pop();
if (command) {
console.log("--- Executor: Undoing last command ---");
command.undo();
globalState.history.redoStack.push(command);
}
},
/**
* 执行重做操作。
*/
redo(): void {
const command = globalState.history.redoStack.pop();
if (command) {
console.log("--- Executor: Redoing last command ---");
command.execute();
globalState.history.undoStack.push(command);
}
},
};
这个架构可以用一张简单的图来表示:
TEXT+------------------+ +--------------------+ +---------------------+ | 各种插件 |----->| app.executeCommand |----->| 全局状态树 | | (工具, 属性面板) | | (命令执行器) | | | +------------------+ +----------^---------+ +-----------^---------+ | 创建 Command | 执行 Command | | +--------------------------+ | 响应式更新 | +-----------v---------+ | UI 视图 | | (画布, 面板等) | +---------------------+
协议🔗
有了骨架,我们还需要定义构成这个世界的“物质”。在这个平台里,组件就是核心“物料”。为了让平台能识别和管理成百上千种不同的组件,我们必须建立一套“协议”。这是所有物料进入这个“世界”的通行证。
我在这里选择使用 Zod
这个工具来定义协议。它不仅能描述数据结构,还能提供开箱即用的类型推断和运行时验证。
一个标准的“按钮”组件,它的协议定义可能长这样:
TYPESCRIPT// protocols/button.protocol.ts
import { z } from "zod";
import { ButtonRenderer } from "../renderers/Button.vue"; // 引入 Vue 组件
import { ActionSchema } from "./action.protocol"; // 引入动作协议
// 1. 定义组件的 Props Schema
const ButtonPropsSchema = z.object({
text: z.string().describe("按钮文字").default("点击我"),
type: z
.enum(["primary", "default", "danger"])
.describe("按钮类型")
.default("default"),
});
// 2. 定义组件支持的事件 Schema
const ButtonEventsSchema = z.object({
onClick: z.array(ActionSchema).describe("点击事件").optional(),
});
// 3. 整合所有信息,形成完整的组件协议
export const ButtonProtocol = {
name: "Button",
label: "按钮",
props: ButtonPropsSchema,
events: ButtonEventsSchema,
renderer: ButtonRenderer, // 渲染器本身也是协议的一部分
};
为什么协议如此重要?🔗
- 标准化:平台通过读取协议,就能知道如何创建它、如何为它生成属性面板、以及它能触发哪些事件。
- 解耦:平台核心代码不依赖任何具体组件的实现,只依赖于这套抽象的协议。
- 自动化:属性面板、代码生成器等工具,都可以基于协议自动工作。
- 类型安全:
Zod
让我们同时拥有了运行时的验证能力和开发时的 TypeScript 类型提示。
插件化:内置工具的实现🔗
平台需要提供很多画布辅助工具,如选择、拖拽、缩放、标尺等。这些工具都应该被实现为“工具类插件”,并由一个“工具管理器”来统一调度。
这里有一个关键的设计原则:区分文档状态和会话状态。
在全局状态树中,session
部分是特殊的:
JSON"session": {
"currentPageId": "page-main",
"selectedComponentIds": ["comp-xyz"],
"canvas": { "zoom": 1, "pan": { "x": 0, "y": 0 } }
}
它被设计为非持久化的,因为它描述的是当前用户与编辑器交互的临时状态,而不是文档内容本身。
- 你把画布放大了
200%
,这是你的个人视图偏好,不应该影响到你的同事看到的画布,也不属于文档内容。 - 你选中了哪个组件,这是你下一步要编辑的目标,同样不属于文档内容。
- 这些状态在刷新后可以丢失并重置为默认值。
关键原则: 对
session
状态的修改,不应该通过Command
执行。因为它们是不需要被撤销、也不需要被同步给其他协作者的。
基于此,工具插件的实现就非常清晰了:
TYPESCRIPT// plugins/ZoomToolPlugin.ts
export const ZoomToolPlugin = {
// 当插件被主应用加载时
setup(context) {
const canvasEl = context.getCanvasElement();
const sessionStore = context.getSessionStore();
const handleWheel = (event) => {
if (event.ctrlKey) {
event.preventDefault();
const newZoom = sessionStore.canvas.zoom - event.deltaY * 0.01;
// 直接修改 session state,不创建 Command,无需记录历史
sessionStore.canvas.zoom = Math.max(0.1, Math.min(newZoom, 5));
}
};
canvasEl.addEventListener("wheel", handleWheel);
// 返回一个清理函数,在插件卸载时调用
return () => canvasEl.removeEventListener("wheel", handleWheel);
},
};
这种划分使得命令历史非常干净,只记录对文档内容的真正修改。
组件拖拽🔗
组件拖拽的过程,可以看作是从一个界面事件到一条命令的“翻译”过程。下面我们来拆解用户的一个简单动作,是如何被我们的架构所处理的。
流程:
用户拖拽物料 -> 画布监听到 drop 事件 -> 画布插件创建 AddComponentCommand -> app.executeCommand -> 状态更新 -> 界面响应式渲染
伪代码🔗
TYPESCRIPT// plugins/CanvasPlugin.ts
export const CanvasPlugin = {
setup(context) {
const canvasEl = context.getCanvasElement();
const handleDrop = (event) => {
event.preventDefault();
const materialName = event.dataTransfer.getData("material/name");
const position = { x: event.offsetX, y: event.offsetY };
// 1. 根据物料名称,从物料协议中获取默认数据
const protocol = context.getMaterialProtocol(materialName);
const defaultProps = zod.parse(protocol.props, {}); // 使用 Zod 解析出带默认值的 props
// 2. 创建一个描述“添加组件”这个意图的 Command
const command = new AddComponentCommand(
context.getSessionStore().currentPageId,
{
id: `comp-${Date.now()}`,
name: materialName,
props: { ...defaultProps, ...position },
}
);
// 3. 将命令提交给中心执行器,由它来改变世界
context.executeCommand(command);
};
canvasEl.addEventListener("drop", handleDrop);
// ...还有 dragover 等事件的处理
},
};
组件属性的配置🔗
组件属性的配置,是“协议驱动动态界面”的核心体现,也是“协议”强大之处的最佳证明。
流程:
用户选中组件 -> Session 状态更新 -> 属性面板插件监听到变化 -> 读取组件协议 -> 动态生成界面 -> 用户修改界面 -> 属性面板插件创建 UpdatePropsCommand -> app.executeCommand
伪代码🔗
VUE<!-- components/PropertiesPanel.vue (属性面板插件的核心UI) --> <template> <div v-if="selectedComponent"> <h3>{{ protocol.label }} 属性</h3> <!-- 动态遍历 Schema 并渲染对应的输入控件 --> <div v-for="(schema, key) in protocol.props.shape" :key="key"> <label>{{ schema.description }}</label> <component :is="getControlForType(schema)" :modelValue="selectedComponent.props[key]" @update:modelValue="onPropChange(key, $event)" /> </div> </div> </template> <script setup> import { computed } from "vue"; import { useApp, UpdateComponentPropsCommand } from "../core"; const app = useApp(); const selectedComponent = computed(() => app.getSelectedComponent()); const protocol = computed(() => app.getProtocolForComponent(selectedComponent.value) ); function onPropChange(key, newValue) { // 创建一个更新属性的 Command const command = new UpdateComponentPropsCommand( { /* ... target component info ... */ }, { [key]: newValue } // 只更新变化的属性,实现最小化更新 ); // 同样,交给中心执行器处理 app.executeCommand(command); } // ... </script>
接口配置🔗
一个应用如果不能与后端服务交互,那它只是一个静态的“模型”。为了赋予应用生命,我们必须能配置和调用 API。这同样遵循“协议驱动”的原则。
平台内会有一个 API 管理面板,用户可以在其中定义所有需要用到的后端接口。这些定义,会作为项目内容的一部分,被储存在全局状态树的 project.apis
字段下。
TYPESCRIPT// protocols/api.protocol.ts
import { z } from "zod";
export const ApiProtocolSchema = z.object({
name: z.string().describe("API 唯一标识,如 fetchUserList"),
url: z.string().url().describe("请求地址"),
method: z.enum(["GET", "POST", "PUT", "DELETE"]).default("GET"),
description: z.string().optional().describe("接口描述"),
// 可以进一步定义 params, body, headers 的 schema
});
使用起来则是有两种形式:
- 初始化请求数据给默认组件进行数据填充。
- 事件配置面板中,用户可以将按钮的“点击事件”与一个“调用 API”的动作绑定。
设计哲学:拥抱 HTTP 状态码🔗
在接口规范上,我们建立了一条强制性原则:所有后端接口必须直接使用 HTTP 状态码来表达成功或失败,严禁在响应体中包装一层业务状态码(如 { code: 0, data: ..., msg: '...' }
)。
- 成功:返回
2xx
状态码,响应体就是纯粹的数据 JSON。 - 失败:返回
4xx
(客户端错误)或5xx
(服务端错误)状态码,响应体可以携带错误的详细信息。
为什么坚持这样做?
- 遵循 Web 标准:这是 API 设计的最佳实践,让 HTTP 协议本身回归其设计初衷。
- 简化前端逻辑:我们无需在每个请求后都写
if (res.code === 0)
这样的模板代码。fetch
或axios
的catch
块天然就能捕获所有非2xx
的响应。 - 平台级错误处理:平台可以提供统一的 API 调用器。这个调用器能够自动处理错误,例如,当捕获到
401
时自动跳转到登录页,捕获到500
时显示统一的错误提示。这极大减轻了低代码开发者的心智负担。
这个看似简单的约束,实际上是整个平台稳定性和开发体验的基石之一。它使得数据交互的逻辑变得异常清晰和健壮。
预览与构建🔗
当用户完成了页面设计、属性配置和事件联动后,就需要验证成果并最终发布。
预览🔗
用户需要一个干净、独立的环境来真实地模拟终端用户的使用体验。
设计思路:
当用户点击“预览”按钮时,系统执行以下操作:
- 状态快照:将当前页面的完整
project
状态树序列化成一个 JSON。 - 新窗口加载:打开一个新的浏览器标签页,并加载一个专用的“预览运行器”页面。
- 状态注入:通过
URL
参数或localStorage
将序列化后的状态 JSON 传递给这个新页面。 - 沙箱渲染:预览运行器启动一个迷你的 Vue 应用,它的唯一职责就是解析传入的状态 JSON,并使用动态渲染逻辑将其完整地渲染出来。
这种方式创建了一个完美的沙箱环境。它与编辑器完全隔离,确保了预览的是应用的真实运行效果,不受任何编辑器插件或工具的影响。用户可以在这里自由点击、交互,验证所有数据请求和页面联动是否符合预期。
最终构建🔗
预览通过后,就轮到正式的构建流程了。这需要一个更可靠、更专业的流程。
设计思路:
在云端服务器上运行一个独立的“构建器”程序。
工作流程:
- 触发构建:编辑器将当前项目的完整
project
状态树序列化成一个project-schema.json
文件并上传,然后触发一个云端构建任务。 - 代码生成:云端构建器读取这个 JSON。它会遍历每个页面,为每个页面生成一个真实的
.vue
文件,将组件树的渲染逻辑固化成模板代码。 - 联动翻译:对于组件的事件联动,构建器会将其翻译成
@click
等事件处理器。处理器内部的代码不再是执行命令,而是直接执行最终业务逻辑(例如,调用 API、修改另一个组件的某个ref
值)。 - 标准打包:代码生成完毕后,构建器在服务器上调用 Vite 或 Webpack,对这些生成的源代码进行标准的打包、压缩和优化。
- 产出物:最终输出一套可以被部署到任何静态服务器的 HTML, JS, CSS 文件。
这种方式将重量级的构建任务从用户的浏览器中移出,保证了构建过程的稳定和高效,是生产级低代码平台的标准做法。
最后🔗
从一个简单的插件化想法出发,我们最终构建起了一个能够支撑复杂低代码平台的“操作系统”。这趟旅程的核心在于:
- 协议是基石:标准化的组件协议,让所有“物料”都能被平台统一理解和管理。
- 命令是灵魂:命令模式统一了所有状态变更的入口,轻松实现了撤销、同步和联动等复杂功能。
- 插件是血肉:分而治之的插件体系,让每个功能模块(工具、属性面板、物料库、构建器)都能独立开发和演进。
这套架构的初期投入看似复杂,但它为上层的“应用程序”(即我们开发的各种组件和功能插件)提供了一套稳定、强大且一致的 API。作为开发者,大部分时候,我们只需思考如何定义新的“协议”和实现新的命令,而无需陷入管理复杂状态和交互的泥潭。
这就是整个插件系统构建之旅的终点——不是创造一个简单的工具,而是设计一个能够驾驭复杂性、拥抱未来变化的强大体系。希望这段从概念到实践的构建之旅,能为你提供一张可行的蓝图。