做 Web 组态编辑器这件事,特别容易进入一种危险状态:
一开始你以为自己在做“一个带拖拽的画布”,做着做着发现自己其实在做的是——渲染引擎、交互系统、协议适配层、配置面板、历史记录、资源管理、权限系统、脚本系统、导出系统,以及一个情绪极不稳定的前端应用。
我这两年看过不少可视化/SCADA/大屏编辑器项目,最后能活下来的团队,通常都不是“技术最炫”的那批,而是边界感最强的那批:知道哪些能力必须咬牙自己做,哪些能力应该站在成熟轮子上二次封装,哪些东西则要坚决忍住,不要第一阶段就手痒。
这篇就聊这个。
先说结论:别把“组态编辑器”理解成一个大组件
它不是一个组件,而是一组能力系统:
- 编辑态:拖拽、吸附、框选、缩放、图层、分组、快捷键、撤销重做。
- 运行态:实时数据绑定、状态映射、动画、告警、权限、性能稳定性。
- 配置态:属性面板、事件系统、变量系统、脚本、数据源配置。
- 交付态:发布、嵌入、导出、版本管理、资源依赖、运行容器。
如果你的团队还在产品 0→1 阶段,我的建议很简单:
- 自己做画布内的核心体验
- 复用画布外的通用基础设施
- 别在“看起来也挺重要”的边角系统里提前透支团队
下面分开说。
一、这些能力,必须自己做
1)场景模型:节点、连线、分组、锚点、层级
很多团队上来先挑画图库,结果最后发现最难的不是“画出来”,而是你的业务对象怎么表达。
一个真正可用的组态编辑器,底层必须有稳定的场景模型,至少要想清楚:
- 节点和连线分别是什么数据结构
- 分组是逻辑分组还是几何分组
- 锚点、吸附点、连接点怎么表示
- 旋转、缩放、父子层级如何继承
- 运行态属性和编辑态属性是否分离
一个最小可用的数据结构大概会长这样:
interface GraphNode {
id: string;
type: string;
x: number;
y: number;
width: number;
height: number;
rotate?: number;
style?: Record<string, any>;
dataBind?: {
pointId: string;
formatter?: string;
qualityField?: string;
};
events?: EditorEvent[];
children?: string[];
}
interface GraphLine {
id: string;
from: { nodeId: string; anchorId: string };
to: { nodeId: string; anchorId: string };
style?: Record<string, any>;
}
这类模型最好自己定义。原因很现实:
- 你迟早会遇到行业组件和通用图元混用
- 你迟早会做运行态数据映射
- 你迟早会加批量操作、规则引擎、导出
如果一开始把数据模型完全交给第三方库,后期改起来会很痛。
2)编辑器交互:这是用户每天骂不骂你的核心
组态编辑器的价值,不在 demo 截图,而在“编辑 3 小时以后人会不会崩”。
真正决定体验的,是这些细节:
- 拖拽时是否稳定
- 缩放后命中是否准确
- 框选是否符合预期
- 多选后移动是否顺手
- 对齐线是否聪明但不烦人
- 吸附和自动布局会不会互相打架
- 键盘快捷键是否符合通用习惯
这部分我非常建议自己做,至少核心逻辑必须掌握在手里。
因为第三方库一般只解决“能编辑”,而你需要的是“能高频编辑”。这两者中间差了一个产品代际。
3)撤销/重做:别再全量快照一把梭了
撤销重做是组态编辑器第一批会被做错的能力。
很多项目初版都这么写:
undoStack.push(JSON.stringify(pageState));
小项目能跑,大项目会出三个问题:
- 内存涨得快:节点一多,快照巨大。
- 性能抖动明显:频繁序列化/反序列化。
- 语义不够:你只知道“状态变了”,不知道“发生了什么操作”。
更靠谱的方案通常是:
- 用命令模式记录操作语义
- 用patch 或最小变更集记录数据差异
- 对连续拖拽、连续输入做合并提交
像这样:
interface Command {
label: string;
execute(): void;
undo(): void;
merge?(next: Command): boolean;
}
class MoveNodesCommand implements Command {
constructor(
private ids: string[],
private before: Array<{ x: number; y: number }>,
private after: Array<{ x: number; y: number }>
) {}
label = 'move-nodes';
execute() {
applyPositions(this.ids, this.after);
}
undo() {
applyPositions(this.ids, this.before);
}
merge(next: Command) {
return next instanceof MoveNodesCommand;
}
}
我这次查资料时,国内几篇关于 Canvas 编辑器 history 的文章,基本也都在强调两条路线:快照法适合入门,命令/差异法才适合生产。这点很一致。
另外,现代浏览器里的 structuredClone() 确实可以比 JSON.parse(JSON.stringify()) 更稳一点,适合某些中间态复制;但它不是 history 的银弹。深拷贝工具能优化实现细节,替代不了正确的历史模型。
4)渲染调度:不是“用了 Canvas”就自动高性能
很多人把 Canvas 当性能护身符。其实不是。
组态编辑器卡不卡,关键看你有没有做这些:
- 分层渲染:背景层、主图层、交互层、辅助层分开
- 脏区刷新:只重绘变化区域
- 命中检测优化:空间索引或粗细两阶段检测
- 预渲染:静态元素缓存
- 高频交互降频:drag 过程减少无意义计算
如果节点数大、动画多,还可以考虑把部分渲染放进 OffscreenCanvas + Worker。查 MDN 和相关资料时能看到,OffscreenCanvas 现在在现代浏览器上的可用性已经比前几年成熟得多,适合把重渲染、位图生成、部分预处理搬离主线程。
但这里要注意一句:
OffscreenCanvas 适合“重绘密集型任务”,不适合把整个编辑器逻辑一股脑扔进 Worker。
因为你的交互命中、DOM 面板、快捷键、属性编辑,核心还是主线程协调。正确姿势通常是:
- 主线程负责交互编排
- Worker 负责重计算/离屏绘制
- 通过消息传递同步最小状态
5)数据绑定和状态表达:这是组态,不是纯绘图
流程图编辑器和组态编辑器最本质的差别,是后者一定要连接运行数据。
也就是说你必须自己定义:
- 点位绑定方式
- 实时值怎么写入节点状态
- 告警如何表现
- 质量码如何影响 UI
- 动画和状态切换的优先级谁更高
比如同一个泵图元,可能同时存在:
- 运行中:绿色旋转
- 停止:灰色静止
- 故障:红色闪烁
- 通讯异常:半透明 + 斜纹遮罩
这套规则必须是你的领域语言,不可能完全靠通用图库给你。
6)行业组件库:差异化就藏在这里
通用矩形、圆形、折线谁都能画。
真正有壁垒的是:
- 电力接线图元件
- 水务/化工管道元件
- 仪表盘与阀门
- 风机/泵/皮带/液位等动态组件
- 告警、联锁、状态切换的语义化封装
用户买的不是一个“会画矩形”的编辑器,买的是一个“我半天能拼出业务现场”的系统。
所以行业组件一定要自己沉淀。
二、这些能力,优先复用或二次封装
1)属性面板和表单引擎
真的没必要从零手搓一个复杂属性面板框架。
如果你的属性编辑本质上是“配置驱动表单”,那就直接站在成熟 UI 组件库或 Schema 表单能力上做二次封装。
比如你可以把每类图元的属性声明成 schema:
const pumpSchema = [
{ key: 'name', label: '名称', component: 'Input' },
{ key: 'style.fill', label: '填充色', component: 'ColorPicker' },
{ key: 'bind.pointId', label: '绑定点位', component: 'PointSelector' },
{ key: 'alarm.enable', label: '开启告警闪烁', component: 'Switch' }
];
然后:
- 通用字段走通用表单渲染
- 少量行业字段单独插槽扩展
这样团队不会被一堆表单细节拖死。
2)代码编辑器、脚本编辑器
表达式、脚本、事件处理器这些需求,最后大概率都要接 Monaco 或 CodeMirror。
别自己做。
“自研代码编辑器”听起来很酷,实际上属于给自己安排加班。
3)图表库
组态平台里总有人会提一句:“要不图表也自己画吧?”
不要。
ECharts、Highcharts 这类生态成熟、能力边界清楚的库,拿来用就行。你的价值不在于重写折线图,而在于:
- 图表组件如何接入数据源
- 如何在编辑器中配置
- 如何统一主题和交互
- 如何和组态页面其他组件联动
4)资源上传、权限、监控
这类能力虽然重要,但对组态编辑器来说不是核心差异点。
更合理的方式是:
- 直接接现有对象存储/上传服务
- 权限接统一账号体系
- 前端监控接现成平台
- 埋点用统一采集方案
这类基础设施做得再漂亮,用户也不会因为“你们上传 SDK 写得真优雅”而续费。
5)协同底座(如果真的要做协同)
如果你已经进入多人协同编辑阶段,优先考虑成熟 CRDT/协同协议方案,不要第一版就自造同步协议。
协同编辑最难的从来不是“别人改了我也看见了”,而是:
- 命令历史怎么与协同操作兼容
- 选区/光标状态怎么表达
- 多人同时拖同一元素如何处理
- 网络抖动下如何保持可恢复
这套东西太容易低估。
三、这些东西,第一阶段千万别自己做
1)完整富文本系统
如果你的组态编辑器里只是偶尔要写说明文字、注释、标题,够用就行。
不要因为一个“文本框支持加粗斜体”需求,把自己拉进富文本深坑。
2)自研图表引擎
除非你的产品本质就是图表引擎,否则不要碰。
3)完整低代码平台全家桶
很多团队做组态编辑器时,会逐渐产生一种幻觉:
既然我们已经有画布、有组件、有属性面板,是不是顺手把页面搭建器、表单设计器、BI、流程编排也做了?
这就是经典项目膨胀现场。
请记住:编辑器成功的关键,不是能力多,而是主链路顺。
4)一次做完所有行业组件
早期最靠谱的策略永远是:
- 先打一个行业样板
- 把 20% 高频组件做到 80 分
- 用真实项目逼出抽象层
- 再做组件扩展体系
而不是会议室里凭空列 300 个组件。
四、一个我更推荐的 0→1 落地顺序
如果让我从头带一个小团队做,我会按这个顺序切:
第 1 阶段:先把“最小可编辑闭环”做出来
目标:能画、能选、能拖、能保存、能撤销。
必做:
- 画布与场景模型
- 基础图元
- 选中/拖拽/缩放
- 属性面板最小集
- 保存/加载
- undo/redo
第 2 阶段:补齐“能交付”的运行能力
目标:不只是编辑器 demo,而是能跑现场页面。
必做:
- 数据源接入(WebSocket/MQTT/SSE)
- 实时绑定
- 动画和状态规则
- 权限和发布
- 大页面性能优化
这里可以把一些低优先级计算放进 requestIdleCallback() 做分片处理,比如:
- 非关键 schema 预处理
- 缩略图生成
- 资源索引整理
- 批量检查和分析
不过 MDN 也明确提醒过,requestIdleCallback() 更适合低优先级后台任务,不要把关键 UI 更新塞进去,更建议加 timeout 兜底。
第 3 阶段:再考虑扩展体系
目标:让产品开始可规模化。
必做:
- 插件机制
- 行业组件扩展
- 事件脚本体系
- 导出和嵌入
- 模板市场/物料体系
第 4 阶段:最后再碰协同和平台化
因为这时候你至少知道:
- 用户到底在哪里卡
- 哪些模型已经稳定
- 哪些交互不会再大改
这时做平台化,才不是空中楼阁。
五、技术选型上,我现在的一个偏见
如果目标是工业场景、SCADA、大屏、拓扑、实时监控这类高频重绘编辑器,我会更偏向:
- Canvas 做主渲染层
- DOM 做面板和外层交互
- 命令模式 + patch 做历史系统
- Schema 驱动属性配置
- Worker / OffscreenCanvas 做重任务隔离
原因很简单:
- SVG 在节点量大、频繁更新时压力更早出现
- 全 DOM 方案做复杂画布交互会比较痛
- 纯快照 history 很难扛到生产环境
- 先把“可编辑”和“可运行”分层,后面更容易扩展
当然,具体项目怎么选,还得看节点规模、动画密度、团队经验和目标行业。但如果你问我 2026 年做这类产品最容易少走弯路的路线,我大概还是这套答案。
最后总结成一句大白话
自己做用户每天都在用、且决定产品上限的部分;复用那些成熟但不构成差异化的部分;克制住把项目做成“宇宙级平台”的冲动。
组态编辑器这类产品,最怕的不是难,而是贪。
你可以先做小,但一定要把骨架做对。
不然第一版写得越快,第二版重写得越彻底。