在现代 Web 应用中,可拖拽的浮动窗口是提升用户体验的常见功能。在 Vue 3 中,借助 Composition API,我们可以将拖拽逻辑封装成一个干净、可复用的“组合式函数”(Composable)。本文将结合一个实际项目中的例子,深度解析如何实现这一功能。



一、 核心思路:关注点分离

传统的拖拽实现往往将 mousedown, mousemove, mouseup 事件的监听和位置计算逻辑直接写在组件实例的 methods 或 setup 中。这样做的问题是:

  1. 逻辑混杂:组件不仅要关心自己的业务逻辑,还要关心底层的 DOM 操作和事件处理,不够清晰。
  1. 难以复用:如果另一个组件也需要拖拽功能,你可能需要复制粘贴大量代码。

Vue 3 的 Composition API 完美解决了这个问题。我们可以创建一个 useDraggable.ts 文件,将所有与拖拽相关的逻辑(状态、事件监听、位置计算)都封装在里面。组件只需要“使用”这个函数,而无需关心其内部实现细节。

二、 实现逻辑的三大关键步骤

在我的 xxxx.vue 组件中,拖拽的实现可以清晰地分为三个部分:目标与句柄、拖拽引擎和样式应用。

1. 步骤一:定义“拖拽目标”和“拖拽句柄” (在组件中)

首先,我们需要告诉拖拽逻辑:哪个元素是需要移动的,以及点击哪个区域可以触发拖拽。

  • 拖拽目标 (Target):整个需要被移动的容器。
  • 拖拽句柄 (Handle):用户可以按住鼠标进行拖拽的区域,通常是窗口的标题栏。如果未指定,可以默认为整个目标。

在我的组件中,是 使用ref来实现的


<!--  xxxx.vue -->
<!-- 1. 定义整个容器为“拖拽目标” -->
<div class="container" ref="containerRef" :style="draggableStyle">
    <Box title="历史事件列表">
        <!-- 标题栏 .headline 将成为“拖拽句柄” -->
        ...
    </Box>
</div>
<!--  xxxx.vue -->
const containerRef = ref<HTMLElement | null>(null); // 拖拽目标 Ref
const handleRef = ref<HTMLElement | null>(null);    // 拖拽句柄 Ref

// 在组件挂载后,找到标题栏元素并赋值给 handleRef
onMounted(() => {
    if (containerRef.value) {
        // Box 组件内部的标题栏 class 是 .headline
        handleRef.value = containerRef.value.querySelector('.headline');
    }
});

2. 步骤二:创建拖拽引擎 useDraggable (核心逻辑)

这是整个功能的核心。useDraggable 是一个独立的函数,它接收“目标”和“句柄”的 ref 作为参数,并返回一个响应式的样式对象。

// src/utils/useDraggable.ts

import { ref, onMounted, onUnmounted, type Ref, computed, type StyleValue, watch } from 'vue';

interface DraggableOptions {
  // 拖拽的句柄元素
  handleRef: Ref<HTMLElement | null | undefined>;
  // 被拖拽的目标元素,我们需要它来读取初始位置
  targetRef: Ref<HTMLElement | null | undefined>;
}

