驾驭拖拽:从原生到vue和react hooks的实现技术探究

1,022 阅读5分钟

拖拽功能在今天的前端开发中变得非常普遍,不论是原生JavaScript还是流行的前端框架如Vue和React,都提供了丰富的拖拽实现方式。本文将带深入探讨拖拽技术的实现原理和不同框架下的实际应用,帮助大家全面掌握拖拽功能的实现方法。

2b10f72e-45b7-4cdd-aa6c-7533e4f55382.gif

原生

需求1:弹出拖拽框案例

  1. 点击拖拽按钮,显示拖拽框(阻止事件冒泡)
  2. 点击关闭按钮,隐藏拖拽框
  3. 点击页面的其他部分,隐藏拖拽框
  4. 点击拖拽框(阻止事件冒泡)
  5. 点击关闭,隐藏拖拽框
  6. 用户点击esc键之后,关闭拖拽框

需求2:拖拽

  1. 拖拽头部注册鼠标按下事件

  2. document注册鼠标移动事件(注意这里是document

    1. 我们知道鼠标距离屏幕左侧和顶部的距离, 拖拽框跟着鼠标移动,需要用鼠标距离屏幕 - 鼠标距离拖拽框的距离,所以当鼠标按下去的时候,先要计算出鼠标距离拖拽框的距离x,y

    2. 当鼠标拖动的时候,鼠标距离屏幕的距离 - x = drag需要移动的距离

    3. 控制屏幕弹框在可视区域内

      左边:最小0,右边:最大为屏幕宽度 - 盒子宽度 - 关闭弹框一半宽度

      上边:最小关闭弹框一半宽度,下边:屏幕高度 - 盒子宽度

  1. document注册鼠标抬起事件,并且移除document的鼠标移动事件

直接上代码吧

<!DOCTYPE html>
<html><head lang="en">
  <meta charset="UTF-8" />
  <title></title>
  <style>
    * {
      padding: 0px;
      margin: 0px;
    }
​
    .drag {
      width: 512px;
      border: #ebebeb solid 1px;
      position: fixed;
      left: 0;
      top: 0;
      box-shadow: 0px 0px 20px #ddd;
      z-index: 2;
      background-color: white;
      display: none;
    }
​
    .drag-title {
      height: 52px;
      line-height: 52px;
      text-align: center;
      font-size: 20px;
      border-bottom: #ebebeb solid 1px;
      cursor: move;
      /* 禁止用户选择文字 */
      user-select: none;
    }
​
    .drag-content {
      padding: 12px;
      text-align: center;
    }
​
    .drag-content p {
      height: 36px;
      line-height: 36px;
    }
​
    .drag-close {
      width: 36px;
      height: 36px;
      line-height: 36px;
      text-align: center;
      border-radius: 50%;
      border: 1px solid #ebebeb;
      position: absolute;
      right: -18px;
      top: -18px;
      background-color: white;
      font-size: 12px;
      cursor: pointer;
    }
​
    .popup-draggable {
      height: 52px;
      line-height: 52px;
      text-align: center;
    }
​
    a {
      text-decoration: none;
      color: #000000;
    }
  </style>
</head><body>
  <div class="popup-draggable">
    <a id="link" href="javascript:void(0);">点击,弹出拖拽框</a>
  </div>
​
  <div class="drag">
    <div class="drag-title">
      请拖拽我
    </div>
    <div class="drag-content">
      <p>需求:弹出拖拽框案例</p>
      <p>1、点击拖拽按钮,显示拖拽框(阻止事件冒泡)</p>
      <p>2、点击关闭按钮,隐藏拖拽框</p>
      <p>3、点击页面的其他部分,隐藏拖拽框</p>
      <p>4、点击拖拽框(阻止事件冒泡)</p>
      <p>5、点击关闭,隐藏拖拽框</p>
      <p>6、用户点击esc键之后,关闭拖拽框</p>
    </div>
    <div class="drag-close">
      关闭
    </div>
  </div>
​
  <script>
    /*
      需求1:弹出拖拽框案例
      1、点击拖拽按钮,显示拖拽框(阻止事件冒泡)
      2、点击关闭按钮,隐藏拖拽框
      3、点击页面的其他部分,隐藏拖拽框
      4、点击拖拽框(阻止事件冒泡)
      5、点击关闭,隐藏拖拽框
      6、用户点击esc键之后,关闭拖拽框
     */
    const link = document.querySelector('#link')
    const drag = document.querySelector('.drag')
    const close = document.querySelector('.drag-close')
    const dragTitle = document.querySelector('.drag-title')
    link.addEventListener('click', function (e) {
      e.stopPropagation()
      drag.style.display = 'block'
      drag.style.top = (window.innerHeight - drag.offsetHeight) / 2 + 'px'
      drag.style.left = (window.innerWidth - drag.offsetWidth) / 2 + 'px'
    })
    document.addEventListener('click', function (e) {
      drag.style.display = 'none'
    })
    drag.addEventListener('click', function (e) {
      e.stopPropagation()
    })
    close.addEventListener('click', function (e) {
      drag.style.display = 'none'
    })
    document.addEventListener('keyup', function (e) {
      if (e.keyCode !== 27) return
      drag.style.display = 'none'
    })
    /*
    需求2:拖拽
    1、拖拽头部注册鼠标按下事件
    2、document注册鼠标移动事件(注意这里是document)
    */
    let x = 0
    let y = 0
​
    dragTitle.addEventListener('mousedown', dragFn)
​
    function dragFn(e) {
      console.log('按下鼠标', e.pageX, e.pageY)
      // 2.1 我们知道鼠标距离屏幕左侧和顶部的距离,
      // 拖拽框跟着鼠标移动,需要用鼠标距离屏幕 - 鼠标距离拖拽框的距离
      // 所以当鼠标按下去的时候,先要计算出鼠标距离拖拽框的距离
      x = e.pageX - drag.offsetLeft
      y = e.pageY - drag.offsetTop
      // 2.2 给document注册移动事件
      document.addEventListener('mousemove', moveFn)
    }
​
    function moveFn(e) {
      console.log('拖动鼠标')
      // 2.3 鼠标距离屏幕的距离 - x = drag需要移动的距离
      let moveX = e.pageX - x
      let moveY = e.pageY - y
      // 2.4 控制屏幕弹框在可视区域内
      // 左边:最小0,右边:最大为屏幕宽度 - 盒子宽度 - 关闭弹框一半宽度
      // 上边:最小关闭弹框一半宽度,下边:屏幕高度 - 盒子宽度
      const maxWidth = window.innerWidth - drag.offsetWidth - 18
      moveX = Math.min(moveX, maxWidth)
      moveX = Math.max(0, moveX)
      const maxHeight = window.innerHeight - drag.offsetHeight
      moveY = Math.min(moveY, maxHeight)
      moveY = Math.max(18, moveY)
      drag.style.left = moveX + 'px'
      drag.style.top = moveY + 'px'
    }
    // 特别注意:这里是给document添加抬起事件
    document.addEventListener('mouseup', function (e) {
      console.log('鼠标弹起了')
      // 特别注意:移除的是document的mousemove事件
      document.removeEventListener('mousemove', moveFn)
    })
  </script>
</body></html>

vue2 组件

为了防止图片被拖动,需要将 <img> 标签的 draggable 属性设置为 false。另外,该组件还实现了点击和拖拽的区分功能。

<template>
  <div
    class="draggable"
    :style="{ left: position.x + 'px', top: position.y + 'px' }"
    @mousedown="handleMouseDown"
    ref="box"
  >
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: {
    x: {
      type: Number,
      default: () => window.innerWidth - 138, // 动态计算默认值
    },
    y: {
      type: Number,
      default: () => window.innerHeight - 80, // 动态计算默认
    },
  },
  data() {
    return {
      position: {
        x: this.x,
        y: this.y,
      },
      isDragging: false,
      startX: 0,
      startY: 0,
    };
  },
  watch: {
    x(newX) {
      this.position.x = newX;
    },
    y(newY) {
      this.position.y = newY;
    },
  },
  methods: {
    handleMouseDown(e) {
      // 防止图片干扰拖拽
      e.preventDefault();

      this.isDragging = false;
      this.startX = e.clientX;
      this.startY = e.clientY;

      const startX = e.clientX - this.position.x;
      const startY = e.clientY - this.position.y;

      const handleMouseMove = (e) => {
        this.isDragging = true;
        let distanceX = e.clientX - startX;
        let distanceY = e.clientY - startY;
        const box = this.$refs.box;
        const maxX = document.documentElement.clientWidth - box.offsetWidth;
        const maxY = document.documentElement.clientHeight - box.offsetHeight;

        // 限制拖拽范围在屏幕内
        distanceX = Math.min(maxX, Math.max(0, distanceX));
        distanceY = Math.min(maxY, Math.max(0, distanceY));

        this.position.x = distanceX;
        this.position.y = distanceY;
      };

      const handleMouseUp = (e) => {
        document.removeEventListener('mousemove', handleMouseMove);
        document.removeEventListener('mouseup', handleMouseUp);

        // 如果鼠标移动距离小于一定值,认为是点击
        const movedDistance = Math.sqrt(
          Math.pow(e.clientX - this.startX, 2) +
          Math.pow(e.clientY - this.startY, 2)
        );
        if (movedDistance < 5) {
          this.$emit('click');
        }
      };

      document.addEventListener('mousemove', handleMouseMove);
      document.addEventListener('mouseup', handleMouseUp);
    },
  },
};
</script>

