Sortable 拖拽排序组件实现原理

如果我们想要实现拖拽排序功能,有很多现成的库可以供我们使用,比如Sortable.js(vuedraggable)、dnd-kit(react-dnd)等可以轻松帮助我们实现这一功能。

本文的目标不是介绍如何使用这些库,而是手动实现一个简单版的Sortable组件。通过本文的阅读,您将深入了解拖拽排序的核心原理。

话不多说,咱们开始吧!

使用模板

使用 Sortable 组件包裹要拖拽的列表项

html 复制代码
<template>
  <van-sortable ghost-class="blue-background-class">
    <div v-for="num in 6" class="sort-item">Item {{ num }}</div>
  </van-sortable>
</template>

<style scoped>
.sort-item {
  position: relative;
  display: block;
  padding: 6px 15px;
  margin-bottom: -1px;
  background-color: #fff;
  border: 1px solid rgba(0,0,0,.125);
  color: #000;
}
.blue-background-class {
  background-color: #c8ebfb;
}
</style>

看看最终的效果

Sortable 组件模板

html 复制代码
<template>
  <div ref="sortableRef" :class="n()">
    <slot />
  </div>
</template>

<script lang="ts" setup>
import { ref } from 'vue'
import { createNamespace } from '@vangle/utils'
import { SortableProps } from './sortable'
import { useSortable } from './use-sortable'
defineOptions({
  name: 'VanSortable'
})

const props = defineProps(SortableProps)

const { n } = createNamespace('sortable')

const sortableRef = ref<HTMLElement | null>(null)

useSortable(props, sortableRef)

</script>

<style lang="less">
@import './sortable.less';
</style>

核心就在 useSortable 钩子里

useSortable 实现

在PC端直接使用原生的拖拽即可,而在移动端我们使用touch事件模拟拖拽。下面两个函数用于区分是否是touch事件和获取鼠标点击的位置

typescript 复制代码
// 获取鼠标点击的位置
function getXY(event: MouseEvent | TouchEvent) {
  if (isTouchEvent(event)) {
    const { clientX, clientY } = event.touches[0]
    return { clientX, clientY }
  } else {
    return { clientX: event.clientX, clientY: event.clientY }
  }
}

// 判断是否是触摸事件
function isTouchEvent(val: unknown): val is TouchEvent {
  const typeStr = Object.prototype.toString.call(val)
  return typeStr.substring(8, typeStr.length - 1) === 'TouchEvent'
}

因此我们需要考虑这两种情况,先来看看 useSortable 的定义

typescript 复制代码
import { onBeforeUnmount, onMounted, ref, Ref, ExtractPropTypes } from 'vue'
import { SortableProps } from './sortable'
export function useSortable(
  props: ExtractPropTypes<typeof SortableProps>,
  sortableRef: Ref<HTMLElement | null>
) {
  // 移动端模拟拖拽反馈图像 https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Drag_operations#setting_the_drag_feedback_image
  const ghost = ref<HTMLElement | null>(null)

  function dragStart(event: MouseEvent | TouchEvent) {}

  // 在组件挂载时添加事件监听器
  onMounted(() => {
    // 添加鼠标/触摸事件监听器
    on(sortableRef.value!, 'mousedown', dragStart)
    on(sortableRef.value!, 'touchstart', dragStart)
  })
  
  // 在组件销毁前移除事件监听器
  onBeforeUnmount(() => {
    off(sortableRef.value!, 'touchstart', dragStart)
    off(sortableRef.value!, 'dragstart', dragStart)
  })
}
  • useSortable 接收props和拖拽列表元素的ref
  • ghost 主要用于移动端模拟拖拽效果
  • onMounted 和 onBeforeUnmount 中为列表元素注册移除事件

dragStart 实现

typescript 复制代码
function dragStart(event: MouseEvent | TouchEvent) {
  const isTouch = isTouchEvent(event)
  // touch事件清除默认行为
  if (isTouch) event.preventDefault()

  // 得到 target
  const draggingItem = (isTouch ? event.targetTouches[0].target : event.target) as HTMLElement

  if (!draggingItem) return

  // 设置拖拽元素类名和属性
  draggingItem.classList.add('dragging')
  if (props.ghostClass) {
    draggingItem.classList.add(props.ghostClass)
  }
  // 设置拖拽属性为 true
  draggingItem.setAttribute('draggable', 'true')

  const rect = draggingItem.getBoundingClientRect()

  const { clientX, clientY } = getXY(event)
  // 计算鼠标相对于拖拽元素里的位置
  const downX = clientX - rect.left
  const downY = clientY - rect.top
  
  // 如果是移动端需要创建 ghost
  if (isTouch) {
    // 克隆当前元素
    ghost.value = draggingItem.cloneNode(true) as HTMLElement
    
    // 设置ghost样式
    css(ghost.value, 'transform', `translate(${draggingItem.offsetLeft}px, ${draggingItem.offsetTop}px)`)
    css(ghost.value, 'opacity', '0.5')
    css(ghost.value, 'position', 'absolute')
    css(ghost.value, 'left', 0)
    css(ghost.value, 'top', 0)
    css(ghost.value, 'width', rect.width)
    css(ghost.value, 'height', rect.height)

    sortableRef.value!.appendChild(ghost.value)
  } else {
    ghost.value = null
  }

  // 添加触摸事件
  on(document, 'touchmove', onDrag)
  on(document, 'touchend', onDragEnd)

  // 添加拖拽事件
  on(document, 'dragover', onDrag)
  on(document, 'dragend', onDragEnd)

  // 鼠标抬起事件
  on(document, 'mouseup', onDragEnd)

  // 获取列表位置信息
  const parentRect = sortableRef.value!.getBoundingClientRect()

  function onDrag(event: MouseEvent | TouchEvent) {
    if (!draggingItem) return
    const { clientX, clientY } = getXY(event)

    const afterElement = getDragAfterElement(sortableRef.value!, clientY)
    if (afterElement == null) {
      sortableRef.value!.appendChild(draggingItem)
    } else {
      sortableRef.value!.insertBefore(draggingItem, afterElement)
    }
    
    if (ghost.value) {
      const x = clientX - parentRect.left - downX
      const y = clientY - parentRect.top - downY
      // 更新幻影元素位置
      css(ghost.value, 'transform', `translate(${x}px, ${y}px)`)
    }
  }

  function onDragEnd() {
    if (draggingItem) {
      draggingItem.classList.remove('dragging')
      if (props.ghostClass) {
        draggingItem.classList.remove(props.ghostClass)
      }
      
      // 移除触摸事件监听器
      off(document, 'touchmove', onDrag)
      off(document, 'touchend', onDragEnd)

      // 移除拖拽事件监听器
      off(document, 'dragover', onDrag)
      off(document, 'dragend', onDragEnd)
      
      // 移除幻影元素
      ghost.value && sortableRef.value!.removeChild(ghost.value!)
    }
  }
}

