手把手实现一个高性能的拖拽网格布局
又踩坑了,touchmove滚动失效
搞了个拖拽网格布局,PC端好好的,手指一碰手机屏幕——页面卡死,根本没法滚动。这谁顶得住?我以为加个 touch-action: manipulation 就完事了,结果发现整个 touch 事件全被干掉了,滑动列表直接罢工。
这里我踩了个坑:为了防止拖拽时误触滚动,我在根元素上监听了 touchstart 和 touchmove,然后无脑调用了 e.preventDefault()。本意是想让拖拽更流畅,结果连正常的页面滚动都给禁了。后来在真机调试里看到控制台报了一堆 passive listener 的 warning,才意识到问题没那么简单。
折腾了半天发现,现代浏览器默认把 touch 事件设为 passive,就是为了保证滚动的流畅性。你一旦主动阻止默认行为,就得显式声明 { passive: false },否则 preventDefault() 直接无效。但就算加上这个,也还是有问题:你不让 touchmove 触发滚动,那用户怎么翻页?尤其在长列表里嵌套可拖拽项的时候,体验直接崩盘。
三种方案对比,我选了最简单的
第一种方案是用现成库,比如 interact.js 或 gridstack.js。这些库确实功能全,但引入后体积涨了一大截,而且定制成本高。我就想要个基础拖拽换位,不想背这么大包袱。
第二种是完全自己实现 touch 事件逻辑,记录起始坐标、判断滑动方向、做节流处理……听着就累。写了半天,边缘情况一堆 bug:比如轻微手抖就被识别成拖拽,或者拖到边界时突然跳位。
最后试了下第三种:改用 HTML5 的 drag & drop API。本来觉得这玩意儿太老古董,兼容性拉胯,但实际上在移动端配合一点手势优化,居然能跑通。关键是它自带事件隔离机制,不会随便劫持 scroll,省了不少心。
核心代码就这几行
其实关键就是两个点:
- 让每个网格项变成可拖拽的(设置 draggable=”true”)
- 在 dragenter 时交换位置,而不是等到 drop 才更新状态
这样用户拖着一个格子划过其他格子时,能实时看到位置变化,体验接近原生 App。
下面是主要实现:
<div class="grid-container" @dragover.prevent @drop="handleDrop">
<div
v-for="(item, index) in gridItems"
:key="item.id"
class="grid-item"
draggable="true"
@dragstart="handleDragStart(index)"
@dragenter="handleDragEnter(index)"
@dragend="handleDragEnd"
>
{{ item.label }}
</div>
</div>
.grid-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
padding: 16px;
}
.grid-item {
background: #f0f0f0;
border-radius: 8px;
text-align: center;
padding: 20px 0;
cursor: move;
user-select: none;
}
export default {
data() {
return {
gridItems: [
{ id: 1, label: 'A' },
{ id: 2, label: 'B' },
{ id: 3, label: 'C' },
{ id: 4, label: 'D' },
{ id: 5, label: 'E' },
{ id: 6, label: 'F' }
],
draggingIndex: -1
}
},
methods: {
handleDragStart(index) {
this.draggingIndex = index
// 必须设置 setData,不然某些浏览器不触发后续事件
event.dataTransfer.setData('text/plain', 'drag')
},
handleDragEnter(targetIndex) {
// 防止自己拖到自己
if (targetIndex === this.draggingIndex) return
// 实时交换位置
const items = [...this.gridItems]
const temp = items[targetIndex]
items[targetIndex] = items[this.draggingIndex]
items[this.draggingIndex] = temp
this.gridItems = items
this.draggingIndex = targetIndex
},
handleDragEnd() {
this.draggingIndex = -1
},
handleDrop() {
// 可在这里做持久化保存
fetch('https://jztheme.com/api/sort', {
method: 'POST',
body: JSON.stringify(this.gridItems.map(i => i.id))
})
}
}
}
踩坑提醒:这三点一定注意
第一,dragenter 不会冒泡,必须绑定在每一个子元素上。 我一开始只绑在 container 上,结果拖进去没反应,调试半天才发现事件根本没触发。后来查文档才知道,dragenter 是 element-specific 的,得每个 item 自己监听。
第二,dataTransfer 必须 set 数据,哪怕只是占位符。 Chrome 某些版本如果没 set,drag 事件链会中断,尤其是移动端 WebView 里特别明显。我加了 event.dataTransfer.setData('text/plain', 'drag') 之后才稳定下来。
第三,不要在 dragover 里做 swap,否则频率太高会导致卡顿。 我之前图省事,在 dragover 里频繁更新数组,结果滑得快一点就会出现“跳帧”现象。换成只在 dragenter 时交换,性能立马回升。
还有个小问题没彻底解决
现在有个小瑕疵:当拖拽到最后一个格子时,如果快速往上划,页面应该跟着滚动才对。但现在容器外的区域没有自动滚屏逻辑。理论上可以通过监听 dragover 坐标,判断是否靠近边缘,然后手动触发 window.scrollBy。但我试了几次,节奏很难把握,容易造成抖动。
暂时先放着了。毕竟大部分用户拖拽距离都不长,超出视口的情况不多。等哪天产品提需求再说吧,优先级不高。
另外 iOS Safari 对 drag & drop 支持有点怪,第一次拖需要长按半秒才触发,不像 Android 那么灵敏。有方案说可以用 CSS 加 -webkit-user-drag: element; 来优化,但我项目里用的是 Vue,动态样式管理麻烦,索性保持原样。
为什么不用 pointer events?
其实我也想过用 pointer events 统一处理 mouse/touch/pen。API 设计确实更现代,setPointerCapture() 能很好解决多点干扰问题。但问题是,低版本安卓 WebView 对 pointer events 的支持很差,我们还得兼容到 Android 8 的系统 WebView,所以只能放弃。
相比之下,drag & drop 虽然老旧,但兼容性反而更好。iOS 10+、Android 5+ 都能跑,除了那个该死的长按延迟。
当然如果你不需要考虑低端机型,pointer events 真的是更好的选择。特别是要做多指拖拽、精细控制抓取状态的时候,它的事件流比 touch 更清晰。
以上是我踩坑后的总结
这个方案不是最优解,也没有炫技的算法,但它简单、可控、出问题容易定位。对于大多数中后台或运营类项目来说,够用了。
如果你想做得更顺滑,可以结合 requestAnimationFrame 做动画插值,或者用 transform 模拟位移避免重排。但我试了下,视觉提升有限,代码复杂度却翻倍,性价比不高。
如果你有更好的实现方式,比如怎么优雅地实现自动滚屏,或者怎么降低 iOS 拖拽延迟,欢迎评论区交流。我现在是真不想再碰这块了,头发经不起消耗。

暂无评论