☰
vue-draggable实现三级树形拖拽排序的完整方案
2026/10/17 10:56:09 网站建设 项目流程

后台管理系统里接到一个分类管理需求:商品类目要做三级结构,一级、二级、三级都能手动拖拽排序,还允许把某个二级节点拖到别的一级下面、把某个三级节点拖到别的二级下面。当时心里的第一反应是“这不就是个树形列表加 vuedraggable 嘛”,真动手才发现,单层拖拽排序的 demo 一抓一大把,三层嵌套排序要处理的东西完全不在一个量级。这篇文章围绕基于 vue-draggable 的三级拖动排序展开,把数据模型、递归组件、层级校验、排序持久化这几个核心点完整过一遍,适合手里正要做多级菜单、多级分类、目录树排序的朋友直接参考。

我会把实现思路和踩过的坑都写出来,包括“为什么单层写法搬到树上会失效”“空 children 数组为什么是必须的”“move 事件里怎么拦截第四层”“一次拖拽后怎么一次性算好所有 sort 字段”。看完照着做,基本能覆盖 90% 的三级树拖拽场景。

1. 三级拖拽排序,难点到底在哪儿

1.1 从列表排序到树形排序,多了哪一维

单级排序就是处理一个数组:[ { id, name, sort } ... ]。拖动某个 item,数组重新排列,顶多把它前后位置对应的 sort 改一下就能提交给后端,逻辑非常线性。

树形排序的本质完全不同。数据不再是线性的,每个节点底下还可能挂着一串 children。按“排序”这两个字理解,其实包含了三种变化:

  • 同一层级的兄弟节点之间互相换顺序;
  • 节点归属的父级发生变化,比如把二级节点拖到另一个一级节点下面;
  • 节点所在的层级深度发生变化,比如把三级节点直接拖成一级节点。

这三件事还经常同时发生:一次拖拽可能既改了当前层级的顺序,又改了目标层级的父子关系。交互上,DOM 结构也变得复杂,拖动某个节点时它的整棵子树怎么表现、要不要一起移动、允不允许移动到更深层,这些全是必须提前想清楚的问题。

很多人在这一步容易陷入误区,觉得“我数据是树形的,给整个 tree 套一个 draggable 就好了”。实际做不到,原因细分下来就是下一个小节要说的。

1.2 vuedraggable 只认数组,不认树

vue-draggable(Vue 2 下对应的包名通常是 vuedraggable,Vue 3 下是 vue-draggable-next,两者 API 大体对应)本质上是 Sortable.js 的封装。它通过 v-model 绑定一个数组,拖动时自动修改数组顺序,同时触发视图更新。注意,它处理的粒度永远是一个“列表”,它不感知父子嵌套关系。

要用它做三级拖动排序,只能自己动手把树的每一层都拆成一个 draggable 容器:外层的 draggable 管理一级数组,一级节点内部再放一个 draggable 管它自己的 children,二级节点内部再放一层管理第三级。也就是说,“三级拖动排序”在组件结构上就是三个(或递归 N 个)draggable 容器互相协作。

容器之间靠 group 参数建立联系。所有层的 draggable 配置同一个 group name,Sortable 才允许在这些容器之间拖进拖出。这点是后面所有实现的地基,也直接影响跨层拖拽能不能成功。

2. 数据结构设计:树形拖拽的一切基础

2.1 用 children 嵌套还是 parentId 平铺

先看后端接口。多数后台系统的数据库里存的是 parent_id 和 sort,但接口返回给前端的经常是已经拼好的树形结构。前端交互阶段,最舒服的就是直接维护嵌套 children 树,拖拽和渲染都围绕这棵树。

const tree = [ { id: 101, name: '一级分类A', children: [ { id: 201, name: '二级分类A-1', children: [ { id: 301, name: '三级分类甲' }, { id: 302, name: '三级分类乙' } ] } ] }, { id: 102, name: '一级分类B', children: [] } ]

提交的时候再把树形转成平铺列表,交给后端去批量更新。前端展示用树、持久化转平铺,两边各管一头,是实践里最省心的分工。

