前端知库

怎样实现拖拽排序?HTML5 拖放 API 和 Pointer 事件怎么选?

拖拽排序的核心是拖动时根据指针位置算出目标下标,松手后把数据数组里的元素移过去,再由数据重新渲染列表。HTML5 拖放 API 能拖文件、能跨窗口,但拖动样式难控制;Pointer 事件配合 setPointerCapture 能完全自己控制跟随和动画,统一鼠标和触摸。两种都要提供键盘或按钮的替代操作。

参考答案

简答

待办清单里按住一项往上拖,其他项自动让开位置,松手后它就排到了新的位置。这就是拖拽排序。

不管用哪种 API,核心都是同一件事:拖动过程中根据指针位置算出“应该插到第几个”,松手时把数据数组里的元素从旧下标移到新下标,再由数据重新渲染列表。直接去挪 DOM 节点,数据和界面就会对不上。

实现方式有两种。HTML5 拖放 API 给元素加 draggable="true",监听 dragstart、dragover、drop,浏览器会画一个半透明的拖动影像;它能接收从桌面拖进来的文件,但影像样式和动画很难控制。Pointer 事件是自己监听 pointerdown、pointermove、pointerup,用 setPointerCapture 保证指针移出元素也能收到事件,跟随效果完全自己画,一套代码同时支持鼠标、触摸和触控笔。列表内排序通常选 Pointer 事件。

还要考虑不能拖动的用户。WCAG 2.2 要求拖动能完成的功能,也要能用单次点击完成,所以每项要有“上移”“下移”按钮或键盘操作。

松手以后数据怎么改

先把和 API 无关的部分写好:把数组里的一项从 from 移到 to。

JavaScript
function move(list, from, to) {
  const next = list.slice();
  const [item] = next.splice(from, 1);
  next.splice(to, 0, item);
  return next;
}

先删再插,返回新数组,不改原数组,方便在 React 这类框架里触发更新。列表渲染时 key 要用每项的 ID,不能用下标,否则移动后组件状态会错位,见《列表中的 key 为什么不能随意使用数组下标?》。

拖动过程中怎么算 to?常见做法是取出每一项的位置,看指针的纵坐标落在哪一项的上半部分还是下半部分。落在某项上半部分,就插到它前面;下半部分,就插到它后面。

用 HTML5 拖放 API 怎么写

MDN 介绍,拖放 API 基于 DOM 事件,拖动时会触发 dragstart、dragenter、dragover、dragleave、drop、dragend 等事件,其中 drag 和 dragover 会反复触发。容易踩的坑是:大多数元素默认不是放置目标,松手时只会播放一个“飞回去”的动画。MDN 写明,要在 dragover 事件里调用 preventDefault(),元素才能成为放置目标,drop 才会触发。

JavaScript
let fromIndex = -1;

list.addEventListener('dragstart', (event) => {
  const item = event.target.closest('li');
  fromIndex = Number(item.dataset.index);
  event.dataTransfer.effectAllowed = 'move';
});

list.addEventListener('dragover', (event) => {
  event.preventDefault(); // 不取消 dragover,drop 就不会触发
});

list.addEventListener('drop', (event) => {
  event.preventDefault();
  const target = event.target.closest('li');
  if (!target) return;
  render(move(items, fromIndex, Number(target.dataset.index)));
});

列表项要写成 <li draggable="true">。MDN 提醒 draggable 是枚举属性,必须写 true 或 false,不能简写成 <li draggable>。

这里没有用 dataTransfer 传数据,而是把下标存在变量里。MDN 说明在页面内拖动元素时,往往不需要定义传输的数据,直接操作被拖的元素就行;而且拖动数据只在 dragstart 和 drop 时可读,在 dragover 里读到的是空字符串。只有拖到页面外,或者从另一个窗口拖进来,才必须用 dataTransfer。

拖放 API 的长处是能和页面外交互:接收从文件管理器拖进来的文件,或者把内容拖到另一个应用。短处是拖动影像由浏览器生成,只能用 setDragImage 换一张图,很难做“其他项平滑让位”这种动画。MDN 对它的描述也是“用鼠标选中可拖动元素”,触屏上的支持情况要在目标设备上实测。

用 Pointer 事件怎么写

Pointer 事件把鼠标、触摸、触控笔统一成一套事件,介绍见《Pointer Events 为什么适合统一鼠标、触摸和触控笔交互?》。拖动时的关键是指针捕获:MDN 介绍,setPointerCapture(pointerId) 会把之后这个指针的事件都发给指定元素,直到调用 releasePointerCapture 或触发 pointerup。这样手指拖出列表项的范围,pointermove 仍然发给它。

JavaScript
item.addEventListener('pointerdown', (event) => {
  item.setPointerCapture(event.pointerId);
  startY = event.clientY;
});

item.addEventListener('pointermove', (event) => {
  if (!item.hasPointerCapture(event.pointerId)) return;
  const dy = event.clientY - startY;
  item.style.transform = `translateY(${dy}px)`; // 跟随指针
  updatePlaceholder(event.clientY); // 根据位置算目标下标,让其他项让位
});

item.addEventListener('pointerup', () => {
  item.style.transform = '';
  commitMove(); // 调用 move() 更新数据
});

item.addEventListener('pointercancel', () => {
  item.style.transform = '';
  cancelMove(); // 恢复原顺序
});

触屏上还要处理滚动冲突。MDN 说明,默认情况下平移和缩放手势由浏览器处理,浏览器开始处理触摸手势时,页面会收到 pointercancel。所以拖动手柄上要写 touch-action: none,告诉浏览器在这里不要滚动页面。只给手柄加,不要给整个列表加,否则用户没法滑动列表。pointercancel 也要处理,比如来电或切换应用打断了拖动,要把列表恢复原状。

两种方式怎么选

列表或看板内部的排序,选 Pointer 事件。跟随效果、让位动画、自动滚动都能自己控制,鼠标和触摸一套代码。

需要和页面外交互时,选拖放 API,比如把桌面上的文件拖进上传区,或者把列表项拖到另一个浏览器窗口。这些只有拖放 API 能做到。

成熟的拖拽库通常也是基于 Pointer 或鼠标、触摸事件自己实现排序,再单独提供键盘支持。

不能拖动的用户怎么办

WCAG 2.2 的 2.5.7“拖动动作”要求:用拖动完成的功能,也要能用单个指针、不需要拖动的方式完成,除非拖动是必需的。它还特别说明,提供键盘操作并不自动满足这一条,因为键盘方案不一定能用点击完成。

所以每一项至少要有“上移”“下移”按钮,或者“移动到…”菜单。键盘用户可以聚焦到这些按钮操作。移动完成后,焦点要留在被移动的那一项上,并用一个 aria-live 区域念出“已移到第 3 位”,读屏用户才知道发生了什么。

常见追问

拖到列表边缘时怎样自动滚动?

在 pointermove 里判断指针离容器上下边缘的距离,进入边缘区域就用 requestAnimationFrame 持续修改容器的 scrollTop,离开或松手就停。距离越近滚得越快,体验会更自然。

长列表用了虚拟滚动,拖拽排序要注意什么?

虚拟列表只渲染可见的几项,不能靠查询 DOM 来算目标下标,要用每项的高度数据算。被拖的那一项滚出可见区域时也不能被卸载,否则指针捕获和拖动状态就丢了。

松手前想让其他项平滑让位,怎么做?

拖动过程中只改其他项的 transform,让它们上下移动一个项的高度,不改数据顺序。松手时再一次性更新数据,并清掉这些位移。只改 transform 不触发重新布局,动画更顺。