1. 为什么要自己实现拖拽排序

  • 灵活性:可完全掌控判定规则(吸附/交换/插入)、动画、自动滚动阈值、边界、禁用项、拖拽手柄、网格布局等。

  • 性能可控:跳过不必要的 reflow/回流,使用 FLIP/transform 动画,和虚拟列表结合。

  • A11y:可按产品需求定制键盘交互和 ARIA。

  • 可维护:减少黑盒行为;当库升级/弃更/体积限制时仍能保证需求落地。


2. 基本概念与设计目标

拖拽排序:用户在一个列表/网格中按住某项,拖动到另一个位置,松手后数据顺序发生改变,UI 与数据一致。

设计目标

  • 统一鼠标/触摸/触控笔:建议使用 Pointer Events(降级到 mouse/touch)。

  • 流畅动画:FLIP + transform: translate3d 避免布局抖动。

  • 准确判定:插入/交换/区间半分(half-split)/中心点碰撞(center-overlap)。

  • 可访问性:屏幕阅读器、焦点管理、键盘操作(↑↓ 或空格+方向键移动)。

  • 边界与滚动:容器内自动滚动;页面滚动;position: sticky 交互。

  • 支持:列表与网格、拖拽手柄、禁用项、锁定轴向、可放回原位(撤销)。


3. 三条技术路线总览

  1. HTML5 Drag & Drop API

    • 优点:API 现成;原生事件;快速 POC。

    • 缺点:移动端体验不佳;拖影样式受限;跨浏览器表现差异;难做细粒度动画与可控碰撞。

    • 场景:后台管理 PC 端 简单场景、低成本卡片排序。

  2. Pointer/Mouse/Touch 自行实现(推荐)

    • 优点:最可控;移动端体验可打磨;动画/命中规则/滚动完全自定义。

    • 缺点:需要更多代码与边界处理。

    • 场景:移动端+PC 端统一、需要动画和高拟物交互。

  3. 第三方库(如 SortableJS / dnd-kit / VueDraggableNext)

    • 优点:成熟、快。

    • 缺点:体积、黑盒限制、与业务定制冲突。

    • 场景:赶工/通用后台,或 MVP 阶段。

后文重点讲路线 2(可控实现),也会给出路线 1 的简版和路线 3 的心智对照。


4. 方案一:HTML5 Drag & Drop API(简版示例)

仅作为教学/备选。移动端需要额外 polyfill,拖影效果受限。

核心事件

  • dragstart:开始拖拽(设置 dataTransfer、样式)

  • dragover:在目标上方移动(必须 event.preventDefault() 才能触发 drop)

  • drop:释放,执行数据交换

  • dragend:结束清理

最小可用列表排序(HTML5 DnD)

<ul id="list" class="list">
  <li draggable="true" data-id="1">A</li>
  <li draggable="true" data-id="2">B</li>
  <li draggable="true" data-id="3">C</li>
  <li draggable="true" data-id="4">D</li>
</ul>

