element-ui表格拖拽排序:行拖拽与列拖拽的完整实现方案
发布时间:2026/9/30 5:22:02来源:尧图网络
表格拖拽排序听起来不算什么大功能但真到了 element-ui 上面就是另一回事。我去年做一个后台管理系统的配置中心模块产品提了两个需求一是列表行的展示顺序要允许管理员手动拖拽调整二是某些列在列表里要能拖动换位置还得把顺序存到服务端。当时项目用的正好是 Vue 2 element-ui 2.xel-table 功能再强官方也没给拖拽这个能力查了一圈 issue官方态度基本是需求太业务化建议自己实现。这篇文章就把我这套行、列拖拽的实现方案完整梳理一遍从核心思路到踩坑实录都有适合正在做 Vue element-ui 后台项目、被类似需求卡住的同学参考。1. 为什么 element-ui 的表格默认没有拖拽能力1.1 官方不做的原因恰恰是业务定制的机会先讲个背景。很多人第一次接到这种需求时第一反应是去 element-ui 文档里翻看看 el-table 是不是有类似 sortable 的属性。翻完会发现表格列排序指内容排序有表头拖拽调宽度有唯独拖拽调整行顺序和列顺序这种操作官方没有内置。官方没有内置并不是他们觉得不需要。你去官方仓库的 issue 区搜一遍就能看到这个问题被提过很多次维护者的回答大意是拖拽排序的交互方式和数据更新策略因业务差异太大组件库不适合做统一的默认行为否则会有一堆定制 case 把组件库变成一个大杂烩。这个理由我是认同的——行拖拽要拖手柄还是整行拖完是本地排序还是即时请求接口跨分页怎么处理树形数据能不能拖到别的层级这些问题的答案在不同项目里完全不一样内置了反而难用。所以社区的标准做法是element-ui 管表格渲染拖拽能力交给 sortablejs 这类成熟的拖拽库中间的数据更新逻辑由我们自己写。这个组合方案也是目前前端社区里最主流的做法Vue 3 Element Plus 时代甚至有人专门封装了拖拽表格组件本质上还是这套思路。1.2 拖拽库选型sortablejs 依然是首选实现拖拽效果有三条路可以走自己写原生 mouse 事件处理从 mousedown 到 mousemove 再到 mouseup处理临界判断、动画、touch 事件、边界位移一套下来至少几百行还容易写出 bug。用 vuedraggable它是 sortablejs 的 Vue 2 封装提供了 draggable 组件适合拖动列表元素。直接用 sortablejs通过 DOM 选择器绑定到表格的具体区域自由度最高不侵入表格的渲染方式。我最终选择的是第三种直接用 sortablejs。原因很简单vuedraggable 强依赖 v-model 的数组和它的组件结构用在 element-ui 的 el-table-column 结构里反而不顺手而 sortablejs 本身就是一个不关心数据层的 DOM 拖拽引擎我只需要告诉它哪些区域可以拖、拖动动画多快、拖完回调什么剩下的事情它全包了。sortablejs 对 touch 端支持也不错移动端后台用起来不用额外做兼容。提示sortablejs 目前最新版本是 1.15.x支持 npm 安装。项目里如果用了 Vue 2不需要特意装 vuedraggable 那层封装直接用原生 sortablejs 配 el-table 反而最稳。2. 行拖拽的核心思路不与 DOM 较劲直接操作数据源2.1 为什么不建议去移动 tr 节点接到行拖拽需求后最容易想到的方案是拖动时把表格里对应的 tr 节点移动位置。听起来很直观但实际操作会发现这条路基本走不通。el-table 内部对行的渲染是有自己一套机制的它会把 data 数组里的每条数据映射成一个 row 对象再根据列配置把每个单元格渲染出来。你手动去移动 DOM 里的 tr表格内部的数据索引并没有变化一旦执行任何触发重渲染的操作比如某个单元格状态变化、调用 doLayout、甚至只是 hover 样式引起的局部刷新DOM 结构会立刻按 data 数组的顺序重新生成你拖动后的结果就回弹了。正确做法是拖拽交互只负责告诉我们在数据层面哪一行被拖到了哪个位置然后我们去修改 data 数组的顺序最后让 Vue 的响应式机制去驱动表格重新渲染。换个说法就是——拖动的时候让 sortablejs 去产生用户看到的视觉效果拖完的一瞬间我们把真实的数据顺序改掉。这也是我在这篇文章里最想强调的一个设计原则让数据成为唯一事实来源DOM 只是数据的投影。2.2 最小可用实现跑通一次行拖拽第一步安装依赖npm install sortablejs第二步给 el-table 加上一个关键属性 row-key。这步很多人会忽略但没有它拖拽后下拉框、展开行、选中状态全部会错乱。template el-table reftable :datatableData row-keyid border el-table-column width60 label拖拽 template slot-scopescope span classdrag-handle⠿/span /template /el-table-column el-table-column propname label名称 / el-table-column propsortOrder label排序值 / /el-table /template注意我在第一列放了一个拖拽手柄并给它加了一个 classdrag-handle。之所以不把整行都设置为可拖拽区域是因为表格里经常有按钮、链接、复选框等交互元素如果整行都能拖用户想点按钮却误触发拖拽的概率会非常高。手柄可以限制拖拽触发区域也让交互上有明确的心理暗示。第三步在组件加载完成后初始化 sortableimport Sortable from sortablejs export default { data() { return { tableData: [], } }, mounted() { this.$nextTick(() { this.initRowSortable() }) }, methods: { initRowSortable() { const tableEl this.$refs.table.$el const tbody tableEl.querySelector(.el-table__body-wrapper tbody) const self this Sortable.create(tbody, { handle: .drag-handle, animation: 150, ghostClass: sortable-ghost, onEnd({ oldIndex, newIndex }) { self.handleRowSort(oldIndex, newIndex) }, }) }, handleRowSort(oldIndex, newIndex) { const list this.tableData const movedItem list.splice(oldIndex, 1)[0] list.splice(newIndex, 0, movedItem) }, }, }这段代码里有两个细节值得说一下。第一个是animation: 150。这个参数表示拖动时其他行让位的动画时长单位为毫秒。如果你不设置动画拖动时其他行会瞬间跳到目标位置交互会显得非常生硬设成 150 左右既能看出平滑过渡又不会觉得拖泥带水。第二个是row-keyid的作用。el-table 在渲染行时会用 row-key 作为每行的唯一标识来维持行的状态。比如行内某个单元格有输入框你拖拽后数据更新了表格重新渲染如果没 row-keyVue 在 diff 过程中可能复用错误的行组件实例导致输入框、展开状态、选中状态全部错乱。加了 row-key 之后表格能正确识别每一行的身份状态就能稳下来。第三个细节是this.$nextTick的必要性。mounted 里直接调this.$refs.table.$el此刻表格的 DOM 已经渲染出来了但有些项目的表格会放在弹窗里、v-if 条件里这时候 DOM 不一定可用。保险做法是初始化之前先确认节点存在再执行Sortable.create。2.3 onEnd 回调为什么拖完还要手动改数据有些第一次接触 sortablejs 的人会有一个误解以为 sortablejs 自带数据排序能力。其实它只负责 DOM 层面的排序拖完以后 data 数组还是原来的顺序。如果只在 onEnd 里打印 oldIndex 和 newIndex不做数据操作松手后 Vue 会按照未变的 data 数组重新渲染行会弹回原位这就是很多人说的拖拽回弹。正确的顺序是先把 oldIndex 对应的行从数组里取出来再插入到 newIndex 的位置。要注意必须使用splice这种原地修改数组的方法而不是重新给tableData赋一个新数组引用否则 el-table 也可能因为 data 引用变化而触发一些额外的重绘。splice 修改后Vue 的响应式系统能检测到数组变化并更新视图。处理完本地排序后下一步通常是持久化。比较常见的做法是在 onEnd 里把当前数组的 id 列表按顺序组装成{ id: newIndex }或[id1, id2, id3]然后通过接口发给后端。发送时机有两种拖完立即请求或者提供一个保存排序按钮。两者各有适用场景如果排序操作很频繁且后端接口性能足够建议即时保存如果排序是一种配置行为用户需要多次调整到满意后再提交就用手动保存避免频繁请求把服务器打崩。async handleRowSort(oldIndex, newIndex) { const list this.tableData const movedItem list.splice(oldIndex, 1)[0] list.splice(newIndex, 0, movedItem) try { const orderIds list.map((item) item.id) await this.$http.post(/api/table/sort, { orderIds }) this.$message.success(排序已保存) } catch (e) { this.$message.error(排序保存失败) // 这里建议把顺序回滚到保存前的状态 } }如果保存失败要做回滚处理。最简单的回滚方式是保存前深拷贝一份原数组失败后把拷贝赋值回去并刷新视图。虽然这种场景不多但一旦遇到接口超时你的表格顺序和用户看到的不一致排查起来很痛苦。3. 列拖拽实现表头交互与列序还原的完整方案3.1 把列配置数组化是列拖拽的前提行拖拽折腾的是 data 数组列拖拽折腾的则是列配置数组。el-table 的列是通过一组el-table-column标签声明的如果你把这些标签硬编码在 template 里想在运行时改变它们的顺序是很麻烦的因为标签的顺序在编译期就定了。所以要实现列拖拽必须把列配置从模板中抽离出来变成一个 JavaScript 数组。每一个数组项对应一列包含列名、字段名、宽度、是否固定、是否排序、是否有自定义插槽等所有信息。然后在 template 里用 v-for 去渲染这些列。el-table reftable :datatableData row-keyid border el-table-column v-forcol in columns :keycol.prop :propcol.prop :labelcol.label :widthcol.width :fixedcol.fixed :sortablecol.sortable :aligncol.align !-- 如果是操作列可以按 col.type 判断渲染不同内容 -- /el-table-column /el-table对应的 columns 数据columns: [ { prop: name, label: 名称, width: 200 }, { prop: category, label: 分类, width: 150 }, { prop: amount, label: 金额, align: right }, { prop: status, label: 状态, width: 120 }, { type: operation, label: 操作, width: 180 }, ]这步做好之后列的顺序就完全由 columns 数组决定了。想调整列顺序只需要改变 columns 数组的排列表格会自动重新渲染。这是列拖拽实现的地基。3.2 sortable 绑定表头拖完只改数组顺序列拖拽的 sortable 目标不再是 tbody而是表头区域。element-ui 2.x 的表格头部结构是.el-table__header-wrapper下的table thead我们需要把整个 thead或者 thead 里的 tr作为拖拽容器。initColumnSortable() { const tableEl this.$refs.table.$el const headerTr tableEl.querySelector(.el-table__header-wrapper thead tr) const self this Sortable.create(headerTr, { animation: 150, filter: .el-table__cell, onEnd({ oldIndex, newIndex }) { self.handleColumnSort(oldIndex, newIndex) }, }) }不过这里有个前提如果列配置里带有fixed固定列element-ui 会额外渲染一个固定层的表头结构是.el-table__fixed或.el-table__fixed-right。直接绑定在.el-table__header-wrapper上只能拖到中间滚动区的列固定列完全绑定不上所以上面的代码只适用于没有任何固定列的场景。有固定列的场景我放到下一章讲。onEnd 里的逻辑跟行拖拽很像也是 splice 移动数组handleColumnSort(oldIndex, newIndex) { const list this.columns const movedCol list.splice(oldIndex, 1)[0] list.splice(newIndex, 0, movedCol) }这里有一个需要注意的地方oldIndex是相对于拖拽容器里所有 th 的索引而 columns 数组的索引也是从 0 开始。如果表格里所有列都来自 columns索引就对得上。但如果有些列没有列配置比如多级表头索引关系会错位我遇到过项目里在表头最前面手动加了一列复选框结果 onEnd 得到的索引和 columns 数组差了 1导致拖完总是不对位。解决办法是在绑定时对拖拽子项做一下过滤或者统一数据处理保证表头所有 th 和 columns 一一对应。提示列拖拽只改变列的显示顺序不会改变表格数据对象中字段的名称和值。用户的感受是这一列移到那边去了但对程序来说只是 columns 数组的顺序变化数据行里的属性完全不动。3.3 自定义列模板的处理思路列配置数组化之后最常见的问题是每个 el-table-column 中间的内容可能是完全不同的自定义结构。比如第一列是缩略图第二列是按钮组第三列是状态 Tag如果用简单的 v-for 渲染每个列里的插槽内容该怎么控制我目前的方案是用type字段区分列类型然后通过具名插槽来渲染。简单点说就是不把每列的内容写死在 template 里而是给需要自定义的列加一个 slot 名字el-table-column v-forcol in columns :keycol.prop :labelcol.label :propcol.prop :widthcol.width template slot-scopescope template v-ifcol.type image img :srcscope.row.thumbnail / /template template v-else-ifcol.type status el-tag :typescope.row.status 1 ? success : info {{ scope.row.status 1 ? 启用 : 停用 }} /el-tag /template template v-else-ifcol.type operation el-button sizemini clickhandleEdit(scope.row)编辑/el-button /template template v-else {{ scope.row[col.prop] }} /template /template /el-table-column这样做的代价是模板里的判断会多一些但换来的是列配置可以完全由数据驱动业务想要新增一种列类型时只需要在模板里加一个分支。对后台系统这种经常要动态调整字段展示的场景来说这个代价完全值得。4. 固定列、树形表格这类特殊场景下的拖拽兼容处理4.1 固定列为什么不能直接拖el-table 的多层表头机制element-ui 表格一旦设置fixed固定列就不是一张简单的 table 了。它会渲染成三部分左侧固定层.el-table__fixed、主体滚动层.el-table__body-wrapper、右侧固定层.el-table__fixed-right。左侧固定层的表头和主体层的表头是高仿的真实表头主体层的表头才是完整的一行 th。这个结构导致列拖拽如果只绑定主体表头就会出现几种奇怪现象想拖最左侧固定列发现它根本不响应拖拽想从主体列中把某一列拖到固定列旁边拖过去之后左右两层的数据列对应关系直接紊乱甚至表头对不上。我踩过这种坑之后的处理方案是拖拽时优先判断当前列是否固定列如果是就排除在拖拽范围外一定要支持固定列调整顺序时就同步操作多维数组并调用doLayout强制刷新。但坦白讲同时支持固定列和非固定列互相穿插拖拽复杂度和收益在大多数业务里不成正比所以我更推荐的做法是直接禁止固定列参与拖拽把固定列和可拖拽列分成两个组。具体实现可以参考 sortablejs 的filter参数和固定列的 class。给固定列设置一个标志 class然后在初始化时通过 filter 排除Sortable.create(headerTr, { animation: 150, filter: .is-fixed-col, // 固定列不参与拖拽 onEnd({ oldIndex, newIndex, item }) { // ... }, })在配置 columns 时固定列对应的 th 上加一个is-fixed-col类名实现时可以在el-table-column上配合header-cell-class-name统一注入。4.2 树形表格的层级问题拖拽不等于平等的行换位树形数据的表格行之间有父子层级关系。如果直接把行拖拽当成扁平数组处理子行有可能被拖到父行上方形成嵌套错乱。我的建议是树形表格的行拖拽只允许限制在同一父级下onEnd时通过当前行的 parentId 做一次判断如果 newIndex 位置的行跟当前行的 parentId 不一致就把排序操作回滚并通过$message提醒用户不能跨层级移动。如果业务真的需要跨层级拖拽那就要在上层父亲的 children 数组里做 splice 操作复杂程度会上升一个档需要同时维护节点展开状态和层级缩进处理起来非常考验边界情况。我目前遇到的需求基本都停留在同层级排序层面所以这里只提醒大家提前想清楚需不需要跨层级免得做到一半推倒重来。4.3 分页表格拖拽完老数据又回来了怎么办分页场景和树形场景完全不同。我们项目里的配置表格是分页加载的每页 20 条如果用户把第一页的某一行拖到最后换页之后再翻回来发现顺序没变因为分页查询服务端根本不知道你要调整跨页顺序。这在产品里通常要单独定义规则要么拖拽只针对当前页内排序要么提供一个全局排序模式把所有数据一次性拉到前端排完再整体提交。从实现角度看当前页内排序和普通不分页表格没有任何区别。全局排序模式则需要后端配合提供一个不分页的全量数据接口表格临时切换成不分页模式铺满所有数据后允许拖拽最后把完整顺序提交。这个方案在数据量上千条时会有性能压力我自己会在全局排序前做一个数量校验超过 500 条就提示用户用其他批量排序工具。5. 我踩过的几个典型坑从回弹到数据错位5.1 拖拽后行内容错乱问题出在 row-key我在行拖拽最开始踩的第一个坑就是没有设 row-key。当时表格里有一个详情展开行我拖动排序后发现展开行明明点在 A 行上展开出来的却是 B 行的内容。排查过程花了很久最后发现是因为没有唯一的行标识Vue 的 diff 过程把组件实例复用到错误的行上导致行内所有状态全部张冠李戴。加上 row-key 之后问题立刻消失。如果你现在也遇到类似症状先检查两件事el-table 是否配置了row-keydata 数组里的每条数据是否都有一个稳定唯一的字段。另外如果你的数据在异步请求后才出现row-key 必须在数据加载前就配置好否则表格不是实时感知的。5.2 拖拽后表格某列宽度/展开状态错乱这个坑出现在我处理列拖拽的第二天。列顺序在 columns 数组里调整后表格本身是重新渲染了但有几个列宽发生了异常有的列变窄有的列被挤压。原因在于 el-table 同一列的宽度信息是依赖列组件实例状态来恢复的列顺序变了但实例缓存没有完全重建。解决办法是给 el-table 加一个:key当 columns 顺序变化后强制表格整体重新渲染一周用下来没有再出现列宽错乱。要注意的是强制重建表格会有一定性能开销如果你的表格几秒钟才刷新一次完全无所谓但如果列拖拽后紧接着要高频操作建议只在拖拽结束后触发一次。el-table reftable :datatableData :keytableKey row-keyid 拖拽结束后执行this.tableKeyhandleColumnSort(oldIndex, newIndex) { const list this.columns const movedCol list.splice(oldIndex, 1)[0] list.splice(newIndex, 0, movedCol) this.tableKey }5.3 sortable 实例被销毁或失效element-ui 表格在数据更新后可能重新渲染 DOMsortable 实例会让位于新 DOM 结构而失效。比如你拖拽修改了表格数据渲染层生成新的节点sortable 的事件绑定还在旧节点上第二次拖拽时什么都不发生。处理方式很粗暴在需要刷新排序功能的地方先销毁 sortable 实例等 DOM 更新后再重建。我会把初始化逻辑抽成一个方法方便在数据变更后调用methods: { refreshSortable() { if (this.rowSortable) { this.rowSortable.destroy() } this.rowSortable null this.$nextTick(() { this.initRowSortable() }) }, }把Sortable.create的返回值赋给组件实例上的属性之后随时可以销毁重建。这类实例生命周期跟随目标 DOM的问题是使用 sortablejs 最常见的坑一定要留好销毁和重建的入口。5.4 拖拽回弹不一定是数据问题如果你已经执行了 splice 改数据但拖完还是回弹很可能问题出在 sortable 的目标选择器上。我在一个项目里遇到过给.el-table__body-wrapper tbody绑定的 sortable拖拽成功但因为这个 el-table 同时配置了max-heightelement-ui 会渲染成两个 body 区域一个是表头下面的可滚动区域一个是底部新增的一行暂无数据。由于我的 tbody 选择器选中的是第一个滚动区域的 tbody而表格渲染行的高宽计算在拖拽期间会触发局部重绘松手瞬间 DOM 顺序被重置于是回弹。排查方法是在 onEnd 里先不执行 splice只打印 oldIndex 和 newIndex然后观察松手后 DOM 是否自动恢复。如果自动恢复说明目标选错了或存在多个 tbody如果 DOM 不恢复说明是数据层没变导致的渲染回弹。把这个分水岭先判断清楚排错效率会高很多。5.5 表头拖拽时文本选中干扰表头拖拽时如果用户从头到尾快速划过会触发文本选中浏览器默认的选中蓝色高亮会很不美观而且可能干扰鼠标事件。解决办法是在 drag 容器上加上user-select: none或在 sortable 初始化时设置属性Sortable.create(headerTr, { animation: 150, draggable: .el-table__header-wrapper th, sort: true, onStart() { document.body.style.cursor grabbing document.body.style.userSelect none }, onEnd() { document.body.style.cursor document.body.style.userSelect }, })这段代码虽然简单但从体验角度讲非常加分。很多人在实现拖拽时只顾功能正常忽略鼠标样式、文本选中这类细节最后拿给测试走一遍准会被提体验不够自然的 bug。6. 性能、样式和服务端持久化的一点补充6.1 ghostClass 和拖拽占位样式sortablejs 的 ghostClass 可以指定一个 class拖动时当前拖拽的那个行会加上这个 class。这个 class 常用来做视觉反馈比如半透明、边框高亮等。我自己的样式一般长这样.sortable-ghost { opacity: 0.4; background: #f5f7fa; }行拖拽时ghost 行半透明其他行通过动画让位交互看起来就很完整了。如果还想更高级一点可以配合chosenClass设置当前选中行的样式设置dragClass控制被拖拽对象的悬浮样式。这些样式建议放在全局样式文件里不要写在 scoped 样式下因为 sortablejs 操作的是表格内部 DOMscoped 样式的作用域不一定能覆盖到。6.2 大数据量下的渲染与拖拽性能表格数据量超过几百行时拖拽过程中的重渲染可能带来明显卡顿。element-ui 表格本身在大数据下就不算流畅拖拽排序这种高频 DOM 操作会放大问题。我的实践是如果表格数据达到 500 行以上先把表格切换为只读模式拖拽期间不响应其他重渲染操作如果数据上千行建议使用虚拟滚动或分页策略不要把所有数据一次性铺开拖拽。不要在小数据项目里优化大数据但在大数据项目里提前预留好分页或分批接口是成本最低的保险方案。6.3 把排序结果交给后端时的字段设计最后说一下服务端持久化。实际项目中不要前端把一整个排序后的对象数组 POST 给后端这样既传输冗余又容易因为字段变动而出错。更稳妥的做法是只传一个有序的 id 列表后端根据 id 列表顺序更新对应的 sort_order 字段。如果你有多个维度的排序比如行的排列顺序、列的显示顺序建议拆成两个接口各自落库互不干扰。列拖拽的持久化和行拖拽类似只不过存的是列配置的 key 顺序比如[name, category, amount, status]后端把它存到用户偏好表。每次进入页面时先请求用户偏好再按照偏好顺序渲染 columns这样用户拖好的列顺序下次打开还是他习惯的样子。弄完这套行、列拖拽之后我最大的感受是element-ui 表格并不是一个不可扩展的黑盒关键在于改变思路——把拖拽看成改变数据顺序的交互手段而不是移动 DOM 的行为。sortablejs 负责交互视觉数据层负责最终事实两层分开之后实现一点都不复杂。你手头如果正好有这个需求可以先把第 2 章和第 3 章的最小代码跑通再去处理固定列和树形表格这些边界情况会比一上来就想要一个万能组件稳妥得多。该拆的分层拆好该加的 row-key 加上这个需求落地其实很快。
网站建设高端定制企业官网