使用vue3 实现个优雅的拖拽

1,816 阅读5分钟

前置知识

vue2 中常用的复用的方式

  • mixin(混入)
  • HOC
  • Renderless (Scoped Slots)

vue3 中的常用复用方式

  • Composition API
  • 自定义指令
  • Plugins

需求介绍

要求实现个可复用的拖拽逻辑,用户可以方便的使用,可拖拽,可设置边界。

技术选型

条条大路通罗马,我们有很多种方式来实现这个功能,本次选用vue3 组合式API 的写法,主要原因是我这种方式写的少,想练练;次要原因是这种方式较为优雅,可以按需引入,tree shaking 也很方便。

实现分析

首先需要考虑的是坐标系与拖动的坐标点取哪个点? 坐标系这里可以简单的使用网页的坐标,left 作为x轴的数值, right 作为 Y轴的数值。 拖动的坐标点就取元素的左上角的点,也方便定位 因为是左上角的点的话,取值的时候就不能取事件的 MouseEvent: clientXMouseEvent: clientY 直接作为坐标,不然就要求用户点击的时候只能点击左上角,不然一赋值,会导致元素直接偏移,体验就很差。这块我的解决方案是计算出来两者的差值,每次赋值的时候,将取出来的 clientXclientY 加上差值,就得到了左上角的坐标 其次看可拖拽这个点

拖拽的事件必定是从元素上开始的,然后考虑到会拖着移动,所以移动事件与结束事件应该是绑定在外层的限制元素上

最后看可设置边界这个点 简单的想个理想模型,左上角的点坐标应该大于等于限制范围的左上角坐标,右下角的点坐标应该小于等于限制范围的右下角的坐标。考虑到实际情况,可能存在限制范围小于拖拽元素的情况,这里如果出现这种情况,对他的处理就是将坐标设为左上角(尽可能覆盖限制范围)。 这边考虑到用户体验,打算支持2种输入,一种是符合直觉的直接传入坐标的范围(minX,miny,maxX,maxY),另一种DX友好的方式是直接传入范围对应的元素,直接在内部计算出来范围,并且监听对应的范围变化,来作为限制。

具体实现

1、求取鼠标点击点与左上角点的差值

// target 是指待移动的元素
  const start = (e: PointerEvent) => {
    const rect = target.value!.getBoundingClientRect();
    // 记录下点击的位置与左上角的偏差
    const pos = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    }
    // 记下本次的偏差值
    pressedDelta.value = pos;
  }

Element.getBoundingClientRect() 方法返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。

2、实现移动

// target 是指待移动的元素 draggingElement 是指外层的事件
// 移动开始
  const start = (e: PointerEvent) => {
    const rect = target.value!.getBoundingClientRect();
    // 记录下点击的位置与左上角的偏差
    const pos = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    }
    pressedDelta.value = pos;
  }
  // 移动事件
  const move = (e: PointerEvent) => {
    // 如果没点击开始则不触发
    if (!pressedDelta.value) {
      return;
    }
    let { x, y } = position.value;
    // 鼠标移动到的位置减去偏差
    if (axis === 'x' || axis === 'both') {
      x = e.clientX - pressedDelta.value.x;
    }
    if (axis === 'y' || axis === 'both') {
      y = e.clientY - pressedDelta.value.y;
    }
    position.value = limitArea({ x, y });
  }
  // 移动结束
  const end = (e: PointerEvent) => {
    if (!pressedDelta.value) {
      return
    }
    pressedDelta.value = undefined;
  }
onMounted(
	()=>{
		target.value!.addEventListener("pointerdown", start);
		draggingElement!.addEventListener("pointermove", move);
		draggingElement!.addEventListener("pointerup", end);
	}
)

3、处理限制区域

