这是一份从 0 到 1 的「前端拖拽排序实现详解」——覆盖原理、数据结构、事件系统、碰撞算法、动画(FLIP)、自动滚动、可访问性(A11y)、移动端兼容(Pointer Events)、等

前端开发:实现拖拽排序功能 在前端开发中,拖拽排序是一种常见的交互功能,它允许用户通过拖拽元素来改变它们的顺序。首先,我们需要创建一个基本的HTML结构,其中包含要排序的元素列表。每个元素都应该具有可拖拽的属性,以便用户可以通过拖拽来改变它们的顺序。通过以上的HTML、CSS和JavaScript代码,我们成功实现了一个简单的拖拽排序功能。用户可以通过拖拽元素来改变它们的顺序,从而实现自定义排序的效果。方法阻止事件冒泡,并交换了拖拽元素和目标元素的内容。函数处理拖拽开始事件,设置了拖拽的效果和数据。,表示这些元素是可拖拽的。 阅读详情

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. 拖拽手柄、轴向锁、网格/瀑布流

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

  • 轴向锁: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。

  • A11y:role="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 声明 DragState、Rect、Point、CollisionStrategy 等类型。


20. 总结与落地清单

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

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

  • A11y/Keyboard:role/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 提示与自动滚动策略)

【信息科学与工程学】计算机科学与自动化——第六十三篇 人机交互之前端交互参数知识库01 此方案可满足千万级并发需求,同时支持时序数据、知识图谱和文档数据库的高效处理,通过云原生架构实现极致弹性。​:每月约$15-20万(100节点K8s集群+数据库集群+CDN流量)1. 宠物上架/下架流程。 阅读详情

相关推荐

TSC条码打印机C#例程(tsclib.dll调用) 【转】

//----program.cs using System;using System.Collections.Generic;using System.Windows.Forms; using System.Runtime.InteropServices;public class TSCLIB_DLL{[DllImport("TSCLIB.dll", EntryPoint = "abou...

weixin_33843947的博客 2441

前端开发攻略---轻松实现排序功能:利用JavaScript创建直观的拖拽排序体验

拖拽事件是指在网页开发中,通过拖拽 API 实现的一系列事件,包括拖拽开始、拖拽移动和拖拽结束等。当用户开始拖拽页面上的元素时,会触发拖拽开始事件;随后,随着鼠标或触摸屏的移动,会触发拖拽移动事件,开发者可以根据鼠标或触摸屏的位置来更新拖拽元素的状态;最后,当用户释放鼠标或触摸屏时,会触发拖拽结束事件,开发者可以在此事件中执行相应的操作,如重新排列元素的顺序或更新页面内容。拖拽事件的使用使得开发者可以轻松地实现各种交互功能,如拖拽排序、拖拽放置等,从而提升用户体验。

nibabaoo的博客 2487

Ubuntu Server 22.04.5系统安装教程

该教程主要是演示在服务器上安装Ubuntu server 22.04系统的详细步骤,主要包含,安装系统前的准备(镜像的下载,启动盘的制作),安装系统时的语言选择,网络设置,切换镜像源(可根据实际需求来配置,本文使用清华镜像源),手动分区,用户设置,ssh服务安装等。以上就是关于Ubuntu Server 22.04系统安装的详细过程。

逆水行舟,不进则退。 7761

前端-拖拽实现列表排序

转载至https://www.crazyming.com/note/757/ 使用拖拽功能来实现排序。 需要先学习w3cschool 关于拖拽的教程:http://www.w3school.com.cn/html5/html_5_draganddrop.asp 上方的教程是操作dom 的,而我的项目使用vue,vue是数据驱动视图的框架,下图中的列表是v-for遍历 友情链接数组 渲染出来的,因此 我这里的拖拽只需对 友情链接数组 进行操作就可以了。 这样的方法 同样也可以 ...

azuretodd的博客 3243

手把手教你用闲置安卓手机搭建个人收款系统(蓝鲸支付私有化部署实战)

本文详细介绍了如何利用闲置安卓手机搭建个人收款系统,实现私有化支付部署。通过开源技术栈和容器化方案,零成本构建安全、高效的支付网关,解决传统支付平台的数据安全和资金风险问题。特别适合个人开发者和小微企业主,无需复杂审批,资金实时到账,显著降低运营成本。

weixin_30363285的博客 380

光纤接口类型与应用场景全解析

本文全面解析了光纤接口的类型与应用场景,详细介绍了SC、LC、FC和ST等主流光纤接口的特点及适用环境。从数据中心的高密度布线到工业环境的极端条件,提供了专业的接口选型指南和实战经验,帮助读者根据具体需求选择最合适的光纤接口方案。

weixin_29289821的博客 411

html5拖动监听

