今天分享一个做内部的可视化搭建工具经验,从零开始设计前端架构,希望对同样在做类似事情的同学有帮助。
先说结论:分层是第一原则
做低代码可视化平台,最怕的就是"一锅粥"——渲染逻辑、交互逻辑、数据逻辑全搅在一起。等到要加新功能的时候,改一个地方崩三个地方。
经过几轮重构,我最终沉淀出一个四层架构:
┌─────────────────────────────────────┐│ 交互层 (Interaction) │ 拖拽、选中、缩放、快捷键├─────────────────────────────────────┤│ 渲染层 (Renderer) │ Canvas/SVG/DOM 渲染引擎├─────────────────────────────────────┤│ 模型层 (Model) │ 组件树、Schema、状态管理├─────────────────────────────────────┤│ 插件层 (Plugin) │ 扩展能力、生命周期钩子└─────────────────────────────────────┘每一层只关心自己的事,通过标准接口通信。下面逐层拆解。
一、模型层:一切的基础是 Schema
低代码平台的核心数据结构是一棵组件树,用 JSON Schema 描述。这棵树决定了画布上渲染什么、怎么渲染、数据怎么流转。
// 组件节点的核心数据结构interface ComponentNode { id: string; // 唯一标识 type: string; // 组件类型,如 'Button', 'Chart', 'Container' props: Record; // 组件属性 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 渲染 |
节点数上限 | ~500 | 10000+ | ~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; }}但真正的难点在于:
- 事件系统:Canvas 没有 DOM 事件冒泡,你得自己实现 hitTest(点击检测)。常见方案是离屏 Canvas 颜色拾取,或者基于包围盒的空间索引(R-Tree / 四叉树)。
- 脏区域渲染:全量重绘在节点多的时候很浪费。记录哪些区域变了,只重绘变化的部分,能大幅提升性能。
- 分层渲染:把静态元素和动态元素放在不同的 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 = new Map(); private hooks: Map = new Map(); private services: Map = 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 服务注入
插件之间不应该直接引用,而是通过两种方式通信:
- 事件总线:松耦合,适合"通知型"通信。比如"节点被选中了"、"数据更新了"。
- 服务注入:适合"能力型"通信。比如插件 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 ` ${generateTemplate(schema)}[xss_clean]${generateScript(schema)}[xss_clean]`; } }); }};不同的目标框架(Vue/React/原生 HTML)对应不同的代码生成插件,核心 Schema 不变,输出随意切换。
总结
回顾整个架构,核心思路就三个:
- Schema 驱动:用一棵 JSON 树描述一切,所有功能从这棵树派生
- 分层解耦:模型、渲染、交互、插件各司其职,通过接口通信
- 微内核 + 插件化:核心最小化,功能全部插件化,用钩子和服务注入串联
这套架构不是一开始就设计出来的,是经过三轮重构才稳定下来的。第一版是"能跑就行"的原型,第二版把渲染层抽出来了,第三版才引入了微内核。如果你也在做类似的项目,建议一开始就把 Schema 设计好,这是地基,后面怎么改都不怕。
渲染引擎和插件系统的选型,取决于你的业务场景。做页面搭建器,DOM + React/Vue 就够了;做工业组态/SCADA,Canvas 渲染引擎 + 微内核插件系统是更好的选择。
下一篇打算聊聊 SCADA Web 化的具体架构,特别是前端渲染层和数据采集层怎么解耦的问题。如果你也在做工业可视化相关的项目,欢迎留言交流。
我是一个专注前端可视化的技术人,分享可视化、Canvas、工业互联网相关的技术实践。关注我,一起在可视化的世界里折腾。