export function useDraggable({ handleRef, targetRef }: DraggableOptions) {
  // 内部状态,始终使用 x/y 像素值来管理位置
  const x = ref(0);
  const y = ref(0);

  // 记录拖拽状态,以便区分初始样式和拖拽后样式
  const hasDragged = ref(false);
 //重置为“从未拖拽过”,这样下一次渲染时,位置重新交给 CSS(即回到初始位置)。
  const resetPosition = () => {
    hasDragged.value = false;
  };

  const onMouseDown = (event: MouseEvent) => {
    // 如果事件的目标是输入框、文本域或按钮,则不执行拖拽逻辑
    const target = event.target as HTMLElement;
    if (['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT', 'OPTION'].includes(target.tagName) || target.isContentEditable) {
        return;
    }
    if (!targetRef.value) return;
    event.preventDefault();

    // **关键**:在拖拽开始时,读取当前计算后的位置
    // 这使得初始位置可以由 CSS 的 top/right/bottom/left 等任何属性定义
    const style = window.getComputedStyle(targetRef.value!);
    const startX = event.clientX;
    const startY = event.clientY;
    // 将 style.left 和 style.top (可能为 'auto') 转换为数值
    const initialLeft = parseFloat(style.left) || 0;
    const initialTop = parseFloat(style.top) || 0;

    x.value = initialLeft;
    y.value = initialTop;
//拖拽过程
    const onMouseMove = (event: MouseEvent) => {
      // 第一次移动时,标记为已拖拽
      if (!hasDragged.value) {
        hasDragged.value = true;
      }

      const dx = event.clientX - startX;
      const dy = event.clientY - startY;
      x.value = initialLeft + dx;
      y.value = initialTop + dy;
    };
//鼠标释放时,解绑监听,结束拖拽
    const onMouseUp = () => {
      document.removeEventListener('mousemove', onMouseMove);
      document.removeEventListener('mouseup', onMouseUp);
    };

    document.addEventListener('mousemove', onMouseMove);
    document.addEventListener('mouseup', onMouseUp);
  };
//当 handleRef 变化时(可能是组件重新渲染或手柄替换):
//移除旧的事件监听
//给新的手柄添加 mousedown 监听,并把鼠标样式改为 move
  watch(handleRef, (newHandle, oldHandle) => {
    if (oldHandle) {
      oldHandle.style.cursor = 'auto';
      oldHandle.removeEventListener('mousedown', onMouseDown);
    }
    if (newHandle) {
      newHandle.style.cursor = 'move';
      newHandle.addEventListener('mousedown', onMouseDown);
    }
  });


  // 计算属性,用于绑定到组件的 style 上
  const style = computed<StyleValue>(() => {
    // 如果从未拖拽过,返回一个空对象,让 CSS 初始样式生效
    if (!hasDragged.value) {
      return {};
    }
    // 一旦开始拖拽,就用 JS 接管位置控制
    return {
      left: `${x.value}px`,
      top: `${y.value}px`,
      // **重要**:清除 right 和 bottom,防止与 left/top 冲突
      right: 'auto',
      bottom: 'auto',
    };
  });

  return {
    style,
    resetPosition,
  };
}

3. 步骤三:连接引擎与视图 (在组件中)

最后一步就是将这三者连接起来。组件调用 useDraggable,并将其返回的样式对象应用到模板中

<!--  xxxx.vue -->

// 1. 导入组合式函数
import { useDraggable } from "@/utils/useDraggable";

// (前面定义的 containerRef 和 handleRef)

// 2. 调用函数,传入目标和句柄的 Ref
const { style: draggableStyle } = useDraggable({
    targetRef: containerRef,
    handleRef: handleRef,
});
<!-- xxx.vue 模板 -->

<!-- 3. 将返回的响应式 style 绑定到目标的 :style 属性 -->
<div class="container" ref="containerRef" :style="draggableStyle">
    ...
</div>

当 useDraggable 内部的 style ref 发生变化时(在 onMouseMove 中),由于 draggableStyle 是一个响应式引用,Vue 会自动更新 :style 绑定,从而实时改变 div.container 在页面上的位置,实现了流畅的拖拽效果。

简单应用

<template>
  <div ref="targetRef" :style="style" class="window">
    <div ref="handleRef" class="header">拖拽我</div>
    <div class="content">内容</div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue"
import { useDraggable } from "@/utils/useDraggable"

const targetRef = ref<HTMLElement | null>(null)
const handleRef = ref<HTMLElement | null>(null)

const { style, resetPosition } = useDraggable({ handleRef, targetRef })
</script>


 

总结:这种方法的优势

  • 高内聚,低耦合:拖拽的复杂逻辑被完全封装在 useDraggable 中,组件本身非常干净,只负责传递 ref 和应用 style。
  • 可复用性:任何需要拖拽功能的组件,只需简单地导入并调用 useDraggable 即可,实现了“一次编写,到处使用”。
  • 响应式:充分利用了 Vue 3 的响应式系统,数据驱动视图,代码声明式且易于理解。
  • 类型安全:结合 TypeScript,可以对 ref 的类型进行约束,使得代码更加健壮。

通过这种方式,我们不仅实现了一个功能,更实践了 Composition API 的核心理念,让代码库变得更加模块化、可维护和可扩展

Logo

为武汉地区的开发者提供学习、交流和合作的平台。社区聚集了众多技术爱好者和专业人士,涵盖了多个领域,包括人工智能、大数据、云计算、区块链等。社区定期举办技术分享、培训和活动,为开发者提供更多的学习和交流机会。

更多推荐