从零构建一个插件系统(六)低代码场景的插件构建思考

#工具相关
22 分钟阅读

不知不觉,插件系统构建之旅来到了最终章。前面几篇我们从核心概念聊起,探讨了串行、并发、缓存等机制,还学习了 Koa、Redux、Vue 3 在各自领域中插件系统的实现。现在,让我们从低代码平台这个复杂的应用场景出发,完整阐述如何构建一个强大的插件系统。

我在 2024 年的主要工作,就是深度参与一个基于 Vue 3 的低代码平台开发。这个平台功能丰富,简单概括如下:

  1. 多页面管理:类似于 Vue Router,支持多个路由页面。
  2. 组件拖拽与嵌套:多个组件可以相互嵌套,组合成一个更复杂的新组件。
  3. 协议驱动的属性配置:每个组件基于不同的协议,展示对应的属性配置面板。
  4. 事件与联动:通过配置事件,让组件能够影响自身和其他组件的状态。
  5. 全局历史与撤销重做:可以对大部分操作进行撤销与重做。
  6. 画布辅助工具:支持缩放、拖拽、标尺等功能。
  7. 预览与构建:可以快速预览和构建一个完整的工程。

面对这样一个平台,如果用“面向过程”一把梭,后果可想而知。这恰恰是我们需要使用插件思想来去重新架构的地方。此外,我们要做的不是写一个简单的插件系统,而是为这个平台构建一个健壮、可扩展的“操作系统”。这篇文章,便是对之前工作的回顾与总结。

推荐配合 漫谈 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
});

使用起来则是有两种形式:

  1. 初始化请求数据给默认组件进行数据填充。
  2. 事件配置面板中,用户可以将按钮的“点击事件”与一个“调用 API”的动作绑定。

设计哲学:拥抱 HTTP 状态码🔗

在接口规范上,我们建立了一条强制性原则:所有后端接口必须直接使用 HTTP 状态码来表达成功或失败,严禁在响应体中包装一层业务状态码(如 { code: 0, data: ..., msg: '...' })。

  • 成功:返回 2xx 状态码,响应体就是纯粹的数据 JSON。
  • 失败:返回 4xx(客户端错误)或 5xx(服务端错误)状态码,响应体可以携带错误的详细信息。

为什么坚持这样做?

  1. 遵循 Web 标准:这是 API 设计的最佳实践,让 HTTP 协议本身回归其设计初衷。
  2. 简化前端逻辑:我们无需在每个请求后都写 if (res.code === 0) 这样的模板代码。fetchaxioscatch 块天然就能捕获所有非 2xx 的响应。
  3. 平台级错误处理:平台可以提供统一的 API 调用器。这个调用器能够自动处理错误,例如,当捕获到 401 时自动跳转到登录页,捕获到 500 时显示统一的错误提示。这极大减轻了低代码开发者的心智负担。

这个看似简单的约束,实际上是整个平台稳定性和开发体验的基石之一。它使得数据交互的逻辑变得异常清晰和健壮。

预览与构建🔗

当用户完成了页面设计、属性配置和事件联动后,就需要验证成果并最终发布。

预览🔗

用户需要一个干净、独立的环境来真实地模拟终端用户的使用体验。

设计思路:

当用户点击“预览”按钮时,系统执行以下操作:

  1. 状态快照:将当前页面的完整 project 状态树序列化成一个 JSON。
  2. 新窗口加载:打开一个新的浏览器标签页,并加载一个专用的“预览运行器”页面。
  3. 状态注入:通过 URL 参数或 localStorage 将序列化后的状态 JSON 传递给这个新页面。
  4. 沙箱渲染:预览运行器启动一个迷你的 Vue 应用,它的唯一职责就是解析传入的状态 JSON,并使用动态渲染逻辑将其完整地渲染出来。

这种方式创建了一个完美的沙箱环境。它与编辑器完全隔离,确保了预览的是应用的真实运行效果,不受任何编辑器插件或工具的影响。用户可以在这里自由点击、交互,验证所有数据请求和页面联动是否符合预期。

最终构建🔗

预览通过后,就轮到正式的构建流程了。这需要一个更可靠、更专业的流程。

设计思路:

在云端服务器上运行一个独立的“构建器”程序。

工作流程:

  1. 触发构建:编辑器将当前项目的完整 project 状态树序列化成一个 project-schema.json 文件并上传,然后触发一个云端构建任务。
  2. 代码生成:云端构建器读取这个 JSON。它会遍历每个页面,为每个页面生成一个真实的 .vue 文件,将组件树的渲染逻辑固化成模板代码。
  3. 联动翻译:对于组件的事件联动,构建器会将其翻译成 @click 等事件处理器。处理器内部的代码不再是执行命令,而是直接执行最终业务逻辑(例如,调用 API、修改另一个组件的某个 ref 值)。
  4. 标准打包:代码生成完毕后,构建器在服务器上调用 Vite 或 Webpack,对这些生成的源代码进行标准的打包、压缩和优化。
  5. 产出物:最终输出一套可以被部署到任何静态服务器的 HTML, JS, CSS 文件。

这种方式将重量级的构建任务从用户的浏览器中移出,保证了构建过程的稳定和高效,是生产级低代码平台的标准做法。

最后🔗

从一个简单的插件化想法出发,我们最终构建起了一个能够支撑复杂低代码平台的“操作系统”。这趟旅程的核心在于:

  • 协议是基石:标准化的组件协议,让所有“物料”都能被平台统一理解和管理。
  • 命令是灵魂:命令模式统一了所有状态变更的入口,轻松实现了撤销、同步和联动等复杂功能。
  • 插件是血肉:分而治之的插件体系,让每个功能模块(工具、属性面板、物料库、构建器)都能独立开发和演进。

这套架构的初期投入看似复杂,但它为上层的“应用程序”(即我们开发的各种组件和功能插件)提供了一套稳定、强大且一致的 API。作为开发者,大部分时候,我们只需思考如何定义新的“协议”和实现新的命令,而无需陷入管理复杂状态和交互的泥潭。

这就是整个插件系统构建之旅的终点——不是创造一个简单的工具,而是设计一个能够驾驭复杂性、拥抱未来变化的强大体系。希望这段从概念到实践的构建之旅,能为你提供一张可行的蓝图。

文章目录