SwapDrag
一个基于原生 HTML5 拖拽(Drag and Drop API)实现的列表排序工具,用于让容器内的子元素可以通过拖拽调整顺序。
它只负责视觉上的拖拽交互,不会主动改变元素的顺序。所有排序逻辑都由使用方在回调中自行处理,例如在 onSort 中触发网络请求,待请求成功后再改变数据顺序。这种「操作数据、不操作 DOM」的思路与 Vue 等框架的理念相契合,尤其适用于需要后端持久化排序的场景。
可前往 useSwap 查看使用示例及效果
特性
- 支持拖动动画与位置交换动画
- 可配置拖拽延迟、动画时长与缓动函数
- 支持拖拽手柄(
handle)与可拖拽/不可拖拽子项过滤(draggable/filter) - 提供
onStart/onMove/onSort/onEnd等回调,方便接入业务逻辑 - 拖拽过程中自动添加选中、阴影、高亮等样式类,便于自定义视觉反馈
Code
ts
import { sleep } from '../../sleep/code'
import { getElementIndex } from '../get-element-index/code'
export type SwapAnimator = () => Promise<void>
export interface SwapDragOptions {
/**
* 拖拽开始前的延迟时间(毫秒)
*
* @default 0
*/
delay?: number
/**
* 拖动动画持续时间(毫秒),设为0则无动画
*
* @default 300
*/
animation?: number
/**
* 拖动动画的缓动函数
*
* @default 'ease'
* @values 'linear' | 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | string
* @see https://developer.mozilla.org/en-US/docs/Web/API/KeyframeEffect/KeyframeEffect#easing
*/
easing?: string
/**
* 容器选择器;若指定则在传入的 `el` 内查找该元素作为容器
*/
selector?: string
/**
* 拖拽手柄选择器;若指定,则只有从该选择器命中的元素上按下才能开始拖拽
*/
handle?: string
/**
* 可以被拖拽的子项
*/
draggable?: string
/**
* 不能被拖拽的子项
*/
filter?: string
/**
* 是否禁用拖拽
*
* @default false
*/
disabled?: boolean
/**
* 选中时的类名
*
* @default 'swap-chosen'
*/
chosenClass?: string
/**
* 阴影时的类名
*
* @default 'swap-ghost'
*/
ghostClass?: string
/**
* 拖动悬停时添加到目标元素的高亮类名
*
* @default 'swap-highlight'
*/
highlightClass?: string
/**
* 拖动的元素位于目标元素之前时添加的类名
*
* @default 'swap-before'
*/
beforeClass?: string
/**
* 拖动的元素位于目标元素之后时添加的类名
*
* @default 'swap-after'
*/
afterClass?: string
/**
* 拖拽开始时的回调函数
*/
onStart?: (e: DragEvent) => void
/**
* 拖动经过某个目标元素时触发。
* 返回 `false` 表示禁止拖入该元素(不会添加高亮,也不会触发 drop)。
*/
onMove?: (evt: SwapEvent, event: DragEvent) => boolean | void
/**
* 松手并完成排序(顺序有变化时触发)。
* 第二个参数 `swap` 为交换动画函数,调用后播放各元素移动到新位置的动画。
*/
onSort?: (evt: SwapEvent, swap: SwapAnimator) => void
/**
* 拖动结束(无论是否完成排序)时触发
*/
onEnd?: () => void
}
export interface SwapEvent {
/**
* 当前正在拖拽的元素
*/
dragged: HTMLElement
/**
* 当前拖入的目标元素(即要与之交换位置的元素)
*/
target: HTMLElement
/**
* 当前正在拖拽的元素在容器中的索引
*/
draggedIndex: number
/**
* 当前拖入的目标元素(即要与之交换位置的元素)在容器中的索引
*/
targetIndex: number
}
export class SwapDrag {
private readonly container: HTMLElement
private delayTimer?: ReturnType<typeof setTimeout>
delay: number
animation: number
easing: string
disabled: boolean
handle?: string
draggable?: string
filter?: string
chosenClass: string
ghostClass: string
highlightClass: string
beforeClass: string
afterClass: string
onStart: (e: DragEvent) => void
onMove: (evt: SwapEvent, event: DragEvent) => boolean | void
onSort: (evt: SwapEvent, swap: SwapAnimator) => void
onEnd: () => void
/**
* 当前正在拖拽的元素
*/
dragged: HTMLElement | null = null
/**
* 当前拖入的目标元素(即要与之交换位置的元素)
*/
target: HTMLElement | null = null
private handlePointerDown = (e: PointerEvent) => {
if (this.disabled) return
const dragged = this.resolveTarget(e.target, this.handle)
if (!dragged) return
if (this.draggable && !dragged.matches(this.draggable)) return
if (this.filter && dragged.matches(this.filter)) return
this.dragged = dragged
if (this.delay > 0) {
this.delayTimer = setTimeout(() => this.activateDrag(), this.delay)
} else {
this.activateDrag()
}
}
private handlePointerUp = () => {
this.clearDragged()
}
private handleDragStart = (e: DragEvent) => {
if (!this.dragged) return
this.dragged.classList.add(this.ghostClass)
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
// Firefox 需要设置数据才能触发拖拽
e.dataTransfer.setData('text/plain', 'swap')
}
this.onStart(e)
}
private handleDragOver = (e: DragEvent) => {
if (this.disabled || !this.dragged) return
const target = this.resolveTarget(e.target)
// 拖到容器空隙时清理残留高亮(与 dragleave 互补)
if (!target) return this.clearTarget()
// 只要命中容器内元素,就始终取消默认行为,确保 drop 能触发
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
e.preventDefault()
if (target === this.target) return
if (target === this.dragged) return this.clearTarget()
const evt = this.createSwapEvent(target)
// 拖到容器空隙时清理残留高亮(与 dragleave 互补)
if (this.onMove(evt, e) === false) return this.clearTarget()
this.clearTargetClasses()
target.classList.add(
this.highlightClass,
evt.targetIndex > evt.draggedIndex
? this.afterClass
: this.beforeClass,
)
this.target = target
}
private handleDragLeave = (e: DragEvent) => {
if (!this.dragged || !this.target) return
// relatedTarget 为 null 或不在容器内部,说明真的离开了容器
const related = e.relatedTarget as Node | null
if (related && this.container.contains(related)) return
this.clearTarget()
}
private handleDrop = (e: DragEvent) => {
if (this.disabled || !this.dragged || !this.target) return
e.preventDefault()
const evt = this.createSwapEvent(this.target)
this.onSort(evt, this.createSwap(this.recordPositions()))
}
private handleDragEnd = () => {
this.clearDragged()
this.clearTarget()
this.onEnd()
}
/**
* 事件名 → 处理函数映射,用于统一注册/销毁
*/
private readonly listeners: Array<[string, (...e: any[]) => void]> = [
['pointerdown', this.handlePointerDown],
['pointerup', this.handlePointerUp],
['dragstart', this.handleDragStart],
['dragover', this.handleDragOver],
['dragleave', this.handleDragLeave],
['drop', this.handleDrop],
['dragend', this.handleDragEnd],
]
constructor(el: HTMLElement, options: SwapDragOptions = {}) {
const { selector } = options
this.delay = options.delay ?? 0
this.animation = options.animation ?? 300
this.easing = options.easing ?? 'ease'
this.disabled = options.disabled ?? false
this.handle = options.handle
this.draggable = options.draggable
this.filter = options.filter
this.chosenClass = options.chosenClass ?? 'swap-chosen'
this.ghostClass = options.ghostClass ?? 'swap-ghost'
this.highlightClass = options.highlightClass ?? 'swap-highlight'
this.beforeClass = options.beforeClass ?? 'swap-before'
this.afterClass = options.afterClass ?? 'swap-after'
this.onStart = options.onStart ?? (() => {})
this.onMove = options.onMove ?? (() => {})
this.onSort = options.onSort ?? (() => {})
this.onEnd = options.onEnd ?? (() => {})
const container = selector
? el.querySelector<HTMLElement>(selector)
: el
if (!container) {
throw new Error('Container element not found')
}
this.container = container
this.listeners.forEach(([type, handler]) =>
container.addEventListener(type, handler),
)
}
/**
* 销毁拖拽实例
* 请在组件卸载时调用此方法,以避免内存泄漏
*/
destroy() {
this.listeners.forEach(([type, handler]) =>
this.container.removeEventListener(type, handler),
)
}
/**
* 从 event.target 中解析出容器内的目标元素
*/
private resolveTarget(
eventTarget: EventTarget | null,
handle?: string,
): HTMLElement | null {
if (!eventTarget || !(eventTarget instanceof HTMLElement)) return null
// 若指定了 handle,则 eventTarget 必须符合 handle 选择器,或其祖先元素符合 handle 选择器
if (handle) {
let current: HTMLElement | null = eventTarget
let matched = false
while (current && current !== this.container) {
if (current.matches(handle)) {
matched = true
break
}
current = current.parentElement
}
if (!matched) return null
}
// 向上查找,直到找到父元素为 container 为止
let el: HTMLElement | null = eventTarget
while (el && el.parentElement && el.parentElement !== this.container) {
// 若父元素为 document.documentElement,视为超出容器范围,返回 null
if (el.parentElement === document.documentElement) {
return null
}
el = el.parentElement
}
return el && el.parentElement === this.container ? el : null
}
/**
* 激活拖拽:使元素可拖拽,并添加选中态类名
*/
private activateDrag() {
if (!this.dragged) return
this.dragged.draggable = true
this.dragged.classList.add(this.chosenClass)
}
/**
* 移除目标元素上的高亮 / 前后置类名
*/
private clearTargetClasses() {
this.target?.classList.remove(
this.highlightClass,
this.afterClass,
this.beforeClass,
)
}
/**
* 清除当前目标及其高亮状态
*/
private clearTarget() {
this.clearTargetClasses()
this.target = null
}
/**
* 清理拖拽状态:清除延时定时器、恢复 draggable、移除相关类名
*/
private clearDragged() {
clearTimeout(this.delayTimer)
if (!this.dragged) return
this.dragged.classList.remove(this.chosenClass, this.ghostClass)
this.dragged.draggable = false
this.dragged = null
}
/**
* 构造一次交换事件的描述对象(拖拽元素与目标元素的索引)
*/
private createSwapEvent(target: HTMLElement): SwapEvent {
const draggedIndex = getElementIndex(this.dragged)
const targetIndex = getElementIndex(target)
return { dragged: this.dragged!, target, draggedIndex, targetIndex }
}
/**
* 记录容器内所有子元素当前的位置,供交换动画计算位移
*/
private recordPositions() {
const rects = new Map<HTMLElement, DOMRect>()
Array.from(this.container.children).forEach((el) => {
rects.set(el as HTMLElement, el.getBoundingClientRect())
})
return rects
}
/**
* 生成交换动画函数:播放各元素从记录位置移动到当前位置的过渡动画
*/
private createSwap(rects: Map<HTMLElement, DOMRect>): SwapAnimator {
return async () => {
const animations: Animation[] = []
for (const [el, prev] of rects) {
if (!el.isConnected) continue // 元素已被移除,跳过
const now = el.getBoundingClientRect()
const dx = prev.left - now.left
const dy = prev.top - now.top
if (dx === 0 && dy === 0) continue // 无需位移,跳过
// 为发生位移的元素创建过渡动画
const animation = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px)` },
{ transform: 'translate(0, 0)' },
],
{ duration: this.animation, easing: this.easing },
)
animations.push(animation)
}
// 全部动画结束后 resolve(加超时兜底,防止个别元素被移除导致无法结束)
const finished = Promise.all(
animations.map((anim) => anim.finished.catch(() => {})),
)
const timeout = sleep(this.animation + 50)
return Promise.race([finished, timeout]).then(() => {})
}
}
}