列表排序算法在前端项目中的实战优化技巧
先上代码,看完你就懂了
做前端这些年,最烦的不是写样式,是那种“看着简单,一做就崩”的交互。比如列表排序——用户拖一下就能重新排,看起来不就是个 drag & drop?可真动手的时候,你会发现:顺序存不住、动画卡顿、移动端不响应……一堆坑。
我最近在做一个后台管理页,需要让用户手动调整菜单顺序,数据要实时同步到后端。一开始想用现成的库,但引入一个 10KB 的插件只为做这个功能,太重了。最后决定自己手撸一套,亲测有效,今天把核心方案分享出来。
// 假设你有一个待排序的列表
let list = [
{ id: 1, name: '首页' },
{ id: 2, name: '产品' },
{ id: 3, name: '关于' },
{ id: 4, name: '联系' }
];
// 核心排序函数
function reorder(list, startIndex, endIndex) {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
}
这段代码是我从 React 官方拖拽库 react-beautiful-dnd 源码里扒出来的逻辑,别小看这几行,它处理了数组移动中最常见的边界问题。比如你从第 0 位拖到第 3 位,或反过来,都能正确插入。
实际项目中,我把这个 reorder 函数封装成了工具方法,放在 utils/array.js 里,以后哪儿要用直接 import。
DOM 层怎么做?HTML5 Drag API 最省事
很多人一上来就想用 mousemove + position 计算,折腾半天发现移动端根本没法用。我建议:能用原生 Drag API 就用,兼容性够用,代码也干净。
<ul class="sortable-list">
<li
draggable="true"
data-id="1"
ondragstart="handleDragStart(event)"
ondragover="handleDragOver(event)"
ondrop="handleDrop(event)"
ondragend="handleDragEnd(event)"
>
首页
</li>
<li
draggable="true"
data-id="2"
ondragstart="handleDragStart(event)"
ondragover="handleDragOver(event)"
ondrop="handleDrop(event)"
ondragend="handleDragEnd(event)"
>
产品
</li>
<!-- 更多 item -->
</ul>
let dragSrcEl = null;
function handleDragStart(e) {
dragSrcEl = this;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.getAttribute('data-id'));
this.classList.add('dragging');
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (this !== dragSrcEl) {
this.classList.add('drag-over');
}
return false;
}
function handleDrop(e) {
e.stopPropagation();
if (this !== dragSrcEl) {
const srcId = parseInt(dragSrcEl.getAttribute('data-id'), 10);
const destId = parseInt(this.getAttribute('data-id'), 10);
const srcIndex = list.findIndex(item => item.id === srcId);
const destIndex = list.findIndex(item => item.id === destId);
list = reorder(list, srcIndex, destIndex);
renderList(); // 重新渲染视图
}
return false;
}
function handleDragEnd() {
document.querySelectorAll('.dragging, .drag-over')
.forEach(el => el.classList.remove('dragging', 'drag-over'));
}
这里注意下,我踩过好几次坑:一定要在 handleDragOver 里调 e.preventDefault(),否则 ondrop 不触发。MDN 文档写得很隐晦,但我记得第一次是因为漏了这句,调试了快一个小时才定位到问题。
另外,draggable="true" 必须加在每个 li 上,不然整个列表都不会进入拖拽状态。
样式细节不能忽略
光有功能不行,用户体验得跟上。比如拖动时那个半透明影子,默认很难看,而且你没法控制它的内容。解决办法是自己画个占位元素,或者干脆接受默认表现——毕竟大多数后台系统用户更关心功能而不是动画。
我选择加点 CSS 让视觉反馈更明确:
.sortable-list .dragging {
opacity: 0.5;
background-color: #f0f0f0;
}
.sortable-list .drag-over {
background-color: #e6f7ff;
border-top: 2px solid #1890ff;
}
这样当用户拖到某个项上方时,会看到一条蓝色上线边框,直观表示“即将插入到这里”。比单纯改背景色更清晰。
移动端怎么办?Touch 事件替代方案
上面这套在 PC 端没问题,但在手机上完全不工作。我又踩坑了,客户拿着 iPad 一点没反应,当场尴尬。
后来补了个 touch 版本,原理类似,监听 touchstart、touchmove、touchend,然后动态计算位置。
核心思路是:手指按住不放超过 300ms 触发拖拽模式,期间根据 Y 坐标判断当前悬停在哪个 item 上。
代码略长,贴关键部分:
let touchTimeout;
let draggingItem = null;
const threshold = 300; // 毫秒
document.querySelectorAll('.sortable-list li').forEach(item => {
item.addEventListener('touchstart', function(e) {
const touch = e.touches[0];
dragStartY = touch.clientY;
touchTimeout = setTimeout(() => {
draggingItem = this;
this.classList.add('dragging');
}, threshold);
});
item.addEventListener('touchmove', function(e) {
if (!draggingItem) return;
clearTimeout(touchTimeout);
const touch = e.touches[0];
const currentY = touch.clientY;
const items = document.querySelectorAll('.sortable-list li');
// 找到当前应该交换的位置
for (let i = 0; i < items.length; i++) {
const box = items[i].getBoundingClientRect();
const center = box.top + box.height / 2;
if (currentY > center && items[i] !== draggingItem) {
// 视觉交换位置(可优化为仅移动 DOM)
if (i > Array.from(items).indexOf(draggingItem)) {
items[i].parentNode.insertBefore(draggingItem, items[i].nextSibling);
} else {
items[i].parentNode.insertBefore(draggingItem, items[i]);
}
break;
}
}
});
item.addEventListener('touchend', function() {
clearTimeout(touchTimeout);
if (draggingItem) {
const fromIndex = list.findIndex(...); // 获取原始索引
const toIndex = getCurrentDomOrder(); // 获取当前 DOM 顺序
list = reorder(list, fromIndex, toIndex);
renderList(); // 强制统一数据与视图
draggingItem = null;
}
});
});
这里有个大坑:touchmove 频率太高,直接操作 DOM 会导致卡顿。我最初的实现每 move 一次就 re-render 整个列表,滑两下页面就卡死了。后来改成只调整 DOM 顺序,最后一次性同步数据,才恢复正常。
但这还不是最优解。如果你项目复杂度高,建议直接上第三方库,比如 SortableJS,支持 PC 和移动端,API 简洁,压缩后才 7KB 左右。
异步保存和防抖
排序完总得存吧?我一开始是每次拖完立刻发请求:
fetch('https://jztheme.com/api/menu-order', {
method: 'POST',
body: JSON.stringify({ order: list.map(i => i.id) })
})
结果用户连拖五次,发了五个请求,后端直接报错“频率超限”。于是加上防抖:
let saveTimer;
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
fetch('https://jztheme.com/api/menu-order', {
method: 'POST',
body: JSON.stringify({ order: list.map(i => i.id) })
});
}, 800);
}
现在只要用户连续操作,就不会频繁提交。等他松手 800ms 后再保存。实测效果很好,既保证了及时性,又避免了压力。
踩坑提醒:这三点一定注意
- 不要依赖 DOM 顺序来维护数据顺序 —— 我试过直接读取
li的顺序作为最终结果,结果发现某些情况下 React 重新渲染导致 DOM 错乱,数据就错了。一定要以 JS 数据源为准,DOM 只是视图。 - 移动端慎用长时间按压触发 —— 有些安卓机自带长按菜单,会冲突。你可以考虑加个“编辑模式”开关,开启后才能拖拽,避免误触。
- IE11 支持有限 —— 虽然 Drag API 在 IE11 有基本支持,但
dataTransfer.setData只支持Text类型,其他类型会报错。如果还要兼容 IE,建议降级为点击上下箭头调整顺序。
拓展玩法:带分组的列表排序
有个需求是左右两个栏,左边未启用,右边已启用,允许互相拖拽。其实本质一样,只是 ondrop 时判断来源容器。
关键是在 dataTransfer 里多塞一个 from-container 标记,目标容器根据这个字段决定是否接收。
进阶技巧:可以给不同类型的 item 设置不同的 effectAllowed,比如只允许复制不允许移动,提升语义化。
总结
以上是我个人对列表排序的完整实践总结。核心就是那几行 reorder 数组操作 + 原生 Drag API,轻量、可控、不依赖框架。
当然也有不足:比如动画不够顺滑,移动端体验不如原生 App。但对大多数管理系统来说,够用了。
这个技巧的拓展用法还有很多,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

暂无评论