Drag and Drop 实现详解与实战踩坑经验分享

一纳利 前端 阅读 2,804
赞 22 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周搞一个拖拽排序功能,本来以为就是调个 HTML5 Drag API 走个过场,结果上线后测试同学直接甩我一句话:“你这拖拽是拿脚写的吧?卡成 PPT 了。”

Drag and Drop 实现详解与实战踩坑经验分享

确实,一拖就掉帧,尤其在列表项多的时候(大概 100+ 个),整个页面直接卡死半秒以上。用户拖着元素移动,视觉反馈延迟严重,手指都松开了元素还在原地晃悠——典型的性能瓶颈。

最要命的是,在移动端(Safari 和 Chrome 都有)上更夸张,touchmove 触发太频繁,主线程直接被干爆。我一度怀疑是不是用了什么奇怪的第三方库(其实没用,纯手写),但排查下来发现,问题出在自己代码里。

找到瓶颈了!

先打开 Chrome DevTools 的 Performance 面板,录一段拖拽过程。一看火焰图,好家伙,mousemove / touchmove 事件回调里堆满了重排(Layout)和重绘(Paint)。每次移动都触发一次 getBoundingClientRect(),然后又去算新位置、更新 DOM 样式……这不卡才怪。

再看内存快照,发现每拖一次就创建一堆临时对象,虽然没内存泄漏,但频繁 GC 也让帧率雪上加霜。

核心问题就两个:

  • 高频事件处理里做了太多同步 DOM 操作
  • 没有做任何节流或防抖,事件触发频率太高

其实还有个小问题是用了 transform: translate() 但没开启硬件加速(比如没加 will-changetranslateZ(0)),不过这个影响不大,先不管。

核心优化:把拖拽逻辑从主线程“偷”出来

试了几种方案:

  • 方案一:给 mousemoverequestAnimationFrame 包裹 —— 有点用,但不够
  • 方案二:彻底不用原生 drag,改用 pointer events + CSS transform —— 更灵活,但代码量大
  • 方案三:用 CSS transform + position: absolute + 节流 + 虚拟占位 —— 最终选它

折腾了半天发现,关键不是“怎么监听”,而是“怎么更新”。我们根本不需要实时更新 DOM 结构,只需要让用户看到元素跟着手指走就行。真正的 DOM 交换,完全可以等拖拽结束再做。

所以思路变成:

  1. 拖拽开始时,clone 一个“影子元素”,绝对定位,跟着鼠标/手指走
  2. 原元素隐藏(或透明),避免干扰布局
  3. 移动过程中只更新影子元素的 transform,不做任何布局计算
  4. 监听容器内其他元素的位置,用 getBoundingClientRect() 预先缓存一次(只在 dragstart 时做)
  5. 当影子元素跨过某个阈值(比如中点),才标记“需要交换”,但不立即操作 DOM
  6. 拖拽结束,一次性更新所有状态和 DOM

这样,99% 的拖拽过程都不碰真实 DOM,自然流畅多了。

代码对比:优化前后差太多

先看优化前的“灾难代码”:

let draggingEl = null;

document.addEventListener('mousedown', (e) => {
  if (!e.target.classList.contains('draggable')) return;
  draggingEl = e.target;
  document.addEventListener('mousemove', handleDrag);
});

function handleDrag(e) {
  // 直接改 style,每次 move 都触发布局
  draggingEl.style.left = e.clientX + 'px';
  draggingEl.style.top = e.clientY + 'px';

  // 实时遍历所有兄弟节点,判断是否需要交换
  const siblings = Array.from(draggingEl.parentNode.children);
  siblings.forEach(sib => {
    if (sib === draggingEl) return;
    const rect = sib.getBoundingClientRect();
    if (e.clientY > rect.top + rect.height / 2) {
      // 立即插入 DOM!疯狂重排
      draggingEl.parentNode.insertBefore(draggingEl, sib.nextSibling);
    }
  });
}

这代码简直是在主线程上开派对,每个 move 都触发 layout + paint + composite,帧率直接掉到 10fps 以下。

优化后的版本:

let draggingEl = null;
let ghostEl = null;
let itemRects = [];
let container = document.querySelector('.drag-container');