<style scoped>
.draggable {
  position: absolute;
  cursor: grab;
  z-index: 2;
}
</style>

vue3 hooks

新建hooks文件

src文件下新建hooks文件夹,新建文件useDraggable.ts

const useDraggable = (x = 0, y = 0) => {
  const position = reactive({x, y})
  const box = ref<HTMLDivElement | null>(null)
  const handleMouseDown = (e: MouseEvent) => {
    const startX = e.clientX - position.x
    const startY = e.clientY - position.y

    const handleMouseMove = (e: MouseEvent) => {
      let distanceX = e.clientX - startX
      let distanceY = e.clientY - startY
      const maxX = document.body.clientWidth - box.value!.offsetWidth
      const maxY = document.body.clientHeight - box.value!.offsetHeight

      distanceX = Math.min(maxX, distanceX)
      distanceX = Math.max(0, distanceX)

      distanceY = Math.min(maxY, distanceY)
      distanceY = Math.max(0, distanceY)
      position.x = distanceX
      position.y = distanceY
    }

    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove)
      document.removeEventListener('mouseup', handleMouseUp)
    }

    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleMouseUp)
  }

  return {
    onMouseDown: handleMouseDown,
    box,
    position
  }
}

export default useDraggable

使用方式

<template>
  <div class="drag" ref="box" :style="dragPositionStyle" @mousedown="onMouseDown">
  </div>
