关于可拖动表格的技术分析

前两天我专门抽了点时间,把一个“能拖列、能拖行、还有二级表头”的表格实现拆了一遍。为了不被业务名词干扰,我先做了一个最小 demo,只保留结构和交互,再回过头去看原来的实现,很多地方一下子就顺了。

这篇文章不聊具体业务,只聊这种表格为什么难、难在哪,以及我最后是怎么把它想明白的。

先说结论

demo 预览点击这里
demo 代码点击这里

这种表格最难的地方,其实不是拖拽本身,而是这两件事:

  1. 页面上看到的是“一级表头、二级表头、表体单元格”,但代码里真正稳定的数据结构,往往是另一套。
  2. 前端拖动出来的是一个视觉结果,后端需要的却通常是一套排序协议,比如“把哪个组移动到哪个组前面”、“把哪个子项移动到哪个排序号后面”。

也就是说,真正复杂的部分不是 vue-draggable-plus,而是:

  • 视图结构怎么建模
  • 拖拽结果怎么映射到后端排序接口

把这两件事想清楚以后,拖拽本身反而成了最简单的一层。

为什么这种表格比普通表格难

普通表格一般只有两层心智模型:

  • 表头
  • 行数据

但是这种可拖动的复杂表格,至少有四层:

  • 左侧固定列
  • 一级表头
  • 二级表头
  • 表体单元格

再往下一看,数据还不一定是分层返回的。很多时候后端给你的并不是:

[
{
header: '某个分组',
children: [...]
}
]

而是一份拍平的数据:

{
rowList: [...],
columnEntries: [...]
}

前端要自己把 columnEntries 再拼成:

  • 一级表头分组
  • 二级表头子项
  • 表体横向的叶子列

这一步如果没想清楚,后面拖拽一定会越来越乱。

我最后采用的抽象方式

为了把问题简化,我在 demo 里只保留三份核心数据:

  • rowItems:表格行
  • flatColumns:拍平后的叶子列
  • columnGroups:一级表头分组

对应的“后端返回”大概长这样:

{
rowList: [
{ id: 'row-1', label: 'Row A', sort: 1, locked: false },
{ id: 'row-2', label: 'Row B', sort: 2, locked: false }
],
columnEntries: [
{
leafId: 'leaf-group-b-1',
groupId: 'group-b',
groupLabel: 'Grouped B',
childLabel: 'Child B1',
metaLabel: 'Day 8',
groupOrder: 2,
childOrder: 1,
columnType: 'group-child',
enabledRowIds: ['row-2']
}
]
}

这里最关键的是:后端返回的是叶子列,不是已经分好层的表头树。

前端拿到这份数据以后,再做一次转换:

rowList         -> rowItems
columnEntries -> flatColumns
flatColumns 分组 -> columnGroups

转换后的职责也就清楚了:

  • rowItems 负责渲染表体行
  • flatColumns 负责渲染真正的单元格
  • columnGroups 负责渲染一级表头和二级表头

这一步特别重要。因为页面上看起来是“一个表”,但代码里其实不是“一份数据走到底”,而是同一份后端数据在前端投影成了三种视图结构

为什么一定要保留一份拍平列

这是我这次最大的一个体会。

一开始很容易想当然地把数据写成树:

[
{
id: 'group-a',
label: 'Group A',
children: [
{ id: 'child-a-1' },
{ id: 'child-a-2' }
]
}
]

这种结构渲染表头当然很舒服,但一旦你开始处理表体和排序,就会发现两个问题:

  1. 表体真正关心的是最终横向顺序,也就是叶子列顺序。
  2. 排序接口通常也不是吃一棵树,而是吃“某个叶子 / 某个组”的位置变化。

所以更稳的做法是:

  • 永远保留一份拍平列 flatColumns
  • 一级表头只是 flatColumns 的一种分组结果

这样一来,表头怎么渲染是一回事,真正的列顺序又是另一回事,两边不会互相绑死。