在拖动目标上触发事件 (源元素): ondragstart - 用户开始拖动元素时触发 ondrag - 元素正在拖动时触发 ondragend - 用户完成元素拖动后触发 释放目标时触发的事件: ondragenter - 当被鼠标拖动的对象进入其容器范围内时触发此事件 ondragover - 当某被...

weixin_30537391的博客 1121

避开这些坑!智鼎MAP性格测试高分指南(附互联网/国企/外企答题模板)

本文深入解析智鼎MAP职业性格测试的核心逻辑与常见陷阱,提供针对互联网、国企、外企三大行业的差异化答题策略与实战模板。帮助求职者理解测试本质,避开追求完美人设、忽视程度副词等心理陷阱,塑造与目标岗位高度匹配的职业画像,从而在招聘关键环节脱颖而出。

rainy的博客 992

前端实现拖拽效果改变元素顺序

在一次工作中,前端要实现通过鼠标实现拖拽改变顺序的功能,之前没有接触过拖拽这一块所以刚开始一筹莫展,幸运的是在查阅学习中实现了前端拖拽功能。操作都是在handleDragEnter()这个函数中来实现的,所以并不难,只需要我们在这个函数中写js逻辑即可。

小祥编程擅长前端 2821

用SimaPro做碳足迹分析:手把手教你完成产品生命周期评估报告

本文详细介绍了如何使用SimaPro进行碳足迹分析,从软件安装到生命周期评估报告的完整流程。通过实战案例演示,帮助制造业企业、咨询机构和科研团队掌握产品生命周期评估(LCA)的核心技术,精准识别环境热点并优化决策,为可持续发展转型提供科学依据。

yhn45678901的博客 715

【Python爬虫教程】第3篇-解决使用reqeusts遇到的ProxyError异常

Python request代理错误提示“HTTPSConnectionPool, Cannot connect to proxy, no such file or directory”

分享技术,记录思考和感悟 1508

动态列表组件 - 拖拽排序功能设计与实现

