Sortable 拖拽排序组件实现原理

发布时间 2023-12-15 15:25:40作者: 柯基与佩奇

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

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

使用模板

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

<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, 0.125);
  color: #000;
}
.blue-background-class {
  background-color: #c8ebfb;
}
</style>

看看最终的效果

temp1.gif

Sortable 组件模板

<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 事件和获取鼠标点击的位置

// 获取鼠标点击的位置
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 的定义

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 实现

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 最接近的一个兄弟元素

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 则移除

下面是移动端的效果图

temp2.gif

上面用到的几个工具函数

// 添加事件监听器
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 等成熟的库