2.2 空 children 数组是必须的,不要忽略

这是个非常隐蔽却影响巨大的细节。内层 draggable 的渲染条件不应该写成v-if="item.children && item.children.length",因为如果某个分类没有任何子级,它的 children 数组是空的,用length判断后,这个节点底下就不会渲染第二层 draggable 容器。

问题来了:外层的节点想拖进这个空分类时,Sortable 根本找不到可用的目标容器,拖拽直接失败,甚至表现为“能拖出去但拖不进去”。解法是在初始化数据时做一个清洗,给每个可能作为父级的节点都补上children: [],渲染内部容器时用v-if="item.children"判断,而不是判断 length。空数组底下自然没有任何行,但容器本身存在,照样可以接收拖进来的节点。

我见过不少项目在这个小地方卡很久,最后排查半天发现只是初始数据没补空 children。

3. 三级拖拽的实现:递归组件 + 嵌套 draggable

3.1 递归树组件怎么搭

整个实现的核心是一个递归组件 TreeNode.vue,它内部渲染一个 draggable,绑定当前层级的 list,循环输出每个节点;节点内部如果存在 children,再递归渲染一个 TreeNode,把item.children作为新的 list 传下去。

<template> <draggable class="drag-container" :list="list" :group="groupConfig" handle=".drag-handle" ghost-class="drag-ghost" chosen-class="drag-chosen" animation="200" :data-depth="depth" @move="handleMove" @change="handleChange" > <div v-for="item in list" :key="item.id" class="drag-item" :data-max-depth="item._maxDepth" > <div class="drag-handle">拖动</div> <div class="node-content">{{ item.name }}</div> <TreeNode v-if="item.children" :list="item.children" :depth="depth + 1" /> </div> </draggable> </template> <script> export default { name: 'TreeNode', props: { list: { type: Array, required: true }, depth: { type: Number, default: 0 } }, computed: { groupConfig() { return { name: 'tree-sort', pull: true, put: true } } }, methods: { handleMove() {}, handleChange() {} } } </script>

这里有个关键点:TreeNode 组件内部不要再对 list 做拷贝,直接使用 prop 里的引用。draggable 的 v-model 绑定到这个引用上,拖拽时 Sortable 会直接修改这个数组,数据才能同步回最顶层的 tree。一旦你在子组件里this.localList = this.list.slice(),拖拽就只会改 localList,视图和数据全断。

深度 depth 从 0 开始,每递归一层就加 1,后面做层级限制时直接读容器的>function computeMaxDepth(node) { if (!node.children || !node.children.length) { node._maxDepth = 0 } else { let childMax = 0 node.children.forEach((child) => { childMax = Math.max(childMax, computeMaxDepth(child)) }) node._maxDepth = childMax + 1 } return node._maxDepth }

move 里做一个简单判断:目标容器层级 + 被拖节点自身的最大层级不能超过 3。

handleMove(evt) { const targetDepth = Number(evt.to.dataset.depth || 0) const draggedMaxDepth = Number(evt.dragged.dataset.maxDepth || 0) if (targetDepth + draggedMaxDepth > 3) { return false } return true }

举个例子:一个带两级子节点的节点,_maxDepth是 2,想把它拖到第三级容器里,那它就会成为第三级,它的孩子会变成第四级。targetDepth + draggedMaxDepth = 2 + 2 > 3,直接拦截,完全符合预期。

4. 拖拽结束后的排序持久化:全量重算最省心

4.1 change 事件的三种类型,为什么别依赖

vuedraggable 在数组发生变化时会触发 change 事件,参数里会带added、removed、moved三种类型:

  • added:节点从其他容器拖入当前容器;
  • removed:节点从当前容器拖出到其他容器;
  • moved:节点在当前容器内部换顺序。

单层列表排序时,这三种类型非常直观,怎么写逻辑都行。但三层嵌套时,一次拖拽会在两个容器分别触发事件:拖出方触发 removed,拖入方触发 added;如果只是同层排序,则只触发 moved。看似清晰,实际组合起来非常容易出现遗漏:跨层移动时旧层的 sort 要重排、新层的 sort 要重排、节点的 parentId 要更新,还要考虑组件版本差异下事件触发顺序不稳定。