代码有点多,容我细细道来:

  1. 使用 isTouchEvent 函数来检测事件类型,判断是鼠标事件还是触摸事件
  2. 如果是触摸事件,则使用 event.targetTouches[0].target 来获取触摸事件的目标元素,否则使用 event.target 来获取鼠标事件的目标元素
  3. draggingItem 添加 'dragging' 标识,如果传入了 ghostClass 则相应添加上
  4. 计算坐标信息,downXdownY 是鼠标或触摸点相对于拖拽元素左上角的偏移量
  5. 如果是 touch 事件创建 ghost 并设置初始样式,添加到列表中
  6. 分别添加 touchmove、touchend,dragover、dragend 事件

onDrag

  1. 获取鼠标或触摸点的坐标,调用 getDragAfterElement 函数来获取距离拖拽点最近的兄弟元素, 并将其存储在 afterElementgetDragAfterElement 的实现后面会介绍

  2. 如果 afterElementnull,说明拖拽元素应该被插入到容器的末尾

  3. 否则说明拖拽元素应该插入到某个兄弟元素之前,使用 insertBefore 方法

  4. 如果 ghost 有值则更新其位置

getDragAfterElement

找到容器内与指定垂直坐标 y 最接近的一个兄弟元素

typescript 复制代码
function getDragAfterElement(container: HTMLElement, y: number) {
  // 得到兄弟元素
  const siblingElements = Array.from(container.children).filter(el => !el.classList.contains('dragging'))

  const item = siblingElements.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 })

  return (item as any).element
}

onDragEnd

在 onDragEnd 中移除拖拽开始为拖拽元素添加的类名,移除移动中和结束事件,如果创建了 ghost 则移除

下面是移动端的效果图

上面用到的几个工具函数

typescript 复制代码
// 添加事件监听器
function on(el: Element | Document, event: string, fn: Function) {
  el.addEventListener(event, fn as any)
}

// 移除事件监听器
function off(el: Element | Document, event: string, fn: Function) {
  el.removeEventListener(event, fn as any)
}

// 设置 CSS 样式
function css(el: HTMLElement, prop: string, val: number | string) {
  let style = el && el.style

  if (!(prop in style) && prop.indexOf('webkit') === -1) {
    prop = '-webkit-' + prop
  }

  (style as any)[prop] = val + (typeof val === 'string' ? '' : 'px')
}

最后

本文只是简单的实现了Sortable组件的核心功能,对于想了解实现原理的伙伴起到借鉴的作用。但在实际工作中推荐使用Sortable.js、dnd-kit 等成熟的库

完整代码请参考 github

推一下最近一直在做的拖拽组件 es-drager

基于 es-drager 实现的一个简单可视化拖拽编辑器 ES Drager Editor 目前还在开发完善中,感兴趣的小伙伴可以学习学习

相关推荐
golitter.1 小时前
Vue组件库Element-ui
前端·vue.js·ui
儒雅的烤地瓜1 小时前
JS | JS中判断数组的6种方法,你知道几个?
javascript·instanceof·判断数组·数组方法·isarray·isprototypeof
道爷我悟了1 小时前
Vue入门-指令学习-v-on
javascript·vue.js·学习
27669582921 小时前
京东e卡滑块 分析
java·javascript·python·node.js·go·滑块·京东
YUELEI1181 小时前
TypeScript 封装 Axios 1.7.7
typescript·axios
PleaSure乐事1 小时前
【Node.js】内置模块FileSystem的保姆级入门讲解
javascript·node.js·es6·filesystem
雷特IT2 小时前
Uncaught TypeError: 0 is not a function的解决方法
前端·javascript
.生产的驴2 小时前
Electron Vue框架环境搭建 Vue3环境搭建
java·前端·vue.js·spring boot·后端·electron·ecmascript
awonw2 小时前
[前端][easyui]easyui select 默认值
前端·javascript·easyui
老齐谈电商2 小时前
Electron桌面应用打包现有的vue项目
javascript·vue.js·electron