用Sortable.js实现拖拽排序的实战经验与避坑指南
优化前:卡得不行
项目上线前压测,客户现场反馈:“拖个列表,手指都抬起来了,item还在半路飘着”。我本地一试——50条数据,Chrome DevTools 里 FPS 直接掉到 8fps,touchmove 回调里 layout thrashing 闪红,console 里一堆 Layout was forced before the end of a frame。更离谱的是,拖拽过程中点击其他按钮完全无响应,React 的 onClick 像被封印了。
一开始我以为是 Sortable.js 版本太老(用的 1.14),升级到 1.15.2 后发现没卵用。又怀疑是 React.memo 没包好,结果加了一圈 memo,性能纹丝不动。最后打开 Performance 面板录了一段 3 秒拖拽,一看火焰图:90% 时间耗在 getBoundingClientRect() + reflow + repaint,而且是每帧都调……不是每帧,是每 touchmove 都来一遍,平均 12ms/次,60fps 下根本扛不住。
找到瘼颈了!
我直接扒 Sortable.js 源码(v1.15.2),定位到核心逻辑在 _onTouchMove 和 _getDragElRect 这俩函数。关键问题就一个:每次 touchmove 都同步调用 el.getBoundingClientRect(),而这个 API 是强制同步 layout 的,尤其在复杂 DOM 结构下(我们列表里每个 item 还套了 3 层 div + 2 个 svg 图标 + 动态 tooltip)——你动一下,浏览器就得重排整个父容器。
顺手在控制台打了几个时间戳:
console.time('getRect'); el.getBoundingClientRect(); console.timeEnd('getRect');
// 平均 4.2ms —— 单次就占掉一帧的 7%!
再看 Sortable 的默认配置:forceFallback: false、scroll: true、animation: 150……全开。尤其是 scroll: true,它内部会每帧检查滚动边界,又触发一次 getBoundingClientRect()。两个地方叠加,单次 touchmove 触发 2 次强制 layout,不卡才怪。
核心优化:砍掉同步 layout,用 requestAnimationFrame 节流 + 缓存 rect
我试了几种方案:
- 方案一:直接关
scroll: false—— 行不通,客户要求必须支持滚动容器内拖拽; - 方案二:用
CSS transform替代 top/left 定位 —— Sortable 默认不用 transform,改起来要动底层渲染逻辑,风险高; - 方案三:劫持
getBoundingClientRect,缓存 + RAF 节流 —— 亲测有效,改 3 行代码,效果最猛。
具体操作:在 Sortable 初始化前,重写元素的 getBoundingClientRect 方法,只对拖拽中的 dragEl 生效,并且加一层 RAF 缓存:
// 重写 getBoundingClientRect,仅对 dragEl 生效
const originalGetRect = Element.prototype.getBoundingClientRect;
let cachedRect = null;
let isDragging = false;
Element.prototype.getBoundingClientRect = function() {
// 只拦截拖拽元素,避免污染全局
if (isDragging && this === document.querySelector('.sortable-drag')) {
if (!cachedRect || performance.now() - cachedRect.timestamp > 16) {
cachedRect = {
rect: originalGetRect.call(this),
timestamp: performance.now()
};
}
return cachedRect.rect;
}
return originalGetRect.call(this);
};
// 在 Sortable 实例上挂载钩子
const sortable = new Sortable(el, {
onStart: () => {
isDragging = true;
cachedRect = null;
},
onEnd: () => {
isDragging = false;
}
});
但这里有个坑:Sortable 内部还会读取 container 的 rect 来判断是否需要滚动。所以还得补一刀,在 onMove 里手动缓存 container rect:
const containerRectCache = { rect: null, ts: 0 };
const sortable = new Sortable(el, {
scroll: true,
onMove: (evt) => {
const now = performance.now();
if (!containerRectCache.rect || now - containerRectCache.ts > 16) {
containerRectCache.rect = evt.to.getBoundingClientRect();
containerRectCache.ts = now;
}
// 强制使用缓存 rect 替换 evt.to.getBoundingClientRect()
Object.defineProperty(evt, 'toRect', {
value: containerRectCache.rect,
writable: false
});
}
});
不过这样太 hack,最终我选了更干净的方式:继承 Sortable 类,覆盖 _getContainerRect 方法:
class OptimizedSortable extends Sortable {
_getContainerRect() {
if (!this._cachedContainerRect || performance.now() - this._cacheTime > 16) {
this._cachedContainerRect = super._getContainerRect();
this._cacheTime = performance.now();
}
return this._cachedContainerRect;
}
}
// 使用
const sortable = new OptimizedSortable(el, {
animation: 0, // 关掉动画,减少 repaint
scrollSensitivity: 30,
scrollSpeed: 10
});
顺手干掉的次要问题
还有几个“不致命但很烦”的点一起收拾了:
- 关闭 animation:设为 0,靠 CSS transition 控制视觉反馈,避免 Sortable 自己用 JS 做定时器;
- 禁用 placeholder 的 box-shadow:我们 placeholder 是个空 div,但加了
box-shadow: 0 2px 8px rgba(0,0,0,0.1),导致每次位置变化都触发 paint,删掉后 FPS +3; - 给拖拽元素加
will-change: transform,让浏览器提前进 GPU 图层(虽然效果不如预期,但总比没有强); - React 侧加
shouldComponentUpdate拦截无关 state 更新,防止拖拽时父组件 rerender 把整个列表刷一遍。
优化后:流畅多了
改完再测:50 条数据,FPS 稳定在 58~60,touchmove 回调执行时间从平均 12ms 降到 0.8ms,layout thrashing 红框彻底消失。更关键是——用户感知明显提升:拖拽时手指跟 item 基本同步,松手后排序动画也顺滑了(因为主线程不卡了)。
真实数据对比(MacBook Pro M1 + Chrome 124):
- 首屏可交互时间:从 5.2s → 0.8s(主要受益于取消了初始 render 时的 sort 计算);
- 拖拽过程平均帧耗时:14.3ms → 1.6ms;
- 内存占用峰值:降了 32MB(GC 更频繁,因为没那么多 layout 中间状态了);
- 真机(iPhone 12)上 100 条数据也能跑出 55fps。
性能数据对比
这是我在测试环境跑的三组对照数据(50 条数据,Chrome Performance 录制 3 秒拖拽):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Layout 平均耗时/次 | 4.2ms | 0.3ms | 93% |
| JS 执行总耗时(3s) | 1842ms | 291ms | 84% |
| 最小 FPS | 7 | 56 | +49 |
| 主线程阻塞 > 50ms 次数 | 12 | 0 | 100% |
注意:这还没算上我们业务层的 debounce 优化(比如把 onSort 里的 API 请求节流到拖拽结束 300ms 后发),那又是额外 200ms 的节省。
踩坑提醒:这三点一定注意
1. 别全局 monkey patch getBoundingClientRect —— 我第一版就是这么干的,结果页面里所有第三方图表库(ECharts)坐标计算全乱了,折腾半天才发现是它也在疯狂调这个 API。一定要限定作用域,只劫持 Sortable 的 dragEl 和 container。
2. 缓存时间别设太长 —— 我试过 32ms,结果快速滚动时 placeholder 错位,最后定在 16ms(≈1 帧),平衡了性能和精度。
3. React 18 的 concurrent rendering 不会帮你解决 layout thrashing —— 别指望 useTransition 能救 Sortable,它底层还是同步 DOM 操作,该卡一样卡。
以上是我的优化经验,有更好的方案欢迎交流
这个方案不是银弹:如果你们列表里有大量 canvas 或 video,可能还要配合 transform: translateZ(0) 强制图层提升;如果要用 Sortable 的 group 多列拖拽,缓存逻辑还得扩展 container 数组。但我敢说,砍掉同步 getBoundingClientRect + RAF 缓存,是绝大多数 Sortable 卡顿问题的最优解——改动小、见效快、不依赖框架。
如果你也在用 Sortable,或者正在踩类似的坑,欢迎评论区甩出你的场景,我来帮你一起看。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做虚拟滚动排序,后续会继续分享这类博客。

暂无评论