function initDrag(e) {
  if (!e.target.classList.contains('draggable')) return;
  
  draggingEl = e.target;
  // 隐藏原元素
  draggingEl.style.opacity = '0';
  
  // 创建 ghost 元素
  ghostEl = draggingEl.cloneNode(true);
  ghostEl.style.position = 'fixed';
  ghostEl.style.pointerEvents = 'none';
  ghostEl.style.zIndex = '9999';
  ghostEl.style.transform = translate(${e.clientX}px, ${e.clientY}px);
  document.body.appendChild(ghostEl);

  // 只在开始时缓存所有 item 的位置
  itemRects = Array.from(container.children)
    .filter(el => el !== draggingEl)
    .map(el => ({
      el,
      rect: el.getBoundingClientRect()
    }));

  document.addEventListener('mousemove', handleDragMove);
  document.addEventListener('mouseup', handleDragEnd);
}

function handleDragMove(e) {
  // 只更新 ghost 的 transform,无布局影响
  ghostEl.style.transform = translate(${e.clientX}px, ${e.clientY}px);

  // 判断是否需要交换(仅逻辑,不动 DOM)
  let targetIndex = -1;
  for (let i = 0; i < itemRects.length; i++) {
    const { rect } = itemRects[i];
    if (e.clientY > rect.top + rect.height / 2) {
      targetIndex = i;
      break;
    }
  }

  // 这里可以高亮提示,但别动真实 DOM
  // 比如给目标元素加个 .drop-hint 类(用 CSS 控制,不影响布局)
}

function handleDragEnd() {
  // 移除 ghost
  if (ghostEl) {
    document.body.removeChild(ghostEl);
    ghostEl = null;
  }
  
  // 恢复原元素
  draggingEl.style.opacity = '1';

  // 此时才真正交换 DOM(一次性操作)
  // 假设我们已经通过某种方式知道目标位置 index
  // container.insertBefore(draggingEl, targetEl);

  // 清理事件
  document.removeEventListener('mousemove', handleDragMove);
  document.removeEventListener('mouseup', handleDragEnd);
  draggingEl = null;
  itemRects = [];
}

container.addEventListener('mousedown', initDrag);

关键点:

  • fixed + transform 的 ghost 元素,完全脱离文档流
  • 真实 DOM 只在 dragstart 和 dragend 时操作一次
  • 中间过程只读缓存的 rect,不做实时查询
  • 所有样式变更都走 transform,GPU 加速友好

这里注意我踩过好几次坑:一开始 ghost 元素用 absolute,结果在滚动容器里坐标错乱;后来改成 fixed,配合 clientX/Y 才对。另外,记得给 ghost 加 pointer-events: none,否则会拦截后续事件。

额外小优化:节流 + passive event

虽然上面方案已经够用,但为了保险,我还是加了两处微调:

  • mousemove / touchmove{ passive: true }(如果不需要 preventDefault
  • 在低端机上,对 handleDragMove 做简单节流(比如每 16ms 执行一次)

不过实测发现,只要不动 DOM,其实不加节流也够流畅。passive event 倒是值得加,能减少输入延迟:

// touch 事件示例
container.addEventListener('touchstart', initDrag, { passive: false });
// 因为 touchstart 里要 preventDefault(防止滚动),所以不能 passive
// 但 touchmove 如果只是读坐标,可以 passive
document.addEventListener('touchmove', handleDragMove, { passive: true });

性能数据对比

在同一个 120 项的列表里测试(MacBook Pro + Chrome):

  • 优化前:拖拽平均帧率 12fps,主线程占用 85ms+/frame,用户明显卡顿
  • 优化后:稳定 60fps,主线程占用 < 5ms/frame,肉眼完全流畅

加载时间倒没变(因为不是首屏问题),但交互响应时间从 300ms+ 降到 < 16ms(一帧内完成)。

移动端(iPhone 12 Safari)效果更明显:原来拖两下就卡死,现在丝滑得像原生 App。

还没完:遗留的小问题

当然,这方案也不是完美。比如:

  • 如果容器本身在滚动(比如 overflow:auto),ghost 的 fixed 定位会飘 —— 解决办法是用 getBoundingClientRect + scrollTop 动态修正,但麻烦,我暂时没加
  • 多列布局(grid/masonry)的交叉检测逻辑复杂,我这个 demo 只处理了单列

不过对于大多数后台管理系统里的拖拽排序,已经完全够用。毕竟,80% 的场景不需要 fancy 的交互动效,能快 + 稳就行。

最后说两句

以上是我踩坑后的总结,核心就一点:拖拽过程中尽量别碰真实 DOM,用视觉欺骗代替实时更新。性能优化很多时候不是加黑科技,而是少做点事。

这个技巧的拓展用法还有很多,比如结合 ResizeObserver 做动态容器适配,或者用 IntersectionObserver 做懒加载拖拽区——后续会继续分享这类博客。

以上是我个人对 Drag and Drop 性能优化的完整讲解,有更优的实现方式欢迎评论区交流。

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

暂无评论