前两天我专门抽了点时间,把一个“能拖列、能拖行、还有二级表头”的表格实现拆了一遍。为了不被业务名词干扰,我先做了一个最小 demo,只保留结构和交互,再回过头去看原来的实现,很多地方一下子就顺了。
这篇文章不聊具体业务,只聊这种表格为什么难、难在哪,以及我最后是怎么把它想明白的。
先说结论
这种表格最难的地方,其实不是拖拽本身,而是这两件事:
- 页面上看到的是“一级表头、二级表头、表体单元格”,但代码里真正稳定的数据结构,往往是另一套。
- 前端拖动出来的是一个视觉结果,后端需要的却通常是一套排序协议,比如“把哪个组移动到哪个组前面”、“把哪个子项移动到哪个排序号后面”。
也就是说,真正复杂的部分不是 vue-draggable-plus,而是:
- 视图结构怎么建模
- 拖拽结果怎么映射到后端排序接口
把这两件事想清楚以后,拖拽本身反而成了最简单的一层。
为什么这种表格比普通表格难
普通表格一般只有两层心智模型:
- 表头
- 行数据
但是这种可拖动的复杂表格,至少有四层:
- 左侧固定列
- 一级表头
- 二级表头
- 表体单元格
再往下一看,数据还不一定是分层返回的。很多时候后端给你的并不是:
[ |
而是一份拍平的数据:
{ |
前端要自己把 columnEntries 再拼成:
- 一级表头分组
- 二级表头子项
- 表体横向的叶子列
这一步如果没想清楚,后面拖拽一定会越来越乱。
我最后采用的抽象方式
为了把问题简化,我在 demo 里只保留三份核心数据:
rowItems:表格行flatColumns:拍平后的叶子列columnGroups:一级表头分组
对应的“后端返回”大概长这样:
{ |
这里最关键的是:后端返回的是叶子列,不是已经分好层的表头树。
前端拿到这份数据以后,再做一次转换:
rowList -> rowItems |
转换后的职责也就清楚了:
rowItems负责渲染表体行flatColumns负责渲染真正的单元格columnGroups负责渲染一级表头和二级表头
这一步特别重要。因为页面上看起来是“一个表”,但代码里其实不是“一份数据走到底”,而是同一份后端数据在前端投影成了三种视图结构。
为什么一定要保留一份拍平列
这是我这次最大的一个体会。
一开始很容易想当然地把数据写成树:
[ |
这种结构渲染表头当然很舒服,但一旦你开始处理表体和排序,就会发现两个问题:
- 表体真正关心的是最终横向顺序,也就是叶子列顺序。
- 排序接口通常也不是吃一棵树,而是吃“某个叶子 / 某个组”的位置变化。
所以更稳的做法是:
- 永远保留一份拍平列
flatColumns - 一级表头只是
flatColumns的一种分组结果
这样一来,表头怎么渲染是一回事,真正的列顺序又是另一回事,两边不会互相绑死。
可拖动表格其实是三种拖拽
我把这个问题拆开以后,事情就简单很多了。所谓“可拖动表格”,本质上不是一个拖拽,而是三种不同的拖拽。
第一种:行拖动
这个是最简单的。
它的核心思路就是:
- 拖动的是
rowItems - 拿到被拖动行的
rowId - 根据
newIndex算出新的sort - 调后端排序接口
- 刷新整张表
伪代码大概是这样:
async function onRowUpdate({ oldIndex, newIndex }) { |
这部分基本没什么悬念。因为“行”在视觉上和数据上通常都是一一对应的。
第二种:一级表头拖动
一级表头拖动就开始有意思了。
页面上你拖的是一个“组”,比如:
- Standalone A
- Grouped B
- Empty Group C
但在代码里,这个组本身通常不是真正参与表体渲染的最小单位。真正渲染表体的是叶子列。
比如一个一级表头下面有两个二级表头:
Grouped B |
那么它在拍平列里其实对应两列。
也就是说,当你拖动“一级表头”时,实际上是在移动一段连续的叶子列区间。
这件事可以抽象成:
- 视图层拖的是
columnGroups - 数据层真正受影响的是
flatColumns
如果只在前端层面理解,很容易觉得“把组交换一下不就好了”。但如果后端接口需要的是:
groupIdtargetGroupIdbefore / after
那你就必须把这次拖拽翻译成排序协议。
我在 demo 里做得比较直白:
async function onGroupUpdate({ oldIndex, newIndex }) { |
这里有两个关键点:
第一,拖拽方向很重要
oldIndex < newIndex 说明是往右拖,往右拖的语义一般是“放到目标后面”。
反过来,往左拖通常是“放到目标前面”。
如果不把这个语义明确下来,后端就不知道你到底是要插前面还是插后面。
第二,一级表头排序和叶子列排序不是一个概念
一级表头改变位置以后,下面所有子列的相对顺序都应该一起跟着走。
所以后端真正维护的,往往不是某一列的顺序,而是整个分组的顺序。
这也是为什么一级表头拖动看起来是在拖一个 DOM,实际上却是在调整一整段数据的原因。
第三种:二级表头拖动
二级表头拖动比一级更绕一点,因为它通常只发生在某个一级分组内部。
比如:
Grouped E |
这时拖动的不是整个组,而是组里的子项。
对应到数据层,移动的就是:
- 某个
leafId - 在同组内的
childOrder
伪代码大概是这样:
async function onChildUpdate(groupId, { oldIndex, newIndex }) { |
看起来跟一级表头差不多,但难点不一样。
一级表头拖的是“组”,二级表头拖的是“叶子列”。
一级表头关心的是 groupOrder,二级表头关心的是 childOrder。
如果这两个概念在代码里没有分开,后面很容易出现一类经典 bug:表头看起来动了,但表体顺序没对;或者表体顺序对了,但二级表头仍然错位。
这类实现里,最难的不是拖,而是映射
如果让我把整件事压缩成一句话,那就是:
多级表头拖拽最难的地方,是把视图结构映射成后端排序协议。
这里面有两层映射。
第一层映射:从后端数据到前端视图
后端给你的是:
- 行列表
- 拍平列
前端要自己推导出:
- 一级表头
- 二级表头
- 第三行元信息
- 表体单元格
这个过程本质上是在做一次“视图建模”。
如果建模不稳,后面每多一种交互,代码都会更乱。
第二层映射:从拖拽结果到排序接口
用户拖的是视觉上的结构:
- 一个一级表头块
- 一个二级表头子项
- 一整行
但后端吃的往往不是这个视图结构,而是:
- 哪个组移动到哪个组前后
- 哪个叶子列移动到哪个叶子列前后
- 哪个行 id 更新成哪个排序号
所以拖拽事件真正干的事不是“改 DOM”,而是“把用户意图翻译成排序参数”。
这也是为什么我现在看这类代码,第一眼不会先看模板,而是先看:
- 数据怎么分层
- 排序接口吃什么参数
- 拖完以后是本地重排还是重新拉接口
这些决定了整份实现到底是不是稳的。
为什么我最后更认同“后端排序 + 前端回刷”
这种表格完全可以做成本地乐观更新:前端先把数组顺序改掉,再慢慢同步后端。
但如果你的场景里同时存在:
- 一级表头
- 二级表头
- 行
- 单元格绑定关系
那本地乐观更新的复杂度会迅速上升。
因为你不只是要改一个数组,而是要同时保证:
- 分组结构正确
- 叶子列顺序正确
- 表体列映射正确
- 勾选状态不串位
相比之下,“后端排序 + 前端回刷”虽然看起来笨一点,但有两个很大的优点:
- 前端最终状态永远以接口返回为准,不容易越改越漂。
- 拖拽逻辑可以收敛成“计算参数 + 调接口”,而不是“自己维护一套复杂状态机”。
所以在 demo 里,我保留了这条完整链路:
拖拽 |
这一步其实不是为了“模拟后端”,而是为了更真实地反映这类业务组件的实现方式。
一个很实用的经验:先做抽象 demo,再回头读业务代码
这次我最大的收获,不是“我会写这个表格了”,而是一个方法论。
遇到这种复杂组件时,直接扎进业务代码里,经常会被这些东西干扰:
- 权限判断
- 接口字段命名
- 特殊类型分支
- 隐藏、禁用、冻结列
- 业务规则
这些都是真问题,但它们会掩盖真正的主干。
如果先把问题抽象成一个小 demo,只保留:
- 行
- 拍平列
- 一级分组
- 二级子项
- 排序接口
那么整个实现骨架会一下子露出来。
等你再回头看业务代码时,很多原本觉得绕的地方,其实就只是“在这条主干上叠了很多业务条件”。
最后总结一下
关于这种可拖动表格,我现在的理解是这样的:
第一,它不是一个“拖拽组件问题”,而是一个“数据建模问题”。
第二,一级表头、二级表头、行,看起来都叫拖拽,但本质上是三种不同的排序行为。
第三,多级表头之所以容易把人绕晕,不是因为 DOM 长得复杂,而是因为“视图上拖的是块,数据上改的是排序协议”。
第四,如果后端本身就是排序真相源,那前端不要太执着于自己维护最终顺序,很多时候回刷反而更稳。
如果以后我再遇到类似的需求,我大概率还是会先做一个脱离业务词汇的小 demo,把结构和映射关系想明白,再决定要不要把具体业务往里填。
因为一旦这层想清楚了,后面的代码虽然还不算简单,但至少不会再是黑盒了。