今天分享一个做内部的可视化搭建工具经验,从零开始设计前端架构,希望对同样在做类似事情的同学有帮助。
先说结论:分层是第一原则
做低代码可视化平台,最怕的就是"一锅粥"------渲染逻辑、交互逻辑、数据逻辑全搅在一起。等到要加新功能的时候,改一个地方崩三个地方。
经过几轮重构,我最终沉淀出一个四层架构:
scss
┌─────────────────────────────────────┐
│ 交互层 (Interaction) │ 拖拽、选中、缩放、快捷键
├─────────────────────────────────────┤
│ 渲染层 (Renderer) │ Canvas/SVG/DOM 渲染引擎
├─────────────────────────────────────┤
│ 模型层 (Model) │ 组件树、Schema、状态管理
├─────────────────────────────────────┤
│ 插件层 (Plugin) │ 扩展能力、生命周期钩子
└─────────────────────────────────────┘
每一层只关心自己的事,通过标准接口通信。下面逐层拆解。
一、模型层:一切的基础是 Schema
低代码平台的核心数据结构是一棵组件树,用 JSON Schema 描述。这棵树决定了画布上渲染什么、怎么渲染、数据怎么流转。
typescript
// 组件节点的核心数据结构
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:
kotlin
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 渲染引擎的骨架其实不复杂:
kotlin
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 层上。静态层不需要频繁重绘,动态层(如动画、实时数据)独立刷新。
kotlin
// 分层渲染示意
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) 来管理交互状态:
kotlin
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;
// ... 其他状态转换
}
}
}
状态机的好处是:交互逻辑清晰、不会出现状态混乱(比如拖拽的时候突然触发了框选)、容易扩展新的交互模式。
四、插件层:微内核是终极答案
这是我认为整个架构中最重要的一层。
一个可视化平台要支持的功能太多了:不同类型的组件、不同的数据源、不同的导出格式、不同的交互工具......如果全部写在核心代码里,代码量会爆炸,而且每加一个功能都要改核心。
微内核 + 插件化是解决这个问题的经典模式。核心思路:
- 内核只做三件事:插件管理、事件总线、服务注册
- 所有业务功能都是插件:组件库是插件、数据源适配器是插件、导出器是插件、工具栏按钮也是插件
typescript
// 微内核定义
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 图表组件"插件:
php
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"能力。
javascript
// 事件总线:发布-订阅
kernel.hook('node:selected', (nodeId) => {
// 属性面板插件监听,更新面板内容
propertyPanel.update(nodeId);
});
// 服务注入:依赖查找
const exporter = kernel.inject('pdf-exporter');
await exporter.export(currentScene);
五、代码生成:从 Schema 到可运行代码
低代码平台的最终产物通常是可部署的代码。代码生成器的设计也很适合用插件化:
typescript
// 代码生成器也是插件
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 不变,输出随意切换。
总结
回顾整个架构,核心思路就三个:
- Schema 驱动:用一棵 JSON 树描述一切,所有功能从这棵树派生
- 分层解耦:模型、渲染、交互、插件各司其职,通过接口通信
- 微内核 + 插件化:核心最小化,功能全部插件化,用钩子和服务注入串联
这套架构不是一开始就设计出来的,是经过三轮重构才稳定下来的。第一版是"能跑就行"的原型,第二版把渲染层抽出来了,第三版才引入了微内核。如果你也在做类似的项目,建议一开始就把 Schema 设计好,这是地基,后面怎么改都不怕。
渲染引擎和插件系统的选型,取决于你的业务场景。做页面搭建器,DOM + React/Vue 就够了;做工业组态/SCADA,Canvas 渲染引擎 + 微内核插件系统是更好的选择。
下一篇打算聊聊 SCADA Web 化的具体架构,特别是前端渲染层和数据采集层怎么解耦的问题。如果你也在做工业可视化相关的项目,欢迎留言交流。
我是一个专注前端可视化的技术人,分享可视化、Canvas、工业互联网相关的技术实践。关注我,一起在可视化的世界里折腾。