Drag and Drop 实现详解与实战踩坑经验分享
优化前:卡得不行
上周搞一个拖拽排序功能,本来以为就是调个 HTML5 Drag API 走个过场,结果上线后测试同学直接甩我一句话:“你这拖拽是拿脚写的吧?卡成 PPT 了。”
确实,一拖就掉帧,尤其在列表项多的时候(大概 100+ 个),整个页面直接卡死半秒以上。用户拖着元素移动,视觉反馈延迟严重,手指都松开了元素还在原地晃悠——典型的性能瓶颈。
最要命的是,在移动端(Safari 和 Chrome 都有)上更夸张,touchmove 触发太频繁,主线程直接被干爆。我一度怀疑是不是用了什么奇怪的第三方库(其实没用,纯手写),但排查下来发现,问题出在自己代码里。
找到瓶颈了!
先打开 Chrome DevTools 的 Performance 面板,录一段拖拽过程。一看火焰图,好家伙,mousemove / touchmove 事件回调里堆满了重排(Layout)和重绘(Paint)。每次移动都触发一次 getBoundingClientRect(),然后又去算新位置、更新 DOM 样式……这不卡才怪。
再看内存快照,发现每拖一次就创建一堆临时对象,虽然没内存泄漏,但频繁 GC 也让帧率雪上加霜。
核心问题就两个:
- 高频事件处理里做了太多同步 DOM 操作
- 没有做任何节流或防抖,事件触发频率太高
其实还有个小问题是用了 transform: translate() 但没开启硬件加速(比如没加 will-change 或 translateZ(0)),不过这个影响不大,先不管。
核心优化:把拖拽逻辑从主线程“偷”出来
试了几种方案:
- 方案一:给
mousemove加requestAnimationFrame包裹 —— 有点用,但不够 - 方案二:彻底不用原生 drag,改用 pointer events + CSS transform —— 更灵活,但代码量大
- 方案三:用 CSS
transform+position: absolute+ 节流 + 虚拟占位 —— 最终选它
折腾了半天发现,关键不是“怎么监听”,而是“怎么更新”。我们根本不需要实时更新 DOM 结构,只需要让用户看到元素跟着手指走就行。真正的 DOM 交换,完全可以等拖拽结束再做。
所以思路变成:
- 拖拽开始时,clone 一个“影子元素”,绝对定位,跟着鼠标/手指走
- 原元素隐藏(或透明),避免干扰布局
- 移动过程中只更新影子元素的
transform,不做任何布局计算 - 监听容器内其他元素的位置,用
getBoundingClientRect()预先缓存一次(只在 dragstart 时做) - 当影子元素跨过某个阈值(比如中点),才标记“需要交换”,但不立即操作 DOM
- 拖拽结束,一次性更新所有状态和 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 性能优化的完整讲解,有更优的实现方式欢迎评论区交流。

暂无评论