核心价值:
算法同学构建一个模型需要大量写json或者yaml参数配置来确定一些模型算子的执行顺序和依赖关系,这个过程又长又容易出错且不直观,然后这个项目的核心价值就是把这个过程”直观化“,降低上述过程的复杂度,同时可以更方便的看到结果。
同时,可以根据算法同学专门特化的模型的需求,增加专门特化的配置。
数据:
- 用户体量:50位算法工程师使用,每天生成的模型工作流有100多个。
- 数据复杂度:一个典型的工作流,平均包含30-50个算子节点,对于复杂一些的,则节点数可以超过 150 个。每个节点都有自己的信息面板,包括输入参数输出参数等等。
- 一般一个工作流的数据序列化之后有200kb左右,最多能到1mb。
- 复杂的交互,比如拖拽,缩放,连线,最麻烦的是上游的节点的数据类型变化中,下游节点需要联动更新,同时要支持撤销和重做。
- 上面这一套下来,属于一个简易的低代码平台了。
难点:
- 大量节点重渲染
场景:一个可视化的“模型训练流水线”
想象一下你负责的“模型训练流程”模块,它看起来像这样: 左侧是“算子选择区”:展示了各种可用的算子,比如“数据清洗”、“特征提取”、“模型训练”等。
中间是“画布区”:用户可以从左侧拖拽算子到画布上,并用线连接它们,形成一个工作流。
右侧是“属性配置区”:当用户点击画布上的某个算子时,右侧会显示该算子的详细参数,比如“学习率”、“迭代次数”等,用户可以在这里修改。
顶部是“控制栏”:有一个“开始训练”按钮和一个“训练状态”的实时显示(如:空闲、训练中、成功、失败)。 在这个场景下,我们的 state 可能长这样:
技术选型:状态管理上:有意进行渐进式的迁移。
原生useState、原生provider、Redux、Zustand、Jotai
几个方案: Redux的话,从来不是比较合理的选择,模版代码太多,用最新的tookit和各种中间件库之后,心智负担还是太重。任何情况下都不考虑。
原生State:全局状态管理简直噩梦。 Provider:涉及context的拆分,一不注意就会出现大量的重渲染,简直是噩梦。
Zustand+useReducer 混合方案:基于架构角度考虑,Zustand负责全局状态管理,Provider负责我所说明的这个场景下的这个模块的状态管理。
- 然后剩下两个方案,只用jotai和只用Zustand。
- 我们业务里面几乎只用useState,甚至会出现一个State传递多层的情况出现,我是基于项目想长期维护的角度,想给他进行渐进式的迁移。最后选择了纯用jotai,它是一个原子化的状态管理框架,从useState切换过来几乎没有成本。
- 然后,因为要管理图表的每个节点的状态,所以用jotai这种原子化的状态,十分方便。
无与伦比的组合能力:Jotai 的派生原子非常强大。我们可以轻易地创建一个派生原子来表示‘当前选中的算子’,或者另一个派生原子来计算‘当前画布是否合法’。这种将状态和计算逻辑组合起来的能力,使得构建复杂但清晰的数据流变得非常简单
技术选型:图谱库
在敲定状态管理库之后,我现在要针对算子工作流来选择一个图谱库,备选项是React Flow、antdv6和x6以及D3.js等等
从业务拓展角度来看: 因为这个业务主要就是模型算子工作流,不涉及更复杂的图,比如树状图,力导向图等等,工作流的节点也不多,所以react-flow就足够满足需求了;
从迭代速度角度来看:react-flow的实现成本最低
React Flow antdv6/x6 D3.js
技术选型:拖拽库
- react-dnd
- 复杂的api,强依赖redux dnd-kit
- 最终选择 react-beautiful-dnd
- 核心的”列表拖拽“,并且强依赖react
- 新开发的pragmatic-drag-and-drop
- 完全框架无关,轻量,可拓展,不依赖react。
- 但是它更像是原生api的封装,使用起来过于原始。而且生态库也不够丰富。
- 架构: -就是封了层事件流,然后在它的事件总线中订阅事件。
浏览器原生能力: - 鼠标事件(Mouse Events): - mousedown: 用户在一个元素上按下鼠标按钮。这是拖拽操作的起点。 - mousemove: 鼠标在屏幕上移动。拖拽过程中,这个事件会高频触发,用来追踪元素的位置。 - mouseup: 用户释放鼠标按钮。这是拖拽操作的终点。 - mouseleave: 鼠标指针移出某个元素的边界。在拖拽中可以用来判断是否离开了某个可放置区域。 - 触摸事件(Touch Events): - touchstart: 手指触摸到屏幕,相当于 mousedown。 - touchmove: 手指在屏幕上滑动,相当于 mousemove。 - touchend: 手指离开屏幕,相当于 mouseup。 - touchcancel: 触摸过程被系统中断(比如来电话了)。
- HTML5 原生拖放 API 事件:
- 被拖拽元素上触发: dragstart, drag, dragend
- 放置元素上触发:dragenter, dragover, dragleave, drop
到底做了哪些封装?
- 核心思想:磨平底层鼠标和触摸事件等等差异,还有不同平台不同设备的的差异,提供一个统一强大且可预测的高级抽象层。
- 传感器Sensors:
- 直接根据拖拽行为来分,里面可以统一配置键盘事件、鼠标事件、触摸事件等等。
- 且不必手动控制拖拽元素的x轴和y轴坐标。
dnd-kit是怎么架构的?
- 核心:@dnd-kit/core
- 上层:@dnd-kit/sortable:基于core覆盖了很多常规需求。
- 下层:
- @dnd-kit/modifiers:可选项,做拖拽移动的约束。
- @dnd-kit/utilities:辅助函数,算css的。
- @dnd-kit/accessibility:无障碍而已。
- @dnd-kit/core
- DndContext:所有拖拽交互的根容器,负责状态管理和事件分发。
- Hooks: useDraggable 和 useDroppable,用于将你的组件注册为可拖拽或可放置的。
- 传感器 (Sensors): PointerSensor, KeyboardSensor 等,用于监听和解析用户的输入意图。
- 碰撞检测算法 (Collision Detection): 如 closestCenter, rectIntersection 等。
- @dnd-kit/sortable
- 基于@dnd-kit/core做的高级抽象,里面封的是core,处理了常见的拖拽使用场景。
- @dnd-kit/modifiers:
- 它让常见约束的实现变得非常简单,你无需自己去编写复杂的坐标计算逻辑。
- restrictToVerticalAxis: 限制只能垂直拖动。
- restrictToHorizontalAxis: 限制只能水平拖动。
- restrictToWindowEdges: 限制不能拖出浏览器窗口。
- createSnapModifier: 创建吸附到网格的修改器
- @dnd-kit/accessibility
- 无障碍访问支持。 职责:专注于提升拖拽操作的可访问性 (a11y),确保使用屏幕阅读器或纯键盘的用户也能理解和操作。
- @dnd-kit/utilities
- 主要是可以把core包算出的transform的值转化成字符串
- 核心:@dnd-kit/core
状态管理:
Redux/Zustand/useReducer 的世界观:状态是一个(或多个)巨大的、集中的对象 (Store)。你通过 selector 从这个大对象里取出你想要的一小片数据。这是一种“自上而下”的模式。 Jotai 的世界观:状态本身就是由无数个微小的、独立的状态单元(原子)组成的。没有一个中心化的 Store。组件直接订阅它所需要的那个或那几个原子。这是一种“自下而上”的模式。
追问一
你刚才提到了性能,说使用 Modifiers 避免了不必要的重渲染。当画布上有上百个节点时,拖动其中一个,dnd-kit 内部是如何做到只更新被拖拽节点的位置,而不触发其他节点的重渲染的?它的渲染优化机制具体是怎样的?
你提到了自定义碰撞检测算法。能具体讲讲你的实现思路吗?document.elementFromPoint 在什么情况下可能会失效?你又是如何处理这些边缘情况的?
dnd-kit 非常强调可访问性 (Accessibility)。你在你的项目中具体做了哪些工作来保证拖拽操作对于键盘用户或者屏幕阅读器用户是可用的?
既然 React-flow 本身已经内置了节点的拖拽功能,你为什么还要引入 dnd-kit?是 React-flow 的拖拽功能有什么无法满足你们需求的缺陷吗?同时引入两个处理拖拽的库,有没有遇到过事件冲突或者状态管理上的问题?你们是如何解决的?
在 onDragEnd 里你们会调用 React-flow 的 API 更新状态。如果这个更新是一个异步操作(比如需要调用后端接口校验),那么在异步返回之前,拖拽的节点应该停留在哪里?是立即回到原位,还是停在释放的位置等待结果?你们是如何处理这种拖拽交互的异步状态和视觉反馈的?
追问二:
你提到,这个库是在 useEffect 里把真实 DOM 元素注册到拖拽引擎里。在 React 中,我们通常认为 useEffect 里的操作是副作用,并且应该小心处理依赖项数组,避免重复执行。在一个复杂的、频繁更新的组件里,你如何保证拖拽逻辑的注册和清理是正确且高效的?会不会因为不当的依赖管理,导致事件监听器被重复绑定,从而引发内存泄漏或逻辑错乱?
既然它是一个框架无关的引擎,那么在 React 环境下,拖拽引起的数据状态变化(比如一个数组中元素的顺序变化),最终还是要通过 useState 或其他 React 状态管理方案来触发组件的重渲染。这个“引擎触发事件 -> React 更新状态 -> 组件重渲染”的流程,相比 dnd-kit 的 Modifiers 机制(在状态更新前同步修改拖拽行为),在处理需要高频实时反馈的拖拽约束(比如吸附网格、限制边界)时,会不会有理论上的性能劣势或体验延迟?
rbd 的一个核心优势是它为列表拖拽提供了非常成熟的“占位符”(placeholder)和“幽灵”(ghost)组件的实现,开发者几乎不需要关心拖拽过程中的视觉样式。在 pragmatic-drag-and-drop 中,这些视觉反馈完全需要开发者自己去实现。请你设想一下,如果要实现一个类似 Trello 的看板,当卡片在不同列之间拖动时,你如何利用这个库提供的底层能力,去手动实现列表空间的动态“推开”和占位符效果?请描述你的技术思路。
这种直接传递 DOM 节点引用的模式,在与“虚拟列表”(如 react-window)结合时,会遇到一个经典难题:列表项滚动出可视区后,其对应的 DOM 节点会被回收复用。这时,之前注册到拖拽引擎的 element 引用就会失效。你有什么方案来解决这个问题吗?
追问三:
你对两者的封装思路拆解得还算清晰。那我们再往深挖一层:
你提到 pragmatic-dnd 内部可能使用事件代理。事件代理的优势是减少监听器数量,但在 pointermove 这种超高频触发的事件上,代理到 document 层面,每次移动都需要从 event.target 向上遍历查找可拖拽的父元素,这个过程会不会反而造成性能损耗?它内部是如何优化这个查找过程的?
dnd-kit 的 Sensor 是一个很棒的抽象。但如果我想实现一个非常特殊的拖拽触发机制,比如“用户必须用两个手指同时按住元素超过500毫秒才能开始拖拽”,你需要如何扩展或自定义一个新的 Sensor?请描述一下自定义 Sensor 的关键实现步骤和API。
两个库都封装了 pointer 事件。当拖拽发生时,页面滚动是一个常见的冲突点。浏览器为了优化滚动性能,对于某些 touchmove 或 pointermove 事件,会将其变为“被动事件监听器”(passive event listeners),导致在监听器内部调用 event.preventDefault() 会失效。这两个库是如何处理这个问题的,以确保拖拽时能阻止页面滚动,同时又不影响正常的页面滚动性能?
当拖拽跨越 iframe 时,原生事件会丢失上下文。比如,鼠标移动到 iframe 内部,主页面的 window 就收不到 mousemove 事件了。dnd-kit 和 pragmatic-dnd 这种构建于原生事件之上的库,是如何解决或缓解跨 iframe 拖拽问题的?如果不能完美解决,它们的局限性在哪里?