拖拽排序实现原理与前端实战技巧分享

程序猿东宁 交互 阅读 2,204
赞 16 收藏
二维码
手机扫码查看
反馈

核心代码就这几行

拖拽排序这东西,我做过不下五次。每次一开始都想着“自己写个原生的吧”,结果折腾半天,不是滚动失效就是 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 来更新状态,那样会触发重渲染,导致拖拽中断。正确做法是:

  1. 用 useRef 获取 DOM 列表
  2. 初始化 Sortable 实例(或原生事件监听)
  3. 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(比如拖拽时页面突然滚动、或者和其他手势冲突),你连排查方向都没有。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多——比如拖拽上传、跨列表交换、结合虚拟滚动的大列表排序,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流。

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

暂无评论