低代码可视化平台的前端架构设计:从渲染引擎到插件系统

0 阅读8分钟

今天分享一个做内部的可视化搭建工具经验,从零开始设计前端架构,希望对同样在做类似事情的同学有帮助。

先说结论:分层是第一原则

做低代码可视化平台,最怕的就是"一锅粥"——渲染逻辑、交互逻辑、数据逻辑全搅在一起。等到要加新功能的时候,改一个地方崩三个地方。

经过几轮重构,我最终沉淀出一个四层架构:

┌─────────────────────────────────────┐
│           交互层 (Interaction)       │  拖拽、选中、缩放、快捷键
├─────────────────────────────────────┤
│           渲染层 (Renderer)          │  Canvas/SVG/DOM 渲染引擎
├─────────────────────────────────────┤
│           模型层 (Model)             │  组件树、Schema、状态管理
├─────────────────────────────────────┤
│           插件层 (Plugin)            │  扩展能力、生命周期钩子
└─────────────────────────────────────┘

每一层只关心自己的事,通过标准接口通信。下面逐层拆解。

一、模型层:一切的基础是 Schema

低代码平台的核心数据结构是一棵组件树,用 JSON Schema 描述。这棵树决定了画布上渲染什么、怎么渲染、数据怎么流转。

// 组件节点的核心数据结构
interface ComponentNode {
  id: string;                    // 唯一标识
  type: string;                  // 组件类型,如 'Button', 'Chart', 'Container'
  props: Record<string, any>;    // 组件属性
  style: CSSProperties;          // 样式
  children?: ComponentNode[];    // 子节点
  events?: EventBinding[];       // 事件绑定
  dataSource?: DataBinding;      // 数据源绑定
}
​
// 组件元数据:描述组件"能做什么"
interface ComponentMeta {
  name: string;
  category: string;              // 分类:基础组件、图表、容器...
  propsSchema: JSONSchema;       // 属性的 JSON Schema,用于自动生成配置面板
  slots?: string[];              // 插槽定义
  events?: string[];             // 可触发的事件
  thumbnail?: string;            // 缩略图
}

这里有个关键设计决策:Schema 是"单一事实来源"(Single Source of Truth) 。画布渲染、属性面板、代码生成、数据绑定,全部从这棵树派生。不要搞多份数据互相同步,那是噩梦的开始。

状态管理:不可变数据 + 命令模式

组件树的每次修改都通过命令(Command)执行,而不是直接 mutate:

class EditorStore {
  private state: EditorState;
  private history: Command[] = [];
  private cursor: number = -1;
​
  execute(command: Command) {
    // 执行命令
    this.state = command.execute(this.state);
    // 记录历史(支持撤销/重做)
    this.history = this.history.slice(0, this.cursor + 1);
    this.history.push(command);
    this.cursor++;
    // 通知订阅者
    this.notify();
  }
​
  undo() {
    if (this.cursor < 0) return;
    this.state = this.history[this.cursor].undo(this.state);
    this.cursor--;
    this.notify();
  }
​
  redo() {
    if (this.cursor >= this.history.length - 1) return;
    this.cursor++;
    this.state = this.history[this.cursor].execute(this.state);
    this.notify();
  }
}

用不可变数据(Immutable)的好处是:状态可追溯、撤销重做天然支持、脏检查高效。代价是每次修改都要创建新对象,但配合结构共享(Structural Sharing),性能完全可以接受。

二、渲染层:Canvas 还是 DOM?

这是做可视化平台绕不开的选择题。我的经验是:看场景

维度DOM 渲染Canvas 渲染SVG 渲染
节点数上限~50010000+~2000
交互复杂度天然支持需要自己实现事件系统天然支持
文本排版原生支持痛苦一般
动画性能一般优秀一般
适用场景表单搭建、页面搭建工业组态、大屏、拓扑图流程图、简单图形

如果你做的是类似"页面搭建器"(表单、后台页面),DOM 渲染就够了,React/Vue 的虚拟 DOM 已经帮你处理了大部分事情。

但如果是工业组态、SCADA、数据大屏这类场景,节点动辄上万,还有大量动画和实时数据刷新,Canvas 几乎是唯一选择。

Canvas 渲染引擎的核心循环

一个 Canvas 渲染引擎的骨架其实不复杂:

