LogicFlow 插件魔改实录:手把手教你重写动态分组(DynamicGroup)🛠️

写在开头

嘿嘿,大家好!👋

今是2025年12月31日上午 8 时 40 分,也是2025年最后一天了,小编做到今天,今年就不做了,感觉这行还是不适应,自己每天不知道在做些什么,天天都忙忙忙,很累很崩溃,钱也没赚到,一点热情也没了。😋

最近,在项目中重度使用了 LogicFlow,不得不说,它的扩展性是真的强。💪💪💪

但是(注意,我要说但是了😄),在使用官方提供的 动态分组DynamicGroup)插件时,由于小编的业务场景比较特殊,遇到了一些让人头秃的问题。

本着 "开源不满足就魔改" 的极客精神,小编决定对 DynamicGroup 插件进行一次深度改造。

所以呢,今天要分享的是 DynamicGroup 插件的改造的完整过程、源码分析以及避坑指南,请诸君按需食用哈。

需求背景与痛点

最近在项目中,小编需要实现一个 "打组与拆组" 功能,不仅要能把部分节点框在一起组成一个整体分组,也需要能随时进行拆组,把子节点放出来。

基于这个需求背景,小编在 LogicFlow 官方文档上找到了两个插件:框选(SelectionSelect)与 动态分组 (DynamicGroup)。

  • SelectionSelect:负责画框框选节点。
  • DynamicGroup:负责把选中的节点 "打包" 成一个组。

这两个插件组合使用理论上能满足小编的业务需求,天作之合属于!🤔

不过,在实际集成过程中,小编发现它们虽然功能强大,但细节上还是有点 "水土不服",无法完全满足细腻的业务需求。于是,小编决定把这两个插件都重写了!

其中,框选插件的改动比较简单,主要是调整了一下样式和微小的交互逻辑,就不在今天展开细说了。我们重点要聊的,动态分组插件的改动过程。

下面,简述小编遇到的几个主要问题:

  • "连坐"机制:官方插件默认的逻辑是,删除分组时,会强制删除分组内的所有子节点。但在小编的"拆组"业务里,用户只是想解散分组,保留里面的子节点。
  • "越狱"现象 :虽然插件提供了 isRestrict: true 来限制节点拖出分组,但在快速拖拽时,节点经常能"穿墙"而出,这个问题其实是和下面的问题应该是同个问题。
  • "误触"跳出 :如果分组内的子节点包含输入框,点击输入框聚焦时,插件偶尔会误判为"拖拽结束(node:drop)",导致子节点莫名其妙地被移出分组。

为了解决这些症状,小编决定给 DynamicGroup 插件开点药食食。😁

DynamicGroup插件源码分析

在动手之前,咱们先来看看 DynamicGroup 插件的构造,这个插件由以下几个核心文件组成,咱们这次改造也沿用了这个结构:

  • index.js (核心逻辑) :插件的入口,负责注册插件和监听全局事件(如 node:add, node:drop)。它维护了一个映射表,决定了节点什么时候进分组,什么时候出分组。

🔉插播一下,插件源码维护得挺好,有非常清晰的代码注释,再结合AI的协助,让人非常容易理解。👏

  • model.js (数据模型) :定义分组的数据属性。比如 isRestrict(是否限制拖拽)、isCollapsed(是否折叠)等状态都是在这里管理的。
  • node.js (视图渲染) :负责画出分组的样子(SVG)。这里也是离用户最近的地方,处理具体的鼠标交互事件(如 mousemove)。
  • utils.js (工具函数):提供一些计算几何关系的辅助方法,比如判断"这个点是不是在那个框里"。

搞清楚了这些,咱们就可以对症下药了!

改造一:解决"连坐"机制

痛点描述

官方默认的逻辑是:分组被删除 = 分组内的所有子节点一起被删除

如下:

但在很多业务场景下,用户点击删除分组,可能只是想"解散"这个组,而不是把里面的业务子节点也删了。

源码定位

打开 index.js,找到 removeNodeFromGroup 方法:

javascript 复制代码
// 官方源码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId);
      this.lf.deleteNode(childId); // 👈 在这里!直接把子节点干掉了
    });
  }
  // ...
};

改造方案

咱们需要引入一个配置项 retainChildren(保留子节点)。在删除前,检查一下这个属性,如果为 true,就只解除关系,不删节点。