大厂技术高级前端Node进阶点击上方程序员成长指北,关注公众号回复1,加入高级Node交流群最终效果原生实现原理关于拖拽标签的图片默认是可以拖动的(效果如上图)然而其他的标签(d...

程序员成长指北 1212

杰理AC695N系列芯片 LED7数码管使用教程

杰理AC695N数码管使用摘要 本教程介绍了杰理AC695N芯片驱动LED7数码管的实现方法: 硬件配置:支持7/12/13脚三种数码管类型,详细说明了7脚数码管的标准段码定义和GPIO接线方式。 软件设置: 通过修改配置文件启用LED7功能 配置GPIO引脚映射(PORTC/PORTA组) 选择与硬件匹配的真值表 层次架构:包含硬件层、驱动层、UI层和应用层的完整设计,支持数字、字符和图标显示功能。 实现要点包括正确配置引脚类型、选择真值表,并通过UI风格设置为LED7模式。

qq_42014563的博客 1346

vue中实现拖拽排序功能的详细教程

在业务中列表拖拽排序是比较常见的需求,下面这篇文章主要给大家介绍了关于vue中实现拖拽排序功能的详细教程,文中通过实例代码介绍的非常详细,需要的朋友可以参考下原生拖拽 API 实现拖拽。

Jack, what else can you do besides holding your little darling? 6758

SIMATIC S7-1200, S7-1500 PID 控制[手册]

SIMATIC S7-1200, S7-1500 PID 控制[手册]

老版QQ原生表情gif动态

这是老版的QQ表情,不过全是动态的的,Windows照片查看器是不会动的,可以右击用浏览器打开查看,再说一遍是老版的,只是分享给宁愿用老版也要动态的人。

前端使用H5中draggable实现拖拽排序效果 两种实现效果

/ insertBefore 插入的节点如果在list中存在,那么则会删除,再插入新的位置。// 在目标节点的下一个元素之前插入拖拽的节点。// 在目标节点的下一个节点之前插入拖动节点。// 在拖动节点的下一个节点之前插入目标节点。// 拖拽节点下标小于目标节点的下标。// 拖拽节点下标大于目标节点的下标。// 在目标节点之前插入拖拽的节点。// 当拖拽放入某个目标节点后。// 当拖拽进入某个节点时候。// 当前拖拽节点拖拽结束。// 当前拖拽节点拖拽结束。// 当拖拽开始的时候。"当前拖动的节点:"

杨杨吖的博客 2860

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

vuedraggable实战:结合具体应用场景实现列表内拖拽排序、列表间移动、复制,介绍、安装、使用、坑点及解决方案

从实战角度介绍如何使用Draggable实现一个配置功能,用到的关键属性和方法,需要注意的事项,坑点以及解决方案。

学海无涯,行者无疆 7万+

ET2012新版免狗免安装绿色软件

ET2012新版免狗免安祖昂绿色软件。,新界面,新视觉,

拖拽排序实现方式

拖拽排序实现方式 问题背景 最近在做项目时遇到一个需求,需要支持数据拖拽排序。通过拖动数据以此来达到对数据重新排列,且可能存在多人同时对相同数据重新排列问题 需求描叙 支持数据拖拽排序,一次只允许拖动一列 允许多人同时对相同数据列表拖拽排序 数据数量最多允许 1000 解决方式 取中值法 实现思路: 创建元素时给元素赋默认位置(position 字段记录该值)。赋值规则:当创建第一个元素时,默认位置赋值为100,第二个元素为 2 * 100 = 200,增加第N个元素时,位置赋值为N*100(等差数列

Wollens Blogs 5309

[前端必修课]: 拖拽排序篇

通过实现前端拖拽排序,可以为用户提供直观、交互式的界面,使他们能够自由地重新排列和组织内容,从而提升用户体验和操作效率

Recording code life 972

sortablejs(前端拖拽排序的实现)

在实际的使用中我们通常使用sortablejs做拖拽排序,这就会涉及到异步请求及请求发送失败的情况。那么就会发现一个问题,当我们的请求发送失败后按照常理应该还原排序但是sortablejs似乎并没有给予相应的方法来实现这个功能。这样就可以实现日常的工作需求,考虑到每次写这么多东西会很麻烦,我们可以使用vue的自定义指令将它封装成一个指令。目前已经有插件完成了这一步骤,大家也可以尝试一下这个插件。sortablejs的配置项。vue3中的使用演示。

qq_55139096的博客 2786

前端实现拖拽排序效果

<div class="link-add"> <div class="formTitle">流程环节</div> <div class="link-add-list"> <div class="test_wrapper" @dragover="dragover($event)"> <transition-group class="transition-wrapper" name="sort">

C05230的博客 2619

拖动排序功能的实现 - 使用HTML、CSS和JavaScript

本文介绍了使用HTML、CSS和JavaScript实现拖动排序功能的方法。通过定义HTML结构、应用CSS样式和使用JavaScript事件处理程序,我们可以创建一个可交互的列表,允许用户通过拖动重新排列项目的顺序。这种功能在任务管理、画廊展示等Web应用程序中非常实用。通过不断学习和探索新技术,我们可以提供更好的用户体验。

l602108654的博客 3446

如何实现拖拽排序

可拖拽排序的菜单效果大家想必都很熟悉,本次我们通过一个可拖拽排序的九宫格案例来演示其实现原理。 先看一下完成效果: 实现原理概述 拖拽原理 当鼠标在【可拖拽小方块】(以下简称砖头)身上按下时,开始监听鼠标移动事件 鼠标事件移动到什么位置,砖头就跟到什么位置 鼠标抬起时,取消鼠标移动事件的监听 排序原理 提前定义好9大坑位的位置(相对外层盒子的left和top) 将9大砖头丢入一个数组,以便后期通过splice方法随意安插和更改砖头的位置 当拖动某块砖头时,先将其从数组中移除(剩余的砖头在逻辑上重新排

欧阳桫的技术博客 3993

web前端编辑器内容-图层拖动排序功能的开发

vue3实现拖动排序,掌握拖动排序的原理。

m0_47531829的博客 1816

拖拽排序业务逻辑实现思路与代码示例

拖拽排序业务逻辑实现思路与代码示例

小李同学的博客 1056

相当流畅的js拖拽排序实现

因为跟随鼠标的样式取决于拖拽开始时元素本身的样式,拖转开始时把元素的样式改变了,那就意味着跟随鼠标的样式也改变了,我们可以加一个setTimeout变成异步,在拖拽开始时还是保持原来的样式,然后过一点点时间在变成添加moving的样式。在拖拽排序的应用场景中,列表排序和图片排序是两个典型的例子。我们找到拖拽项的父元素,用事件委托的方式找到父元素,也就是.list并给它注册一个ondragstart事件,当拖拽开始时,可以使用event.target来获取被拖拽的元素,给它的类型样式添加一个moving。

6578

拖拽排序后台设计与实现

项目中有一个需求是拖拽排序,将类似卡片的东西拖拽重新排列允许更改元素的排序允许新增数据,并更新现有排序允许删除数据,并更新现有排序。

sdujava2011 1402
上一篇: MyBatis 的配置与使用,以及它与原生 JDBC 在项目开发中的不同点和优势
下一篇: Web 程序的经典三层架构(Controller 控制层、Service 业务层、DAO 持久层)三层架构是Web应用开发中常用的代码组织方式,分为控制层、业务层和持久层。控制层接收请求并返回响应,业
星空下的DeppBing
博客等级 码龄7年 552粉丝 260原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

星空下的DeppBing

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值