Vue 技巧-可拖动组件开发

60 阅读3分钟

组件概述

最近实现了一个基于 Vue 的可拖动组件,支持鼠标拖动、点击事件、窗口边界限制等功能。在开发过程中经历了多次优化和错误尝试,以此文做个总结和分享。

核心实现思路

核心实现思路是通过 fixed 定位设置组件位置,然后通过鼠标或者手指拖动来改变组件位置。

错误尝试与解决方案

事件丢失问题

错误尝试1:使用 mouse 事件

// 最初使用 mouse 事件
window.addEventListener('mousemove', this.handleDrag)
window.addEventListener('mouseup', this.stopDrag)
  • 问题:

    • 鼠标快速移动时事件可能丢失。
    • 移出组件区域后无法继续拖动。
    • 拖动体验不流畅(不跟手)。

最终解决方案:使用 Pointer Events + setPointerCapture

// 使用 pointer 事件
event.target.setPointerCapture(event.pointerId);
this.$el.addEventListener('pointermove', this.handleDrag);
this.$el.addEventListener('pointerup', this.stopDrag);
// 清理
this.stopDrag = function() {
  event.target.releasePointerCapture(event.pointerId);
  this.$el.removeEventListener('pointermove', this.handleDrag);
  this.$el.removeEventListener('pointerup', this.stopDrag);
}
  • 优势:
    • 提供了更可靠的事件捕获机制。
    • 支持更好的跨设备兼容性。
    • 解决了由于鼠标快速移动导致的事件丢失问题。

位置计算问题

错误尝试1:基于绝对坐标的位置计算

let newX = event.clientX - this.startPosition.x;
let newY = event.clientY - this.startPosition.y;
  • 问题:

    • 容易积累误差。

    • 位置更新不够准确。

    • 导致拖动过程中的不流畅体验。

最终解决方案:基于相对位移的位置计算

const deltaX = event.clientX - this.lastMousePosition.x;
const deltaY = event.clientY - this.lastMousePosition.y;
let newX = this.position.x + deltaX;
let newY = this.position.y + deltaY;
// 更新最后记录的位置
this.lastMousePosition = { x: event.clientX, y: event.clientY };
this.position = { x: newX, y: newY };
  • 优势:

    • 通过累加相对变化量而非绝对值,避免了累积误差。

    • 提供了更准确、平滑的位置更新。

    • 显著提升了用户体验。

点击判断问题

错误尝试1:基于时间间隔的点击判断

const dragDuration = Date.now() - this.dragStartTime;
if (dragDuration < 200) { // 例如,小于200毫秒认为是点击
  this.$emit('click', event);
  return;
}
  • 问题:

  • 时间阈值设置得不当可能导致误判。

  • 用户体验不佳,因为这取决于用户的具体操作速度。

最终解决方案:基于拖动距离的点击判断

const dragDistance = Math.sqrt(
  Math.pow(event.clientX - this.dragStartPosition.x, 2) +
  Math.pow(event.clientY - this.dragStartPosition.y, 2)
);
if (dragDistance < 1) { // 小于一定距离(如1像素)则认为是点击
  this.$emit('doClick', event);
  return;
}
  • 优势:

    • 更加直观地反映了用户的意图。

    • 减少了误判的可能性。

    • 提升了整体的交互质量。

核心技术点

Pointer Events API

  • 概述:使用 pointer 事件替代传统的 mouse 事件。

  • 主要事件

  • pointerdown

  • pointermove

  • pointerup

  • 优势

  • 统一处理鼠标、触摸和触控笔输入。

  • 提供更连续的事件流。

  • 更好的跨设备兼容性。

事件捕获机制

  • 功能:通过 setPointerCapturereleasePointerCapture 方法确保事件捕获。

  • 优点

    • 解决快速拖动时事件丢失的问题。

    • 即使指针移出元素区域也能继续捕获事件。

// 设置指针捕获
this.$el.setPointerCapture(event.pointerId)
// 释放指针捕获
this.$el.releasePointerCapture(event.pointerId)

位置计算

  • 方法:基于相对位移计算新位置,避免累积误差。

  • 实现

// 计算相对位移
const deltaX = event.clientX - this.lastMousePosition.x;
const deltaY = event.clientY - this.lastMousePosition.y;
// 更新位置
let newX = this.position.x + deltaX;
let newY = this.position.y + deltaY;
  • 好处:提供更平滑的拖动体验。

窗口边界限制

  • 目的:限制元素在窗口范围内移动,防止被拖出可视区域。

  • 实现

if (this.boundToWindow) {
  const rect = this.$el.getBoundingClientRect();
  newX = Math.max(0, Math.min(newX, window.innerWidth - rect.width));
  newY = Math.max(0, Math.min(newY, window.innerHeight - rect.height));
}

组件接口

Props

  • initialX: 初始 X 坐标 (Number)

  • initialY: 初始 Y 坐标 (Number)

  • isDraggable: 是否可拖动 (Boolean)

  • zIndex: 层级值 (Number)

  • customStyle: 自定义样式 (Object)

  • boundToWindow: 是否限制在窗口内 (Boolean)

Events

  • onDrag(position): 拖动时触发。

  • onDragEnd(position): 拖动结束时触发。

  • doClick(event): 点击时触发。

使用示例

<template>
  <Draggable
    :initial-x="100"
    :initial-y="100"
    :bound-to-window="true"
    @doClick="handleClick"
    @onDrag="handleDrag"
    @onDragEnd="handleDragEnd"
  >
    <div class="content">可拖动的内容</div>
  </Draggable>