// 要求不超过边界
  const limitArea = ({ x = position.value.x, y = position.value.y }: { x: number, y: number } = position.value) => {
    if (x < areaLimit.value.startX) {
      x = areaLimit.value.startX;
    }
    if (x > areaLimit.value.endX - size.value.width) {
      x = areaLimit.value.endX - size.value.width;
    }
    if (y < areaLimit.value.startY) {
      y = areaLimit.value.startY;
    }
    if (y > areaLimit.value.endY - size.value.height) {
      y = areaLimit.value.endY - size.value.height;
    }
    // 如果元素小于限制区域 将元素移动到左上角
    if(areaLimit.value.endY - areaLimit.value.startY < size.value.height){
      y = areaLimit.value.startY
    }
    if(areaLimit.value.endX - areaLimit.value.startX < size.value.width){
      x = areaLimit.value.startX
    }
    return {
      x, y
    }
  }

4、监听输入的限制元素的变化

let mo: MutationObserver;
 let re: ResizeObserver;
  // 更新相对位置

  const initWatch = () => {
    const limitdiv = unref(limitDOM) ? unref(limitDOM) : document.getElementById("app")
    const callback = () => {
      const { left, right, top, bottom } = limitdiv!.getBoundingClientRect()
      areaLimit.value = {
        startX: left,
        startY: top,
        endX: right,
        endY: bottom,
      }
      position.value = limitArea();
    };
    if (limitdiv) {
      mo = new MutationObserver(callback);
      mo.observe(limitdiv, {
        attributes: true,
      });
      re = new ResizeObserver(callback)
      re.observe(limitdiv)
      callback();
    }
  }

  onMounted(
    () => {
      initWatch()
    }
  )

// 凡事有开始就有结束,别忘记处理
  onBeforeUnmount(
    () => {
      if (mo) {
        mo?.disconnect()
      }
      if (re) {
        re?.disconnect()
      }
    }
  )

上面的使用 观察者 的这种方式性能会比较好点,当然可能有漏掉的情况,我这边时间原因没有怎么测试,但思路都是这样的。

最后来看下整体的效果

drag例子动图

以上是所有的代码(供参考)

import { ref, onMounted, computed, unref, onBeforeUnmount } from "vue";

import type { Ref } from "vue";

  

export const defaultWindow = /*#__PURE__*/ window;

  
  
  

interface useDragOption {

  draggingElement?: any,

  draggingHandle?: any,

  initialValue?: any,

  axis?: 'x' | 'y' | 'both',

  limitDOM?: Ref<HTMLElement | null | undefined>,

}

  
  

// Composables

