Arco Design Tree 组件拖拽节点实战:从 onDrop 数据更新到 allowDrop 拦截与源码解析
Arco Design Tree 组件拖拽节点实战:从 onDrop 数据更新到 allowDrop 拦截与源码解析
导读
本文围绕 Arco Design React 组件库中 Tree(树)组件的「拖拽」能力展开,以仓库内 components/Tree/demo/drag.md 官方示例为核心,完整讲解如何开启节点拖拽、如何通过 onDrop 回调手工更新 treeData 实现真正的树结构调整,并结合源码剖析 dropPosition 的三种落点语义、allowDrop 的拦截机制与内置的拖拽视觉反馈。读完本文,你将能够独立实现一个可拖拽重排、可限制放置位置、带自定义拖拽高亮动画的树组件。
一、示例功能与整体效果
drag.md 示例演示的是可拖拽的树节点:整棵树处于 draggable 状态,用户可以用鼠标按住节点标题将其拖放到任意合法位置——包括作为某节点的子节点插入,或者插入到某节点的前/后。示例同时通过一个 Checkbox 动态切换 checkable 属性,说明拖拽能力可以与节点勾选功能并存。
示例中定义了两棵主干数据 Trunk 0-0 与 Trunk 0-1,其中还刻意埋入了两个"特殊节点"以展示细粒度控制:
Leaf 0-0-2-1:通过节点数据上的draggable: false单独禁用了该节点的拖拽(标题明确标注 "Drag disabled");Branch 0-1-1:通过checkable: false关闭了该分支的复选框显示。
这与 Tree 组件的两套 API 对应:组件级属性 TreeProps.draggable 控制整树是否可拖拽,而节点级属性 NodeProps.draggable 允许对单个节点单独收放拖拽能力,二者在 node.tsx 的渲染中被合并使用。
二、完整可运行示例代码
import { useState } from 'react';
import { Tree, Checkbox } from '@arco-design/web-react';
const TreeNode = Tree.Node;
const TreeData = [
{
title: 'Trunk 0-0',
key: '0-0',
children: [
{
title: 'Leaf 0-0-1',
key: '0-0-1',
},
{
title: 'Branch 0-0-2',
key: '0-0-2',
disableCheckbox: true,
children: [
{
draggable: false,
title: 'Leaf 0-0-2-1 (Drag disabled)',
key: '0-0-2-1',
},
],
},
],
},
{
title: 'Trunk 0-1',
key: '0-1',
children: [
{
title: 'Branch 0-1-1',
key: '0-1-1',
checkable: false,
children: [
{
title: 'Leaf 0-1-1-1',
key: '0-1-1-1',
},
{
title: 'Leaf 0-1-1-2',
key: '0-1-1-2',
},
],
},
{
title: 'Leaf 0-1-2',
key: '0-1-2',
},
],
},
];
function App() {
const [treeData, setTreeData] = useState(TreeData);
const [checkedKeys, setCheckedKeys] = useState([]);
const [checked, setChecked] = useState(false);
return (
<div>
<Checkbox
checked={checked}
onChange={setChecked}
style={{ marginBottom: 20 }}
>
checkable
</Checkbox>
<Tree
draggable
blockNode
checkable={checked}
onDrop={({ dragNode, dropNode, dropPosition }) => {
const loop = (data, key, callback) => {
data.some((item, index, arr) => {
if (item.key === key) {
callback(item, index, arr);
return true;
}
if (item.children) {
return loop(item.children, key, callback);
}
});
};
const data = [...treeData];
let dragItem;
loop(data, dragNode.props._key, (item, index, arr) => {
arr.splice(index, 1);
dragItem = item;
dragItem.className = 'tree-node-dropover';
});
if (dropPosition === 0) {
loop(data, dropNode.props._key, (item, index, arr) => {
item.children = item.children || [];
item.children.push(dragItem);
});
} else {
loop(data, dropNode.props._key, (item, index, arr) => {
arr.splice(dropPosition < 0 ? index : index + 1, 0, dragItem);
});
}
setTreeData([...data]);
setTimeout(() => {
dragItem.className = '';
setTreeData([...data]);
}, 1000);
}}
treeData={treeData}
></Tree>
</div>
);
}
export default App;
.tree-node-dropover > .arco-tree-node-title,
.tree-node-dropover > .arco-tree-node-title:hover {
animation: blinkBg 0.4s 2;
}
@keyframes blinkBg {
0% {
background-color: transparent;
}
100% {
background-color: var(--color-primary-light-1);
}
}
说明:
Tree.Node是 Arco Tree 的静态属性(见 index.tsxstatic Node = Node),示例中const TreeNode = Tree.Node只是引用声明,实际数据驱动渲染依靠treeData,无需手动创建节点。
三、核心机制:onDrop 回调与 dropPosition 三态语义
1. dropPosition 的三种取值
在 Arco Tree 的拖拽实现中,鼠标悬停在某个节点标题的上下 1/4 区域与中间区域,会解析出不同的落点意图。这一判定逻辑位于 node.tsx 的 updateDragOverState:
const rect = dom.getBoundingClientRect();
const gapHeight = rect.height / 4;
const diff = pageY - offsetY;
const position = diff < gapHeight ? -1 : diff < rect.height - gapHeight ? 0 : 1;
由此可以得到 dropPosition 的三种取值(类型定义见 interface.ts 的 onDrop 签名):
| dropPosition | 语义 | 鼠标位置 | 示例中的处理 |
|---|---|---|---|
-1 |
放置到目标节点之前(同级前置) | 悬停于节点标题上部 1/4 区域 | 在目标节点所在数组的 index 处插入 |
0 |
作为目标节点的子节点插入 | 悬停于节点标题中间区域 | item.children.push(dragItem) |
1 |
放置到目标节点之后(同级后置) | 悬停于节点标题下部 1/4 区域 | 在目标节点所在数组的 index + 1 处插入 |
从源码结构可以推断:以 1/4 高度作为分界,越靠近节点边缘越倾向"插入到同级",落在中间区域则"成为子节点",这与常见树组件(如 antd Tree)的拖拽判定习惯一致。
2. onDrop 回调参数
onDrop 接收一个包含以下字段的对象(interface.ts):
e:原生DragEvent<HTMLSpanElement>事件对象;dragNode:被拖拽的节点(NodeInstance,即一棵由内部TreeNode组件构建的 ReactElement,可通过dragNode.props._key取到内部 key);dropNode:释放时鼠标所在的目标节点(NodeInstance | null,拖拽到树外释放时可能为null);dropPosition:落点位置,取值为0 | -1 | 1。
其中 NodeInstance 定义为 ReactElement<PropsWithChildren<NodeProps>, typeof TreeNode>(见 interface.ts),因此示例中通过 dragNode.props._key、dropNode.props._key 访问节点的内部 key。
四、数据更新逻辑逐行拆解
示例中最核心的部分是 onDrop 内部的"查找 → 移除 → 插入"三步走。由于 Tree 组件不会自动修改 treeData(onDrop 只负责上报事件),数据结构的变化必须由使用者自己完成并调用 setTreeData 重新驱动渲染。
1. loop:通用的深度优先查找工具函数
const loop = (data, key, callback) => {
data.some((item, index, arr) => {
if (item.key === key) {
callback(item, index, arr);
return true;
}
if (item.children) {
return loop(item.children, key, callback);
}
});
};
- 基于
Array.prototype.some实现深度优先遍历:先比较当前层级节点,未命中且存在children时递归下钻; - 命中后立即执行
callback(item, index, arr),其中arr是命中节点所在的那一层数组,index是其在数组中的位置——这是后续splice操作的基础; - 借助
some的短路特性,找到目标即终止遍历。
2. 第一步:从原数据中移除被拖拽节点
const data = [...treeData];
let dragItem;
loop(data, dragNode.props._key, (item, index, arr) => {
arr.splice(index, 1);
dragItem = item;
dragItem.className = 'tree-node-dropover';
});
- 先浅拷贝
treeData,避免直接修改 React state(但注意:子节点对象仍是引用,后续用[...data]触发重渲染); - 按
dragNode.props._key定位被拖节点,splice(index, 1)将其从原位置摘除; - 给被拖节点临时挂上
tree-node-dropover类名,配合示例 CSS 播放一次"呼吸闪烁"动画,用于标记"刚刚移动过"。
3. 第二步:根据 dropPosition 决定插入方式
if (dropPosition === 0) {
loop(data, dropNode.props._key, (item, index, arr) => {
item.children = item.children || [];
item.children.push(dragItem);
});
} else {
loop(data, dropNode.props._key, (item, index, arr) => {
arr.splice(dropPosition < 0 ? index : index + 1, 0, dragItem);
});
}
dropPosition === 0:作为子节点。item.children = item.children || []兼容目标节点原本是叶子节点的情况,随后push到子列表末尾;dropPosition < 0(即-1):插入到目标节点之前,即arr.splice(index, 0, dragItem);dropPosition > 0(即1):插入到目标节点之后,即arr.splice(index + 1, 0, dragItem)。
4. 第三步:写回 state 并清理动画状态
setTreeData([...data]);
setTimeout(() => {
dragItem.className = '';
setTreeData([...data]);
}, 1000);
- 使用
[...data]生成新数组引用,确保 React 能感知到treeData变化并重渲染; blinkBg动画持续 0.4s × 2 次(共 0.8s),1 秒后清空className恢复原始样式,再触发一次渲染。
注意一个易错点:
dropPosition === 0分支中push会把被拖节点追加为最后一个子节点。若期望"插到子列表指定位置",可在此分支改用arr上目标节点的children数组与index做更精细的定位。
五、拖拽 API 全景:属性与回调
示例只使用了 draggable 与 onDrop,但 Arco Tree 围绕拖拽提供了一整套属性/回调(见 README.zh-CN.md 与 interface.ts),实际项目往往需要组合使用:
Tree 级属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
draggable |
boolean |
- |
是否可拖拽(整树开关) |
allowDrop |
AllowDrop |
() => true |
是否允许拖拽时放置在该节点(2.7.0 引入,dragNode 参数自 2.23.0 起可用) |
AllowDrop 的类型定义为(interface.ts):
export type AllowDrop = (options: {
dropNode: NodeInstance;
dragNode: NodeInstance | null;
dropPosition: number;
}) => boolean;
拖拽回调
| 回调 | 签名要点 | 触发时机 |
|---|---|---|
onDragStart |
(e, node) => void |
节点开始拖拽 |
onDragOver |
(e, node) => void |
节点被拖拽至某个可释放目标上 |
onDragLeave |
(e, node) => void |
节点离开可释放目标 |
onDrop |
({ e, dragNode, dropNode, dropPosition }) => void |
在可释放目标上释放 |
onDragEnd |
(e, node) => void |
节点结束拖拽(无论是否成功放置) |
Tree.Node 级属性
| 属性 | 类型 | 说明 |
|---|---|---|
draggable |
boolean |
当前节点是否可拖拽(优先级高于 Tree 级开关) |
icons.dragIcon |
ReactNode |
自定义拖拽手柄图标(自 2.9.0 起支持) |
拖拽手柄图标的默认实现位于 node.tsx:当节点可拖拽时,标题末尾渲染一个拖拽图标(默认 IconDragDotVertical),可通过 icons 属性替换。
六、源码级原理:从 DOM 事件到回调触发的完整链路
1. 节点内部的拖拽事件处理(node.tsx)
每个树节点标题(arco-tree-node-title span)上绑定了全套 HTML5 Drag 事件(node.tsx):
onDragStart:置isDragging状态,写入e.dataTransfer.setData('text/plain', '')(Firefox 必需),随后调用上下文中的onNodeDragStart;onDragOver:preventDefault后调用updateDragOverState,经 Raf 节流(throttleByRaf)计算dragPosition并调用allowDrop判定;onDrop:stopPropagation+preventDefault,把state.dragPosition一并交给onNodeDrop,最后复位拖拽高亮状态;onDragLeave/onDragEnd:取消节流任务并复位状态。
节点还会根据拖拽状态输出对应样式类(node.tsx):
arco-tree-node-title-gap-top/-gap-bottom:落点在节点上方/下方时显示插入指示线;arco-tree-node-title-highlight:落点在节点中间时高亮标题背景;arco-tree-node-title-dragging:节点正在被拖拽时置灰。
这些类对应的视觉样式在 style/index.less 中实现:gap 类通过 ::before 伪元素绘制一条 2px 的 @color-primary-6 指示线(@tree-draggable-size-gap-height: 2px,见 style/token.less),highlight 与 dragging 分别使用主题变量控制背景与文字颜色。
2. Tree 容器层的状态管理与守卫逻辑(index.tsx)
所有节点事件在容器层(index.tsx)被汇聚并执行关键校验:
handleNodeDragStart(index.tsx):记录this.dragNode并重置dropPosition = 0;handleNodeDragOver(index.tsx):实时记录当前dropPosition;handleNodeDrop(index.tsx):触发onDrop前的三道守卫:this.dragNode必须存在(未拖拽时忽略 drop);!this.isChildOfNode(node, this.dragNode):禁止把节点拖入自己的子孙节点(会产生循环引用);!this.isSameNode(this.dragNode, nodeInstance):禁止拖放到自身;- 若设置了
allowDrop且返回false,直接 return,不触发onDrop。
isChildOfNode 通过 key2nodeProps 沿 parentKey 链向上回溯判断祖先关系(index.tsx),isSameNode 依次比较实例引用、key、props._key(index.tsx)。
这些内置守卫意味着:即使你不在 onDrop 中做任何校验,组件也已经帮用户拦截了"拖到自身"和"拖进自己子树"两种非法操作——这是示例代码能够只专注于"移动数据"的原因之一。
3. 与受控/非受控状态的关系
从 index.tsx 的 componentDidUpdate 与 getTreeData(index.tsx)可以看出,treeData 与 children 二选一驱动树结构。拖拽本身不改动组件内部 state,onDrop 之后必须由外部更新 treeData(受控)或通过 setState 刷新,否则节点会"拖完弹回"。
七、进阶实践:用 allowDrop 精细控制放置规则
仅靠内置守卫还不够时,可使用 allowDrop 在视觉反馈与事件触发两个层面同步拦截。它的调用点有两处:
- 拖拽悬停时:
handleAllowDrop(index.tsx)被updateDragOverState调用,返回false时不会出现插入线/高亮(isAllowDrop参与类名拼接,见 node.tsx); - 释放时:
handleNodeDrop再次校验allowDrop,返回false则直接不触发onDrop。
典型用法示例(限制只能拖到叶子节点、且禁止作为其子节点):
<Tree
draggable
allowDrop={({ dropNode, dropPosition }) => {
// 只允许放置到叶子节点,且不允许成为其子节点
return dropNode.props.isLeaf && dropPosition !== 0;
}}
/>
再结合 onDragStart / onDragEnd 可以做"拖拽时禁用其他交互"等联动效果。完整回调签名与默认值可对照 interface.ts 与 index.tsx(allowDrop 默认 () => true)。
八、测试与验证依据
仓库中拖拽示例的渲染结果被快照测试覆盖:demo.test.ts 通过 demoTest('Tree') 自动遍历所有 demo 并生成快照,其中 renders Tree/demo/drag.md correctly 一节(demo.test.ts.snap)记录了拖拽示例的实际 DOM 结构,可以从中确认:
- 可拖拽节点输出
arco-tree-node-draggable类,标题节点带draggable="true"属性并渲染arco-tree-node-drag-icon(内含arco-icon-drag-dot-vertical图标); draggable: false的Leaf 0-0-2-1节点对应标题不带draggable属性,验证了节点级禁用生效;blockNode模式下标题带arco-tree-node-title-block类,拖拽区域占满整行。
这些快照可作为"拖拽开关、禁用节点、块级节点"三类行为的行为级回归验证依据。
结语
Arco Tree 的拖拽能力由"组件级开关 + 节点级开关 + 回调上报 + 内置守卫"四层构成:draggable 控制能否拖,onDrop 提供 dragNode / dropNode / dropPosition 三元信息交由业务侧完成数据结构变更,allowDrop 在悬停与释放两个环节双重拦截,内置的 isChildOfNode / isSameNode 守卫则自动规避循环引用与自拖自放。掌握示例中的 loop + splice 数据变换模式,再叠加 allowDrop 规则与自定义高亮样式(如示例的 blinkBg 动画),即可快速落地从菜单树重排到组织架构调整等各类拖拽场景。