用 Vue 3 Composition API 打造可复用的拖拽功能 (useDraggable)
本文介绍了在Vue3中使用CompositionAPI封装可复用的拖拽功能。通过将拖拽逻辑分离为独立模块(useDraggable.ts),实现与业务组件解耦。关键步骤包括:定义拖拽目标/句柄的ref引用,创建处理位置计算和事件监听的组合函数,以及将返回的响应式样式绑定到组件。这种方法具有高内聚、低耦合的特点,支持类型安全,并能在不同组件间复用拖拽逻辑,充分体现了Vue3组合式API的模块化优势。
在现代 Web 应用中,可拖拽的浮动窗口是提升用户体验的常见功能。在 Vue 3 中,借助 Composition API,我们可以将拖拽逻辑封装成一个干净、可复用的“组合式函数”(Composable)。本文将结合一个实际项目中的例子,深度解析如何实现这一功能。
一、 核心思路:关注点分离
传统的拖拽实现往往将 mousedown, mousemove, mouseup 事件的监听和位置计算逻辑直接写在组件实例的 methods 或 setup 中。这样做的问题是:
- 逻辑混杂:组件不仅要关心自己的业务逻辑,还要关心底层的 DOM 操作和事件处理,不够清晰。
- 难以复用:如果另一个组件也需要拖拽功能,你可能需要复制粘贴大量代码。
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 的核心理念,让代码库变得更加模块化、可维护和可扩展
为武汉地区的开发者提供学习、交流和合作的平台。社区聚集了众多技术爱好者和专业人士,涵盖了多个领域,包括人工智能、大数据、云计算、区块链等。社区定期举办技术分享、培训和活动,为开发者提供更多的学习和交流机会。
更多推荐


所有评论(0)