我的建议是:不在 change 事件里去精确判断类型,change 只当作一个信号,收到信号后对整个树做一次全量重算。

4.2 遍历整棵树重新编号,一次搞定所有字段

拖拽结束后,无论发生了多复杂的结构变化,v-model 已经把原 tree 的引用改成了最新状态。此时只需要递归遍历每个层级的每个节点,依次重设 parentId 和 sort 即可。

function reindexTree(nodes, parentId = null) { return nodes.map((node, index) => { const { children } = node const record = { ...node, parentId, sort: index + 1 } if (children) { record.children = children.length ? reindexTree(children, node.id) : [] } return record }) }

sort 从 1 开始连续递增,每个层级都是独立的编号区间。parentId 根节点为 null,子节点指向实际父级 id。这样做的好处是数据永远自洽,不管用户连续拖拽了多少步、中间产生了多少次跨层移动,最终提交结果都是完整的、正确的。

需要提交平铺列表时,再递归一次拍平:

function flattenTree(nodes, result = []) { nodes.forEach((node) => { result.push({ id: node.id, parentId: node.parentId, sort: node.sort, name: node.name }) if (node.children && node.children.length) { flattenTree(node.children, result) } }) return result }

4.3 提交时机:防抖比实时提交靠谱

排序接口的提交时机要考虑现实情况。用户拖一个节点,高频操作下可能一两秒内连拖好几次,如果每次 change 都立刻请求后端,既浪费请求量,还可能因为上一次请求没返回、下一次拖拽又开始了而出错。

三种方案对比:

提交时机优点缺点
每次 change 立即提交实现最直接请求频率太高,连续拖拽时容易雪崩
change 后防抖 500ms 提交平衡体验和请求量,推荐拖完后退出页面可能没提交,需要额外处理
手动点保存按钮提交最可控,无冗余请求用户容易忘记保存,要加未保存提示

我实际项目里通常选防抖方案:change 触发后 setTimeout 500ms 执行全量重算和提交,用户如果继续拖,就清掉上一次定时器重新计时。这样就算用户连续拖十几次,最终也只会有一两次请求。

5. 实战踩坑清单:嵌套拖拽的高频问题

这里把最常见的几个坑先总览一下,再逐个展开。

现象常见原因排查方向
拖拽后视图不动v-model 绑定的是拷贝数组,或 Vuex 里直接改了 getter 返回的新数组console.log 看 change 里的 list 是否变化,确认绑定引用一致
能拖出去但拖不进空节点空分类没有 children 数组,内层 draggable 根本没渲染检查内层容器是否用v-if="item.children"渲染
跨层移动后父子关系错乱只算了 sort,没重算 parentId用 reindexTree 全量重算
拖到根容器变一级后,展开状态丢失递归组件 key 用了 index,拖拽后组件复用错乱key 用 node.id,展开状态用 id 做键
节点不多却很卡每次 change 都全量重算并立即触发全树渲染防抖提交,默认折叠懒渲染子树

5.1 拖完视图没动,先检查引用有没有断

这是最高频的问题。很多人把 TreeNode 写成“展示组件”,内部把 list 拷贝到 data 再传给 draggable。拖拽时列表变了,但改的是拷贝,原来的树纹丝不动,视图自然不刷新。

排查思路很简单:在 handleChange 里打印触发事件,确认数据结构到底有没有变;再在根组件里 watch 整个 tree 的变化,看是不是同一个引用。解决方案是让 TreeNode 只做薄封装,直接把 prop 里的 list 透传给 draggable,不要中间复制。如果树存在 Vuex 里,就通过 mutation 更新整棵树,而不是给 draggable 绑定一个 computed 返回的新数组,那样 computed 缓存和拖拽内部的直接修改会互相打架。

5.2 空节点拖不进去,先补空 children 数组

