{T}

VTable 拖拽限制分析与树形拖拽扩展思路

概述

做完行拖拽和列拖拽后,这一节回过头给拖拽能力划定真实边界:它们在基础平铺表格里能成立,不代表天然兼容所有复杂表格场景。重点排查了 row-key 在展开行场景下可能改变原有表现、自动补 key 只适合平铺列表、展开行与树形数据都不适合直接套用当前行拖拽方案。还指出 SortableJS 官方的嵌套示例针对的是标准嵌套列表 DOM,不等于能直接套进 el-table 这种特殊表格结构。真正好用的树形拖拽要解决"层级归属反馈"这一维,关键 API 会从 onEnd 迁移到拖拽过程中的 onMove 与 relatedRect。一个可维护的基础组件,既要知道自己能做什么,也要明确自己现在还不该做什么。

学习目标

  • 认识拖拽能力在基础表格成立,不代表兼容所有复杂表格场景
  • 理解 row-key 在展开行场景下可能改变原有表现,不能机械写死
  • 知道自动补 key 只适合平铺列表,不等于树形/展开行可直接复用
  • 明白展开行与树形数据都不适合直接套用当前行拖拽方案
  • 区分 SortableJS 嵌套示例的 DOM 语义与 el-table 的特殊结构
  • 理解真正好用的树形拖拽要解决层级归属反馈
  • 知道后续扩展树形拖拽关键在 onMove 与 relatedRect
  • 建立"先定义边界,再扩展能力"的组件库开发意识

一、基础表格成立不等于所有场景都适合拖拽

这一节没有继续往前硬堆功能,而是先回头检查之前做好的行拖拽和列拖拽,在复杂表格里到底还能不能正常工作。结果很明确:对基础平铺表格没问题,对复杂表格(尤其是展开行、树形数据、合并单元格这类结构)问题会明显暴露出来。

text
基础表格 -> 拖拽成立
复杂表格 -> 需要重新审视拖拽边界

复杂表格交互的难点,通常都出在结构层,而不是 API 层。做完能力之后回头查边界,是组件库开发里非常必要的一步,这一节的价值不是"修一个 bug",而是给拖拽能力划定真实边界。

二、row-key 可能影响复杂表格原有表现

第一件排查到的事,是给 VTable 统一补了 row-key 后,展开行示例的表现和之前不一样了。这说明 row-key 不是无害默认值,它会影响表格内部对行状态、展开态和节点复用的判断。虽然在拖拽排序里稳定 key 很重要,但并不代表应该对所有表格场景无脑统一注入一份 row-key。

text
拖拽场景 -> 需要稳定 key
展开行场景 -> 要重新验证 row-key 是否改变默认行为

组件默认值如果会改变官方组件行为,就必须谨慎;row-key 对树形、展开、选择状态都会产生影响,在复杂场景里最好按能力开关或按具体场景显式传入,而不是一刀切写死。

三、自动补 key 只适合平铺列表

解决基础行拖拽时做了一个增强:如果用户没传 row-key,就给 localData 自动补一份。这个思路在平铺表格里很实用,因为拖拽排序时 Vue 需要稳定 key。但到了复杂场景,这件事就不再那么简单——展开行不是普通平铺结构,树形数据本身也有自己的层级 key 语义。所以这个"自动补 key"更多是针对基础表格拖拽的优化,而不是一个全局真理。

ts
function addRowKey(arr: any[]) {
  return arr.map((item, index) => ({
    ...item,
    id: item.id ?? `${Math.random()}-${index}`
  }))
}

自动补 key 是容错,不是万能解;树形表格和展开行通常已经有更复杂的结构语义,不能只从"拖拽能不能排"来决定;对复杂表格,优先尊重原始数据结构的 key 设计。

四、展开行不适合直接套用行拖拽

展开行场景有一个结构性问题:当前拖拽方案默认把每一行看成一个独立、平铺的 tr,但展开行实际上会额外插入一块展开内容,这块内容和原始数据行之间不是简单的一一映射。一旦仍然把它按普通 tbody tr 去做拖拽,就很容易出现结构错乱、展开内容和主行不同步、排序结果不符合用户理解。

