Arco Design Tree 组件拖拽节点实战:从 onDrop 数据更新到 allowDrop 拦截与源码解析

原创2026-10-13 14:05:511,907 阅读
文章标签:UI组件前端设计系统

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.tsx static 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 前的三道守卫:
    1. this.dragNode 必须存在(未拖拽时忽略 drop);
    2. !this.isChildOfNode(node, this.dragNode):禁止把节点拖入自己的子孙节点(会产生循环引用);
    3. !this.isSameNode(this.dragNode, nodeInstance):禁止拖放到自身;
    4. 若设置了 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 动画),即可快速落地从菜单树重排到组织架构调整等各类拖拽场景。

登录后查看全文
arco-design