手把手实现一个高性能的拖拽网格布局

诗诗 交互 阅读 878
赞 14 收藏
二维码
手机扫码查看
反馈

又踩坑了,touchmove滚动失效

搞了个拖拽网格布局,PC端好好的,手指一碰手机屏幕——页面卡死,根本没法滚动。这谁顶得住?我以为加个 touch-action: manipulation 就完事了,结果发现整个 touch 事件全被干掉了,滑动列表直接罢工。

手把手实现一个高性能的拖拽网格布局

这里我踩了个坑:为了防止拖拽时误触滚动,我在根元素上监听了 touchstarttouchmove,然后无脑调用了 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 拖拽延迟,欢迎评论区交流。我现在是真不想再碰这块了,头发经不起消耗。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论