text
普通行   -> 一行就是一个数据项
展开行   -> 一行数据 + 一块附加展开内容

展开行的难点不是"拖不动",而是"拖动后语义不再稳定"。这类结构不适合直接复用基础行拖拽方案,如果真要支持,应该单独为展开行设计拖拽规则。

五、树形数据不是平铺排序而是层级排序

树形数据的表格行虽然也渲染成一行一行,但它的逻辑实际上不是简单重排行数组,而是节点层级、缩进关系、父子关系、同级顺序。所以对树形数据来说,拖拽不是"从第 2 行换到第 5 行"这么简单,而是要判断是换同级顺序,还是要改父子关系。

text
普通表格拖拽 -> reorder list
树形拖拽     -> reorder tree / move node

只要存在缩进、层级、父子结构,就不能把它继续当成平铺数组处理;树形拖拽如果处理不好,最先出问题的就是"节点到底归谁"。当前行拖拽方案只适合平铺列表,不适合树结构直接复用。

六、SortableJS 嵌套示例不等于能直接套进 el-table

把 SortableJS 官方的嵌套拖拽示例和当前 el-table 渲染出来的 DOM 做了对比,两者的关键差异在于:官方 nested list 示例针对的是常规嵌套列表 DOM,而 el-table 的树形数据和展开行,是表格结构渲染出的特殊 DOM。所以即便 SortableJS 本身支持嵌套列表,也不代表它能直接无缝接到当前表格结构上。

text
SortableJS nested list -> 适合标准列表嵌套
Element Table tree/expand -> 不是同一种 DOM 语义

看到第三方库支持某能力,不要立刻默认自己这个场景也能直接接;组件库生成出来的 DOM 结构,往往和普通列表完全不同。这正是为什么在这里停下来做边界分析,而不是继续硬接。

七、真正好用的树形拖拽要解决层级归属反馈

真正成熟的树形拖拽,不是单纯把节点移动到新位置,而是要清楚表达当前拖动后会落在哪一层、成为谁的子节点。体验关键点往往包括:会出现一条定位线、定位线长度会变化、不同长度对应不同层级关系。

text
定位线更长   -> 更外层
定位线缩进   -> 更内层 / 子节点

树形拖拽比普通拖拽多了"层级归属反馈"这一维;没有这层反馈,用户不知道自己是换顺序还是改父级关系。这是后续如果要继续扩展树形拖拽时,必须补上的交互设计。

八、后续扩展树形拖拽关键在 onMove 与 relatedRect

普通拖拽排序,只在 onEnd 知道旧索引和新索引就够了;树形拖拽则需要在拖动过程中就判断当前相对位置、目标节点矩形、应该插到外层还是内层,这时候只看 oldIndex / newIndex 已经不够了。关键 API 往往从 onEnd 迁移到拖拽过程中的 onMove 和位置矩形信息 relatedRect。

text
普通拖拽 -> onEnd 即可
树形拖拽 -> onMove + relatedRect 更关键

树形拖拽一旦进入层级判断,就必须在拖拽过程中做更多计算,这一层更接近"自定义交互系统",不再是简单接一个库就结束。这一节停在这里是合理的,因为这已经是下一阶段的话题了。


常见问题

问题原因解决方案
行拖拽在树形表格里表现异常树形结构不是普通列表重排问题先禁用拖拽,后续单独设计树形拖拽方案
展开行表格拖拽后 DOM 和数据不同步展开结构引入了额外层级把展开行拖拽视为独立扩展场景,不直接复用普通表格逻辑
以为 SortableJS demo 能直接套进 el-table忽略了 DOM 结构差异先对照实际表格 DOM,再决定是否能复用示例

延伸阅读

边界控制示例

ts
const canEnableRowDrag = (options: {
  isTree: boolean
  hasExpandRow: boolean
}) => {
  if (options.isTree) return false
  if (options.hasExpandRow) return false
  return true
}

先处理"什么场景允许开启拖拽":树形表格和展开行表格结构复杂,优先禁用是更稳妥的工程策略。这类边界函数能帮助页面层在接入组件时更早做判断。先定义边界,再扩展能力,比盲目支持所有场景更可维护。