为什么要如此做❓

因为小编还有另外的业务需求是右键删除节点功能,这个操作就需要把子节点也一起删除了,所以增加配置控制的形式更加灵活一些。

修改 index.js 文件:

javascript 复制代码
// 改造后代码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  // 获取配置属性
  const retainChildren = model.properties && model.properties.retainChildren;
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId); // 解除映射关系
      // 关键判断:只有不保留时,才删除子节点
      if (!retainChildren) {
        this.lf.deleteNode(childId);
      }
    });
  }
  // ... 后续逻辑不变
};

这样,我们在创建分组时,只要加上 properties: { retainChildren: true },就能实现"拆组"效果了。

改造二:拒绝"误触"跳出

痛点描述

如果你的节点里包含输入框(HTML 节点),当你点击输入框聚焦时,LogicFlow 可能会触发 node:drop 事件(因为它认为你完成了一次交互)。

官方插件在监听 node:drop 时,会重新计算节点应该属于哪个分组。结果就是:点了一下输入框,插件误判你把节点移走了,直接把它踢出了分组。

源码定位

打开 index.js 文件,找到 addNodeToGroup 方法:

javascript 复制代码
// 官方源码逻辑
addNodeToGroup = (node) => {
  // ... 计算节点当前的位置 bounds
  
  const preGroupId = this.nodeGroupMap.get(node.id); // 原来的组
  const targetGroup = this.getGroupByBounds(bounds, node); // 现在位置对应的组
  
  if (preGroupId) {
    // 👈 问题在这里:只要有原分组,它就默认先移除,再看要不要加入新组
    // 如果计算有些许误差,或者逻辑不够严谨,节点就"丢"了
    const group = this.lf.getNodeModelById(preGroupId);
    group.removeChild(node.id); 
    this.nodeGroupMap.delete(node.id);
  }
  
  // ...
};

这个方法会在 node:drop 事件被触发时被调用。

改造方案

逻辑很简单:如果节点原来的组和现在的组是同一个,那就啥也别动! 稳住别浪!🌊

修改 index.js 文件:

javascript 复制代码
// 改造后代码逻辑
addNodeToGroup = (node) => {
  // ... 前面获取 bounds 逻辑不变

  const preGroupId = this.nodeGroupMap.get(node.id);
  const targetGroup = this.getGroupByBounds(bounds, node);
  const targetGroupId = targetGroup ? targetGroup.id : undefined;

  // 新增核心判断,直接 return 掉!
  if (preGroupId === targetGroupId) {
    return;
  }

  // ... 下面才是真正的移动逻辑
  if (preGroupId) {
    // ... 移除旧组逻辑
  }
  if (targetGroup) {
    // ... 加入新组逻辑
  }
};

这样子就搞定了:

改造三:"越狱"现象之绝对防御

痛点描述

DynamicGroup 插件支持设置 isRestrict: true 来限制子节点不能拖出分组。

这个问题挺奇怪的,小编在群里反馈给过官方维护人员,但是他们好像没有定位到这个问题,在小编本地也确实比较难复现,只会偶尔出现。但是呢,小编的测试同学却能一次一次的复现给小编看,这种问题最难受了。😭

面对铁证,小编也很无奈,只能请AI来协助了,我让AI大致分析了整个源码情况。

限制子节点的拖动范围原理大概是在 graphModel.addNodeMoveRules 里加规则。

但是!这个规则校验是基于"下一次位置是否合法"来拦截的。如果你鼠标甩得特别快(比如高 DPI 鼠标),deltaX/deltaY 很大,直接跳过了边界检测,节点就会"穿墙"而出。

源码定位

这次咱们要去视图层 node.js 文件。因为规则拦截(Model 层)已经防不住了,我们必须在渲染层(View 层)做最后的兜底。

我们需要监听 node:mousemove 事件,这是节点移动最频繁触发的地方。

改造方案

node.js 文件中,我们重写 onNodeMouseMove 方法,并在组件挂载时(componentDidMount)监听它。

逻辑核心

  1. 实时计算节点的新位置。
  2. 判断是否超出了分组的边界。
  3. 如果超出,强制将坐标修正回边界内(暴力修正)。
  4. 同步更新连线(这一点很容易漏,如果不更连线,节点回去了,线还在外面)。

修改 node.js 文件:

javascript 复制代码
// 改造后代码逻辑