这个坑我在第二小节已经重点提过。内层 draggable 容器必须始终存在,节点才能作为放置目标。写代码时不要用v-if="item.children && item.children.length",改成v-if="item.children"即可。初始化数据时确保每个节点都有 children 字段,哪怕是空数组。

如果你是在接口返回后再处理数据,清洗一段代码就能统一补齐:遍历树,发现没有 children 字段就补children: []。

5.3 跨层移动后 parentId 和旧层排序全部乱套

只更新目标层级的 sort,忽略旧层级的 sort 重排,是另一个常见翻车点。举例:一级分类 A 原来有两个二级节点 a1、a2,把 a2 拖到另一个一级分类 B 下面。a2 的 sort 在新结构里重新编号了,但 A 底下只剩 a1,它的 sort 如果不重算,还是老值,后端接收后排序就出现空洞。

这正好是第四小节推荐全量重算的原因:reindexTree 会同时把旧层、新层、父层级所有相关节点的 sort 全部重新编号,不会出现漏网之鱼。

5.4 递归组件 key 用 index,拖几次就乱了

递归树组件里,每个节点还带着展开、收起的内部状态。如果 v-for 的 key 用的是 index,拖拽后同一层级顺序变化,Vue 会复用旧的组件实例,导致展开状态错乱、动画闪烁,甚至节点内容显示错位。

解决办法:key 一律用item.id,展开状态用一个以 id 为键的对象或 Map 保存,而不是存在组件自身的 data 里。这样无论节点拖到哪个层级、哪个位置,状态都能跟着 id 走。

5.5 数据量大时卡顿,问题不在拖拽本身

三级树理论上节点不会特别多,但总有一些极端情况,比如每个分类几千条。卡顿来源主要有三个:递归渲染了所有层级的全部节点、change 触发后立即执行全树重算、ghost 动画和 DOM 更新叠加。

优化方向也明确:默认展开第一层,子树懒渲染,用户点击展开时才加载下一层;change 后防抖再重算;如果数据超过几百个节点,提交频率还需要进一步降低。真要说到虚拟滚动,树形结构实现成本很高,业务上先靠懒渲染基本就能解决。

6. 从“能拖”到“好用”的细节打磨

6.1 手柄与整行拖动的取舍

分类行上往往同时有展开箭头、编辑按钮、删除按钮。整行可拖虽然是视觉上最直接的方案,但实际点按钮时很容易误触拖拽。给整行加上handle=".drag-handle"之后,只有手柄图标能拖动,点击其他区域不受影响,手感和误触率都会有明显改善。

手柄图标放在节点内容左侧,紧挨着展开箭头。注意不要让拖拽手柄和展开箭头重叠,否则点击展开时也会触发拖拽。

6.2 空节点和空容器也要有“落点感”

空的 draggable 容器在页面上没有任何可见内容,用户会不知道该往哪拖。给空容器加一个虚线边框的占位提示,比如“拖到此处添加子级”,容器存在感强很多,拖拽成功率也高。这里有个细节:占位提示本身不能是 draggable 的 item,否则会被当成可拖节点,单纯做个容器背景即可。

6.3 最终提交给服务端的数据长什么样

全量重算加拍平之后,提交数据通常是这个格式:

{ "items": [ { "id": 101, "parentId": null, "sort": 1 }, { "id": 201, "parentId": 101, "sort": 1 }, { "id": 301, "parentId": 201, "sort": 1 }, { "id": 302, "parentId": 201, "sort": 2 } ] }

sort 在同级内从 1 开始连续不中断,parentId 准确指向实际父级,根节点 parentId 为 null。只要这个结构是准的,后端无论按 sort 重新排列同级节点,还是按 parentId 重建父子关系,都能正确还原前端展示的树。

做了几个多级排序需求之后,我的习惯是:把“递归渲染 + move 层级校验 + 全量重算 + 防抖提交”这套逻辑抽成一个通用组件,不同业务只是节点字段名不同,传一个labelKey和层级限制参数就能复用。如果你也在做类似功能,建议一开始就按这个方向封装,后面接新需求会省下大把时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询