class RenderEngine {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private sceneGraph: SceneNode[];  // 场景图
  private dirty: boolean = true;
  private rafId: number = 0;
​
  // 渲染主循环
  private loop = () => {
    if (this.dirty) {
      this.clear();
      this.render(this.sceneGraph);
      this.dirty = false;
    }
    this.rafId = requestAnimationFrame(this.loop);
  };
​
  private render(nodes: SceneNode[]) {
    for (const node of nodes) {
      this.ctx.save();
      // 应用变换矩阵(位移、旋转、缩放)
      this.applyTransform(node.transform);
      // 调用节点自身的绘制方法
      node.draw(this.ctx);
      // 递归渲染子节点
      if (node.children) {
        this.render(node.children);
      }
      this.ctx.restore();
    }
  }
​
  // 标记脏区域,触发重绘
  markDirty() {
    this.dirty = true;
  }
}

但真正的难点在于:

  1. 事件系统:Canvas 没有 DOM 事件冒泡,你得自己实现 hitTest(点击检测)。常见方案是离屏 Canvas 颜色拾取,或者基于包围盒的空间索引(R-Tree / 四叉树)。
  2. 脏区域渲染:全量重绘在节点多的时候很浪费。记录哪些区域变了,只重绘变化的部分,能大幅提升性能。
  3. 分层渲染:把静态元素和动态元素放在不同的 Canvas 层上。静态层不需要频繁重绘,动态层(如动画、实时数据)独立刷新。
// 分层渲染示意
class LayeredRenderer {
  private staticCanvas: HTMLCanvasElement;   // 静态层:背景、固定元素
  private dynamicCanvas: HTMLCanvasElement;  // 动态层:动画、实时数据
  private interactCanvas: HTMLCanvasElement; // 交互层:选中框、拖拽辅助线
​
  renderStatic() {
    // 只在布局变化时重绘
    this.drawNodes(this.staticCanvas, this.staticNodes);
  }
​
  renderDynamic() {
    // 每帧或数据更新时重绘
    this.drawNodes(this.dynamicCanvas, this.dynamicNodes);
  }
​
  renderInteraction() {
    // 鼠标移动时重绘
    this.drawSelectionBox(this.interactCanvas);
    this.drawAlignGuides(this.interactCanvas);
  }
}

这个分层策略在实际项目中效果非常明显——我们的场景有 8000+ 节点,分层后帧率从 15fps 稳定到了 50fps 以上。

三、交互层:拖拽不只是 mousedown + mousemove

可视化编辑器的交互比想象中复杂得多。拖拽组件到画布、拖拽调整位置、拖拽调整大小、框选、对齐辅助线、吸附……每一个都是独立的交互状态。

我推荐用有限状态机(FSM) 来管理交互状态:

type InteractionState = 
  | 'idle'           // 空闲
  | 'dragging'       // 拖拽移动
  | 'resizing'       // 调整大小
  | 'selecting'      // 框选
  | 'connecting'     // 连线
  | 'panning';       // 画布平移class InteractionFSM {
  private state: InteractionState = 'idle';
​
  transition(event: MouseEvent | KeyboardEvent) {
    switch (this.state) {
      case 'idle':
        if (isMouseDownOnNode(event)) this.state = 'dragging';
        else if (isMouseDownOnHandle(event)) this.state = 'resizing';
        else if (isMouseDownOnCanvas(event)) this.state = 'selecting';
        else if (isSpacePressed(event)) this.state = 'panning';
        break;
      case 'dragging':
        if (isMouseUp(event)) {
          this.commitDrag();
          this.state = 'idle';
        }
        break;
      // ... 其他状态转换
    }
  }
}

状态机的好处是:交互逻辑清晰、不会出现状态混乱(比如拖拽的时候突然触发了框选)、容易扩展新的交互模式。

四、插件层:微内核是终极答案

这是我认为整个架构中最重要的一层。

一个可视化平台要支持的功能太多了:不同类型的组件、不同的数据源、不同的导出格式、不同的交互工具……如果全部写在核心代码里,代码量会爆炸,而且每加一个功能都要改核心。

微内核 + 插件化是解决这个问题的经典模式。核心思路:

  • 内核只做三件事:插件管理、事件总线、服务注册
  • 所有业务功能都是插件:组件库是插件、数据源适配器是插件、导出器是插件、工具栏按钮也是插件
// 微内核定义
class EditorKernel {
  private plugins: Map<string, Plugin> = new Map();
  private hooks: Map<string, Function[]> = new Map();
  private services: Map<string, any> = new Map();
​
  // 注册插件
  use(plugin: Plugin) {
    plugin.install(this);
    this.plugins.set(plugin.name, plugin);
    return this;
  }
​
  // 注册钩子(类似 Webpack 的 tapable)
  hook(name: string, fn: Function) {
    if (!this.hooks.has(name)) this.hooks.set(name, []);
    this.hooks.get(name)!.push(fn);
  }
​
  // 触发钩子
  async callHook(name: string, ...args: any[]) {
    const fns = this.hooks.get(name) || [];
    for (const fn of fns) {
      await fn(...args);
    }
  }
​
  // 注册/获取服务
  provide(name: string, service: any) { this.services.set(name, service); }
  inject(name: string) { return this.services.get(name); }
}
​
// 插件接口
interface Plugin {
  name: string;
  dependencies?: string[];
  install(kernel: EditorKernel): void;
  activate?(): void;
  deactivate?(): void;
}