</template>
<script lang="ts" setup>
import useDraggable from '@/hooks/useDraggable'
const { position, onMouseDown, box} = useDraggable()
const dragPositionStyle = computed(() => ({left: `${position.x}px`, top: `${position.y}px`}))
</script>

react hooks

新建hooks文件

src文件下新建hooks文件夹,新建文件useDraggable.ts

import { useRef, useState } from 'react';

const useDraggable = (x = 0, y = 0) => {
  const [position, setPosition] = useState({ x, y });
  const box = useRef(null);
  const handleMouseDown = (e) => {
    const startX = e.clientX - position.x;
    const startY = e.clientY - position.y;

    const handleMouseMove = (e) => {
      let distanceX = e.clientX - startX;
      let distanceY = e.clientY - startY;

      let maxX = document.body.clientWidth - box.current.offsetWidth;
      let maxY = document.body.clientHeight - box.current.offsetHeight;

      distanceX = Math.min(maxX, distanceX);
      distanceX = Math.max(0, distanceX);

      distanceY = Math.min(maxY, distanceY);
      distanceY = Math.max(0, distanceY);
      setPosition({
        x: distanceX,
        y: distanceY,
      });
    };

    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };

    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  return {
    style: {
      position: 'fixed',
      left: position.x + 'px',
      top: position.y + 'px',
      cursor: 'move',
    },
    onMouseDown: handleMouseDown,
    box
  };
};

export default useDraggable;

使用方式

import useDraggable from '@/hooks/useDraggable';
const { style, onMouseDown, box } = useDraggable(window.innerWidth - 160, window.innerHeight / 2 - 100);
<div style={style} onMouseDown={onMouseDown} ref={box}></div>

vuereacthooks略有不同,大家可以在此基础扩展功能和细节!