
做后台管理系统这些年element-ui 的 table 是绕不开的核心组件。列表、排序、筛选、分页、批量操作几乎每个项目都要写一遍。但真到了用户体验阶段“拖拽调整行顺序”“拖拽调整列顺序”这类需求就冒出来了而且 element-ui 官方一直没给出现成的拖拽方案社区里也没有一套能无脑抄的标准代码。我前前后后在四五个项目里踩过一遍把行拖拽和列拖拽都摸透了这里把完整思路和代码整理出来里面有可以直接照搬的部分也有必须留意的坑。这篇文章主要面向 Vue2 element-ui 的开发者如果你在用 Vue3 的 element-plus代码思路也通用只是 DOM 类名和部分生命周期要换一下。内容会比较长核心是两套实现行拖拽用 sortablejs 绑定表格体列拖拽用配置驱动的 columns 数组加表头拖拽最后会补充原生拖放方案的参考实现以及我实际项目中遇到的一堆奇葩问题。1. 动手前的需求拆解与方案选型1.1 行拖拽的几种实现路径先说结论行拖拽优先选择 sortablejs 这个库不要自己用 HTML5 拖放 API 从零造轮子除非你的需求简单到只拖一两行并且不在乎动画和移动端体验。sortablejs 能直接作用于一个 DOM 容器让容器内部的子元素支持通过拖拽改变顺序。对于 element-ui 的 table数据行最终渲染在.el-table__body-wrapper下的一个tbody里每一行对应一个tr所以理论上只要把 sortablejs 绑定到这个tbody上就能实现行拖拽。这个方案的收益非常明显拖拽过程中的占位、动画、手柄控制、禁用区间这些都是现成的不需要自己处理。原生 HTML5 拖放方案最大的问题在于体验不一致。Chrome 下用起来还算正常Firefox、Safari 以及部分国产浏览器里拖拽时的光标、半透明效果、拖动图像都会有细微区别。而且如果不自己写dragover的占位逻辑列表在拖动过程中不会给用户任何视觉反馈看起来很生硬。不过它也有不可替代的优势零依赖、代码量少、不需要额外安装包适合那种不允许随便加依赖的项目。我在后面第 5 章专门写了原生方案的实现思路。1.2 为什么列拖拽比行拖拽麻烦得多很多人以为列拖拽是行拖拽的简单镜像实际操作起来完全不是一回事。行拖拽面对的是数据数组顺序变了直接重排数组Vue 的响应式系统会帮助表格重新渲染逻辑很直接。但列拖拽面对的是“列配置”。el-table 的列是在模板里通过el-table-column声明的虽然它本身支持数组配置渲染但一旦你的列里有自定义插槽、固定列、复杂表头事情就变得复杂了。更麻烦的是 element-ui 对 fixed 列的处理方式。当某一列配置了fixed表格会在主表格之外额外渲染一份固定列的 DOM用于实现滚动时固定列悬浮的效果。也就是说同一个列会在页面上出现两套表头单元格和两套单元格。如果你的拖拽逻辑直接作用在表头上很容易同时改到这两套 DOM导致拖完以后表格列错位、宽度错乱甚至表头和数据不齐。所以在做列拖拽之前必须先把实现方案定清楚。我采用的是“配置驱动 表头 sortablejs”的组合把列定义为一个columns数组表格用v-for渲染列拖拽表头时只修改columns数组的顺序让 Vue 响应式机制去驱动整个表格重新渲染。这样只需要管一套数据源DOM 层的乱七八糟交给框架处理。1.3 本次方案的整体结构整个实现我会分成两条线行拖拽安装 sortablejs在表格数据加载完成、DOM 渲染后将table底层tbody绑定为可排序容器拖拽结束根据oldIndex和newIndex重新排列数据数组。列拖拽将列定义抽离成columns配置项模板中用v-for循环输出列初始化时给表头tr绑定 sortablejs拖拽结束后更新columns数组的顺序并刷新表格布局。下面先讲依赖安装和行拖拽的完整实现。2. 核心实现一行拖拽2.1 准备工作让数据带上唯一标识行拖拽最忌讳的事情就是拖完以后数据顺序变了但表格里某一行的内容却识别错了。为了不让表格渲染和数据识别出现问题el-table必须配置row-key并且这个 key 值要能唯一标识一行数据。如果你的接口返回的数据本身有id或其它唯一字段直接用就行el-table :datalist row-keyid如果接口返回的数据没有唯一字段比如一些老旧系统返回的是纯数组那你需要在拿到数据后手动给每个对象补一个const raw await fetchList() this.list raw.map((item, index) ({ ...item, _id: item.id || row_${Date.now()}_${index} }))这里的_id就是我们给表格用的 row-key。为什么不能直接用数组下标因为在拖拽排序的过程中行会频繁交换位置如果用下标作为 keyVue 在 diff 时会认为所有行的身份都变了可能重绘整张表格严重时会闪屏甚至在拖拽过程中出现数据错乱。还有一点要提醒如果列表数据来自分页接口每一页的数据都会重新加载那么刷新页面后行顺序会被后端重新决定。拖拽排序通常只作用于当前页跨页排序需要把每一页的顺序都传给后端这个我们放到后面问题排查章节再细说。2.2 把 sortablejs 绑定到表格体依赖安装很简单npm install sortablejs --save然后在需要用到的组件里引入import Sortable from sortablejs初始化拖拽的时机很关键。必须等表格 DOM 渲染出来以后才能绑定。一般放在this.$nextTick回调里。如果你用v-if控制表格显隐或者是打开弹窗以后才显示表格要等弹窗动画结束、表格真正渲染之后再初始化。下面这段是行拖拽的绑定逻辑initRowSortable() { const table this.$refs.myTable if (!table) return const tbody table.$el.querySelector(.el-table__body-wrapper tbody) if (!tbody) return this.rowSortable Sortable.create(tbody, { handle: .drag-handle, animation: 150, ghostClass: row-ghost, chosenClass: row-chosen, onEnd: (evt) { const { oldIndex, newIndex } evt if (oldIndex newIndex) return // 重排数据 const list this.list.slice() const [moved] list.splice(oldIndex, 1) list.splice(newIndex, 0, moved) this.list list this.handleSortChange(this.list) } }) }其中handle: .drag-handle的意思是只有点击到带有drag-handle这个 class 的元素时才启动拖拽。这是一个非常实用的配置强烈建议加上。原因很简单如果整行都能拖拽那么用户想选中表格里的文字、想点击行内的输入框或者按钮都会误触发拖拽体验会变得很糟糕。ghostClass是占位元素的样式拖动时原来位置会留下一个占位符用来提示用户“如果你现在放下这一行会出现在这里”。chosenClass是当前正在拖拽的行的样式。这两个 class 配上合适的 CSS 后拖拽过程会有很自然的视觉反馈。2.3 重排数据的正确姿势上面代码里重排数据那段看起来简单实际有讲究。我见过很多同学直接这样写// 错误示范 const moved this.list.splice(oldIndex, 1)[0] this.list.splice(newIndex, 0, moved)这种写法的风险在于this.list是响应式数组虽然splice本身是能触发 Vue 更新的但如果你之后马上在同一个 tick 里读取this.list拿到的顺序可能不是最新的。更稳妥的做法是先用slice()拷贝一份在拷贝的数组上做操作再整体赋值回this.list。这样 Vue 能明确感知到数组引用变了必定会触发视图更新。还有一个常见的需求是拖拽结束后弹窗提示或者提交到后端。比如顺序变化了要调用保存接口。我在上面代码里留了一个handleSortChange方法你可以在这个方法里发请求handleSortChange(newList) { // 防抖避免拖拽过程中多次触发 if (this.sortTimer) clearTimeout(this.sortTimer) this.sortTimer setTimeout(() { const ids newList.map(item item.id) saveSort(ids).then(() { this.$message.success(排序已保存) }) }, 500) }因为onEnd只在拖拽真正结束时触发所以防抖不是必须的但如果你后续扩展了“拖拽列”或者其他复杂交互还是建议加上。2.4 行拖拽的样式与手柄列为了让用户知道哪些行可以拖拽我通常在表格最前面加一个拖拽手柄列el-table-column width50 aligncenter template slot-scopescope i classel-icon-rank drag-handle stylecursor: move;/i /template /el-table-column宽度不用多大50px 足够。注意这个手柄列本身不要设置fixed否则会出现和列拖拽一样的双渲染问题。样式可以这样加.row-ghost { opacity: 0.3; background: #f0f9eb !important; } .row-chosen { background: #ecf5ff !important; }ghostClass那行最好加上!important因为 element-ui 本身给表格行设置了背景色不加!important很容易被覆盖导致占位符的视觉反馈看不出来。3. 核心实现二列拖拽3.1 把列配置改造成可渲染的 columns 数组列拖拽首先要改造表格的写法。原本你的模板里可能是这样的el-table :datalist el-table-column propname label姓名 / el-table-column propage label年龄 / el-table-column propaddress label地址 / /el-table要做列拖拽得把它改成这样el-table :datalist refmyTable row-keyid el-table-column v-forcol in columns :keycol.prop :propcol.prop :labelcol.label :widthcol.width :min-widthcol.minWidth :aligncol.align :fixedcol.fixed template slot-scopescope slot :namecol.slotName :rowscope.row :indexscope.$index/slot /template /el-table-column /el-table注意:key尽量不要用数组下标用col.prop最好。因为拖拽改变columns顺序后如果 key 是下标Vue 复用了同一位置的组件实例可能会把上一列的宽度、对齐方式继承过来出现列内容错乱的怪问题。columns数组结构大概是这样columns: [ { prop: name, label: 姓名, width: 150, align: center }, { prop: age, label: 年龄, width: 100, align: center }, { prop: address, label: 地址, minWidth: 200 } ]在实际项目中不是所有列都适合放进columns里。操作列比如“编辑、删除”按钮通常固定在最右侧不动多选列typeselection通常在左侧不动。我的建议是只把需要参与拖拽排序的列放进columns操作列和多选列还是用静态的el-table-column写在模板里放在最前或最后。这样能避免很多插槽、fixed 带来的兼容问题。3.2 绑定表头拖拽列拖拽的初始化同样放在$nextTick里。这次要绑定的不是tbody而是表头initColumnSortable() { const table this.$refs.myTable if (!table) return const headerTr table.$el.querySelector(.el-table__header-wrapper tr) if (!headerTr) return this.columnSortable Sortable.create(headerTr, { animation: 150, filter: .fixed-col, .el-table-column--selection, onEnd: (evt) { const { oldIndex, newIndex } evt if (oldIndex newIndex) return const columns this.columns.slice() const [moved] columns.splice(oldIndex, 1) columns.splice(newIndex, 0, moved) this.columns columns this.$nextTick(() { table.doLayout() }) } }) }filter是 sortablejs 的一个选项匹配到这些元素时不允许拖拽。这里过滤了两种列带有fixed-colclass 的列以及 element-ui 的多选列它的 class 通常包含el-table-column--selection。这一手很关键因为如果你不过滤 fixed 列拖完以后表格的固定列宽度和位置会乱成一锅粥我后面会专门讲这个问题。doLayout()是 element-ui 表格组件暴露的方法作用是重新计算表格的列宽和布局。拖拽改变列顺序后调用一下可以强制表格重新计算避免出现列宽错乱或者数据行与表头对不齐的情况。3.3 拖拽后数据列与视图同步上面的代码里拖拽结束时直接替换了this.columns因为模板中是v-for渲染 columnsVue 会立即根据新数组调整列的顺序。这一步看起来简单但有一个隐藏问题当你移动列时表格已有的数据单元格不会自动跟着表头移动因为表格的数据渲染也是由列配置驱动的Vue 会更新列但 element-ui 内部缓存了一些列宽和布局信息所以需要doLayout()兜底。实际操作中我还在onEnd里加了一步强制刷新this.$forceUpdate()不过说实话$forceUpdate能不用就不用它会重新渲染整个组件浪费性能。大多数情况下更新columns数组后在$nextTick里调用doLayout()就够了。如果发现列位置变了但列宽没变可以考虑把table.doLayout()换成table.$el.querySelector(.el-table__header-wrapper).style.display none table.$el.offsetHeight // 触发重排 table.$el.querySelector(.el-table__header-wrapper).style.display 用这种 hack 强制浏览器重排是最后的手段正常用doLayout()就好。3.4 列宽和表头过滤器的注意事项实际项目里表头单元格的th并不仅仅包含用户定义的列还有可能是多级表头、时间列、序号列。在绑定 sortable 之前最好先确认headerTr.children的数量和columns数组的长度能对应上。如果不对应说明表格里还有静态列或者使用了多级表头结构。我的建议是如果表格有复杂的多级表头列拖拽不要用 sortablejs 直接操作表头 DOM改用纯配置方式即完全靠重排columns数组来控制。这时候表头拖拽绑定对象还是要选好避免绑定到最外层的tr因为多级表头有多个tr每个tr的行列跨度不同。还有一点element-ui 在列宽自适应时会对th做宽度计算排序后th的宽度可能还停留在旧列上所以doLayout()这么重要。如果你发现拖拽后列的宽度跟着“移动”了但那不是你要的效果那就需要给每列设置明确的width不要用min-width自适应拖拽体验才会稳定。4. 实战中的那些坑踩坑记录与排查思路4.1 fixed 固定列拖拽后错位这是我遇到最多的问题也是很多人做列拖拽时第一个撞上的墙。现象是把某一列拖到另一个位置后表格最右侧的固定列变宽了或者表头和数据错开甚至固定列被挤到看不见了。原因是 element-ui 对固定列的渲染方式是复制一份 DOM 放到.el-table__fixed容器里也就是说一个带fixed属性的列表头 DOM 实际上出现了两次主表头一次固定容器表头一次。sortablejs 绑定的.el-table__header-wrapper tr里包含了主表头的所有th包括 fixed 列当它把固定列的th拖拽移动之后element-ui 内部并不会同步更新固定容器里的那份 DOM两个表的列顺序不一致渲染自然错乱。最常见的解决办法是拖拽排序的列范围不包含 fixed 列。你可以给固定列对应的th设置一个不可拖拽的 class然后在 sortable 配置里用filter过滤掉。我的做法是在渲染列时如果col.fixed有值就给它的表头单元格加fixed-col类const headerTr table.$el.querySelector(.el-table__header-wrapper tr) if (headerTr) { const thList headerTr.querySelectorAll(th) thList.forEach(th { const text th.innerText const matched this.columns.find(c c.label text) if (matched matched.fixed) { th.classList.add(fixed-col) } }) }不过更省心的方案是让固定列永远不参与拖拽。如果你当前需要拖拽的列还没设置 fixed那就先不设置非要用 fixed就把固定列放在 columns 数组的两端并在onEnd里判断 oldIndex 和 newIndex 是否越过了固定列边界越界就不执行重排或者把拖到边界的列弹回原处。我后来在项目里建议产品经理“固定列显示在右端且不参与排序”这样能减少一大半问题。产品经理一般也都能接受因为固定列通常是操作列放最后是符合直觉的。4.2 行拖拽与展开行、多级表头的兼容行拖拽看起来只是操作tbody但如果你的表格用了展开行功能也就是typeexpand这一列行拖拽会把展开的详情行一起拖走。详情行的tr是一个扩展行它的 class 和普通数据行不同但都在同一个tbody里sortablejs 会把它们一视同仁地当作可拖拽行处理这通常不是你想要的。解决办法是在onEnd回调里判断被抓取的元素是不是真正的数据行。判断方式可以看tr的 classonEnd: (evt) { const row evt.item if (row.classList.contains(el-table__expanded-cell)) { // 是展开行不处理排序 this.$nextTick(() this.rowSortable.sort(this.oldOrder)) return } // 正常排序逻辑 }如果表格有多个层级比如树形数据tree-props情况会更复杂。树形表格的父子关系是数据模型决定的直接拖拽行只改变数组顺序并不能自动改变父子层级。如果需求是让用户能通过拖拽调整树形结构的父子关系那么光靠 sortablejs 就不够了需要自己在onEnd中解析拖拽前后行的数据判断父级变化然后递归更新树形数据。这是一个相当复杂的需求我在实际项目中一般会先追问产品能否简化为“只排序同级数据”否则宁可多花一周开发。4.3 拖拽后出现闪烁、占位符残留如果你发现拖拽过程中表格在闪烁或者拖完后页面上残留半透明的行通常是因为 element-ui 的行样式和 sortablejs 的 ghost 样式冲突。解决占位符残留问题可以在onEnd里手动清掉 ghost 元素onEnd: (evt) { const ghost document.querySelector(.row-ghost) if (ghost) ghost.remove() // ...重排逻辑 }闪烁问题的根源往往是初始化时绑定错了目标或者行内某个子元素也触发了拖拽。如果你绑定时没有配置handle整行都可以拖拽行内如果有图片、按钮、输入框这些元素会响应浏览器的原生拖拽行为造成闪烁。所以再次建议你配置handle并用.drag-handle作为手柄。另外sortablejs 的animation属性设置成 150ms 左右比较合适设置太大会导致拖拽行跟不上鼠标太小又显得生硬。4.4 大表格性能优化拖拽卡顿当一页表格数据达到几百上千行时行拖拽会变得明显卡顿。原因很简单sortablejs 在onEnd里重排整个数据数组Vue 收到数组变化后会重新渲染整个表格几千行 DOM 的创建和销毁非常耗时。我的优化思路是分两步。第一步在拖拽过程中不要让 Vue 感知到数组一直在变只在拖拽结束时同步一次。sortablejs 拖拽过程中只是 DOM 层的移动onEnd才真正触发数据重排所以这步通常不会出问题。第二步如果表格数据量实在太大考虑用虚拟滚动方案比如用el-table的height属性启用内部滚动或者引入第三方虚拟表格组件。但虚拟滚动和 sortablejs 的配合比较麻烦因为虚拟滚动会复用行 DOM拖拽时行被移出可视区再回来位置信息会丢失。我的经验是数据超过 500 行还要求拖拽排序先考虑改交互改成点击上下移动按钮或者弹窗里排序效果更可靠。与分页功能结合也是个坑。如果表格是分页的每页只有 10 条拖拽结束后的oldIndex和newIndex只是当前页内的相对位置传给后端时要加上偏移量const pageOffset (this.page - 1) * this.pageSize const realOldIndex oldIndex pageOffset const realNewIndex newIndex pageOffset否则后端拿到的顺序永远是每页从 0 开始跨页排序会完全错乱。4.5 与后端排序的接口联调问题拖拽完了前端数据排序好最终要持久化到后端。这块我遇到过两个问题。第一个问题拖拽完立刻刷新页面顺序被重置了。排查后发现是因为接口只在组件销毁时保存或者保存接口没等拖拽结束就调用导致提交的是旧顺序。解决方案是在onEnd里发起保存并给保存接口加防抖避免快速连续拖拽时发出多次请求。第二个问题后端返回的数据没有自动排好序。拖拽后我把排序 id 数组传给后端后端返回的列表顺序不是按这个数组来的而是按数据库默认排序返回的。这个问题不在前端但前端要兜底。我的做法是保存完接口后同时把最新顺序存到localStorage下次进入页面时先用本地顺序渲染等接口数据到了再校验顺序。如果后端做了处理以接口为准如果后端没做就以本地为准。这个方案适合排序不跨设备同步的业务场景。4.6 表格固定高度、横向滚动时的拖拽边界处理表格如果设置了固定高度数据区域会出现纵向滚动条。这时拖拽某一行到可视区域顶部或底部之外sortablejs 默认不会自动滚动容器的滚动条用户必须手动滚动才能把目标行拖到远处。这个问题在列拖拽时也存在横向滚动时拖拽列到可视区域左右边缘同样不会自动滚动。sortablejs 提供了scroll配置Sortable.create(tbody, { scroll: true, scrollSensitivity: 30, scrollSpeed: 10, bubbleScroll: true })scrollSensitivity是触发滚动的灵敏度数值越大越靠近边缘才触发。scrollSpeed是滚动速度。这两个参数需要根据实际容器大小微调我习惯设置 sensitivity 为 30、speed 为 10在大多数后台表格里效果都不错。如果容器不是 window 滚动而是某个内部 div 滚动需要给 sortablejs 指定scrollFn或者scrollEl去明确滚动容器否则它只认最近的滚动元素。5. 扩展思路不依赖 sortablejs 的原生实现5.1 原生 HTML5 拖放实现行拖拽如果你的项目比较老旧不想引入第三方依赖可以用原生 HTML5 拖放 API 实现行拖拽。核心思路是给每一行设置draggabletrue然后在dragstart、dragover、drop事件里处理数据。el-table :datalist row-keyid row-drag-startonRowDragStart row-drag-overonRowDragOver row-droponRowDrop el-table-column label排序 template slot-scopescope span draggabletrue classdrag-handle-native dragstartonDragStart(scope.$index) dragover.preventonDragOver(scope.$index) droponDrop(scope.$index)拖我/span /template /el-table-column /el-table数据操作逻辑如下onDragStart(index) { this.draggingIndex index }, onDrop(targetIndex) { if (this.draggingIndex targetIndex) return const list this.list.slice() const [moved] list.splice(this.draggingIndex, 1) list.splice(targetIndex, 0, moved) this.list list }原生方案的缺点很明显没有拖拽过程中的位置占位提示没有动画拖拽的 ghost 行样式不统一。如果你要实现“拖到什么位置能看到一条水平线”这种体验需要自己在dragover里动态计算并插入一个占位行代码量会迅速增加。结论是能用 sortablejs 就用 sortablejs原生方案适合应付极简需求。5.2 原生方案实现列拖拽列拖拽的原生实现同样基于 mousedown、mousemove、mouseup。基本思路是监听表头每个th的mousedown记录当前列索引startIndex和鼠标起始坐标startX。mousemove时判断当前鼠标所在的th如果进入相邻列的区间超过一定阈值交换这两列在columns数组中的位置。mouseup结束本次拖拽。核心代码大概是mousedown(e, index) { this.dragging true this.startIndex index this.lastX e.clientX }, mousemove(e) { if (!this.dragging) return const diff e.clientX - this.lastX if (Math.abs(diff) 30) { const targetIndex diff 0 ? this.startIndex 1 : this.startIndex - 1 const columns this.columns.slice() const [moved] columns.splice(this.startIndex, 1) columns.splice(targetIndex, 0, moved) this.startIndex targetIndex this.columns columns this.lastX e.clientX this.$nextTick(() this.$refs.myTable.doLayout()) } }这个方案有个好处没有 ghost 元素不改变 DOM 结构只是直接操作 columns 配置element-ui 内部状态不容易被破坏。坏处是拖动列到最左或最右时如果容器有横向滚动没有自动滚动的处理逻辑需要额外写滚动代码。5.3 两种方案对比与适用场景我列一张表格对比一下对比项sortablejs 方案原生方案开发速度快配置丰富慢需要处理细节动画与占位自带体验好需要手写移动端触屏支持基本不支持依赖体积增加约 40KB零依赖DOM 侵入性较高会改动 element-ui 内部 DOM较低与 fixed 列兼容容易出问题需过滤也需要处理适用场景正规后台项目极简需求或依赖受限项目如果你在评估方案我的建议是公司内部中后台系统直接选 sortablejs省心省力如果是电子签名、低代码平台的组件库需要严格控制体积或者需要嵌入到第三方页面可以考虑原生方案。6. 最后再分享一点个人体会我之前在一个项目里同时做了行拖拽和列拖拽上线后真实用户反馈很有意思约六成用户根本不知道表格的列可以拖拽换位置约三成用户试过一次后觉得不习惯又改回去了剩下的一成用户非常喜欢这个功能说整理报表效率提升很多。这个反馈让我反思了很久。拖拽交互虽然开发成本不高但并不是所有业务场景都需要。如果用户只是偶尔微调列顺序不如在工具栏放一个“列设置”按钮通过弹窗勾选显示哪些列、用上下箭头调整顺序反而更容易被发现和学习。拖拽适合的是高频操作、需要快速调整的场景比如自定义仪表盘、看板表格、字段配置界面。如果决定要做拖拽一定不要忘了把用户调整后的顺序存下来。我后来把columns数组存到了localStorage按当前路由地址做区分下次进页面先读取本地的列顺序再和默认列配置合并。这个功能虽然不起眼但对用户体验的提升非常明显相当于记住了用户的个人习惯。代码层面最后提醒一句无论行拖拽还是列拖拽初始化前都要判断 DOM 是否已经存在组件销毁时记得销毁 sortable 实例否则在 Vue 路由切换后会出现内存泄漏或者事件重复绑定。beforeDestroy() { if (this.rowSortable) { this.rowSortable.destroy() } if (this.columnSortable) { this.columnSortable.destroy() } }这算是我踩了无数坑之后总结下来的底线操作。希望这篇文章能帮你少走弯路把拖拽功能顺顺当当做出来。