可拖动表格其实是三种拖拽

我把这个问题拆开以后,事情就简单很多了。所谓“可拖动表格”,本质上不是一个拖拽,而是三种不同的拖拽。

第一种:行拖动

这个是最简单的。

它的核心思路就是:

  1. 拖动的是 rowItems
  2. 拿到被拖动行的 rowId
  3. 根据 newIndex 算出新的 sort
  4. 调后端排序接口
  5. 刷新整张表

伪代码大概是这样:

async function onRowUpdate({ oldIndex, newIndex }) {
const rowId = rowItems[oldIndex].id;

await changeRowSort({
rowId,
sort: newIndex + 1
});

await refreshTable();
}

这部分基本没什么悬念。因为“行”在视觉上和数据上通常都是一一对应的。

第二种:一级表头拖动

一级表头拖动就开始有意思了。

页面上你拖的是一个“组”,比如:

  • Standalone A
  • Grouped B
  • Empty Group C

但在代码里,这个组本身通常不是真正参与表体渲染的最小单位。真正渲染表体的是叶子列。

比如一个一级表头下面有两个二级表头:

Grouped B
- Child B1
- Child B2

那么它在拍平列里其实对应两列。

也就是说,当你拖动“一级表头”时,实际上是在移动一段连续的叶子列区间。

这件事可以抽象成:

  • 视图层拖的是 columnGroups
  • 数据层真正受影响的是 flatColumns

如果只在前端层面理解,很容易觉得“把组交换一下不就好了”。但如果后端接口需要的是:

  • groupId
  • targetGroupId
  • before / after

那你就必须把这次拖拽翻译成排序协议。

我在 demo 里做得比较直白:

async function onGroupUpdate({ oldIndex, newIndex }) {
const movingRight = oldIndex < newIndex;
const sourceGroupId = dragState.groupId;
const targetGroup = movingRight
? columnGroups[newIndex - 1]
: columnGroups[newIndex + 1];

await changeGroupSort({
groupId: sourceGroupId,
targetGroupId: targetGroup.id,
place: movingRight ? 'after' : 'before'
});

await refreshTable();
}

这里有两个关键点:

第一,拖拽方向很重要

oldIndex < newIndex 说明是往右拖,往右拖的语义一般是“放到目标后面”。

反过来,往左拖通常是“放到目标前面”。

如果不把这个语义明确下来,后端就不知道你到底是要插前面还是插后面。

第二,一级表头排序和叶子列排序不是一个概念

一级表头改变位置以后,下面所有子列的相对顺序都应该一起跟着走。

所以后端真正维护的,往往不是某一列的顺序,而是整个分组的顺序。

这也是为什么一级表头拖动看起来是在拖一个 DOM,实际上却是在调整一整段数据的原因。

第三种:二级表头拖动

二级表头拖动比一级更绕一点,因为它通常只发生在某个一级分组内部。

比如:

Grouped E
- Child E1
- Child E2

这时拖动的不是整个组,而是组里的子项。

对应到数据层,移动的就是:

  • 某个 leafId
  • 在同组内的 childOrder

伪代码大概是这样:

async function onChildUpdate(groupId, { oldIndex, newIndex }) {
const movingRight = oldIndex < newIndex;
const sourceLeafId = dragState.childId;
const group = getGroupById(groupId);
const targetChild = movingRight
? group.children[newIndex - 1]
: group.children[newIndex + 1];

await changeChildSort({
leafId: sourceLeafId,
targetLeafId: targetChild.leafId,
place: movingRight ? 'after' : 'before'
});

await refreshTable();
}

看起来跟一级表头差不多,但难点不一样。

一级表头拖的是“组”,二级表头拖的是“叶子列”。

一级表头关心的是 groupOrder,二级表头关心的是 childOrder

如果这两个概念在代码里没有分开,后面很容易出现一类经典 bug:表头看起来动了,但表体顺序没对;或者表体顺序对了,但二级表头仍然错位。

