拖拽排序这个交互,放在普通列表里可能三五行代码就收工了,一旦搬进 el-table,麻烦会成倍冒出来:行拖拽的 DOM 归属、拖拽后数据与视图对不上、某些单元格里还塞着输入框和可划选的文字。这篇围绕 Vue 项目里用 vuedraggable 做拖拽时碰到的几类典型场景做一次完整复盘,覆盖 el-table 行拖拽、局部元素禁拖,以及拖拽和文字复制、输入框输入之间的相互干扰。目标读者是已经写过 Vue 表单、接触过 el-table,但第一次把拖拽塞进表格里的同学;如果你只会写最基础的列表拖拽,顺着往下跟也能落地。
1. 需求拆解与选型思路
1.1 三个诉求背后的技术本质
先把你提到的三条需求摆出来看:允许 el-table 行拖拽、部分元素不允许拖拽、拖拽不能影响文字复制和输入框输入。这三条其实分别对应了三个不同的技术层面,混在一起看很容易糊,拆开就清楚了。
第一条是DOM 层的问题。el-table 的行不是你自己渲染的,是组件内部生成的一层tbody > tr。vuedraggable 是组件写法,它要求你把它作为父节点把你的列表项包起来。可 el-table 的模板结构由它自己控制,你没法在中间插一个 draggable 组件去包裹 tbody。所以这一步的核心矛盾是:拖拽库想接管 DOM 结构,而表格组件不给你这个位置。解决办法要么是拿到组件内部的真实 DOM 手动初始化拖拽实例,要么是找组件提供的插槽和钩子去桥接。
第二条是事件层的问题。Sortable 这类库在初始化时会往容器上挂mousedown监听,判断指针按下的位置和移动方向来决定是否启动拖拽。当容器里存在输入框、按钮、可划选文字这些元素时,按下动作应该优先交给它们处理,而不是被拖拽逻辑截胡。这就涉及到拖拽库暴露的过滤参数,以及你自己写的事件判断逻辑。
第三条是交互层的问题。拖拽过程一旦开始,库为了不让浏览器默认行为干扰拖拽视觉,会调用preventDefault。这个动作会顺带干掉文本选中和光标定位,于是你发现输入框点不进去、文字选不中。这不是 bug,是拖拽机制和浏览器默认行为之间的天然冲突,需要在合适的时机做规避。
把这三层想明白,后面所有具体代码才有落点。
1.2 选型:vuedraggable 与它底下的 Sortable.js
vuedraggable 本身不是拖拽引擎,它是 Sortable.js 的 Vue 封装。真正干活的排序算法、指针事件计算、动画过渡都在 Sortable.js 里,vuedraggable 提供的是一个符合 Vue 心智的组件外壳——数据双向绑定、列表项插槽、事件转发。
版本对应关系这块要提前确认,踩错版本是新手最常见的第一个坑:
| Vue 版本 | vuedraggable 版本 | 引入方式 |
|---|---|---|
| Vue 2.x | vuedraggable 2.x | npm i vuedraggable@2 |
| Vue 3.x | vuedraggable 4.x(vuedraggable@next) | npm i vuedraggable@next |
Vue 3 项目里如果误装了 2.x,报错会非常隐晦,通常是运行时找不到默认导出或者渲染报错,排查方向一定要先看版本。
那为什么不用 HTML5 原生的draggable="true"?原生拖拽有两组事件:dragstart/dragover/drop,看起来不用依赖。但实际在表格行这种场景下,原生的几个硬伤很劝退:放置位置的计算要自己写,得实时判断鼠标落在哪两行之间;拖拽过程中的视觉反馈(半透明幽灵元素、占位空隙)默认效果很弱,全靠 CSS 硬补;移动端的触摸支持基本等于没有,touch事件不会触发drag系列。而 Sortable.js 用 pointer/mouse/touch 事件模拟了一套拖拽,视觉反馈开箱即用,移动端也能跑,这才是它成为事实标准的原因。
所以选型结论很直接:普通列表直接用<draggable>组件,省心;el-table 行拖拽退回到Sortable.create手动挂载,因为组件封装在这里反而碍事。两种方式混用是正常做法,不是"用错了库"。
2. 环境准备与最小可运行示例
2.1 依赖安装与初始化位置
先装依赖。Vue 3 项目:
npm i vuedraggable@next sortablejsVue 2 项目:
npm i vuedraggable@2 sortablejs为什么两个都要装?因为你迟早会用到 Sortable.js 的原生 API。vuedraggable 虽然依赖了 sortablejs,但把它作为直接依赖显式写进 package.json 更稳妥,避免某些构建工具在依赖提升后找不到模块。
初始化位置的选择有个原则:等 DOM 渲染完再挂拖拽。el-table 的 tbody 是异步渲染出来的,在created里找 DOM 必然找不到。mounted里也不一定稳,如果表格数据是接口回来后才渲染的,mounted时 tbody 里可能一个 tr 都没有,这时候挂 Sortable 虽然不报错,但拖拽容器的判断可能落空。
稳妥的写法是在数据到位之后再挂:
async mounted() { this.tableData = await fetchTableData() this.$nextTick(() => { this.initRowDrag() }) }$nextTick这一步别省,它保证数据更新后的 DOM 已经 patch 完成,tbody 里确实有行了。
2.2 普通列表拖拽:先跑通基线
在动表格之前,先把最简单的列表拖拽跑通,这样后面出问题你能判断是拖拽库的锅还是表格的锅。
Vue 3 的写法:
<template> <draggable v-model="list" item-key="id" :animation="150" ghost-class="drag-ghost" @end="onDragEnd" > <template #item="{ element }"> <div class="list-item">{{ element.name }}</div> </template> </draggable> </template> <script setup> import { ref } from 'vue' import draggable from 'vuedraggable' const list = ref([ { id: 1, name: '第一项' }, { id: 2, name: '第二项' }, { id: 3, name: '第三项' } ]) function onDragEnd(evt) { console.log('从', evt.oldIndex, '移到', evt.newIndex) } </script>几个关键参数值得单独说:
item-key:Vue 3 版本必填,告诉组件用哪个字段做 diff 的 key。不填会警告,而且拖拽后可能出现 DOM 复用错乱。animation:拖拽松手后其他元素让位的过渡时长,单位毫秒。给 150 左右体感最舒服,不设的话元素是瞬间跳位,很生硬。ghost-class:拖拽时占位元素的样式类,默认是个很淡的透明效果,自己覆盖一下会更清楚。
Vue 2 的写法差异在插槽,用的是v-for加:key:
<draggable v-model="list" :animation="150"> <div v-for="item in list" :key="item.id">{{ item.name }}</div> </draggable>这里有个高频困惑:v-model和:list有什么区别?v-model是双向的,拖拽结束后组件会自动更新你的数组顺序;:list是单向传入,你需要监听@end或@change自己改数据。用 v-model 更省事,但前提是你的数组是响应式的(ref 或 data 里的属性)。如果传的是普通变量,拖拽视觉上会动,但数据不会变,松手后视图又弹回去,这就是"拖拽回弹"的第一种成因。
跑通这个基线之后,再往 el-table 上搬。
3. el-table 行拖拽的完整实现
3.1 拿到 tbody:绕开组件封装的必要一步
el-table 的行拖拽,核心就一行事:找到真正的 tbody,交给 Sortable。
import Sortable from 'sortablejs' initRowDrag() { // 注意:要用表格实例的 $el 去找,不能用 document.querySelector const wrapper = this.$refs.tableRef.$el.querySelector( '.el-table__body-wrapper tbody' ) if (!wrapper) return this.sortableInstance = Sortable.create(wrapper, { animation: 150, handle: '.drag-handle', ghostClass: 'row-ghost', onEnd: this.handleRowEnd }) }为什么要用this.$refs.tableRef.$el而不是document.querySelector('.el-table__body-wrapper tbody')?因为页面上可能不止一个 el-table(弹窗里、抽屉里都可能有),用全局选择器会抓错容器。用组件实例的$el限定范围,是最起码的隔离。
为什么是.el-table__body-wrapper tbody而不是直接tbody?因为 el-table 在启用 fixed 列或者某些布局模式下,会在.el-table__fixed-body-wrapper里再渲染一份 tbody。如果你直接querySelector('tbody')拿到的是文档流里第一个,有可能抓到固定列那份,拖拽就会表现得很诡异——拖的是主表,动的是固定列。
挂载之后,把实例存下来(this.sortableInstance),组件销毁时记得destroy(),不然切换路由后残留的事件监听可能引发内存泄漏或者莫名其妙的报错:
beforeUnmount() { this.sortableInstance?.destroy() this.sortableInstance = null }3.2 row-key 与数据更新的正确姿势
el-table 一定要设row-key,这个属性对拖拽来说不是可选项。
<el-table ref="tableRef" :data="tableData" row-key="id" border > <el-table-column prop="name" label="名称" /> <el-table-column prop="value" label="数值" /> </el-table>原因在于 el-table 内部用row-key做行的 diff。拖拽改变了行的顺序,如果你没给 key,Vue 会按索引复用 DOM 节点,结果就是拖拽后某些行的内容串了位置,或者输入框里残留了别的行的值。给了 key 之后,行跟着数据的身份走,顺序变了 DOM 也会正确重排。
数据更新逻辑写在onEnd里:
handleRowEnd({ oldIndex, newIndex }) { if (oldIndex === newIndex) return // 用新数组替换,触发响应式更新 const rows = [...this.tableData] const [moved] = rows.splice(oldIndex, 1) rows.splice(newIndex, 0, moved) this.tableData = rows }这里的写法有两个细节:一是先判断oldIndex === newIndex直接 return,避免无意义的数据变动触发下游的 watch 或者接口调用;二是用splice之后的副本整体赋值,而不是原地操作原数组再手动触发更新。整体赋值能保证所有依赖这个数组的地方都收到变更通知,原地改虽然 Vue 2 里有时候也能更新(要看具体方法),但语义上不干净,Vue 3 里更推荐替换引用。
为什么不直接把this.tableData[oldIndex]和this.tableData[newIndex]交换?因为拖拽是"插入"语义不是"交换"语义。从第 1 行拖到第 5 行,中间的行都要依次前移一位,简单的交换会得到错误结果。用 splice 先删后插才符合真实的位置变化。
3.3 fixed 列、多级表头带来的多 tbody 陷阱
前面提到 fixed 列会多出一个 tbody,这里展开说。
el-table 的固定列实现方式是:把固定列的单元格单独渲染在一层覆盖的 DOM 里,滚动时这层不动。这意味着页面上可能存在两套 tr,一套在主表格,一套在固定列区域。你在主 tbody 上挂了拖拽,用户拖的其实是主表格的行,视觉上看固定列那几条纹丝不动——交互上会很割裂。
处理思路有两个方向:
方向一:拖拽场景下别用 fixed 列。这是最省事的。拖拽排序本来就要求用户能看到完整的行,固定列会压缩可视区域,也增加理解成本。如果产品经理坚持要固定列,你得接受拖拽过程中固定列不跟随的视觉瑕疵,或者做额外的 DOM 同步——成本很高,一般不建议。
方向二:只挂主 tbody,接受视觉差异,同时在拖拽开始和结束时给固定列区域的对应行加个同步的视觉状态。这个方案实现复杂,除非业务强需求,否则不划算。
多级表头是另一个坑。多级表头会让 tbody 上方多几层 thead,但 tbody 本身结构没变,一般来说不影响拖拽。真正有影响的是展开行(type="expand"),展开行渲染在 tbody 里作为一个额外的 tr,拖拽时它会被当成可拖拽的一行,导致用户能把展开的详情拖走。这种就得配合下一节的禁拖方案处理。
3.4 合并单元格与拖拽的冲突
如果你的表格用了span-method做行合并,拖拽会把它彻底搅乱。
合并单元格的本质是某些 tr 上的某些 td 被隐藏,用rowspan撑开。拖拽改变行顺序之后,原来的合并规则是按行的索引或者数据算的,顺序一变,合并的位置就全乱了。表现是:拖拽后表格的边框、单元格归属完全错位,看着像表格坏了。
我实测的处理原则是:拖拽排序和合并单元格尽量别同时用。如果业务上确实需要分组后组内拖拽,正确做法是拆成多个小的 el-table,每个分组一个表格,拖拽只在组内生效,跨组拖拽靠拖拽库的 group 配置做转移。这样合并单元格是组内静态的,拖拽改变的是组内顺序,冲突就消失了。
如果一定要在同一个表里做,那就得在onEnd之后重新计算合并规则,并且用$nextTick等 DOM 重排完成后再刷新 span 数据。这条路径维护成本很高,属于"能用但别轻易上"的级别。
4. 局部禁拖的实现方式
4.1 filter、draggable、handle 三个参数的分工
Sortable.js 给了三个容易混淆的配置,先分清楚:
handle:指定拖拽手柄。只有按住符合选择器的元素时才能启动拖拽,其他区域按住是选中文字。filter:指定不可拖拽的元素。按下落在匹配元素上时不启动拖拽。draggable:指定哪些子元素是可拖拽项。默认是容器直接子元素,指定选择器后只有匹配的才能拖。
三个参数配合使用才能精确控制。比如你在表格里放了操作按钮列,用户点按钮时不应该触发拖拽:
Sortable.create(tbody, { handle: '.drag-handle', filter: '.no-drag, .el-button, input, textarea', draggable: 'tr', animation: 150, onEnd: this.handleRowEnd })filter支持逗号分隔的多个选择器,这点很实用。把所有"不该触发拖拽"的元素列进去,拖拽逻辑就会让路。
这里有个细节:filter在 Sortable.js 里的行为和handle是叠加的,不是互斥的。当你同时设了二者,只有"按住 handle 里的元素、且不匹配 filter"时才启动拖拽。理解了这层,才不会出现"设了 handle 但还是拖不了"的困惑。
4.2 按行控制:动态判断是否允许拖动
前面是元素层级的禁拖,还有一种是行层面的禁拖——比如某些行是"已锁定"状态,不允许参与排序。
Sortable 的filter也能接函数,或者你可以用 CSS 类配合。更直观的做法是在渲染行的时候给不可拖拽的行打上类:
<el-table :data="tableData" :row-class-name="rowClassName" row-key="id">rowClassName({ row }) { return row.locked ? 'row-locked no-drag' : '' }然后filter: '.no-drag'。这里之所以加row-locked和no-drag两个类,是为了把"业务状态"和"拖拽行为"解耦——row-locked负责样式(灰色、禁止图标),no-drag负责交互(不参与拖拽)。以后业务规则变了,改哪个类就改哪层,不会互相牵扯。
需要留意一个细节:设了 filter 之后,被过滤的行在拖拽时仍会让位。也就是说,你拖动 A 行经过一个锁定的 B 行,B 行的位置视觉上还是会被"挤"一下,只是它自己不能被拿起。如果业务要求锁定行完全不动,比如固定在某几个位置,那 filter 就做不到,得用onMove回调拦截:
onMove(evt) { const targetLocked = evt.related.className.includes('no-drag') // 返回 false 阻止这次移动 return !targetLocked }onMove返回false会拒绝这次移动,是更严格的管控手段。
4.3 拖拽事件链上的拦截
除了配置项,事件层面也能做拦截。Sortable 提供了onStart、onMove、onEnd、onSort等钩子,可以在这几个时间点插入你的判断。
比如"拖拽进行中触发某些条件就取消这次拖拽":在onMove里做检查,条件不满足就返回false。或者"拖拽结束时校验业务规则",在onEnd里发现不符合就把数据顺序还原回去——这种场景是异步校验,比如拖拽后要调接口,接口返回失败就回滚:
async handleRowEnd({ oldIndex, newIndex }) { const rows = [...this.tableData] const [moved] = rows.splice(oldIndex, 1) rows.splice(newIndex, 0, moved) this.tableData = rows try { await reorderApi(rows.map(r => r.id)) } catch (e) { // 回滚到拖拽前的顺序 this.tableData = this.originalData } }注意把拖拽前的顺序存一份(this.originalData),回滚才有依据。这个"乐观更新 + 失败回滚"的模式在前端拖拽里很常见,用户体验比"拖完等接口再更新"要流畅得多。
5. 拖拽与文字复制、输入框输入的冲突解决
5.1 冲突的根因:mousedown 被谁接管了
这个问题的根子在于浏览器事件的优先级。当你在一个元素上按下鼠标,浏览器会按事件流依次分发mousedown。Sortable 在容器上监听了全局(其实是委托到容器)的mousedown并做了处理——只要指针落在可拖拽元素上,它就认为你可能要拖拽,开始跟踪指针移动。
问题在于,文字选中和输入框激活都依赖 mousedown 的默认行为。当用户在输入框里按下鼠标想定位光标,或者在文本上按下想划选一段,Sortable 抢先了这次事件的解释权,默认行为被干预,结果就是:
- 输入框点进去但光标不出现,或者要双击才能聚焦
- 文本拖选的时候变成了拖拽,一划就把整行拖走了
- 输入框里输入的内容,因为行的重排,值串到了别的行
这不是谁的 bug,是拖拽和文本交互天然抢同一个事件。解决办法就是在事件分发的早期把拖拽排除掉,让位于文本交互。
5.2 手柄方案:最省事的隔离方式
如果业务允许,给拖拽加手柄是最干净的解法。用户先按住手柄这一小块区域,再移动鼠标才会启动拖拽;按住其他任何地方都不会触发拖拽,文字选中、输入框定位全都正常。
实施步骤很直白:
第一步,表格加一列专门放手柄:
<el-table-column label="" width="50"> <template #default> <i class="drag-handle el-icon-rank" /> </template> </el-table-column>第二步,Sortable 配置handle: '.drag-handle':
Sortable.create(tbody, { handle: '.drag-handle', animation: 150, onEnd: this.handleRowEnd })就这么简单。为什么手柄能解决问题?因为handle限定了拖拽的启动区域,只有指针按在手柄上时 Sortable 才会介入,其他地方它完全不碰。文字选中和输入框获得焦点走的是浏览器默认路径,两者井水不犯河水。
实测体感上,手柄方案唯一的"缺点"是用户要先找到手柄在哪,交互成本略高。但它解决的问题是其他方案绕不过的,所以大多数企业级表格拖拽都是这个形态。用图标el-icon-rank或者一个⋮⋮的符号都行,形似"抓手"提供了直觉上的可拖拽暗示。
5.3 filter 方案:让输入框自己说了算
如果产品坚持"整行都能拖、不要手柄",那退而求其次用filter把交互元素排除出去:
Sortable.create(tbody, { animation: 150, filter: 'input, textarea, .el-input, .el-input__inner, .no-drag, .el-button', onEnd: this.handleRowEnd })这段配置的含义是:只要按下的位置在输入框、文本域、按钮或者标了no-drag的元素内部,就不启动拖拽,让浏览器正常处理。
不过 filter 方案有个副作用要提前知道:在表格的空白区域或者纯文本单元格上,文字选中仍然会被拖拽干扰。因为 filter 只排除它匹配到的元素,没有匹配到的地方拖拽照常启动。也就是说,用户想选中"名称"列里的文字去复制,鼠标一划还是会把行拖走。
这是 filter 方案的固有缺陷。如果你的场景里文本选中和拖拽同样重要,那就只有回到手柄方案,或者做更细的事件判断。
5.4 拖拽开始前的"安全检查"逻辑
有时候配置项解决不了全部问题,比如在拖拽事件里需要动态判断当前指针落点。这时可以借助onStart来兜底:
Sortable.create(tbody, { animation: 150, onStart(evt) { const path = evt.originalEvent.composedPath?.() || [] // 检查事件路径里是否包含输入框 const hasInput = path.some(el => el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable ) if (hasInput) { // 取消本次拖拽 evt.cancel?.() } }, onEnd: this.handleRowEnd })上面用了composedPath,它的好处是能拿到完整的冒泡路径,比只判断evt.target更准。不过要说明的是,cancel这个 API 在不同版本的 Sortable 里支持情况不一样,有的版本没有或者名字不同,所以这个例子只作为一个"思路",落地前先查一下项目里装的 Sortable 具体版本对应文档。
更通用、也更稳的做法是:在拖拽的初始化阶段就区分好容器。比如把整行拆成"可拖拽区域"和"交互区域"两部分,只在可拖拽区域上挂 Sortable。这种物理隔离比运行时判断更可靠,代价是布局上要稍微设计一下。
关于输入框输入的内容串行这个具体现象,还想补一句。它往往不是拖拽本身引起的,而是前面提到的row-key缺失导致的 DOM 复用问题。拖拽改变了行顺序,Vue 按索引复用节点,输入框的 DOM 被复用了,value 就会串。所以如果你遇到"拖拽后输入框里的值跑到别的行去了",第一件事是检查row-key设了没有,而不是去怪拖拽库。
6. 常见问题排查与性能优化
6.1 拖拽回弹、位置错乱
这是最高频的问题,现象是:拖拽松手后,行跳回原位,或者跳到一个奇怪的位置。原因有几个层次,按排查顺序列一下。
第一层:数据没更新。用了:list单向传值但忘了在onEnd里改数据,或者用了普通变量而不是响应式数组。表现是视觉上拖了,松手弹回。检查办法是打断点看onEnd有没有触发、数组有没有变。修复方式是改用v-model,或者老老实实在onEnd里手动 splice。
第二层:row-key 缺失或重复。缺 row-key 会导致 DOM 复用错乱,位置看着"回弹"其实是节点迁移错了。key 值有重复的话,diff 会判断失误,表现类似。检查方式是打印一下数据里 key 字段有没有空值或者重复。
第三层:新旧索引没判断。oldIndex === newIndex时还执行 splice,虽然结果不变,但如果 splice 逻辑写得不对(比如先删后插时索引没算对),可能造成数据错位。老实加一行提前 return。
第四层:数据源和视图源不是同一个。有些同学表格用接口数据渲染,拖拽改的却是本地另一个副本,两边不同步,松手后视图从接口数据重新渲染,自然弹回。排查点在于:onEnd里改的数组,是不是el-table :data绑定那个数组。
这四层排查完,绝大多数回弹问题都能定位。
6.2 拖拽结束同步弹窗打断收尾
有一个很隐蔽的坑:在onEnd回调里同步弹出 Modal(比如拖拽后要用户确认新顺序),会打断浏览器对拖拽的收尾处理,表现为弹窗出来后拖拽的幽灵元素还在、行位置错乱、或者弹窗被立刻关闭。
根因是onEnd是在拖拽的收尾阶段触发的,此时浏览器还在处理拖拽相关的 DOM 和状态清理动作。同步弹窗改变了焦点和 DOM 结构,和收尾过程撞车。
稳妥的做法是延迟到下一个事件循环再弹:
onEnd(evt) { // 先用 nextTick 或 setTimeout 让拖拽收尾完成 this.$nextTick(() => { this.showConfirmModal(evt.oldIndex, evt.newIndex) }) }$nextTick在这里通常够用,如果弹窗仍然有异常,可以退一步用setTimeout(fn, 0),把弹窗逻辑推到宏任务里。核心思想是让拖拽的收尾动作先跑完,你再动界面。
这个坑在官方 issue 里也有人提过,但因为没有稳定复现,文档里不会写。记住"拖拽事件里别同步改焦点/挂载 Modal"这个原则,能避开不少玄学问题。
6.3 其他零碎问题清单
拖拽相关的杂项问题我整理成一张表,方便对照排查:
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 拖拽时页面滚动条乱跳 | 拖拽库在容器上做了 scroll 处理 | 设置scroll: false或调整scrollSensitivity |
| 拖拽的幽灵元素样式很丑 | 未设 ghostClass | 自定义ghost-class覆盖透明度和边框 |
| 移动端拖拽很卡或者拖不动 | 触摸事件被页面其它手势拦截 | 检查touchstart是否被 preventDefault;设delayOnTouchOnly |
| 拖拽后表格宽度闪一下 | fixed 列宽度重算 | 避免在拖拽场景用 fixed,或给固定宽度不用 min-width |
| 表格空白区双击才可拖 | 拖拽把手柄限定在特定元素 | 检查handle选择器是否覆盖了预期区域 |
| 拖拽完成后滚动位置回到顶部 | 数据整体替换触发重渲染 | 记录 scrollTop 并在$nextTick后还原 |
关于 el-table 的 width 和 min-width 也能顺带说一句:这两个属性在 el-table 里其实可以写不带px的数字(比如:width="120"),组件内部会做转换。如果你写的是width="120px"这种带单位的字符串,某些版本会有解析差异,宽度可能不符合预期。拖拽后宽度一闪的怪现象,有时候也跟这个有关,统一用数字形式更稳。
6.4 大数据量下的性能取舍
行的数量如果上到几百甚至上千,拖拽体验会肉眼可见地下滑。原因有两个:一是 Sortable 每次移动都要重新计算所有行的位置,行数多了计算量大;二是数据更新后 Vue 要重新渲染大量 DOM 节点。
针对性的优化方向:
一是分页,从根本上减少当前拖拽的行数。这是最有效的,把每页控制在 50 到 100 行,拖拽就顺滑了。代价是跨页拖拽没法直接做,只能分页内排序。
二是用虚拟滚动。el-table 本身对虚拟滚动支持有限,社区有el-table-virtual-scroll之类的方案,但它和 Sortable 的兼容性需要自己验证,虚拟滚动只渲染可视区域的行,而 Sortable 需要这些行真实存在才能拖。这对组合我试过,坑比较多,不建议在核心功能上轻易上马。
三是降低拖拽过程中的响应式开销。拖拽期间用v-if或者条件类把一些重组件(比如带图表、带编辑器的单元格)卸载掉,拖拽结束再挂回来。这个优化收益明显,代价是实现复杂,需要根据你的单元格内容评估值不值得。
四是onEnd里的数据更新用浅拷贝替换整块。前面给的写法就是这个思路。避免在onEnd里对原数组做多次可变操作,一次性构造新数组替换,能让 Vue 的 diff 一次性完成。
真实的业务里,一页 50 行的拖拽体验和 500 行的体验差别巨大,遇到性能抱怨时先看行数,再谈优化手段。
6.5 拖拽实例的生命周期管理
最后一个常被忽略的问题是实例泄漏。拖拽绑定在 DOM 上,组件销毁时如果不解绑,重新进入页面时会挂上第二个 Sortable 实例,表现是拖拽行为异常(拖一下触发两次onEnd,或者行为完全错乱)。
标准写法:
mounted() { this.$nextTick(() => this.initRowDrag()) }, beforeUnmount() { if (this.sortableInstance) { this.sortableInstance.destroy() this.sortableInstance = null } }Vue 2 里对应beforeDestroy。销毁要放在 before 系列钩子里,因为 mounted 之后 DOM 还在,destroy 之后再操作可能报错。另外initRowDrag里要判重,避免因为$nextTick或者 watch 多次调用导致重复挂载:
initRowDrag() { if (this.sortableInstance) return // ... 创建实例 }我在实际项目里吃过这个亏:同一个详情页来回切换,拖三四次之后表格开始抽风,排查了半天才发现是重复挂载。加了一行判重和 destroy 之后就干净了。这类问题不常见,但一旦出现很难第一眼看出原因,属于"写的时候就该带上"的防御性代码。
拖拽这条线真正难的地方从来不是"怎么让它动起来",而是"怎么让它在该动的地方动、不该动的地方别添乱"。把 el-table 内部的 tbody 找对、row-key 补齐、禁拖区域用 handle 或 filter 划清、拖拽事件里别同步改界面,这几件事做到位,剩下的就是业务数据怎么存的细节了。我另一个体会是,版本问题在拖拽这块格外重要,Vue 2 和 Vue 3 的 vuedraggable 是两套写法,遇到异常先确认版本,能省下大量瞎折腾的时间。如果后续还要扩展,可以考虑把拖拽逻辑抽成一个自定义指令,这样多个表格可以共用同一套初始化、销毁和数据回写的逻辑,维护成本会低不少。