<style>
.list { list-style: none; padding: 0; width: 240px; }
.list li { padding: 8px 12px; margin: 6px 0; background: #f5f5f5; border-radius: 6px; cursor: grab; }
.list li.dragging { opacity: .5; }
</style>

<script>
const list = document.getElementById('list');
let draggingEl = null;

list.addEventListener('dragstart', e => {
  draggingEl = e.target;
  draggingEl.classList.add('dragging');
  e.dataTransfer.effectAllowed = 'move';
});

list.addEventListener('dragend', () => {
  draggingEl?.classList.remove('dragging');
  draggingEl = null;
});

list.addEventListener('dragover', e => {
  e.preventDefault(); // 允许 drop
  const afterEl = getDragAfterElement(list, e.clientY);
  if (!afterEl) {
    list.appendChild(draggingEl);
  } else {
    list.insertBefore(draggingEl, afterEl);
  }
});

function getDragAfterElement(container, y) {
  const els = [...container.querySelectorAll('li:not(.dragging)')];
  let nearest = null;
  let offset = Number.NEGATIVE_INFINITY;
  for (const el of els) {
    const rect = el.getBoundingClientRect();
    const diff = y - rect.top - rect.height / 2; // 半分判定
    if (diff < 0 && diff > offset) {
      offset = diff;
      nearest = el;
    }
  }
  return nearest;
}
</script>

要点:

  • dragover 内使用「半分判定」:光标超过目标项中线就插在它前面。

  • 数据层可在 dragend 或外部按钮触发时,读取 DOM 次序映射为数组顺序。

缺陷:移动端弱、动画难控、dataTransfer.setDragImage 仍不如自绘幻影灵活。
接下来我们用路线 2 做真正可控、移动端友好的实现。


5. 方案二:Pointer/Mouse/Touch 统一实现(推荐)

为什么选择 Pointer Events

  • pointerdown / pointermove / pointerup 覆盖鼠标、触控笔、触摸。

  • 可通过 event.pointerType 区分 "mouse" | "touch" | "pen"

  • 配合 setPointerCapture,事件在指针捕获期间稳定派发到指定元素。

兼容性:现代浏览器基本支持。极老设备降级到 touchstart/mousedown 也可。

基础步骤

  1. pointerdown:记录起点,克隆一个 拖影(drag ghost)或将项设置为绝对定位(脱离文档流)。

  2. pointermove:计算偏移,移动拖影;找当前命中项;预演插入(占位符)。

  3. pointerup提交顺序(更新数组),移除占位符/拖影,释放捕获。

占位符策略

  • 在列表中插一个与被拖项同尺寸的 placeholder,原位置被占。

  • 拖影不影响布局;其他项根据插入位置平滑过渡(FLIP)。


6. 排序判定核心:碰撞/命中策略

常见命中策略:

  1. 半分判定(Half-Split)

    • 纵向列表:指针 Y 超过目标项中线,插到它后面;否则前面。

    • 简单直觉,易实现。

  2. 中心点判定(Center-Over)

    • 以拖拽目标中心点与其它项矩形碰撞决定位置。

    • 可扩展到网格布局。

  3. 最近距离/最小交叉面积

    • 计算拖影中心与各项中心的距离/交叉面积,选择最佳目标。

    • 更平滑但计算量更大,列表大时需优化。

本教程大部分示例采用 半分判定(列表)与 中心点判定(网格)。


7. 动画策略:FLIP

FLIP:First → Last → Invert → Play

  • First:记录每个元素初始位置(getBoundingClientRect)。

  • Last:应用新布局(插入占位/改变顺序/更新 DOM)。

  • Invert:计算两次位置差异 dx/dy,把元素瞬间 transform: translate(...)回原位置。

  • Play:移除 transform,以过渡动画回到新位置(transition)。

优点:避免频繁 reflow;动画流畅;与 transform 硬件加速友好。


8. 自动滚动(Auto-Scroll)

当拖影接近可滚动容器边缘或窗口边缘,自动滚动以扩展可拖区域。

关键点:

  • 设定 阈值(顶部/底部/左右各 20~40px)。

  • 根据接近程度计算 速度(线性或指数)。

  • 通过 requestAnimationFrame 循环滚动,直到 pointermove 离开阈值或松手。

  • 注意与页面滚动/内部滚动的优先级(命中最近的滚动容器)。


9. 拖拽手柄、轴向锁、网格/瀑布流

  • 拖拽手柄:仅在指定子元素 handlepointerdown 才允许启动拖拽;防止误触。

  • 轴向锁axis: 'y' | 'x' | 'both',列表常选 'y',网格 'both'

  • 网格/瀑布流:使用中心点判定 + 最近格子吸附,动画仍然用 FLIP。


10. 可访问性(A11y)与键盘可操作性

  • 列表容器:role="list" / role="listbox"(根据语义)。

  • 列表项:role="listitem" / role="option",可加 aria-grabbed="true|false" 表示抓取状态(注:旧属性在 ARIA 1.2 中弱化,但读屏仍有帮助)。

  • 视觉焦点:tabindex="0",使用键盘 ↑↓ 或 Space 开始抓取,Enter/Space 确认放下,Esc 取消。

  • 提示:aria-live="polite" 的区域在排序变更时读出“已移动到第 n 位”。

  • 注意 aria-dropeffect 已废弃,使用动态描述或 aria-describedby 替代提示。


11. 数据结构与不可变更新(性能友好)

  • 使用 id 稳定标识项;DOM 节点与数据项一一映射。

  • 排序提交时使用不可变更新,如:

    function arrayMove(arr, from, to) {
      const next = arr.slice();
      const [item] = next.splice(from, 1);
      next.splice(to, 0, item);
      return next;
    }
    
  • React/Vue 等框架中保证 key 稳定,避免错误复用。


12. 完整 Vanilla 实现

12.1 列表排序(Pointer Events + 半分判定 + FLIP + 自动滚动 + 手柄)

可直接用一个 HTML 文件打开运行(现代浏览器)。

<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8"/>
<title>Sortable List - Vanilla</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
  * { box-sizing: border-box; }
  body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 0; padding: 24px; background: #fafafa; }
  h1 { margin: 0 0 16px; font-size: 20px; }
  .panel { max-width: 520px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 6px 24px rgba(0,0,0,.08); padding: 16px; }

  .list { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow: auto; border: 1px dashed #ddd; border-radius: 8px; }
  .item { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin: 8px; background: #f6f7f8; border-radius: 8px; transition: transform .25s ease, box-shadow .2s ease, opacity .2s ease; will-change: transform; }
  .item[aria-grabbed="true"] { box-shadow: 0 8px 24px rgba(0,0,0,.18); }
  .handle { width: 18px; height: 18px; border-radius: 4px; background: #ccc; cursor: grab; flex: none; position: relative; }
  .handle::before, .handle::after {
    content:""; position:absolute; left:50%; top:50%; width:12px; height:2px; background:#fff; transform:translate(-50%,-50%);
    box-shadow: 0 -4px 0 0 #fff, 0 4px 0 0 #fff;
  }
  .label { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .placeholder { height: 0; margin: 8px; border-radius: 8px; background: rgba(0, 125, 250, .15); transition: height .2s ease; }
  .ghost {
    position: fixed; left: 0; top: 0; z-index: 9999; pointer-events: none;
    transform: translate3d(var(--x,0), var(--y,0), 0);
  }
</style>
</head>
<body>
<div class="panel" role="region" aria-label="可排序列表区域">
  <h1>拖拽排序(Vanilla + Pointer Events + FLIP + AutoScroll)</h1>
  <ul id="list" class="list" role="listbox" aria-live="polite"></ul>
</div>

<script>
(() => {
  const data = Array.from({length: 20}, (_, i) => ({ id: String(i+1), text: `任务 ${i+1}` }));
  const list = document.getElementById('list');

  // 渲染
  function render(items) {
    list.innerHTML = '';
    items.forEach((it, idx) => {
      const li = document.createElement('li');
      li.className = 'item';
      li.setAttribute('role', 'option');
      li.setAttribute('tabindex', '0');
      li.dataset.id = it.id;
      li.dataset.index = idx;

      const handle = document.createElement('div');
      handle.className = 'handle';
      handle.setAttribute('aria-hidden', 'true');
      handle.title = '拖拽手柄';

      const label = document.createElement('div');
      label.className = 'label';
      label.textContent = it.text;

      li.appendChild(handle);
      li.appendChild(label);
      list.appendChild(li);
    });
  }
  render(data);

  // 工具:不可变移动
  function arrayMove(arr, from, to) {
    const next = arr.slice();
    const [m] = next.splice(from, 1);
    next.splice(to, 0, m);
    return next;
  }

  // FLIP:记录元素初始位(First)
  function measurePositions(container) {
    const map = new Map();
    container.querySelectorAll('.item').forEach(el => {
      map.set(el.dataset.id, el.getBoundingClientRect());
    });
    return map;
  }

  // FLIP:播放动画
  function playFLIP(container, first) {
    container.querySelectorAll('.item').forEach(el => {
      const last = el.getBoundingClientRect();
      const f = first.get(el.dataset.id);
      if (!f) return;
      const dx = f.left - last.left;
      const dy = f.top - last.top;
      if (dx || dy) {
        el.style.transform = `translate(${dx}px, ${dy}px)`;
        el.style.transition = 'none';
        el.getBoundingClientRect(); // 强制 reflow
        requestAnimationFrame(() => {
          el.style.transition = 'transform .25s ease';
          el.style.transform = '';
        });
      }
    });
  }

  // 自动滚动
  let rafScroll = null;
  function autoScrollIfNeeded(container, pointerY, edge = 36, maxSpeed = 18) {
    cancelAnimationFrame(rafScroll);
    const rect = container.getBoundingClientRect();
    let direction = 0;
    if (pointerY - rect.top < edge) direction = -1;
    else if (rect.bottom - pointerY < edge) direction = 1;
    if (!direction) return;

    const dist = direction < 0 ? (pointerY - rect.top) : (rect.bottom - pointerY);
    const speed = Math.round((1 - Math.max(0, dist) / edge) * maxSpeed);

    const step = () => {
      container.scrollTop += direction * Math.max(1, speed);
      rafScroll = requestAnimationFrame(step);
    };
    rafScroll = requestAnimationFrame(step);
  }

  // 拖拽状态
  let dragging = null; // {id, startX, startY, offsetX, offsetY, node, ghost, placeholder}
  let positions = null;

  // 命中:半分判定,返回插入参考元素
  function getInsertPosition(container, y, excludeEl) {
    const items = [...container.querySelectorAll('.item')].filter(el => el !== excludeEl);
    let nearest = null;
    let offset = Number.NEGATIVE_INFINITY;
    for (const el of items) {
      const rect = el.getBoundingClientRect();
      const diff = y - (rect.top + rect.height / 2);
      if (diff < 0 && diff > offset) {
        offset = diff;
        nearest = el;
      }
    }
    return nearest; // 返回要插在其前面的元素;如果为 null,则 append 到末尾
  }

  // 只允许在手柄上开始拖拽
  list.addEventListener('pointerdown', (e) => {
    const handle = e.target.closest('.handle');
    if (!handle) return;
    const item = e.target.closest('.item');
    if (!item) return;

    e.preventDefault();
    item.setPointerCapture?.(e.pointerId);

    const rect = item.getBoundingClientRect();
    dragging = {
      id: item.dataset.id,
      node: item,
      startX: e.clientX,
      startY: e.clientY,
      offsetX: e.clientX - rect.left,
      offsetY: e.clientY - rect.top,
      width: rect.width,
      height: rect.height,
      originIndex: Number(item.dataset.index),
      placeholder: null,
      ghost: null
    };

    // A11y state
    item.setAttribute('aria-grabbed', 'true');

    // 创建占位
    const ph = document.createElement('div');
    ph.className = 'placeholder';
    ph.style.height = rect.height + 'px';
    dragging.placeholder = ph;
    item.parentNode.insertBefore(ph, item.nextSibling);

    // 记录 First
    positions = measurePositions(list);

    // 脱离文档流 & 创建 ghost
    const ghost = item.cloneNode(true);
    ghost.classList.add('ghost');
    ghost.style.width = rect.width + 'px';
    ghost.style.height = rect.height + 'px';
    ghost.querySelector('.handle')?.remove(); // ghost 不需要手柄可交互
    document.body.appendChild(ghost);
    dragging.ghost = ghost;

    // 原节点隐形但占位由 placeholder 负责
    item.style.opacity = '0';
    item.style.pointerEvents = 'none';

    moveGhost(e.clientX, e.clientY);
  });

  function moveGhost(x, y) {
    if (!dragging) return;
    const gx = x - dragging.offsetX;
    const gy = y - dragging.offsetY;
    dragging.ghost.style.setProperty('--x', gx + 'px');
    dragging.ghost.style.setProperty('--y', gy + 'px');
  }

  window.addEventListener('pointermove', (e) => {
    if (!dragging) return;

    moveGhost(e.clientX, e.clientY);

    // 自动滚动
    autoScrollIfNeeded(list, e.clientY);

    // 预演插入
    const ref = getInsertPosition(list, e.clientY, dragging.node);
    if (ref) {
      list.insertBefore(dragging.placeholder, ref);
    } else {
      list.appendChild(dragging.placeholder);
    }

    // FLIP:让其他项平滑过渡
    playFLIP(list, positions);
  });

  window.addEventListener('pointerup', (e) => {
    if (!dragging) return;
    cancelAnimationFrame(rafScroll);

    const items = [...list.querySelectorAll('.item')];
    const phIndex = [...list.children].indexOf(dragging.placeholder);
    const to = Math.max(0, phIndex - items.filter(n => n.compareDocumentPosition(dragging.placeholder) & Node.DOCUMENT_POSITION_FOLLOWING).length);
    const from = dragging.originIndex;

    // 更新数据(不可变)
    const nextData = arrayMove(data, from, to);
    data.splice(0, data.length, ...nextData); // 覆盖原数组内容

    // 清理拖影/占位
    dragging.node.style.opacity = '';
    dragging.node.style.pointerEvents = '';
    dragging.node.setAttribute('aria-grabbed', 'false');
    dragging.placeholder.remove();
    dragging.ghost.remove();

    dragging = null;

    // 重新渲染并播放 FLIP
    const first = measurePositions(list);
    render(data);
    const lastPositions = first; // 旧 first 实际上是更新前,此处需重测
    const newFirst = lastPositions; // 为讲解简化:先渲染再做 FLIP
    playFLIP(list, newFirst);

    // 更新 index 标记(便于下次 from)
    [...list.querySelectorAll('.item')].forEach((el, i) => el.dataset.index = i);

    // A11y 提示
    list.setAttribute('aria-label', `排序已更新`);
  });

  // 键盘交互(↑/↓ 移动当前项)
  list.addEventListener('keydown', (e) => {
    const current = document.activeElement?.closest('.item');
    if (!current) return;
    const idx = Number(current.dataset.index);
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (idx < data.length - 1) {
        const next = arrayMove(data, idx, idx + 1);
        data.splice(0, data.length, ...next);
        const first = measurePositions(list);
        render(data);
        playFLIP(list, first);
        const focusEl = list.querySelector(`.item[data-index="${idx+1}"]`) || list.querySelectorAll('.item')[idx+1];
        focusEl?.focus();
      }
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      if (idx > 0) {
        const next = arrayMove(data, idx, idx - 1);
        data.splice(0, data.length, ...next);
        const first = measurePositions(list);
        render(data);
        playFLIP(list, first);
        const focusEl = list.querySelector(`.item[data-index="${idx-1}"]`) || list.querySelectorAll('.item')[idx-1];
        focusEl?.focus();
      }
    }
  });

})();
</script>
</body>
</html>

说明与要点:

  • 仅允许从 .handle 开始拖拽,降低误触。

  • 使用 占位符 保持布局稳定。

  • FLIP 动画避免抖动;transform 不会触发布局重排。

  • 自动滚动 只对 .list 容器生效;如需页面滚动,可检测 document.scrollingElement

  • A11yrole="listbox",键盘 ↑↓ 也能移动,aria-live 给出状态提示(此处用 aria-label 简化示例)。


12.2 网格排序(中心点命中 + 吸附)

下面给出核心逻辑片段(在上面基础上替换命中判定):

function getNearestCell(container, point) {
  // 返回最接近拖影中心点的 item
  let best = null;
  let bestDist = Infinity;
  const items = [...container.querySelectorAll('.item')];
  for (const el of items) {
    const r = el.getBoundingClientRect();
    const cx = r.left + r.width / 2;
    const cy = r.top + r.height / 2;
    const dx = point.x - cx;
    const dy = point.y - cy;
    const dist = dx*dx + dy*dy;
    if (dist < bestDist) {
      bestDist = dist;
      best = el;
    }
  }
  return best;
}

// pointermove 中:
const center = { x: e.clientX, y: e.clientY };
const target = getNearestCell(gridContainer, center);
if (target && target !== dragging.node) {
  // 根据 target 位置和半分判定决定插入 index
}

网格排序的插入规则:

  • 计算 线性 index(行主序),例如 index = row * colCount + col

  • 拖影中心指向的卡片为参考,若 center.x 在其左半/上半则插前,否则插后。

  • FLIP 动画同列表。


13. React 版:useSortable Hook 与组件

目标:在 React 中以“受控数据 + 可插拔事件”方式使用,保持 不可变更新key 稳定

13.1 Hook 最小实现

// useSortable.js
import { useCallback, useLayoutEffect, useRef, useState } from 'react';

export function arrayMove(arr, from, to) {
  const next = arr.slice();
  const [m] = next.splice(from, 1);
  next.splice(to, 0, m);
  return next;
}

export function useSortable({ items, onChange, getId = it => it.id }) {
  const ref = useRef(null);
  const state = useRef({ dragging: null, positions: null });
  const [_, force] = useState({});

  const measure = useCallback(() => {
    const el = ref.current;
    const map = new Map();
    el?.querySelectorAll('[data-id]').forEach(n => map.set(n.dataset.id, n.getBoundingClientRect()));
    return map;
  }, []);

  const playFLIP = useCallback((first) => {
    const el = ref.current;
    el?.querySelectorAll('[data-id]').forEach(n => {
      const last = n.getBoundingClientRect();
      const f = first.get(n.dataset.id);
      if (!f) return;
      const dx = f.left - last.left;
      const dy = f.top - last.top;
      if (dx || dy) {
        n.style.transform = `translate(${dx}px, ${dy}px)`;
        n.style.transition = 'none';
        n.getBoundingClientRect();
        requestAnimationFrame(() => {
          n.style.transition = 'transform .25s ease';
          n.style.transform = '';
        });
      }
    });
  }, []);

  useLayoutEffect(() => {
    const root = ref.current;
    if (!root) return;

    const onPointerDown = (e) => {
      const handle = e.target.closest('[data-handle]');
      if (!handle) return;
      const item = e.target.closest('[data-id]');
      if (!item) return;
      e.preventDefault();
      const rect = item.getBoundingClientRect();
      state.current.dragging = {
        id: item.dataset.id, node: item,
        startX: e.clientX, startY: e.clientY,
        offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
        width: rect.width, height: rect.height, originIndex: Number(item.dataset.index),
        placeholder: null, ghost: null
      };
      item.setPointerCapture?.(e.pointerId);
      item.setAttribute('aria-grabbed','true');

      // placeholder
      const ph = document.createElement('div');
      ph.style.height = rect.height + 'px';
      ph.className = 'placeholder';
      state.current.dragging.placeholder = ph;
      item.parentNode.insertBefore(ph, item.nextSibling);

      state.current.positions = measure();

      // ghost
      const ghost = item.cloneNode(true);
      ghost.style.cssText = `position:fixed;left:0;top:0;z-index:9999;pointer-events:none;transform:translate3d(${e.clientX - rect.left}px,${e.clientY - rect.top}px,0);width:${rect.width}px;height:${rect.height}px`;
      ghost.querySelector('[data-handle]')?.remove();
      document.body.appendChild(ghost);
      state.current.dragging.ghost = ghost;

      item.style.opacity = '0';
      item.style.pointerEvents = 'none';
    };

    const onPointerMove = (e) => {
      const d = state.current.dragging; if (!d) return;
      d.ghost.style.transform = `translate3d(${e.clientX - d.offsetX}px,${e.clientY - d.offsetY}px,0)`;

      // 半分判定
      const refEl = getInsertPosition(root, e.clientY, d.node);
      if (refEl) root.insertBefore(d.placeholder, refEl);
      else root.appendChild(d.placeholder);

      playFLIP(state.current.positions);
    };

    const onPointerUp = (e) => {
      const d = state.current.dragging; if (!d) return;
      const children = [...root.children];
      const phIndex = children.indexOf(d.placeholder);
      const to = phIndex - 1 < 0 ? 0 : phIndex; // 计算目标 index(可按需修正)
      const from = d.originIndex;

      d.node.style.opacity = '';
      d.node.style.pointerEvents = '';
      d.node.setAttribute('aria-grabbed','false');
      d.placeholder.remove(); d.ghost.remove();
      state.current.dragging = null;

      onChange(arrayMove(items, from, to));
      // 交由父组件重新渲染后,使用 FLIP
      requestAnimationFrame(() => playFLIP(state.current.positions));
    };

    root.addEventListener('pointerdown', onPointerDown);
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    return () => {
      root.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
    };
  }, [items, measure, playFLIP, onChange]);

  return { ref };
}

// 辅助:半分判定
function getInsertPosition(container, y, excludeEl) {
  const items = [...container.querySelectorAll('[data-id]')].filter(el => el !== excludeEl);
  let nearest = null; let offset = Number.NEGATIVE_INFINITY;
  for (const el of items) {
    const rect = el.getBoundingClientRect();
    const diff = y - (rect.top + rect.height / 2);
    if (diff < 0 && diff > offset) { offset = diff; nearest = el; }
  }
  return nearest;
}

13.2 使用示例

// App.jsx
import { useState } from 'react';
import { useSortable } from './useSortable';

export default function App() {
  const [items, setItems] = useState(
    Array.from({length: 10}, (_, i) => ({ id: String(i+1), text: `条目 ${i+1}` }))
  );
  const { ref } = useSortable({ items, onChange: setItems });

  return (
    <div style={{ padding: 16 }}>
      <h2>React 拖拽排序</h2>
      <ul ref={ref} style={{ listStyle:'none', padding:0, width:300 }}>
        {items.map((it, idx) => (
          <li key={it.id} data-id={it.id} data-index={idx}
              style={{ display:'flex', gap:8, background:'#f5f5f5', padding:'8px 10px', borderRadius:8, margin:'8px 0', transition:'transform .25s ease' }}
              role="option" tabIndex={0}>
            <span data-handle style={{ width:18, height:18, background:'#ccc', borderRadius:4, flex:'none', cursor:'grab' }}/>
            <span style={{ flex:1 }}>{it.text}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}


14. Vue 3 版:指令 v-sortable 与组件

14.1 指令版(更贴近 DOM)

<template>
  <div class="wrap">
    <h2>Vue 3 拖拽排序(指令)</h2>
    <ul v-sortable="{ items, onChange: handleChange }" class="vlist">
      <li v-for="(it, i) in items" :key="it.id" :data-id="it.id" :data-index="i" class="vitem" role="option" tabindex="0">
        <span class="vhandle" data-handle></span>
        <span class="vlabel">{{ it.text }}</span>
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const items = ref(Array.from({length: 12}, (_,i) => ({ id: String(i+1), text: `任务 ${i+1}`})));
function handleChange(next) { items.value = next; }
</script>

<script>
function arrayMove(arr, from, to) {
  const next = arr.slice();
  const [m] = next.splice(from, 1);
  next.splice(to, 0, m);
  return next;
}

export default {
  directives: {
    sortable: {
      mounted(el, binding) {
        const { items, onChange } = binding.value;
        const state = { dragging: null, positions: null };

        function measure() {
          const map = new Map();
          el.querySelectorAll('[data-id]').forEach(n => map.set(n.dataset.id, n.getBoundingClientRect()));
          return map;
        }
        function playFLIP(first) {
          el.querySelectorAll('[data-id]').forEach(n => {
            const last = n.getBoundingClientRect();
            const f = first.get(n.dataset.id);
            if (!f) return;
            const dx = f.left - last.left;
            const dy = f.top - last.top;
            if (dx || dy) {
              n.style.transform = `translate(${dx}px, ${dy}px)`;
              n.style.transition = 'none';
              n.getBoundingClientRect();
              requestAnimationFrame(() => {
                n.style.transition = 'transform .25s ease';
                n.style.transform = '';
              });
            }
          });
        }
        function getInsertPosition(container, y, excludeEl) {
          const items = [...container.querySelectorAll('[data-id]')].filter(el => el !== excludeEl);
          let nearest = null; let offset = Number.NEGATIVE_INFINITY;
          for (const el of items) {
            const rect = el.getBoundingClientRect();
            const diff = y - (rect.top + rect.height / 2);
            if (diff < 0 && diff > offset) { offset = diff; nearest = el; }
          }
          return nearest;
        }

        const onPointerDown = (e) => {
          const handle = e.target.closest('[data-handle]');
          if (!handle) return;
          const item = e.target.closest('[data-id]');
          if (!item) return;
          e.preventDefault();
          const rect = item.getBoundingClientRect();
          state.dragging = {
            id: item.dataset.id, node: item,
            offsetX: e.clientX - rect.left, offsetY: e.clientY - rect.top,
            originIndex: Number(item.dataset.index),
            ghost: null, placeholder: null
          };
          item.setAttribute('aria-grabbed','true');
          const ph = document.createElement('div');
          ph.className = 'vplaceholder'; ph.style.height = rect.height + 'px';
          state.dragging.placeholder = ph;
          item.parentNode.insertBefore(ph, item.nextSibling);
          state.positions = measure();
          const ghost = item.cloneNode(true);
          ghost.className += ' vghost';
          ghost.style.cssText = `position:fixed;left:0;top:0;z-index:9999;pointer-events:none;transform:translate3d(${e.clientX - rect.left}px,${e.clientY - rect.top}px,0);width:${rect.width}px;height:${rect.height}px`;
          ghost.querySelector('[data-handle]')?.remove();
          document.body.appendChild(ghost);
          state.dragging.ghost = ghost;
          item.style.opacity = '0'; item.style.pointerEvents='none';
        };

        const onPointerMove = (e) => {
          if (!state.dragging) return;
          const d = state.dragging;
          d.ghost.style.transform = `translate3d(${e.clientX - d.offsetX}px,${e.clientY - d.offsetY}px,0)`;
          const refEl = getInsertPosition(el, e.clientY, d.node);
          if (refEl) el.insertBefore(d.placeholder, refEl); else el.appendChild(d.placeholder);
          playFLIP(state.positions);
        };

        const onPointerUp = (e) => {
          if (!state.dragging) return;
          const d = state.dragging;
          const children = [...el.children];
          const phIndex = children.indexOf(d.placeholder);
          const to = phIndex; // 简化
          const from = d.originIndex;

          d.node.style.opacity=''; d.node.style.pointerEvents='';
          d.node.setAttribute('aria-grabbed','false');
          d.placeholder.remove(); d.ghost.remove();
          state.dragging = null;

          onChange(arrayMove(items.value ?? items, from, to));
          requestAnimationFrame(() => playFLIP(state.positions));
        };

        el.addEventListener('pointerdown', onPointerDown);
        window.addEventListener('pointermove', onPointerMove);
        window.addEventListener('pointerup', onPointerUp);

        el._cleanup = () => {
          el.removeEventListener('pointerdown', onPointerDown);
          window.removeEventListener('pointermove', onPointerMove);
          window.removeEventListener('pointerup', onPointerUp);
        };
      },
      unmounted(el) { el._cleanup && el._cleanup(); }
    }
  }
}
</script>

<style scoped>
.wrap { padding: 16px; }
.vlist { list-style: none; padding: 0; width: 320px; }
.vitem { display: flex; gap: 8px; background: #f5f5f5; padding: 8px 10px; border-radius: 8px; margin: 8px 0; transition: transform .25s ease; }
.vhandle { width: 18px; height: 18px; background: #ccc; border-radius: 4px; cursor: grab; flex: none; }
.vplaceholder { height: 0; margin: 8px 0; background: rgba(0,125,250,.15); transition: height .2s ease; }
.vghost { opacity: 0.95; }
</style>


15. 与成熟库对比

  • SortableJS

    • 上手简单、生态成熟、支持手柄/交换/多容器、PC 端友好。

    • 移动端可用但细节不如完全自控;动画可自定但需要 Hook。

  • @dnd-kit(React)

    • 现代、以 Pointer 为核心,扩展性强;专注于 React 生态;可组合传感器与碰撞检测。

    • 学习曲线略高;网格/复杂布局需要自配策略。

  • VueDraggableNext(基于 Sortable)

    • Vue 生态首选之一;组合式 API 友好;事件齐全。

    • 仍受 Sortable 能力边界影响。

何时用库? 快速交付/MVP;需求接近库默认能力;团队已有使用经验。
何时自研? 复杂动画、移动端体验要打磨、个性化碰撞/吸附、多容器联动、虚拟化。


16. 常见坑位 & Debug 清单

  1. 滚动穿透:拖拽时禁止页面滚动?移动端可给 body { touch-action: none; } 或在拖拽时设置 document.body.style.overflow = 'hidden'(注意副作用)。

  2. 选择文本:拖拽时可能选中文本,给容器加 user-select: none(仅拖拽期间)。

  3. 指针捕获setPointerCapture 让移动过程中事件稳定派发到同一元素。

  4. 变换坐标系:祖先元素有 transform 会影响 position: fixed 的参考系,ghost 可能“跟丢”,可改为 position: absolute 附着在最近定位容器。

  5. 高频测量getBoundingClientRect() 开销不小,尽量在关键节点测量,不要每帧测量所有项。

  6. 过渡冲突:FLIP 前要 transition: none,下一帧再恢复。

  7. 触屏延迟:现代浏览器默认无 300ms 延迟;若遇老机型,使用 touch-action 优化点击延迟。

  8. 可访问性:ARIA 提示文本过多会打断阅读;可使用 aria-live="polite" 并节流播报。

  9. 禁用项:给不可拖拽项加 pointer-events: none 的手柄,或在 pointerdown 阶段判断 data-disabled

  10. SSR/水合:在 React/Vue 的 SSR 场景中,拖拽仅客户端启用,事件绑定放在 useEffect/onMounted


17. 性能优化与大列表/虚拟化

  • 虚拟列表:仅渲染可视区 + buffer;拖拽时需要“幽灵项”保持视觉连续;排序提交基于索引映射而非 DOM 顺序。

  • 批量样式修改:把需要修改的元素收集后统一下一帧改样式,减少样式计算次数。

  • 只测量必要项:常规只测量邻近项;或使用 空间索引(如 grid 每格缓存 rect)。

  • 变更最小原则:只在占位符变化时才触发 FLIP;requestAnimationFrame 合帧。

  • 合并自动滚动与移动:避免两个 rAF 互相竞争。


18. 扩展:多容器/嵌套排序/穿梭

  • 多容器:每个容器维护自己的列表与边界;拖影跨容器时,切换“当前激活容器”;命中判定在容器矩形内进行。

  • 嵌套:父列表与子列表使用不同层级的手柄与命中策略;阻止事件向上冒泡避免冲突。

  • 穿梭(左右两列互相移动):当拖影中心进入另一个容器区域,创建新占位符并更新“当前容器指针”;松手后提交跨容器移动。


19. 单元测试与可维护性建议

  • 核心算法可测

    • arrayMove、命中判定(半分/中心点)、自动滚动计算速度函数。

  • 事件模拟:在 JSDOM 或 Cypress 中模拟 pointer 事件序列(down → move → up)。

  • A11y:使用 jest-axe 或 Storybook a11y 插件检查基础问题。

  • 解耦:将 测量命中动画滚动 抽成独立模块,主逻辑组合调用。

  • 类型化:使用 TypeScript 声明 DragStateRectPointCollisionStrategy 等类型。


20. 总结与落地清单

  • 选型:PC 简单可用 HTML5 DnD;移动端与复杂动画用 Pointer 自研或 dnd-kit/SortableJS。

  • 关键技术:占位符 + FLIP + 半分/中心点命中 + 自动滚动 + 手柄 + 轴向锁。

  • A11y/Keyboardrole/tabindex/aria-live;支持 ↑↓ 或 Space/Enter 交互。

  • 性能:只测量必要项;rAF 合帧;transform 动画;必要时上虚拟化。

  • 工程化:把“测量/命中/动画/滚动”做成模块或 Hook/指令,单测覆盖算法层。


附:可直接复制使用的工具函数集合(TS/JS)

// types.ts
export type Id = string | number;
export interface Item { id: Id; [k: string]: any }
export interface Rect { top: number; left: number; width: number; height: number; }
export interface Point { x: number; y: number; }
export type Collision = (rects: Rect[], point: Point) => number; // 返回命中索引

// arrayMove.ts
export function arrayMove<T>(arr: T[], from: number, to: number) {
  const next = arr.slice();
  const [m] = next.splice(from, 1);
  next.splice(to, 0, m);
  return next;
}

// measure.ts
export function measureRects(nodes: Element[]): Rect[] {
  return nodes.map(n => {
    const r = n.getBoundingClientRect();
    return { top: r.top, left: r.left, width: r.width, height: r.height };
  });
}

// collisions.ts
export function halfSplitCollision(rects: Rect[], point: Point): number {
  // 列表半分:找到第一个中线在 point.y 之上的项,插入其前
  let target = rects.length;
  for (let i = 0; i < rects.length; i++) {
    const r = rects[i];
    const mid = r.top + r.height / 2;
    if (point.y < mid) { target = i; break; }
  }
  return target;
}

export function centerNearestCollision(rects: Rect[], point: Point): number {
  // 网格:选择最靠近中心的项
  let best = 0, bestDist = Infinity;
  for (let i = 0; i < rects.length; i++) {
    const r = rects[i];
    const cx = r.left + r.width/2;
    const cy = r.top + r.height/2;
    const dx = point.x - cx;
    const dy = point.y - cy;
    const dist = dx*dx + dy*dy;
    if (dist < bestDist) { bestDist = dist; best = i; }
  }
  return best;
}


最后

以上提供了从原理到工程实践的完整路径,以及 Vanilla/React/Vue 三套可落地代码。你可以直接把 Vanilla 版本保存为 HTML 文件试跑,再根据项目选型移植到 React/Vue。
如果你希望我根据你的实际 UI/数据结构定制一个可插拔的小库(含类型声明、完整注释、A11y 提示与自动滚动策略)

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