拖拽排序实现原理与前端实战技巧分享
核心代码就这几行
拖拽排序这东西,我做过不下五次。每次一开始都想着“自己写个原生的吧”,结果折腾半天,不是滚动失效就是 touch 事件冲突,最后还是乖乖用现成方案。但如果你只是想快速实现一个列表拖拽排序,其实原生 JavaScript 配合 HTML5 Drag and Drop API 完全够用,而且不用引入任何依赖。
先看最基础的实现:
<ul id="sortable-list">
<li draggable="true">Item 1</li>
<li draggable="true">Item 2</li>
<li draggable="true">Item 3</li>
<li draggable="true">Item 4</li>
</ul>
#sortable-list li {
padding: 12px;
margin: 4px 0;
background: #f5f5f5;
cursor: move;
user-select: none;
}
const list = document.getElementById('sortable-list');
let draggedItem = null;
list.addEventListener('dragstart', (e) => {
draggedItem = e.target;
setTimeout(() => e.target.classList.add('dragging'), 0);
});
list.addEventListener('dragend', (e) => {
e.target.classList.remove('dragging');
draggedItem = null;
});
list.addEventListener('dragover', (e) => {
e.preventDefault();
const afterElement = getDragAfterElement(list, e.clientY);
if (afterElement == null) {
list.appendChild(draggedItem);
} else {
list.insertBefore(draggedItem, afterElement);
}
});
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('li:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
这段代码亲测有效,能跑通基本的拖拽排序。核心逻辑就两块:一是记录拖拽元素,二是通过 dragover 事件动态插入到目标位置。关键点在于 getDragAfterElement 这个函数——它通过判断鼠标 Y 坐标是否在元素中点以上,来决定插入位置。这个方法比单纯用 event.target 稳定得多,因为 event.target 在快速拖动时经常乱跳。
踩坑提醒:这三点一定注意
别看上面代码简单,实际用起来我踩过不少坑,这里重点说三个:
- 移动端完全不生效:HTML5 的 drag 事件在 iOS 和部分 Android 浏览器上根本不起作用。如果你要做移动端兼容,建议直接放弃这套方案,改用
touchstart/touchmove/touchend自己实现,或者用现成库(比如 SortableJS)。 - 样式闪烁问题:拖拽开始时如果不加
setTimeout,被拖元素会短暂消失再出现,视觉上很卡。加个 0 毫秒的延迟,让浏览器先渲染完再加 class,就能避免这个问题。 - 父容器高度塌陷:当拖拽元素离开原位置时,如果列表项高度不固定,后面的元素会瞬间上移,导致
clientY计算错位。解决方案是给每个 item 加min-height,或者在拖拽开始时给被拖元素设置一个占位符(placeholder)。
特别是第三点,我在一个动态高度的卡片列表里栽过跟头。后来发现,与其硬算坐标,不如在拖拽开始时 clone 一个透明占位元素留在原地,等拖拽结束再移除,这样布局不会崩。
这个场景最好用
如果你是在做后台管理系统、CMS 编辑器、或者配置面板这类桌面端优先的项目,原生 drag & drop 其实挺香的。它不需要额外依赖,体积小,性能也还行。
但如果是面向 C 端、需要支持手机的场景,我建议直接上 SortableJS。它封装了各种边界情况,支持 touch、嵌套、动画、甚至 Vue/React 插件。虽然多了一个 10KB 左右的依赖,但省下的调试时间绝对值回票价。
举个例子,用 SortableJS 实现同样功能,代码更清爽:
import Sortable from 'sortablejs';
new Sortable(document.getElementById('sortable-list'), {
animation: 150,
ghostClass: 'dragging',
onEnd: function (evt) {
console.log('新顺序:', evt.newIndex, evt.oldIndex);
// 这里可以发请求同步后端
}
});
而且它自动处理了占位、滚动、跨容器拖拽这些麻烦事。我后来在 jztheme.com 的后台组件库里就统一用了 SortableJS,省心。
高级技巧:怎么和 React/Vue 集成
很多人问:“我在用 React,怎么搞?” 其实不管是 React 还是 Vue,核心思路都是:UI 层由框架控制,拖拽逻辑交给原生或第三方库。
以 React 为例,不要试图在 onDragStart 里 setState 来更新状态,那样会触发重渲染,导致拖拽中断。正确做法是:
- 用 useRef 获取 DOM 列表
- 初始化 Sortable 实例(或原生事件监听)
- 在
onEnd回调里,根据 newIndex/oldIndex 手动调整 state 数组
下面是一个简化版的 React Hook 封装:
import { useEffect, useRef } from 'react';
import Sortable from 'sortablejs';
function useSortable(listRef, items, setItems) {
useEffect(() => {
const el = listRef.current;
if (!el) return;
const sortable = new Sortable(el, {
onEnd: (evt) => {
const newItems = [...items];
const [movedItem] = newItems.splice(evt.oldIndex, 1);
newItems.splice(evt.newIndex, 0, movedItem);
setItems(newItems);
}
});
return () => sortable.destroy();
}, [items, setItems]);
}
然后在组件里这么用:
const MyList = () => {
const [items, setItems] = useState(['A', 'B', 'C']);
const listRef = useRef(null);
useSortable(listRef, items, setItems);
return (
<ul ref={listRef}>
{items.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
);
};
注意:key 最好用稳定 ID,别用 index,否则拖拽时 React 会复用错误的 DOM 节点,导致视觉错乱。这点我踩过坑,折腾了半天才发现是 key 的问题。
结尾:别重复造轮子,但要懂原理
拖拽排序看起来简单,但真要做得健壮,涉及事件流、布局计算、性能优化、跨平台兼容,细节非常多。我现在的策略是:内部工具用原生方案快速实现,对外产品一律用 SortableJS 或类似成熟库。
不过,哪怕你用库,也建议至少看一遍原生实现。不然遇到奇怪 bug(比如拖拽时页面突然滚动、或者和其他手势冲突),你连排查方向都没有。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多——比如拖拽上传、跨列表交换、结合虚拟滚动的大列表排序,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论