前端列表排序的多种实现方案与性能对比实战

A. 春红 交互 阅读 1,244
赞 16 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上周改一个后台管理页的排序功能,用户要求能拖拽调整列表顺序。我一开始想用现成的库,比如 SortableJS,但项目里已经有一堆依赖了,不想再加。于是决定自己撸一个轻量版。折腾了半天,发现核心逻辑其实就几十行代码,关键在于怎么处理 DOM 移动和数据同步。

前端列表排序的多种实现方案与性能对比实战

下面这个方案亲测有效,支持鼠标和触屏(简单场景),而且不依赖任何第三方库。直接上核心代码:

<ul id="sortable-list">
  <li data-id="1">Item 1</li>
  <li data-id="2">Item 2</li>
  <li data-id="3">Item 3</li>
  <li data-id="4">Item 4</li>
</ul>
#sortable-list {
  list-style: none;
  padding: 0;
  margin: 0;
}
#sortable-list li {
  padding: 12px;
  background: #f5f5f5;
  border: 1px solid #ddd;
  margin-bottom: 4px;
  cursor: move;
  user-select: none;
}
#sortable-list li.dragging {
  opacity: 0.6;
  background: #e0e0e0;
}
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');
  // 同步数据逻辑放这里
  updateOrder();
});

list.addEventListener('dragover', e => {
  e.preventDefault();
});

list.addEventListener('drop', e => {
  e.preventDefault();
  if (draggedItem !== e.target) {
    list.insertBefore(draggedItem, e.target);
  }
});

function updateOrder() {
  const order = Array.from(list.children).map(li => li.dataset.id);
  console.log('新顺序:', order);
  // 这里可以发请求:fetch('https://jztheme.com/api/update-order', { method: 'POST', body: JSON.stringify(order) })
}

踩坑提醒:这三点一定注意

写完第一版后,测试时发现一堆问题。这里重点说三个我踩过好几次的坑:

  • dragover 必须 preventDefault:不然 drop 事件根本不会触发。这是 HTML5 Drag API 的反人类设计之一,文档里写了但很容易忽略。
  • 移动端基本不可用:HTML5 拖拽在 iOS Safari 上支持极差,安卓也各种不一致。如果你要做跨端,建议直接上 touch 事件手写,或者老老实实用库。我后来在另一个项目里用 touchmove 实现了一套,但代码量翻倍,而且要处理滚动冲突。
  • 动态插入元素要重新绑定:如果列表是通过 JS 动态生成的(比如分页加载),记得重新给新元素加上 draggable=”true” 属性,否则拖不动。我有次上线后才发现新加载的数据不能拖,尴尬死了。

另外,dragstart 里加 setTimeout 是为了确保样式类能正确应用——因为浏览器在 dragstart 触发时可能还没完全进入拖拽状态,直接加类有时会失效。这个 trick 是我在 Stack Overflow 上扒到的,实测有效。

这个场景最好用:带动画的平滑过渡

产品经理总喜欢加“丝滑动画”。原生拖拽移动 DOM 节点是瞬移的,看起来很生硬。我试过几种方案,最简单的办法是在 CSS 里加个 transition:

#sortable-list li {
  transition: transform 0.2s ease, opacity 0.2s ease;
}

但问题来了:拖拽过程中频繁触发重排,动画会卡顿甚至错乱。后来我改用 transform 模拟位置变化,但实现起来太复杂,最后妥协了——只在 drop 完成后加一个短暂的高亮反馈,比如背景色闪一下:

function highlightItem(item) {
  item.style.backgroundColor = '#ffeb3b';
  setTimeout(() => {
    item.style.backgroundColor = '';
  }, 300);
}

// 在 drop 事件里调用
list.addEventListener('drop', e => {
  // ... 插入逻辑
  highlightItem(draggedItem);
});

虽然不是真正的位移动画,但用户感知上“有反馈”就够了。毕竟我们不是做交互动效比赛,能用就行。

进阶技巧:和 Vue/React 数据联动

如果你在用框架,千万别直接操作 DOM。我见过有人在 React 里混用原生拖拽和 setState,结果状态和 UI 对不上,debug 到凌晨三点。

正确姿势是:把拖拽逻辑封装成指令或 Hook,只负责计算新顺序索引,然后通知上层更新状态。比如在 Vue 3 里可以这样写:

// composables/useSortable.js
export function useSortable(listRef, onUpdate) {
  let draggedIndex = null;

  const onDragStart = (e, index) => {
    draggedIndex = index;
    e.target.classList.add('dragging');
  };

  const onDrop = (e, targetIndex) => {
    if (draggedIndex === null || draggedIndex === targetIndex) return;
    onUpdate(draggedIndex, targetIndex); // 通知父组件交换位置
    e.target.classList.remove('dragging');
  };

  return { onDragStart, onDrop };
}

然后在组件里:

<template>
  <li
    v-for="(item, index) in items"
    :key="item.id"
    draggable="true"
    @dragstart="onDragStart($event, index)"
    @dragover.prevent
    @drop="onDrop($event, index)"
  >
    {{ item.name }}
  </li>
</template>

<script setup>
import { ref } from 'vue';
import { useSortable } from './composables/useSortable';

const items = ref([
  { id: 1, name: 'Item 1' },
  { id: 2, name: 'Item 2' },
  // ...
]);

const { onDragStart, onDrop } = useSortable(null, (from, to) => {
  const moved = items.value.splice(from, 1)[0];
  items.value.splice(to, 0, moved);
});
</script>

这样数据流清晰,也不会和框架的渲染机制打架。React 的思路类似,用 useCallback 包裹事件处理器就行。

别忘了防抖和错误处理

线上环境一定要考虑网络失败的情况。比如用户拖完后发请求,结果 500 了,这时候 UI 已经变了,但数据没保存。我的做法是:

  • 先 revert UI 到原始状态
  • 弹个 toast 提示“保存失败,请重试”
  • 提供“重试”按钮,用缓存的 order 再发一次

另外,高频拖拽可能导致多次请求。虽然用户不太可能一秒拖十次,但加个 debounce 更稳妥:

let saveTimeout;
function updateOrder() {
  clearTimeout(saveTimeout);
  saveTimeout = setTimeout(() => {
    const order = /* 获取顺序 */;
    fetch('https://jztheme.com/api/update-order', {
      method: 'POST',
      body: JSON.stringify(order)
    }).catch(err => {
      console.error('保存失败', err);
      // revert UI + 提示
    });
  }, 500);
}

结尾碎碎念

列表排序看着简单,真做起来细节一堆。我这个方案适合中小项目快速落地,如果要做复杂的嵌套排序、跨容器拖拽,还是上 dnd-kit 或 SortableJS 吧,省心。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如结合虚拟滚动、树形结构排序),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流——特别是移动端的优雅解法,求推荐!

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

暂无评论