举个实际例子——一个"ECharts 图表组件"插件:

const echartsPlugin: Plugin = {
  name: 'echarts-components',
  dependencies: ['component-registry'],
​
  install(kernel) {
    const registry = kernel.inject('component-registry');
​
    // 注册一批 ECharts 组件
    registry.register('LineChart', {
      category: '图表',
      propsSchema: { /* ... */ },
      render: (props, bindData) => {
        const chart = echarts.init(container);
        chart.setOption(bindData ? mergeData(props, bindData) : props);
        return chart;
      }
    });
​
    registry.register('BarChart', { /* ... */ });
    registry.register('PieChart', { /* ... */ });
​
    // 监听数据更新事件,刷新图表
    kernel.hook('data:update', (nodeId, data) => {
      const chart = chartInstances.get(nodeId);
      if (chart) chart.setOption(data, { notMerge: false });
    });
  }
};
​
// 使用
const editor = new EditorKernel();
editor
  .use(corePlugin)           // 核心功能
  .use(componentRegistry)    // 组件注册中心
  .use(echartsPlugin)        // ECharts 图表
  .use(mqttDataSource)       // MQTT 数据源
  .use(exportHtmlPlugin)     // 导出 HTML
  .use(alignPlugin);         // 对齐辅助线

这个设计参考了 VS Code 和 Webpack 的插件体系。VS Code 的成功很大程度上归功于它的插件架构——核心编辑器很轻,语言支持、主题、调试器全是插件。Webpack 的 tapable 钩子系统也是同样的思路,整个构建流程都是通过钩子串起来的。

插件间通信:事件总线 vs 服务注入

插件之间不应该直接引用,而是通过两种方式通信:

  1. 事件总线:松耦合,适合"通知型"通信。比如"节点被选中了"、"数据更新了"。
  2. 服务注入:适合"能力型"通信。比如插件 A 需要用到插件 B 提供的"导出 PDF"能力。
// 事件总线:发布-订阅
kernel.hook('node:selected', (nodeId) => {
  // 属性面板插件监听,更新面板内容
  propertyPanel.update(nodeId);
});
​
// 服务注入:依赖查找
const exporter = kernel.inject('pdf-exporter');
await exporter.export(currentScene);

五、代码生成:从 Schema 到可运行代码

低代码平台的最终产物通常是可部署的代码。代码生成器的设计也很适合用插件化:

// 代码生成器也是插件
const vueCodegenPlugin: Plugin = {
  name: 'vue-codegen',
  install(kernel) {
    kernel.provide('codegen:vue', {
      generate(schema: ComponentNode): string {
        return `
<template>
  ${generateTemplate(schema)}
</template>
​
<script setup>
${generateScript(schema)}
</script>
​
<style scoped>
${generateStyle(schema)}
</style>`;
      }
    });
  }
};

不同的目标框架(Vue/React/原生 HTML)对应不同的代码生成插件,核心 Schema 不变,输出随意切换。

总结

回顾整个架构,核心思路就三个:

  1. Schema 驱动:用一棵 JSON 树描述一切,所有功能从这棵树派生
  2. 分层解耦:模型、渲染、交互、插件各司其职,通过接口通信
  3. 微内核 + 插件化:核心最小化,功能全部插件化,用钩子和服务注入串联

这套架构不是一开始就设计出来的,是经过三轮重构才稳定下来的。第一版是"能跑就行"的原型,第二版把渲染层抽出来了,第三版才引入了微内核。如果你也在做类似的项目,建议一开始就把 Schema 设计好,这是地基,后面怎么改都不怕。

渲染引擎和插件系统的选型,取决于你的业务场景。做页面搭建器,DOM + React/Vue 就够了;做工业组态/SCADA,Canvas 渲染引擎 + 微内核插件系统是更好的选择。

下一篇打算聊聊 SCADA Web 化的具体架构,特别是前端渲染层和数据采集层怎么解耦的问题。如果你也在做工业可视化相关的项目,欢迎留言交流。


我是一个专注前端可视化的技术人,分享可视化、Canvas、工业互联网相关的技术实践。关注我,一起在可视化的世界里折腾。