简介:LogicFlow 是一款面向前端开发者的流程可视化框架,专注于业务流程建模与流程图编辑场景,基于 TypeScript 与 MVVM 架构设计,内置撤销重做、对齐线、键盘快捷键等开箱即用能力,能快速嵌入业务系统满足类流程图需求。框架本身采用模块化与插件化思想,节点、边、控制点均可自定义,配合丰富的扩展机制适合团队在真实项目中二次开发。该框架资源包共含 333 个文件,压缩后大小约 997KB,以 84 个 tsx 组件文件、82 个 ts 类型与逻辑文件、36 个 js 脚本为核心源码,另有 44 个 markdown 说明文档和 css、less 样式、png 图标、json 配置文件,构成一套结构完整的示例工程。当前已有 3784 人学习下载,对于需要自研流程编辑器的中高级前端工程师,既能通过源码理解节点渲染、事件交互与插件注册等底层原理,也能参考官方设计快速落地业务功能,明显降低从零搭建编辑器的时间成本。
1. LogicFlow不是画板,是图数据引擎
做流程可视化十有八九是从“画个图”开始,但等到审批流、工作流真正落地,你会发现难点从来不在怎么画线,而在怎么把用户的拖拽变成一份可存储、可回放、可校验的结构化数据。LogicFlow就是冲着这个痛点来的:一个基于TypeScript的流程可视化编辑框架,内部用MVVM的思路管理图元状态,渲染层基于SVG实现,同时支持图编辑器常见的节点、锚点、连线和插件扩展。它适合两类人:一类是要在业务系统里嵌入“让别人拖出流程”的开发者,另一类是已经画过图但被数据同步、撤销重做、自定义节点折磨过的前端工程师。这篇我会从架构设计讲到实际初始化,再到自定义节点和性能自查,把值得抄的代码和踩过的坑都摊开说。
2. 从SVG到MVVM:LogicFlow的架构设计为什么这样选
2.1 图元模型:节点、边与锚点的数据协议
LogicFlow中最核心的数据结构只有两个数组:nodes和edges。这个结构听起来简单,但因为所有交互最终都要落到画布上,字段设计必须同时兼顾布局坐标和业务挂载。
interface GraphData { nodes: Array<{ id: string; type: string; x: number; y: number; properties?: Record<string, any>; }>; edges: Array<{ id: string; type?: string; sourceNodeId: string; targetNodeId: string; sourceAnchorId?: string; targetAnchorId?: string; properties?: Record<string, any>; }>; }节点坐标x/y存的是画布坐标系里的绝对坐标,不是屏幕坐标。这意味着画布一旦发生缩放或者平移,视觉位置和存储坐标会出现偏差,后面避坑章节我会专门交代这一点。type是渲染分发依据,LogicFlow内置了rect、circle、diamond等基础图形,但实际业务里大部分节点都是自定义的,这套协议里type就是你和自定义view之间的映射key。
边比节点多了一层锚点概念。锚点挂在节点上,是边上连线的具体接入口。为什么不能直接从节点中心连到另一个节点中心?因为一个审批节点往往有多个出入口:同意走A分支,驳回走B分支,全部连线都指向中心会乱成一团。锚点正是为了解决“这个线该从哪个位置接出去、接到哪个口”的问题。sourceAnchorId和targetAnchorId未指定时,LogicFlow会取节点Model上配置的默认锚点;如果自定义节点没配锚点,边可能连不上,或者落在奇怪的位置。
我一般建议在动手画图之前,先让后端同事确认properties的字段结构。因为properties是业务数据和框架交互的唯一通道,节点标题、状态色、是否可删除这些逻辑都应该挂在里面,而不是散落在框架字段上。
2.2 MVVM与TypeScript:状态管理怎么影响渲染性能
LogicFlow没有直接操作SVG节点来实现增删改,而是引入了Model层。每个节点实例对应一个NodeModel,每条边对应一个EdgeModel,它们持有自己的状态数据;view层通过getShape()方法把Model渲染成SVG元素。这个数据流向是单向的:外部调用 API 修改Model,Model触发事件,view接收新状态后重新渲染对应图元。
这个设计很直接地解决了一个问题:撤销重做。如果交互直接操作DOM,撤销需要记录一整套DOM快照,这在浏览器里既难做又费内存。但MVVM模式下,每次操作本质上是对数据数组的增删改,记录历史只需要保存GraphData的快照,成本低很多。另一个受益场景是联动校验:比如“连线前检查两个节点类型是否允许相连”,这类规则放在事件回调里检查数据,比等SVG渲染完再去查DOM节点要可靠。
TypeScript在这套架构里的作用是给数据协议上了一道保险。GraphData定义好之后,后端定义的流程JSON可以映射成强类型数据,前端不会因为字段名拼错导致页面静默失败。不过有一点要注意:LogicFlow内部的数据协议虽然有类型声明,但properties是Record<string, any>,一旦业务数据塞得太深,类型检查就形同虚设。我见过有人把后端接口返回的完整对象直接塞进properties,结果循环引用导致JSON.stringify直接抛错,这个后面会展开说。
3. 从零搭建一个graph-editor:初始化、节点注册与数据持久化
3.1 初始化与画布参数:第一个坑就是容器高度
安装依赖只需要一个核心包,样式文件需要手动引进来。
npm install @logicflow/coreimport LogicFlow from '@logicflow/core'; import '@logicflow/core/dist/style/index.css'; const container = document.getElementById('lf-container') as HTMLElement; const lf = new LogicFlow({ container, width: 1200, height: 800, grid: true, stopMoveGraph: false, edgeType: 'polyline', });container是挂载点,这个DOM元素必须有实际渲染高度。我见过不下三次画布空白,排查半天发现是父容器用了flex布局且没给高度,子元素高度塌成0。LogicFlow不会帮你撑开容器,它会拿width和height配置去渲染画布,但容器自身高度为0时仍然什么都看不到。stopMoveGraph控制是否允许用户拖动画布,如果你做的是审批设计器,一般设为false让用户可以拖动查看全局;如果是内嵌在表单里的只读流程图,可以设为true。edgeType决定默认连线类型,polyline是折线,适合审批流;line是直线,适合拓扑图;bezier适合需要平滑曲线的场景。
初始化完成后,页面应该出现一个带网格的空画布。如果没出现,打开控制台看有没有报错,或者检查container是否已经被其他元素占用。
3.2 节点与边:从数据协议到画布上的实体
LogicFlow有两种添加图元的方式:全量渲染和增量添加。
// 全量渲染:一次性把整张图交给框架 lf.render({ nodes: [ { id: 'start-node', type: 'rect', x: 100, y: 100, properties: { title: '开始' } }, { id: 'end-node', type: 'rect', x: 500, y: 100, properties: { title: '结束' } }, ], edges: [ { id: 'edge-1', sourceNodeId: 'start-node', targetNodeId: 'end-node' }, ], });// 增量添加:适用于用户正在编辑、动态增加节点的场景 lf.addNode({ id: 'approve-node-1', type: 'rect', x: 300, y: 250, properties: { title: '审批' }, }); lf.addEdge({ sourceNodeId: 'start-node', targetNodeId: 'approve-node-1', sourceAnchorId: 'start-node:out', targetAnchorId: 'approve-node-1:in', });这里要分清两种模式的适用场景。lf.render()是重建整个图,调用次数多了性能会明显下降;lf.addNode()和lf.addEdge()是局部更新,用户拖拽编辑时应该用增量API。另外render会清空当前画布再重新绘制,如果前端页面在做数据回显、用户又已经改了几笔,记得先把当前数据合并好再render,不然一笔编辑就被覆盖了。
锚点ID的写法(比如start-node:out)取决于节点Model里怎么定义锚点。内置基础节点的锚点规则相对固定,但自定义节点你完全可以定义一套自己的锚点命名。如果锚点ID写错,边不会直接报错,但会出现在默认锚点上——这往往让人觉得“连线位置不对”,其实是对锚点配置不了解。
3.3 数据持久化:保存与回放
流程编辑器最核心的闭环就是“用户画完 → 保存 → 后端存储 → 下次打开回放”。
// 保存:从画布实例中取出完整数据结构 const graphData = lf.getGraphData(); console.log(graphData.nodes.length, graphData.edges.length); // 交给后端时建议做一个白名单过滤 const payload = JSON.stringify({ nodes: graphData.nodes.map((n) => ({ id: n.id, type: n.type, x: n.x, y: n.y, properties: n.properties, })), edges: graphData.edges, });// 回放:从服务端拿到保存时的字符串 const savedData = JSON.parse(savedString); lf.render(savedData);后端存储时建议直接用TEXT类型存JSON字符串,不要拆表存节点和边。原因有两个:一是图结构天然是嵌套的,拆表之后重建关联关系反而复杂;二是GraphData是前端渲染协议,后端很难预测前端会扩展什么字段,存原始JSON最稳妥。getGraphData()返回的对象里除了nodes和edges,可能还会有其他图形状态字段,保存前最好明确要存哪些,避免把框架内部字段也存进业务库。
4. 避坑与常见问题:节点、边、数据序列化的五个坑
4.1 缩放后读出的坐标对不上视觉位置
现象:用户按住缩放快捷键或者点击Control插件放大了画布,此时读取某个节点的x、y,发现和鼠标指针距离明显不符,保存以后再回放,节点位置整体偏移。
原因:getGraphData()返回的是画布坐标系坐标,不随视口缩放变化。缩放只是改变了SVG的视口变换,并没有修改Model里的坐标数值。常见的错误是拿屏幕事件里的offset坐标直接和存储坐标做对比。
解决:在事件回调里获取坐标时,先用lf.getTransform()拿到当前画布的缩放比例和偏移量,再对offsetX、offsetY做一次手动的坐标系转换。关键代码参考这里:
const transform = lf.getTransform(); // transform 形如 { translateX, translateY, scale } function screenToCanvas(x: number, y: number) { return { x: (x - transform.translateX) / transform.scale, y: (y - transform.translateY) / transform.scale, }; }4.2 自定义节点里的事件被画布吞掉
现象:在自定义节点的view里放了一个按钮或下拉框,点击按钮没反应,但点击节点本身却能触发node:click事件。
原因:LogicFlow对画布做了全局事件委托,节点上的鼠标事件会被框架先捕获处理。如果你在自定义view里绑定的DOM事件没有阻止冒泡,框架的事件系统会先截获,导致按钮回调不执行。
解决:在自定义DOM元素的监听回调里调用event.stopPropagation(),阻止事件继续冒泡到画布层。如果按钮点击还需要触发业务流程,在stopPropagation之后再调用你自己的业务函数,这样既不干扰框架的节点选中逻辑,又能响应交互。
4.3 组件卸载后重新挂载,事件重复触发或者画布卡顿
现象:在Vue或React项目里,页面A退出再进入,画布虽然能正常显示,但点击一次节点,弹出框弹两次;内存逐步上涨,页面越来越卡。
原因:LogicFlow实例没有随组件销毁。很多开发者只在mounted或useEffect里做了初始化,忘了在卸载阶段清理,导致旧实例的事件监听还留在内存里,新实例又创建了一份监听。
解决:在组件卸载的生命周期里显式清理:
// Vue 3 onUnmounted(() => { lf.clear(); });// React,useEffect 返回的清理函数 useEffect(() => { const lf = new LogicFlow({ container }); return () => { lf.clear(); }; }, []);lf.clear()会清空画布数据并释放大部分资源;如果你用过lf.on()绑定了自定义事件,在clear之前先调lf.off()把监听摘掉。这不是玄学,是血泪经验——尤其是单页应用里反复切换路由,一次不清理,用户操作十次页面之后就明显掉帧。
4.4 锚点ID没写,边落到默认锚点上位置诡异
现象:addEdge时只传了sourceNodeId和targetNodeId,结果连线从节点左上角或者中心附近冒出来,折线还歪七扭八。
原因:节点Model可能定义了多个锚点(上下左右各一个),框架在未指定sourceAnchorId和targetAnchorId时会自动选择默认锚点。默认锚点的计算规则在自定义节点里如果你覆盖了getAnchor()或anchor配置,可能出现和视觉不对应的情况。
解决:给关键节点画锚点的时候,建议在节点Model上显式声明锚点位置,并且每个锚点给一个稳定的ID。添加边时,把锚点ID也写进参数:
lf.addEdge({ sourceNodeId: 'approve-node-1', targetNodeId: 'end-node', sourceAnchorId: 'approve-node-1:bottom', targetAnchorId: 'end-node:top', });如果边是从某个特定出口出去的业务语义(比如“驳回”和“通过”走不同分支),锚点ID更是必须要传给后端的字段。不要依赖框架自动选,自动选的结果往往不是业务想要的结果。
4.5 properties里塞了不可序列化的对象,保存时直接抛错
现象:画布操作都正常,一执行JSON.stringify(getGraphData())就报错Converting circular structure to JSON,或者properties里存了Date对象,保存后被后端识别成字符串。
原因:properties是透传的,开发者往里塞什么框架都不拦。常见做法是把后端接口返回的嵌套对象原封不动塞进去,里面包含循环引用或者非JSON类型。
解决:给properties做一层净化再存储。我的习惯是保存前写一个sanitizeProps函数,手动摘出需要的字段,把复杂对象转换成扁平结构,大字段单独存业务表,不在properties里放冗长的关联对象。自定义节点里如果你展示的数据也是从properties取的,前端取值前先用可选链?.兜底,避免字段缺失时整段渲染崩溃。
5. 自定义节点与业务接入:把流程图变成业务可视化
5.1 继承NodeModel改写图形:官方推荐的扩展方式
内置rect、circle满足不了真实系统,通常要改成圆角卡片、带icon的状态节点、甚至带进度条。LogicFlow的自定义节点方式是通过继承Model和View两个类来完成的。
import LogicFlow, { RectNode, RectNodeModel, h } from '@logicflow/core'; class StartNodeModel extends RectNodeModel { setAttributes() { this.width = 120; this.height = 40; this.radius = 20; this.fill = '#1B7F3B'; this.text = { value: '开始', fontSize: 14, color: '#FFFFFF', }; } } class StartNodeView extends RectNode { getShape() { const { model } = this.props; const { x, y, width, height, radius, fill } = model; return h('g', {}, [ h('rect', { x: x - width / 2, y: y - height / 2, width, height, rx: radius, fill, }), h('text', { x, y: y + 4, textAnchor: 'middle', fontSize: 14, fill: '#FFFFFF', }, model.text.value), ]); } } lf.register({ type: 'start-node', view: StartNodeView, model: StartNodeModel, });这段代码里h()是LogicFlow暴露出的hyperscript风格函数,用来创建SVG元素。它和React的createElement类似,第一个参数是标签名,第二个是属性对象,第三个是子节点或文本。setAttributes()是Model的属性钩子,每次节点初始化或者属性变化时都会执行,宽高、填充色、文本这些都应该写在这里,而不是在view里硬编码。register把type映射到对应的Model和View,之后addNode里type: 'start-node'就能渲染出这个自定义图形。
如果想在节点上显示后端返回的动态名称,可以在setAttributes里读取this.properties.title。注意:properties在addNode时传入,setAttributes执行时机可能在properties赋值之前,所以取值时要判空。自定义节点最常用的做法是把业务状态放到properties里,Model层负责算样式,View层只负责渲染——这样可以保持自定义view逻辑纯粹,不会因为业务数据变更到处找渲染入口。
5.2 事件系统:把交互行为转成业务动作
画布上的每次点击、拖拽、删除,LogicFlow都会派发事件。事件名是node:click、node:drag、node:delete、edge:click这样的格式。回调参数里带data,包含当前图元的核心数据。
lf.on('node:click', ({ data }) => { if (data.type === 'approval-node') { openApprovalPanel(data.id, data.properties); } }); lf.on('node:drag', ({ data }) => { // 拖拽结束或者拖拽过程中需要实时保存坐标,可以在这里做节流后上报 });事件回调里拿到的data.x、data.y是画布坐标,如果需要转换成业务坐标参照上一章的坐标转换方法。事件机制的核心价值是把“用户动作”和“业务动作”解耦:view层只管显示,事件层负责调度。比如审核流里点击节点弹出审核人配置面板,不要在自定义view按钮的click里直接写弹窗逻辑,而是通过事件回调往外抛,这样组件复用性和可维护性都会好很多。
LogicFlow自带@logicflow/extension扩展包,里面提供了控制条、框选、小地图等二开插件。用不用插件不是关键,关键是理解它的插件机制怎么绑定生命周期。我见过最省事的方式是把插件注册和业务初始化分开,这样排查问题的时候不用在几百行的初始化函数里找。
6. 性能自查与进阶技巧:几百个节点的画布还能不能流畅
流程编辑器做到后期,画布上挂几百个节点、上千条边是常态。这时候最需要关心的不是某一个API怎么用,而是整个画布的渲染链路哪里会卡。
我的自查清单通常是这样:第一,确认渲染模式。页面首次回放用lf.render(),之后所有增删改都必须走lf.addNode()、lf.deleteNode()、lf.addEdge()这类增量API。如果你发现某段逻辑里做了“先getGraphData()再render()更新”的操作,趁早改成增量操作,不然节点一多,整棵树重新渲染的成本会指数级上升。第二,检查事件回调里有没有做重活。node:drag这类事件触发频率极高,回调里如果频繁操作DOM或者同步请求接口,画布必然会掉帧。正确做法是拖拽结束事件里再上报坐标,拖拽过程中只做本地状态更新。第三,打开浏览器性能面板,录制一段拖拽操作,看NodeJS层面的脚本执行耗时和渲染耗时。如果瓶颈在SVG渲染,考虑关闭网格显示、降低动画帧率。
还有一个通用技巧是用focusOn做节点定位:
lf.focusOn({ id: 'approve-node-1', });这个方法会平滑滚动画布到目标节点并把视角调整到合适位置,比手动计算坐标然后translate靠谱得多。每次打开一个大流程、要做节点定位时,强制走一遍这个方法,能省掉不少手工换算的麻烦。
最后说一个我自己的习惯:整个项目开发过程中,我坚持在保存前执行一遍“三查”——查nodes里有没有重复id,查edges引用的sourceNodeId是否真实存在,查properties是否全部可JSON序列化。这三查听起来基础,但翻车往往就翻在这些细节上。从那以后我每次上线前都强制走一遍这个清单,再配合性能面板的数据,确认渲染耗时在可接受范围内,才放心交付。希望帮到你。
本文还有配套的精品资源,点击获取