</template>
<script>
export default {
  methods: {
    handleClick(event) {
      // 处理点击事件
    },
    handleDrag(position) {
      // 处理拖动事件
    },
    handleDragEnd(position) {
      // 处理拖动结束事件
    }
  }
}
</script>

组件源码

<template>
  <div
    class="draggable-component"
    :style="{
      position: 'fixed',
      left: position.x + 'px',
      top: position.y + 'px',
      cursor: isDraggable ? 'move' : 'default',
      zIndex: zIndex,
      ...customStyle
    }"
    @pointerdown="startDrag"
  >
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: 'Draggable',
  props: {
    // 初始X坐标
    initialX: {
      type: Number,
      default: 0
    },
    // 初始Y坐标
    initialY: {
      type: Number,
      default: 0
    },
    // 是否可拖动
    isDraggable: {
      type: Boolean,
      default: true
    },
    // z-index值
    zIndex: {
      type: Number,
      default: 1000
    },
    // 自定义样式
    customStyle: {
      type: Object,
      default: () => ({})
    },
    // 是否限制在窗口内
    boundToWindow: {
      type: Boolean,
      default: true
    },
    // 拖动时的回调
    onDrag: {
      type: Function,
      default: null
    },
    // 拖动结束时的回调
    onDragEnd: {
      type: Function,
      default: null
    }
  },
  data() {
    return {
      position: {
        x: this.initialX,
        y: this.initialY
      },
      isDragging: false,
      startPosition: {
        x: 0,
        y: 0
      },
      dragStartTime: 0,
      lastMousePosition: {
        x: 0,
        y: 0
      },
      // 记录拖动开始时的位置
      dragStartPosition: {
        x: 0,
        y: 0
      }
    }
  },
  mounted() {
    // 使用pointer事件替代mouse事件
    window.addEventListener('pointermove', this.handleDrag)
    window.addEventListener('pointerup', this.stopDrag)
  },
  methods: {
    startDrag(event) {
      if (!this.isDraggable) return
      
      // 设置指针捕获
      this.$el.setPointerCapture(event.pointerId)
      
      this.dragStartTime = Date.now()
      this.isDragging = true
      
      // 记录初始鼠标位置
      this.lastMousePosition = {
        x: event.clientX,
        y: event.clientY
      }
      
      // 记录拖动开始时的位置
      this.dragStartPosition = {
        x: event.clientX,
        y: event.clientY
      }
      
      // 记录组件初始位置
      this.startPosition = {
        x: this.position.x,
        y: this.position.y
      }
      
      // 添加样式防止选中文本
      document.body.style.userSelect = 'none'
      document.body.style.cursor = 'move'

      // 直接在元素上监听事件
      this.$el.addEventListener('pointermove', this.handleDrag)
      this.$el.addEventListener('pointerup', this.stopDrag)
    },
    handleDrag(event) {
      if (!this.isDragging) return
      
      event.preventDefault()
      event.stopPropagation()
      
      // 计算鼠标移动的距离
      const deltaX = event.clientX - this.lastMousePosition.x
      const deltaY = event.clientY - this.lastMousePosition.y
      
      // 更新最后鼠标位置
      this.lastMousePosition = {
        x: event.clientX,
        y: event.clientY
      }
      
      // 计算新位置
      let newX = this.position.x + deltaX
      let newY = this.position.y + deltaY

      if (this.boundToWindow) {
        const rect = this.$el.getBoundingClientRect()
        newX = Math.max(0, Math.min(newX, window.innerWidth - rect.width))
        newY = Math.max(0, Math.min(newY, window.innerHeight - rect.height))
      }
      
      this.position = {
        x: newX,
        y: newY
      }

      if (this.onDrag) {
        this.onDrag(this.position)
      }
    },
    stopDrag(event) {
      if (!this.isDragging) return
      
      event.preventDefault()
      event.stopPropagation()
      
      // 释放指针捕获
      this.$el.releasePointerCapture(event.pointerId)
      
      this.isDragging = false
      
      // 移除事件监听
      this.$el.removeEventListener('pointermove', this.handleDrag)
      this.$el.removeEventListener('pointerup', this.stopDrag)
      
      // 恢复body样式
      document.body.style.userSelect = ''
      document.body.style.cursor = ''

      // 计算拖动距离
      const dragDistance = Math.sqrt(
        Math.pow(event.clientX - this.dragStartPosition.x, 2) +
        Math.pow(event.clientY - this.dragStartPosition.y, 2)
      )
     console.log(dragDistance);
     
      // 如果拖动距离小于5像素,认为是点击
      if (dragDistance < 1) {
        this.$emit('doClick', event)
        return
      }

      if (this.onDragEnd) {
        this.onDragEnd(this.position)
      }
    }
  },
  beforeDestroy() {
    // 确保清理所有事件监听
    this.$el.removeEventListener('pointermove', this.handleDrag)
    this.$el.removeEventListener('pointerup', this.stopDrag)
    document.body.style.userSelect = ''
    document.body.style.cursor = ''
  }
}
</script>

<style scoped>
.draggable-component {
  user-select: none;
  touch-action: none; /* 防止触摸设备上的默认行为 */
}
</style>

开发经验总结

优先使用现代的Pointer Events API:Pointer Events提供了一种统一的方式来处理不同类型的输入设备(如鼠标、触摸屏或触控笔)上的用户交互。相比于传统的touchstart/touchendmousedown/mouseup等事件,Pointer Events不仅简化了跨平台兼容性问题,还提高了代码的可维护性和灵活性。例如,您可以使用pointerdownpointermove以及pointerup来替代多个特定于设备的事件处理器。