ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue拖拽排序:vuedraggable与el-table行拖拽冲突处理

Vue拖拽排序:vuedraggable与el-table行拖拽冲突处理 在 Vue 项目里做拖拽交互绕不开 vuedraggable 这个库。它把底层 sortablejs 的排序能力封装成了 Vue 组件用起来确实顺手——普通列表拖一拖改个顺序几分钟就能跑起来。但一旦把场景换成 el-table 的行拖拽或者需要在拖拽区域里放输入框、允许用户选中文字复制事情就开始变得棘手了。我最近在一个后台管理项目里同时碰到了这三类需求任务列表要能拖拽排序表格里的行要能手动调整顺序而行内的备注输入框和可复制的订单号又不希望被拖拽打扰。前前后后调了两天踩了不少坑才把这套方案稳定下来。这篇就把我从选型、实现到排查的完整过程写出来重点讲清楚允许 el-table 行拖拽怎么落地、部分元素如何禁止拖拽、以及拖拽和文字复制、输入框输入冲突时到底该怎么解。只要你用过 Vue不管 Vue2 还是 Vue3看完都能直接抄作业。1. 需求拆解与整体方案设计思路1.1 先把三类拖拽场景拆开看拿到需求的时候我没有急着写代码而是先把拖拽相关的东西全部列了出来。梳理完之后发现表面上都是拖拽实际上对应的是三种完全不同的交互模型。第一种是普通列表的排序拖拽。比如左侧的任务卡片或者菜单项用户按住卡片拖动松手后顺序变化数据跟着更新。这种是最标准的用法vuedraggable 直接套就能用几乎不需要额外处理。第二种是 el-table 的行拖拽。用户希望表格里的每一行能像列表项一样上下拖动来调整顺序。这个就麻烦了因为 el-table 自己会渲染一套复杂的 DOM 结构包括表头、表体、固定列、滚动容器等等。你没法像普通列表那样简单地用一个容器包住所有行拖拽的落点、行高对齐、数据同步都得单独考虑。第三种是混合场景拖拽区域内部还有交互元素。比如某一行里有个输入框用来填备注有个订单号文本需要用户能选中复制。这时候如果整行都能拖用户一点击输入框准备打字结果被判定成拖拽起手光标进不去想选中一段文字复制鼠标一动整个行就飘起来了。这类冲突是最容易被忽略、又最影响体验的。把这三类拆开之后方案就有了方向普通列表用 vuedraggable 标准用法el-table 行拖拽想办法在保证表格结构完整的前提下挂载拖拽能力交互冲突则通过精确控制哪里能拖、哪里不能拖来解决。1.2 为什么选 vuedraggable 而不是自己写原生拖拽原生的 HTML5 拖拽 APIdragstart、dragover、drop 那一套我也试过说实话写起来非常痛苦。它要求你给每个可拖元素加draggabletrue然后手动处理拖拽过程中的预览图、放置位置计算、跨容器判断光是拖拽时的占位样式就得写一堆 CSS。而且原生 API 在移动端的兼容性一直是个老大难触屏设备上的表现和桌面端差异很大。vuedraggable 的好处在于它是基于 sortablejs 封装的而 sortablejs 已经把拖拽的动画、占位、跨列表这些细节都处理好了。你只需要关心数据怎么变剩下的交给它。更关键的是它暴露了handle、filter、preventOnFilter这几个属性正好能精准解决我前面说的部分元素不允许拖拽的问题。如果自己写原生拖拽这套过滤逻辑得从头实现工作量翻倍还不一定稳定。至于为什么不用 el-table 社区里那些专门的拖拽插件我的考虑是项目里已经有 vuedraggable 了再引入一个只为了表格拖拽的库会增加维护成本而且那些库的更新频率和文档质量参差不齐。既然 vuedraggable 底层就是 sortablejs而 sortablejs 完全可以直接作用在 el-table 的 tbody 上那用一套技术栈解决所有拖拽需求是最省心的。1.3 整体数据流与组件结构设计方案定下来之后我把数据流理了一遍。核心原则是拖拽只改数据DOM 由 Vue 响应式更新绝不去手动搬 DOM 节点。这一点很关键很多拖拽后位置错乱、闪回的问题根源都是既改了 DOM 又改了数据两边不同步。具体来说数据源始终是一个数组比如tableData。拖拽结束后我们根据拖拽事件的oldIndex和newIndex调整数组顺序Vue 检测到数组变化后重新渲染行顺序自然就对了。组件结构上普通列表直接用一个draggable包裹列表项el-table 的行拖拽则采用指令式挂载的思路——在表格渲染完成后拿到它的 tbody 元素手动初始化 sortable 实例。这样做的好处是不破坏 el-table 自身的渲染逻辑表头、列宽、固定列都不受影响。至于交互冲突统一通过 sortable 的filter和handle配置来收口保证规则集中、好维护。2. vuedraggable 核心配置与 el-table 行拖拽落地2.1 几个必须搞懂的属性含义在动手之前先把 vuedraggable 几个关键属性讲透不然遇到问题根本不知道该调哪个。v-model是数据绑定。注意这里绑定的必须是一个数组vuedraggable 会直接修改这个数组的顺序。如果你用的是:list属性则是单向的数组对象本身会被原地修改但不会触发 v-model 的更新回调。我一开始图省事用了:list结果数据变了但页面某些依赖数组引用变化的逻辑没触发排查了半天后来统一改成v-model才稳定。item-key是每一项的唯一标识通常绑一个 id。这个属性非常重要它让 vuedraggable 能准确追踪每个元素的身份。如果不设置vuedraggable 只能靠索引来判断一旦拖拽过程中数据更新就容易认错元素导致渲染错乱。官方文档里其实强调了这一点但很多人包括我会直接忽略。handle指定只有匹配这个选择器的元素才能触发拖拽。比如你设置handle.drag-handle那用户只有按住带有drag-handle类的那个图标才能拖点行内其他地方不会触发。这是解决部分元素允许拖拽最直接的手段。filter指定匹配这个选择器的元素不能被拖拽。它和handle的区别在于handle是白名单filter是黑名单。handle说只有这里能拖filter说除了这里都能拖。实际项目里我两个都用过后面会讲什么场景用哪个。preventOnFilter这个属性容易被忽略但很关键。默认值是 true意思是当用户点击被filter命中的元素时会阻止默认的拖拽起手行为。但它同时也可能影响元素自身的默认交互比如输入框的聚焦。所以有时候需要把它设为 false让输入框能正常响应点击。2.2 el-table 行拖拽的两种实现路线对比el-table 的行拖拽我试过两条路线这里把优缺点都摆出来。路线一用draggable包裹 tbody。思路是把 el-table 默认渲染的 tbody 替换掉用 draggable 组件来生成行。代码大概是这样el-table :datatableData row-keyid draggable v-modeltableData tagtbody item-keyid :animation180 template #item{ element } tr td{{ element.name }}/td /tr /template /draggable /el-table乍看很优雅但实际跑起来问题不少。最明显的是列宽对不齐——el-table 用colgroup来统一控制每一列的宽度而你手写的trtd没有继承这套宽度结果就是表头和表体错位。固定列也基本失效了因为固定列是 el-table 自己计算出来的克隆节点你替换了 tbody 它就找不到目标了。路线二用 sortable 直接挂在 el-table 的 tbody 上。这是我最终采用的方案。不动 el-table 的模板结构等它渲染完了通过 ref 拿到表格的 tbody然后初始化拖拽import Sortable from sortablejs mounted() { this.initRowDrag() }, methods: { initRowDrag() { const el this.$refs.tableRef.$el.querySelector( .el-table__body-wrapper tbody ) if (!el) return this.sortableInstance Sortable.create(el, { animation: 180, handle: .drag-handle, filter: .no-drag, preventOnFilter: false, onEnd: this.handleDragEnd }) }, handleDragEnd({ oldIndex, newIndex }) { if (oldIndex newIndex) return const list this.tableData const [moved] list.splice(oldIndex, 1) list.splice(newIndex, 0, moved) } }因为 vuedraggable 底层就是 sortablejs所以这里直接用 sortablejs 并不算换技术栈反而更灵活。它的优势很明显表格的 DOM 结构完全不变列宽、固定列、表头全部正常拖拽的顺序变化通过onEnd手动处理数据数据流一目了然。注意el-table 如果开了固定列fixedDOM 里会有多个 tbody.el-table__body-wrapper tbody可能会匹配到多个元素。这时要用querySelector精确选中主表体或者给主表体加个特定的 class 更保险。2.3 row-key 与数据更新的正确姿势无论用哪条路线el-table 上的row-key都建议设上。它是 el-table 用来做行身份识别的拖拽后数据重排时如果没有 row-keyel-table 会按索引复用 DOM可能出现拖动后内容没变但顺序显示错了这种诡异现象。数据更新那一步最容易踩的坑是既让 vuedraggable 自动改了数组又在onEnd里手动 splice导致同一行被移动两次。要记住一个原则如果用 v-model 让组件自己管数据就不要在 onEnd 里再动手如果像路线二那样手动挂 sortable数据就必须自己 splice。二者选一个别混用。另外splice 的时候要小心数组响应式。Vue2 里直接对数组用splice是可以触发更新的Vue2 重写了数组的变异方法但如果你用的是index赋值或者delete就不会触发。所以老老实实用splice是最稳妥的。3. 部分元素禁止拖拽与交互冲突处理3.1 handle 和 filter 到底该用哪个这两个属性经常让人纠结我用一句话总结如果可拖拽区域比较小、很明确用 handle如果不可拖拽的元素比较明确用 filter。举个例子。我的任务表格里每行最左边有个拖动排序的图标用户按住它才能拖。这种就是典型的 handle 场景handle.drag-handle一行搞定其他区域完全不受影响输入框、文字选择都正常。但另一种情况比如整行都可以拖只有行里的输入框和几个操作按钮不能拖。这时候用 handle 就不合适了因为你不可能给整行的每个单元格都加个把手。那就用 filter把输入框、按钮这些排除掉filter: .el-input, .el-textarea, .no-drag, input, textarea, buttonsortablejs 的filter支持 CSS 选择器多个用逗号分隔就行。实测下来这种写法既直观又好维护。有一种特殊情况要提醒el-table 的单元格里如果放了可滚动、可点击的元素filter 要写得足够精确。我遇到过设置filter: .el-input之后输入框还是被拖拽的情况后来发现是 el-input 外层包了一层el-input__wrapper实际接收鼠标事件的元素没有被匹配到。解决办法是把选择器写成.el-input, .el-input__wrapper, .el-input__inner, input把整条链路都覆盖上。3.2 输入框和文字复制为什么总被拖拽打断这里得从 sortablejs 的机制说起。它默认在容器上监听mousedown事件一旦触发就进入预备拖拽状态接着监听mousemove如果鼠标移动超过一定阈值就正式开始拖拽。问题就出在这输入框的聚焦、文字的选中本质上都是 mousedown mousemove 的组合操作和拖拽的触发条件高度重合。所以用户点击输入框准备输入时sortable 以为要拖拽把事件接管了输入框自然聚焦不了。用户想选中一段文字拖动鼠标时也被判定成拖拽文字选不中。解决思路有两个层面。第一层是用 filter 把输入框和可复制区域排除出去让 sortable 在这些元素上不监听 mousedown。这一步能解决大部分问题。第二层是设置preventOnFilter: false让被 filter 命中的元素恢复默认行为。这个属性的含义是当点击被过滤的元素时是否阻止默认行为设成 false 后输入框的聚焦、文字的选中就能正常工作了。我最初的配置是preventOnFilter: true结果输入框虽然不拖了但点进去光标不出现特别别扭。改成 false 之后一切正常。这个细节文档里讲得不显眼但实际影响很大。注意filter和preventOnFilter是一对搭档通常要一起配置。只配 filter 不配 preventOnFilter交互元素可能变成既不拖也没反应的状态。3.3 拖拽过程中保留文字复制的实操配置还有一个更细的需求用户希望拖拽功能在但选中文字复制也要能正常做。这两件事的冲突点在于sortable 判断拖拽起手的阈值和文字选中的鼠标移动没有明显区分。我的做法是给可复制的文本套一个专门的类比如.copyable然后把它加进 filterfilter: .copyable, .el-input, .no-drag, input, textarea光这样还不够。因为 sortable 的拖拽判断是在容器级监听的如果用户从文字的左边一直拖到了右边空白处鼠标移出.copyable范围后事件可能还是会被 sortable 捕获。更稳妥的方式是在.copyable元素上再监听一次mousedown手动阻止事件冒泡// 在 mounted 里给可复制区域绑定 document.querySelectorAll(.copyable).forEach(el { el.addEventListener(mousedown, e e.stopPropagation()) })stopPropagation会阻止事件冒泡到 sortable 监听的容器上这样在可复制区域内做任何鼠标操作都不会触发拖拽。这个方案我实测下来非常稳文字选中、双击选词、拖拽复制全都没问题。不过要注意如果表格数据是动态渲染的每次数据更新后新增的.copyable元素不会自动绑定事件。这种情况要么在数据更新后用nextTick重新绑定要么改用事件委托在表格容器上统一监听 mousedown判断e.target是否带有.copyable类container.addEventListener(mousedown, e { if (e.target.closest(.copyable)) { e.stopPropagation() } })事件委托的方式一劳永逸不用关心元素什么时候新增我最后就是用的这个。4. 实操记录从零搭一套可拖拽的表格4.1 环境准备与依赖安装先把依赖装好。Vue2 项目里vuedraggable 用 2.x 版本npm install vuedraggable2.24.3 sortablejs1.15.0 --saveVue3 项目里vuedraggable 对应的是 4.x 版本包名也有所变化npm install vuedraggablenext sortablejs --save之所以两个包都装是因为 vuedraggable 主要是给普通列表用的而 el-table 的行拖拽我用 sortablejs 直接挂载。虽然 vuedraggable 依赖里已经带了 sortablejs但显式安装一下版本更可控也方便单独 import。这里提醒一句Vue2 装 vuedraggable4.x 会报错因为 4.x 是按 Vue3 的 Composition API 写的。反过来 Vue3 装 2.x 也不行。所以装之前先确认项目用的是哪个 Vue 版本这个坑我帮同事排查过一次。4.2 完整代码结构与关键片段下面是我项目里实际跑通的精简版结构把核心逻辑抽出来方便复用。模板部分表格正常写每行加个拖动把手和备注输入框template el-table reftableRef :datatableData row-keyid border el-table-column width50 template #default i classel-icon-rank drag-handle / /template /el-table-column el-table-column propname label名称 / el-table-column label订单号 template #default{ row } span classcopyable{{ row.orderNo }}/span /template /el-table-column el-table-column label备注 template #default{ row } el-input v-modelrow.remark sizesmall / /template /el-table-column /el-table /template逻辑部分初始化拖拽和事件委托import Sortable from sortablejs export default { data() { return { tableData: [], sortableInstance: null } }, mounted() { this.$nextTick(() { this.initRowDrag() this.bindCopyable() }) }, beforeDestroy() { if (this.sortableInstance) { this.sortableInstance.destroy() this.sortableInstance null } }, methods: { initRowDrag() { const tbody this.$refs.tableRef.$el.querySelector( .el-table__body-wrapper tbody ) if (!tbody) return this.sortableInstance Sortable.create(tbody, { animation: 180, handle: .drag-handle, filter: .copyable, .el-input, .el-input__wrapper, input, textarea, preventOnFilter: false, ghostClass: row-ghost, onEnd: this.handleDragEnd }) }, bindCopyable() { const wrap this.$refs.tableRef.$el wrap.addEventListener(mousedown, e { if (e.target.closest(.copyable)) { e.stopPropagation() } }) }, handleDragEnd({ oldIndex, newIndex }) { if (oldIndex newIndex) return const list this.tableData const [moved] list.splice(oldIndex, 1) list.splice(newIndex, 0, moved) } } }配一点样式让拖拽时的占位行有个淡淡的底色.row-ghost { background: #ecf5ff; opacity: 0.6; }这套代码跑起来之后效果是按住把手能拖行点击输入框能正常输入订单号能选中复制整行其他区域按下去不会误触拖拽。基本上把需求全覆盖了。4.3 拖拽结束后的数据持久化前后端分离的项目里拖拽改的是前端的顺序最终要同步到后端。我的做法是在handleDragEnd里数据调整完之后调一个接口把新的顺序 id 数组发过去handleDragEnd({ oldIndex, newIndex }) { if (oldIndex newIndex) return const list this.tableData const [moved] list.splice(oldIndex, 1) list.splice(newIndex, 0, moved) const ids list.map(item item.id) this.saveOrder(ids) }, async saveOrder(ids) { await this.$http.post(/api/task/sort, { ids }) }这里有个细节要注意不要等接口返回再更新视图。因为拖拽的视觉反馈是即时的如果等接口回来再重排数据中间会有一段空白或抖动。正确做法是先改前端数据让界面立刻响应接口失败时再回滚或者提示用户刷新。这也是拖拽类交互的一个通用原则——本地优先异步兜底。如果接口失败需要回滚可以在调用前把原数组存一份const backup [...list]失败时用backup恢复。不过说实话排序这种操作失败概率很低我一般就提示一下同步失败请刷新重试不搞太复杂的回滚逻辑。5. 常见问题排查与避坑清单5.1 拖拽回弹的几种原因拖拽回弹是我遇到最多的问题表现是松手后行弹回原来的位置。排查下来有这几种原因。第一种数据没有真正更新。比如手动挂了 sortable但onEnd里忘了写 spliceDOM 变了但数据没变Vue 下次渲染时又按旧数据把行摆回去了。解决办法就是确保onEnd里的数据更新逻辑没漏。第二种同一个数据被移动了两次。前面提过v-model 自动更新和手动 splice 混用会导致这个问题。第二次移动把行又移回了原位看起来就是回弹。第三种oldIndex 和 newIndex 相同但没做判断。理论上相同就不用动但如果代码不判断直接 splicesplice(oldIndex, 1)再splice(newIndex, 0, moved)在索引相同时其实不会变倒也不会有问题。真正会出问题的是索引计算错误比如在有固定列或者有展开行的表格里DOM 索引和数据索引对不上。这种情况要检查 tbody 里是不是混入了非数据行的 tr。第四种row-key 缺失或重复。没有唯一 keyel-table 的行复用会乱拖完看起来就像没动。给每行一个唯一的 id 绑到 row-key 上就能解决。5.2 表格滚动与样式异常处理el-table 的滚动条区域也是个容易出问题的地方。默认情况下sortable 挂载的 tbody 在滚动容器里拖拽到边缘时不会自动滚动。如果表格数据多用户想把最后一行拖到最上面就很费劲。解决办法是给 sortable 开启scroll配置Sortable.create(tbody, { scroll: true, scrollSensitivity: 30, scrollSpeed: 10, // ... })scrollSensitivity是鼠标距离边缘多少像素开始滚动scrollSpeed是滚动速度。这两个值根据表格高度调一下我的经验是 30 和 10 比较舒服。另一个样式问题是拖拽行的高度。el-table 的行高受单元格 padding 影响sortable 拖拽时的占位元素ghost如果没有继承行高会出现占位比实际行矮一截的情况视觉上很跳。给 ghost 类补上和行一致的样式即可.row-ghost td { padding: 12px 0; }还有就是 el-table 的滚动条宽度会对齐造成影响固定列场景下更明显。如果你发现拖动时行整体偏移了几个像素多半是滚动条占位导致 tbody 宽度变化sortable 计算落点时的参照有问题。可以在初始化时给 tbody 一个稳定的宽度或者干脆关掉固定列用横向滚动代替。5.3 问题速查表与实操心得把上面这些问题整理成一张速查表方便对照排查现象可能原因解决方向松手后行回弹数据未更新或更新两次检查 onEnd 里的 splice 逻辑输入框点不进去filter 未覆盖或 preventOnFilter 为 true补全选择器设 preventOnFilter: false文字选不中拖拽抢先响应 mousedown给可复制元素加 stopPropagation列宽对不齐用 draggable 替换了 tbody改用 sortable 挂载到原生 tbody拖动到边缘不滚动未开启 scroll 配置设置 scroll、scrollSensitivity行顺序显示错乱row-key 缺失或重复给每行唯一 id 绑定 row-key拖拽后内容错位DOM 索引与数据索引不一致检查 tbody 是否混入非数据行再补充几个文档里不会写、但实际很有用的心得。第一拖拽手把的图标最好做大一点。我一开始用的是默认小图标用户反馈不好按。后来把图标的点击热区扩大到 30x30 像素操作顺畅了很多。图标本身可以小但外层包一个大的点击区域。第二拖拽时的动画时长不要设太长。180 毫秒是我的经验值太快感觉生硬太慢又拖沓。超过 300 毫秒的话用户连续拖多行时会觉得卡顿。第三表格数据量大时慎用行拖拽。如果表格有几百行每次拖拽都触发整个列表的重新渲染会有明显的卡顿。这种情况下可以考虑分页或者只在当前页内做拖拽排序跨页排序用其他交互方式。第四记得在组件销毁时 destroy 掉 sortable 实例。我遇到过一次内存泄漏页面切来切去之后拖拽行为变得诡异最后发现是旧的 sortable 实例没销毁多个实例叠加在同一个 tbody 上。在beforeDestroy里调用sortableInstance.destroy()就能避免。第五开发阶段可以给 ghost 类加个醒目的颜色。比如亮黄色。这样调试时能一眼看清占位元素到底落在哪里很多对齐问题一看就知道了。上线前再换成柔和的颜色。这套方案我在项目里跑了一个多月中间又迭代了几次目前状态下拖拽、输入、复制三个交互互不干扰用户反馈也不错。真要说还有什么可以优化的就是拖拽过程中的异步同步可以加个 loading 提示让用户知道数据在保存避免他们在接口返回前又去拖别的行。这个属于体验优化不影响核心功能后面有精力再补。
返回列表