// 1. 在 component 绑定事件
componentDidMount() {
  super.componentDidMount();
  // 监听更底层的 mousemove
  this.props.graphModel.eventCenter.on("node:mousemove", this.onNodeMouseMove);
}

// 2. 核心处理逻辑
onNodeMouseMove({ data }) {
  const { model: curGroup, graphModel } = this.props;
  const model = graphModel.getNodeModelById(data.id); // 当前拖动的节点

  // 只有开启了限制,且是自家孩子,才管
  if (curGroup.children.has(model.id) && curGroup.isRestrict) {
    const groupBounds = curGroup.getBounds();
    const nodeBounds = model.getBounds();
    const padding = 10; // 内边距,别贴得太死

    let newX = model.x;
    let newY = model.y;

    // X轴 暴力修正
    if (nodeBounds.minX < groupBounds.minX + padding) {
      newX = groupBounds.minX + padding + model.width / 2;
    } else if (nodeBounds.maxX > groupBounds.maxX - padding) {
      newX = groupBounds.maxX - padding - model.width / 2;
    }

    // Y轴 暴力修正
    if (nodeBounds.minY < groupBounds.minY + padding) {
      newY = groupBounds.minY + padding + model.height / 2;
    } else if (nodeBounds.maxY > groupBounds.maxY - padding) {
      newY = groupBounds.maxY - padding - model.height / 2;
    }

    // 如果位置被我们强行修正了
    if (newX !== model.x || newY !== model.y) {
      // 移动节点
      model.moveTo(newX, newY);
      
      // 关键:手动更新连线!
      // 否则会出现节点被墙挡住了,但连线跟着鼠标飞出去了的诡异画面,非常神奇💥
      this.updateRelatedEdges(model, graphModel);
    }
  }
}

// 辅助方法:更新连线
updateRelatedEdges(model, graphModel) {
  const edges = graphModel.getNodeEdges(model.id);
  edges.forEach((edge) => {
    // 重新计算并设置连线的起点/终点
    if (edge.sourceNodeId === model.id) {
       // ... updateStartPoint 逻辑
    }
    if (edge.targetNodeId === model.id) {
       // ... updateEndPoint 逻辑
    }
  });
}

这里的改造大部分是AI在帮我写的,我只是最终确定一下代码逻辑的合理性,与没有太离谱和边界把控,还需要在页面进行测试验证,最终,确定基本没有什么问题,才交由小编的测试同学去验证,最终也顺利通过验证。😀

这里还是得表扬AI一番啊,这个问题前后大概花了十几二十分钟就搞定了,要是没有AI,靠人工来解决这个问题,时间上应该不敢想象吧。🤔

总结

通过对 DynamicGroup 插件的这番改造,咱们不仅解决了一系列交互 Bug,更重要的是深入理解了 LogicFlow 插件的运行机制。

  • Model 层 负责数据准确性(如删除逻辑)。
  • Logic 层(index.js)负责业务流转(如分组进出判断)。
  • View 层 负责极致的交互体验(如拖拽边界修正)。

希望这篇踩坑实录能给正在使用 LogicFlow 的小伙伴们一些灵感,嘿嘿。😉


至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。

老样子,点赞+评论=你会了,收藏=你精通了。

相关推荐
阿蔹2 小时前
UI测试自动化-Web-Python-Selenium-2-元素操作、浏览器操作
前端·python·selenium·ui·自动化
Irene19912 小时前
Vue 3 中编写单文件组件(SFC)的编译时语法糖:<script setup>
vue.js
2501_944446002 小时前
Flutter&OpenHarmony状态管理方案详解
开发语言·javascript·flutter
T_Donna2 小时前
【问题解决】react native: cli.init is not a function
javascript·react native·react.js
谎言西西里2 小时前
React hooks 之 一篇文章掌握 useState 和 useEffect 的核心机制
前端·react.js
qx092 小时前
html中使用vue3+elementplus
javascript·vue.js·html
Apifox.2 小时前
Apifox 12 月更新| AI 生成用例同步生成测试数据、接口文档完整性检测、设计 SSE 流式接口、从 Git 仓库导入数据
前端·人工智能·git·ai·postman·团队开发
bjzhang752 小时前
使用 HTML + JavaScript 实现滑动验证码
前端·javascript·html
不老刘3 小时前
前端面试八股文:JavaScript 原型链
javascript·原型链