这类实现里,最难的不是拖,而是映射

如果让我把整件事压缩成一句话,那就是:

多级表头拖拽最难的地方,是把视图结构映射成后端排序协议。

这里面有两层映射。

第一层映射:从后端数据到前端视图

后端给你的是:

  • 行列表
  • 拍平列

前端要自己推导出:

  • 一级表头
  • 二级表头
  • 第三行元信息
  • 表体单元格

这个过程本质上是在做一次“视图建模”。

如果建模不稳,后面每多一种交互,代码都会更乱。

第二层映射:从拖拽结果到排序接口

用户拖的是视觉上的结构:

  • 一个一级表头块
  • 一个二级表头子项
  • 一整行

但后端吃的往往不是这个视图结构,而是:

  • 哪个组移动到哪个组前后
  • 哪个叶子列移动到哪个叶子列前后
  • 哪个行 id 更新成哪个排序号

所以拖拽事件真正干的事不是“改 DOM”,而是“把用户意图翻译成排序参数”。

这也是为什么我现在看这类代码,第一眼不会先看模板,而是先看:

  • 数据怎么分层
  • 排序接口吃什么参数
  • 拖完以后是本地重排还是重新拉接口

这些决定了整份实现到底是不是稳的。

为什么我最后更认同“后端排序 + 前端回刷”

这种表格完全可以做成本地乐观更新:前端先把数组顺序改掉,再慢慢同步后端。

但如果你的场景里同时存在:

  • 一级表头
  • 二级表头
  • 单元格绑定关系

那本地乐观更新的复杂度会迅速上升。

因为你不只是要改一个数组,而是要同时保证:

  • 分组结构正确
  • 叶子列顺序正确
  • 表体列映射正确
  • 勾选状态不串位

相比之下,“后端排序 + 前端回刷”虽然看起来笨一点,但有两个很大的优点:

  1. 前端最终状态永远以接口返回为准,不容易越改越漂。
  2. 拖拽逻辑可以收敛成“计算参数 + 调接口”,而不是“自己维护一套复杂状态机”。

所以在 demo 里,我保留了这条完整链路:

拖拽
-> 调 mock 排序接口
-> 返回新数据
-> rebuildViewModel
-> 重新渲染

这一步其实不是为了“模拟后端”,而是为了更真实地反映这类业务组件的实现方式。

一个很实用的经验:先做抽象 demo,再回头读业务代码

这次我最大的收获,不是“我会写这个表格了”,而是一个方法论。

遇到这种复杂组件时,直接扎进业务代码里,经常会被这些东西干扰:

  • 权限判断
  • 接口字段命名
  • 特殊类型分支
  • 隐藏、禁用、冻结列
  • 业务规则

这些都是真问题,但它们会掩盖真正的主干。

如果先把问题抽象成一个小 demo,只保留:

  • 拍平列
  • 一级分组
  • 二级子项
  • 排序接口

那么整个实现骨架会一下子露出来。

等你再回头看业务代码时,很多原本觉得绕的地方,其实就只是“在这条主干上叠了很多业务条件”。

最后总结一下

关于这种可拖动表格,我现在的理解是这样的:

第一,它不是一个“拖拽组件问题”,而是一个“数据建模问题”。

第二,一级表头、二级表头、行,看起来都叫拖拽,但本质上是三种不同的排序行为。

第三,多级表头之所以容易把人绕晕,不是因为 DOM 长得复杂,而是因为“视图上拖的是块,数据上改的是排序协议”。

第四,如果后端本身就是排序真相源,那前端不要太执着于自己维护最终顺序,很多时候回刷反而更稳。

如果以后我再遇到类似的需求,我大概率还是会先做一个脱离业务词汇的小 demo,把结构和映射关系想明白,再决定要不要把具体业务往里填。

因为一旦这层想清楚了,后面的代码虽然还不算简单,但至少不会再是黑盒了。