const useDrag = (target: Ref<HTMLElement | null>, options: useDragOption) => {

  const {

    draggingElement = defaultWindow,

    draggingHandle = target,

    initialValue,

    axis = 'both',

    limitDOM,

  } = options;

  

  const position = ref(

    initialValue ?? { x: 0, y: 0 },

  )

  

  const size = computed(

    () => {

      return {

        width: target?.value?.offsetWidth || 0,

        height: target?.value?.offsetHeight || 0,

      }

    }

  )

  
  
  

  // 移动的变化

  const pressedDelta = ref();

  

  onMounted(

    () => {

      // 判断是否有target

      if (!unref(target)) {

        console.warn("drag目标元素不存在,请检查!");

        return;

      }

      // 注册事件 此处可以搞个整体的事件,来提高一些性能,累了毁灭吧

      target.value!.addEventListener("pointerdown", start);

      draggingElement!.addEventListener("pointermove", move);

      draggingElement!.addEventListener("pointerup", end);

  

    }

  )

  

  onBeforeUnmount(

    () => {

      // 判断是否有target

      if (unref(target)) {

        // 取消事件

        target.value!.removeEventListener("pointerdown", start);

        draggingElement!.removeEventListener("pointermove", move);

        draggingElement!.removeEventListener("pointerup", end);

      }

    }

  )

  
  

  // 移动开始

  const start = (e: PointerEvent) => {

    const rect = target.value!.getBoundingClientRect();

    // 记录下点击的位置与左上角的偏差

    const pos = {

      x: e.clientX - rect.left,

      y: e.clientY - rect.top,

    }

    pressedDelta.value = pos;

  }

  

  // 移动事件

  const move = (e: PointerEvent) => {

    // 如果没点击开始则不触发

    if (!pressedDelta.value) {

      return;

    }

    let { x, y } = position.value;

    // 鼠标移动到的位置减去偏差

    if (axis === 'x' || axis === 'both') {

      x = e.clientX - pressedDelta.value.x;

    }

    if (axis === 'y' || axis === 'both') {

      y = e.clientY - pressedDelta.value.y;

    }

    position.value = limitArea({ x, y });

  }

  

  // 移动结束

  const end = (e: PointerEvent) => {

    if (!pressedDelta.value) {

      return

    }

    pressedDelta.value = undefined;

  }

  

  // 要求不超过边界

  const limitArea = ({ x = position.value.x, y = position.value.y }: { x: number, y: number } = position.value) => {

    if (x < areaLimit.value.startX) {

      x = areaLimit.value.startX;

    }

    if (x > areaLimit.value.endX - size.value.width) {

      x = areaLimit.value.endX - size.value.width;

    }

    if (y < areaLimit.value.startY) {

      y = areaLimit.value.startY;

    }

    if (y > areaLimit.value.endY - size.value.height) {

      y = areaLimit.value.endY - size.value.height;

    }

  
  

    // 如果元素小于限制区域 将元素移动到左上角

    if(areaLimit.value.endY - areaLimit.value.startY < size.value.height){

      y = areaLimit.value.startY

    }

    if(areaLimit.value.endX - areaLimit.value.startX < size.value.width){

      x = areaLimit.value.startX

    }

  

    return {

      x, y

    }

  }

  
  
  

  // 计算div的返回限制范围

  

  const areaLimit = ref(

    {

      startX: 0,

      startY: 0,

      endX: 500,

      endY: 500,

    }

  )

  let mo: MutationObserver;

  let re: ResizeObserver;

  // 更新相对位置

  const initWatch = () => {

    const limitdiv = unref(limitDOM) ? unref(limitDOM) : document.getElementById("app")

    const callback = () => {

      const { left, right, top, bottom } = limitdiv!.getBoundingClientRect()

      areaLimit.value = {

        startX: left,

        startY: top,

        endX: right,

        endY: bottom,

      }

      position.value = limitArea();

    };

    if (limitdiv) {

      mo = new MutationObserver(callback);

      mo.observe(limitdiv, {

        attributes: true,

      });

      re = new ResizeObserver(callback)

      re.observe(limitdiv)

      callback();

    }

  
  
  

  }

  onMounted(

    () => {

      initWatch()

    }

  )

  onBeforeUnmount(

    () => {

      if (mo) {

        mo?.disconnect()

      }

      if (re) {

        re?.disconnect()

      }

    }

  )

  
  
  

  return {

    draggingHandle,

    x: computed(() => position.value.x),

    y: computed(() => position.value.y),

    position,

    isDragging: computed(() => !!pressedDelta.value),

    style: computed(

      () => `left:${position.value.x}px;top:${position.value.y}px;`,

    )

  }

}

export { useDrag }
<script setup lang="ts">

import { shallowRef } from "vue";

import { useDrag } from "./use/drag"

  

const el = shallowRef()

const limit = shallowRef()

const { x, y, isDragging, style } = useDrag(el, { limitDOM: limit })

  

</script>

  

<template>

  <div>

    <div ref="limit" style="height:500px;width:100%;border: 1px solid yellow;">

      我是限制框

    </div>

    <div></div>

    <div ref="el" :style="style" style="position: fixed;border: 1px solid red;">

      <div style="width:100px;height:100px;">

        x:{{ x }}

        y:{{ y }}

        isDragging:{{ isDragging }}

      </div>

  

    </div>

  </div>

</template>

  

<style scoped></style>