
做后台管理系统的朋友应该都遇到过这种需求列表要能手动排序。拿 element-ui 的 table 来说官方组件提供了数据展示、排序、筛选、分页这些能力拖拽排序却一直没有内置支持。我当时接到这个需求第一反应是“这还不简单”结果真上手发现坑比想象中多。折腾了一轮之后我沉淀出一套基于 sortablejs 配合 el-table 实现“行拖拽 列拖拽”的通用方案代码可以直接抄今天把这套东西完整拆开讲一遍。这篇内容适合两类人一类是后台管理系统开发需要在表格上做自定义排序另一类是正在做中后台配置平台需要让用户自由调整字段顺序或行顺序。先说明一个结论行拖拽和列拖拽虽然都叫拖拽但实现思路完全不一样。行拖拽是“改数据数组顺序”列拖拽是“改列配置数组顺序”理清这一点后面的代码就不会迷路。1. 从需求到方案表格拖拽到底该怎么做1.1 拖拽的本质是“数据重排”不是“移动 DOM”很多新手拿到拖拽需求第一反应是操作 DOM把某个 tr 或者 th 挪到另一个位置。放进 Vue element-ui 的场景这就是典型的绕远路。el-table 是数据驱动渲染的:datatableData数组是什么顺序表格行就是什么顺序columns数组是什么顺序列就是什么顺序。所以拖拽要做的核心事情只有一件拖拽结束的时候把对应的数据数组重新排序Vue 检测到数组变化自动重新渲染表格。拿行拖拽举例拖拽前tableData是[A, B, C]把第二行拖到第一行结束后数组变成[B, A, C]表格自然就变了。拖拽过程中的 CSS 动画、占位样式都是“视觉效果”真正落地的逻辑就是一次数组重排。这也解释了为什么 sortablejs 这类库能和 el-table 配合默契它在拖拽过程中操作 DOM但拖拽结束后我们只关心它的onEnd回调里给的oldIndex和newIndex拿这两个索引去重排数组完事。1.2 为什么 element-ui 不原生支持拖拽这个问题以前不少人提过 issue官方一直没做核心原因是表格组件结构太复杂了不是不想做是做起来容易翻车。el-table 的 DOM 结构和普通列表完全不一样它分成表头区header、表体区body、表尾区footer只有开启合计行才有而且表头区和表体区是分开渲染的两个table。更麻烦的是一旦启用固定列fixed属性底层会额外渲染左侧固定表、右侧固定表两套 DOM再加上主表格页面里实际有三个表格在同步显示同一份数据。这种情况下拖拽库面对的是一堆“看起来重复”的 DOM比如拖拽主表体的某一列固定列的相同内容还挂在另一个表格里。官方如果要做必须同时处理主表、左固定表、右固定表三份 DOM 的联动还要考虑合并单元格、树形展开、合计行这些特性任何一项处理不好就是 bug。所以社区里普遍的做法是引入 sortablejs 或类库手动绑定表格的 DOM 容器再在回调里更新数据。1.3 主流方案的横向对比当前做 el-table 拖拽市面上主要有四条路我整理了一个对比表格方便大家选型。方案优点缺点适用场景sortablejs 手动绑定 DOM灵活、成熟、体积不大社区案例多需要自己适配 el-table 的各种 DOM 细节大多数中后台项目本文主推vuedraggable 自定义列表组件化封装API 友好和 el-table 原生组件配合不直接要魔改行模板希望用拖拽列表语义重新组织数据的场景umy-ui 的 u-table原生支持行/列拖拽、虚拟滚动引入新组件库迁移成本高新项目想开箱即用HTML5 原生拖拽事件无第三方依赖排序逻辑、触屏兼容、占位样式全要自己写极简单场景且明确不需要兼容触屏我最后选了 sortablejs原因很简单它支持handle指定拖拽手柄、支持ghostClass定制占位样式、支持回调事件拿索引而且不挑框架。我当时项目里已经用 element-ui 了再引入 umy-ui 整个组件库替换成本太高vuedraggable 对表格这种复杂 DOM 的控制力又不够细所以 sortablejs 是最务实的选项。2. 行拖拽实战Sortable 绑定 tbody2.1 最小可用实现先说行拖拽这是所有拖拽需求里最常用的。实现思路一句话用 Sortable 绑定 el-table 的表体 tbody拖拽结束后重排tableData。先安装依赖我在项目里用的是 sortablejs不是 Sortable 那个老包npm install sortablejs模板部分我用一个拖拽手柄列来触发拖拽这样可以避免用户选中文字或者点击其他按钮时误触发。同时给 el-table 加上row-key这个属性是必须的后面会详细解释。template el-table reftable :datatableData row-keyid border el-table-column label排序 width80 aligncenter template slot-scopescope span classdrag-handle stylecursor: grab;#8942;#8942;/span /template /el-table-column el-table-column propname label名称 / el-table-column propprice label价格 / /el-table /templateJS 部分核心代码就几行import Sortable from sortablejs export default { data() { return { tableData: [ { id: 1, name: 苹果, price: 5 }, { id: 2, name: 香蕉, price: 3 }, { id: 3, name: 橘子, price: 4 } ] } }, mounted() { this.initRowDrag() }, methods: { initRowDrag() { this.$nextTick(() { const tbody this.$refs.table.$el.querySelector(.el-table__body-wrapper tbody) this.rowSortable Sortable.create(tbody, { handle: .drag-handle, animation: 150, ghostClass: sortable-ghost, onEnd: ({ oldIndex, newIndex }) { if (oldIndex newIndex) return const arr [...this.tableData] const [moved] arr.splice(oldIndex, 1) arr.splice(newIndex, 0, moved) this.tableData arr } }) }) } } }这里有几个关键点每个都是真实踩坑后总结出来的第一Sortable.create(tbody, ...)的容器要选 tbody不要选el-table__body-wrapper。因为 Sortable 期望容器内的直接子元素是要排序的行如果选外层 div它的直接子元素是 table、tbody拖拽对象就对不上。第二handle: .drag-handle把拖拽范围限定在手柄列这是我最推荐的做法。我曾经试过整行拖拽结果表格里有按钮、输入框的场景一团乱鼠标点一下按钮就进入拖拽状态体验非常差。第三也是最容易踩坑的onEnd里的oldIndex和newIndex是 Sortable 基于 DOM 计算出的索引默认和tableData的下标是能对上号的。但如果你加了typeindex、typeselection、typeexpand这类列它们不会影响 tbody 里的 tr 数量所以索引依然对齐这个可以放心。2.2 占位样式与动画设置上面的代码能跑通之后你会发现拖拽过程中默认的占位效果非常丑一个半透明的行跟幽灵一样飘着。这时候需要给 Sortable 配几个视觉参数实际效果立竿见影。Sortable.create(tbody, { handle: .drag-handle, animation: 250, // 拖拽排序时其他行的移动动画时长单位毫秒 ghostClass: sortable-ghost, // 占位元素的 class chosenClass: sortable-chosen, // 选中的行 dragClass: sortable-drag, // 正在拖拽的行 forceFallback: true // 强制使用 fallback 模式占位效果更稳定 })配合一段 CSS占位行可以做成虚线边框加灰色背景视觉反馈很清晰.sortable-ghost { opacity: 0.4; background: #f0f9ff !important; } .sortable-drag { opacity: 0.9; } .sortable-chosen { background: #f0f9ff; }forceFallback: true这个配置容易被人忽略但实际很有用。默认 sortablejs 在桌面端用的是 HTML5 的拖拽 API拖拽时系统会生成一个默认的拖拽“快照”图片可能会盖住元素本身而且 ghost 占位的样式不稳定。开启forceFallback后库内部改为模拟拖拽占位元素和拖动元素都由我们自己定义的 class 控制视觉上明显更可控、更一致。2.3 onEnd 里的数据同步逻辑数据同步是整个行拖拽最容易写错的地方最常见的错误写法是直接拿原数组做 splice// 错误示例拖动两次之后索引就乱了 onEnd({ oldIndex, newIndex }) { const [moved] this.tableData.splice(oldIndex, 1) this.tableData.splice(newIndex, 0, moved) }看起来没毛病其实这里有坑。onEnd触发时Sortable 已经完成了 DOM 移动但 Vue 的响应式数组还没更新。虽然上面这段代码在多数场景能正常工作但如果你的tableData是对象数组且被多处引用直接修改原数组会导致 Vue 的依赖追踪出现偏差特别是页面同时存在多个排序相关组件时会出现 A 表格排序结果影响到 B 表格的诡异现象。推荐的做法是每次拖拽前复制一份原数组基于副本重排后整体赋值onEnd: ({ oldIndex, newIndex }) { if (oldIndex newIndex) return const arr this.tableData.slice() const [moved] arr.splice(oldIndex, 1) arr.splice(newIndex, 0, moved) this.tableData arr }这里slice()是浅拷贝够用了因为我们只调整数组元素的位置不修改元素本身。还有一个小问题行拖拽结束后el-table 的行高度或样式偶尔会错乱尤其是表格启用了固定列或者 row 高度不统一的时候。遇到这种情况在重排完数据之后补一句this.$nextTick(() { this.$refs.table.doLayout() })doLayout()是 el-table 暴露的实例方法作用是根据当前数据重新计算表格布局和列宽。几乎我遇到的拖拽后视觉异常90% 都是靠这一句搞定的。3. 列拖拽实战表头才是操作入口3.1 列配置化改造行拖拽相对简单因为行和tableData数组天然一一对应。列拖拽略有不同列不是数据而是配置。el-table 里的每一列是一个el-table-column组件如果直接写死在模板里拖拽后要动态增删模板节点非常别扭。所以做列拖拽的第一步是把列配置数组化。我把原来的模板改成 v-for 渲染el-table reftable :datatableData row-keyid border el-table-column v-forcol in columns :keycol.key :propcol.prop :labelcol.label :widthcol.width :min-widthcol.minWidth :fixedcol.fixed template slot-scopescope v-ifcol.slot slot :namecol.slot :rowscope.row/slot /template /el-table-column /el-table对应的 columns 数组长这样data() { return { columns: [ { key: name, prop: name, label: 名称, width: 160 }, { key: price, prop: price, label: 价格, width: 120 }, { key: address, prop: address, label: 地址, minWidth: 200 } ] } }这里有一个容易忽略的点key一定要给一个长期稳定且唯一的字段不能直接用prop。因为拖拽后 columns 数组顺序变了如果 key 恰好是索引会导致 Vue 复用组件时渲染错乱。3.2 绑定表头实现列排序列拖拽的操作入口是表头用户按住表头某一列左右拖动松手后整列移动位置。所以 Sortable 要绑定的容器是表头tr不是表体。mounted() { this.$nextTick(() { const headerTr this.$refs.table.$el.querySelector(.el-table__header-wrapper tr) this.columnSortable Sortable.create(headerTr, { animation: 150, draggable: th, ghostClass: sortable-ghost, onEnd: ({ oldIndex, newIndex }) { if (oldIndex newIndex) return const arr this.columns.slice() const [moved] arr.splice(oldIndex, 1) arr.splice(newIndex, 0, moved) this.columns arr this.$nextTick(() { this.$refs.table.doLayout() }) } }) }) }这里有一个重要细节表头 tr 里包含的 th 数量和 columns 数组长度不一定一致。如果列配置里有typeselection多选列或者typeindex序号列这些列不写在 columns 数组里但表头会渲染成独立的 th。此时 Sortable 给出的oldIndex和newIndex会比 columns 数组的下标多偏移一段直接拿来用就会发现排错位。处理方式有两种把 selection、index 这类特殊列也放进 columns 数组用字段区分类型渲染的时候判断。在初始化 Sortable 时排除特殊列让这些列在页面上固定不可拖拽。我在项目中用的是第二种更省事。给特殊列的 th 加一个自定义属性或者 class然后在初始化时通过draggable选择器过滤// 模板里给特殊列加 class el-table-column typeselection class-namefixed-col / // 初始化时排除 Sortable.create(headerTr, { draggable: th:not(.fixed-col), // ... })这样拖拽只对业务列生效多选列、序号列原地不动索引偏移问题也顺带解决了。3.3 列宽、固定列与 doLayout 的坑列拖拽比行拖拽更容易出现列宽错乱原因是 element-ui 的列宽方案比较复杂。当列有明确width时表格会根据 columns 数组的宽度总和计算表格总宽度当列只有min-width时列宽由内容撑开这种动态列宽对拖拽特别不友好。我之前遇到过一个场景项目里表格列用min-width: 120行内文字一长列宽就会被内容撑得很宽。拖拽后虽然 columns 数组重新排序了el-table 内部计算列宽的缓存却仍然引用旧列的宽度导致表头宽度和表体宽度错位看起来像是“表格裂开了”。解决思路是所有可拖拽的列都尽量设置固定width。如果实在需要自适应拖拽结束后必须调用doLayout()强制重新计算布局。另外如果项目里同时有固定列fixed: left或fixed: right列拖拽的复杂度会明显上升这点下一章单独讲。再补充一个小坑表头 th 里如果同时有排序图标、筛选图标拖拽的时候很容易误触这些交互。我遇到的效果是拖拽结束松手表格列按排序图标排序了或者筛选弹窗弹出来了。这是因为拖拽过程中鼠标经历了 mousedown、mousemove、mouseup而排序图标的 click 事件在 mouseup 时被触发。解决办法很简单给这些图标所在的区域设置pointer-events: none或者确认只在没有点击到图标本身的区域才开始拖拽后者的实现成本高。我更推荐在模板里给排序、筛选功能加一层 span 包裹并加上pointer-events: none把点击事件全部屏蔽在拖拽之外。4. 固定列、合并单元格、树形数据的高级坑4.1 固定列的“多表格”问题固定列是 element-ui table 的一个常用功能把操作列固定在右侧用户左右滚动数据列时操作列不动。但在拖拽场景下固定列会带来巨大的麻烦。前面讲过el-table 一旦启用固定列底层渲染时会同时存在三个表格 DOM主表格、左侧固定表格、右侧固定表格。Sortable 默认只能绑一个容器如果你只绑了主表格的表体或表头拖拽过程中固定列不会跟着动看起来就像“同一行数据被劈成了两半”。我的处理原则是拖拽行时固定列保持不动拖拽列的动态顺序时固定列单独配置不参与 Sortable 绑定。为什么这样处理因为行拖拽本质上改的是数据数组数组重排后固定列会基于响应式数据自动重新渲染所以并不需要“让固定列也拖起来”。真正需要关心的是列拖拽列顺序一变固定列和后边的数据列顺序全都要联动。我在项目里的实际做法是把 columns 数组拆成三段——左侧固定列数组、中间可拖拽列数组、右侧固定列数组。模板里分三段渲染Sortable 只绑定中间可拖拽列的表头 tr左右固定列完全不参与。data() { return { leftFixedColumns: [ { key: index, type: index, label: 序号, width: 60 } ], middleColumns: [ { key: name, prop: name, label: 名称, width: 160 }, { key: price, prop: price, label: 价格, width: 120 } ], rightFixedColumns: [ { key: operation, prop: operation, label: 操作, width: 180 } ] } }模板里对应渲染三组列el-table-column v-forcol in leftFixedColumns :keycol.key v-bindcol fixedleft / el-table-column v-forcol in middleColumns :keycol.key v-bindcol / el-table-column v-forcol in rightFixedColumns :keycol.key v-bindcol fixedright /拖拽只作用于中间列的容器。左、右固定列的顺序由代码硬编码不经过 Sortable从根源上避免了“固定列被拖走”的诡异问题。4.2 span-method 合并单元格的联动如果表格用了span-method合并单元格行拖拽的坑会集中爆发。el-table 的span-method是一个函数它根据行数据、列数据、行索引计算出哪个单元格要跨行或跨列。问题在于合并规则是基于位置的一旦行顺序改变旧的合并规则就不再适用。举个例子原来第三行和第四行的名称字段是合并的拖拽后这两行被拆开了表格仍然试图对旧位置执行合并渲染结果完全乱套。解决办法是span-method尽量不要依赖固定的行下标而是依赖数据本身的内容特征。比如在方法里根据业务字段判断哪些行应该合并而不是根据索引硬编码spanMethod({ row, column, rowIndex, columnIndex }) { if (column.property name) { if (row.name this.tableData[rowIndex - 1]?.name) { return { rowspan: 0, colspan: 0 } } // 计算连续相同 name 的数量 let count 1 for (let i rowIndex 1; i this.tableData.length; i) { if (this.tableData[i].name row.name) count else break } return { rowspan: count, colspan: 1 } } }这样每次拖拽后tableData重新排序span-method会基于新数据重新计算合并视觉上就正常了。所以用合并单元格的表格一定要把span-method写成“纯函数”也就是只依赖当前行和当前数据上下文不依赖缓存的中间变量。我见过有人把合并结果缓存在 data 里拖拽后忘了重新计算结果表格画成了一团排查半天才定位到。4.3 树形数据的同级排序el-table 支持树形数据通过row-key和children字段可以展开折叠。但一旦启用树形行拖拽的索引对不上号的问题就非常棘手。原因在于Sortable 看到的表体 tr 是“渲染后的全部行”包括展开的子行。而tableData是一个嵌套结构父节点和子节点不在一层数组里Sortable 给出的oldIndex是 DOM 层的索引跟tableData的数组下标根本无法一一对应。我在项目中试过处理树形拖拽拖拽开始前先把tableData摊平成带父路径的列表拖拽结束后再根据路径回填到树里。逻辑能通但代码量很大而且展开状态、层级缩进的处理很容易出边界 bug。后来我放弃了这种复杂度改用“同级排序”的策略只允许拖拽展开后的同一层级内的行子节点不支持跨层拖拽。实现方式是在 Sortable 的onMove回调里校验拖拽目标是否和原位置处于同一层级onMove({ dragged, related, willInsertAfter }) { const dragLevel dragged.dataset.level const targetLevel related.dataset.level return dragLevel targetLevel }同时在模板渲染每一行的时候把层级信息写到行元素上。更简单的替代方案是树形表格不用拖拽改成每行提供“上移/下移”按钮交互更明确实现也稳定很多。如果产品经理非得要拖拽体验建议先在原型上确认清楚跨层拖拽的真实场景再决定投入多少成本。4.4 常见问题速查表把前面几节踩过的坑整理成速查表方便以后定位问题现象根本原因解决方案拖拽行后数据顺序混乱缺少row-key或 key 不唯一给 el-table 加row-key并保证字段唯一拖拽中占位效果难看未配置 ghostClass 或未开 forceFallback配置ghostClass、chosenClass、dragClass开启forceFallback拖拽后表格列错位列 width 不固定或未重算布局列尽量设置固定width结束后调用doLayout()索引偏移导致排序错误含 selection/index 列或树形展开过滤特殊列或采用同级排序策略固定列裂开固定列参与拖拽绑定拆分配置数组固定列不绑定 Sortable合并单元格乱套span-method 缓存了旧位置改造成纯函数基于数据内容计算弹窗里的表格拖不了初始化时机太早DOM 未渲染在opened事件里初始化或用$nextTick确保 tbody 存在拖拽时页面跟着滚动默认滚动容器处理不当配置scroll相关参数指定滚动容器5. 体验优化与数据持久化5.1 拖拽过程的视觉反馈细节拖拽能不能用视觉反馈占一半。除了前面说的 ghost 占位样式还有几个细节很影响体感。第一个是动画时长。animation: 150表示拖拽时其他行自动让位的动画时长是 150ms项目里我试过 0、100、150、300最终觉得 150ms 最舒服太快了没有“让位”的提示太慢了显得拖泥带水。第二个是拖拽手柄的交互反馈。手柄列那一小块的鼠标样式我用cursor: grab拖拽过程中鼠标按住的瞬间没有视觉变化可以再加一个:active样式.drag-handle { cursor: grab; color: #c0c4cc; font-size: 16px; letter-spacing: 2px; user-select: none; display: inline-block; padding: 4px; } .drag-handle:active { cursor: grabbing; color: #409eff; }用户拖拽之前看到手柄是灰色的拖拽过程中变成蓝色这个细节能把“可拖拽”的暗示表达得很清楚。第三个是禁用状态。有的行在业务上不允许排序比如已经提交的订单行需要在Sortable配置里加上filter或者disabled处理。我给不可拖拽的行在 DOM 上加了类名.row-disabled然后在初始化时配置Sortable.create(tbody, { handle: .drag-handle, filter: .row-disabled, onMove(evt) { return !evt.related.classList.contains(row-disabled) } })这样既不能在禁用行上触发拖拽也不能把某行拖到禁用行的位置。5.2 顺序保存到后端拖拽排序改的只是前端数组最终要持久化必须把新顺序传给后端。最通用的接口设计是传一个有序的 ID 数组后端根据数组顺序批量更新排序字段。假设你的资源是菜单前端发送请求async saveSort() { const ids this.tableData.map(item item.id) await this.$http.put(/api/menu/sort, { ids }) }后端拿到 ids 后按数组顺序给每条记录的sort字段赋值一般用循环更新或 CASE WHEN 批量更新重点是要放在一个事务里避免执行到一半失败导致排序不完整。前端需要在拖拽结束时立即调这个接口吗我的建议是不要每次拖拽立刻请求。用户连续拖几行时会发十几个请求服务端压力大而且用户可能拖完又反悔拖回去。更稳妥的做法是拖拽结束后只更新前端状态等用户点击保存按钮或离开页面时再统一提交。如果产品上要求实时保存最好加一个节流或防抖比如拖拽结束后 500ms 再发请求并且做请求去重只发送最后一次排序结果。还有一点容易被忽略如果前端缓存了排序结果保存成功后要同步清理或确认。比如用户拖拽后刷新页面如果没有保存前端应该恢复到后端返回的排序否则会出现“明明拖拽了一刷新就还原”的困惑感。所以一般来说拖拽顺序变更后要有一个明显的保存入口或者用状态标识提醒用户“排序未保存”。5.3 触屏、弹窗与异步数据兼容触屏设备上拖拽排序是很多后台项目忽略的盲区。sortablejs 本身支持触屏事件但默认参数在手机上体验非常差手指一滑页面就开始拖拽根本分不清是滚动还是拖行。要解决这个问题需要配置delay和touchStartThresholdSortable.create(tbody, { delay: 200, // 手指按住超过 200ms 才进入拖拽状态 touchStartThreshold: 8, // 手指移动超过 8px 才判定为拖拽否则算滚动 })这样在触屏设备上用户滚动页面时不会误触拖拽长按之后才能正常拖动行。弹窗场景是另一个高频问题。如果表格在el-dialog里初始化 Sortable 时this.$refs.table可能还没渲染出来因为弹窗默认懒渲染。解决方案是把初始化逻辑放到弹窗的opened事件里el-dialog openedinitRowDrag el-table reftable.../el-table /el-dialog不要依赖mounted那个时机弹窗内容还没进 DOM。同样的道理适用于el-tabs里的懒加载页签切到对应页签后再初始化。最后说一下异步数据的场景。如果表格数据是接口返回的第一次渲染时tableData为空Sortable 绑定的 tbody 里没有任何行拖拽自然不会生效。这时候数据加载回来后要重新确认绑定或者在拿到数据后重新初始化一次。我习惯把初始化逻辑抽成一个方法在数据加载完成后的$nextTick里调用这样即使后续有重复初始化也不会出大问题。需要提一句的是Sortable.create在第二次调用时如果作用在同一个容器上可能会重复绑定所以我在方法开头加一层保护先销毁上一个实例再重建if (this.rowSortable) { this.rowSortable.destroy() this.rowSortable null }这样整个流程就健壮很多。最后再分享一个小技巧属于个人经验沉淀拖拽只做一件事就是改数据顺序其他都不要让拖拽去承担。我在最初实现时想在onEnd里顺便改个状态、发个请求、弹个提示结果每次拖动动效都卡顿排查很久发现不是拖拽本身的问题而是回调里做的多余操作触发了 Vue 的大范围更新。把拖拽回调保持精简数据重排之后让 Vue 自己完成渲染视觉表现是最流畅的。等基础功能稳定了要加什么功能、要做什么优化都往这个稳定版本上加效率会高很多。