Vue3 可拖动指令(draggable)
1. 指令概述
1.1 开发背景
在前端开发中,元素拖动是常见交互需求(如可拖拽弹窗、自定义布局组件、拖拽排序模块等)。原生实现拖动需处理多端事件(mousedown/mousemove/mouseup)、计算坐标偏移、限制边界、优化性能等,重复编码成本高且易出现兼容性或性能问题。
为解决上述痛点,封装 Vue 指令 draggable,将拖动逻辑抽象为可复用的指令,避免重复开发,同时通过性能优化确保拖动流畅性,降低业务代码与拖动逻辑的耦合度。
1.2 核心用途
该指令用于为 Vue 组件或 DOM 元素快速添加拖动功能,支持以下核心场景:
- 弹窗组件:仅允许通过标题栏(拖动句柄)拖动弹窗主体;
- 可定制面板:支持拖动面板调整位置(如数据看板、仪表盘组件);
- 轻量拖拽交互:无需引入大型拖拽库(如 vue-draggable-next),满足简单拖动需求。
1.3 关键特性
- 支持指定拖动句柄(仅允许点击元素的特定区域触发拖动);
- 内置边界限制(确保元素拖动时不超出浏览器视口);
- 优化拖动性能(避免布局抖动、减少重绘重排);
- 完整生命周期管理(挂载时初始化、卸载时清理资源,防止内存泄漏)。
2. 实现思路分析
指令基于 Vue 3 指令生命周期(mounted/unmounted)设计,核心逻辑围绕「事件监听 - 状态管理 - 坐标计算 - 性能优化」展开,具体拆解如下:
2.1 指令挂载阶段(mounted 钩子)
mounted 是指令绑定到 DOM 元素后的初始化入口,主要完成 5 件事:
步骤 1:确定拖动句柄(Drag Handle)
- 默认拖动句柄为绑定指令的元素本身 (如
则整个 div 可触发拖动);
- 若通过 binding.value 传入选择器(如 .drag-header),则从绑定元素的子节点中查询匹配元素作为句柄(仅点击句柄时触发拖动);
- 若查询不到匹配的句柄元素,自动降级为「整个元素可拖动」,保证兼容性。
步骤 2:初始化基础样式
- 性能优化:设置 el.style.willChange = 'left, top',告知浏览器提前优化这两个属性的动画渲染;
- 定位初始化:若元素未设置 position 或为 static(默认值),强制设为 fixed(确保 left/top 定位基于视口,符合拖动预期)。
步骤 3:定义核心状态变量
- isDragging:布尔值,标记当前是否处于拖动状态(避免非拖动时触发 mousemove 逻辑);
- offsetX/offsetY:鼠标与元素边界的偏移量(解决「点击元素任意位置拖动时,元素不跳变」的问题);
- animationId:requestAnimationFrame 的返回 ID,用于拖动结束时取消未执行的动画,避免性能浪费。
步骤 4:实现事件处理函数
拖动逻辑依赖「鼠标按下 - 移动 - 释放」三步事件,函数职责如下:
函数名 | 触发事件 | 核心逻辑 |
---|---|---|
handleMouseDown | mousedown | 1. 仅响应鼠标左键(e.button === 0);2. 计算 offsetX/offsetY;3. 提升元素 z-index 避免遮挡;4. 阻止句柄文本选中(e.preventDefault)。 |
handleMouseMove | mousemove | 1. 若未处于拖动状态(!isDragging)则跳过;2. 计算鼠标当前坐标与偏移量的差值(目标位置);3. 调用 updatePosition 更新元素位置。 |
handleMouseUp | mouseup/mouseleave | 1. 重置 isDragging 为 false;2. 取消未执行的 requestAnimationFrame;3. (可选)恢复 z-index(当前代码注释保留,按需启用)。 |
updatePosition | 内部调用 | 1. 用 requestAnimationFrame 确保动画流畅(与浏览器重绘节奏同步);2. 计算视口与元素尺寸,限制元素不超出视口;3. 设置元素 left/top 完成定位。 |
步骤 5:绑定事件监听
- 拖动句柄绑定 mousedown(触发拖动开始);
- 文档(document)绑定 mousemove/mouseup(确保鼠标移出元素范围仍能正常拖动 / 结束);
- 文档绑定 mouseleave(防止鼠标移出浏览器窗口后,拖动状态未重置);
- 保存事件处理函数与句柄引用到 el._draggableHandlers(供卸载时清理)。
2.2 指令卸载阶段(unmounted 钩子)
当绑定指令的元素被销毁时,需清理资源防止内存泄漏:
- 从 el._draggableHandlers 中获取之前保存的事件处理函数与拖动句柄;
- 移除所有事件监听(避免事件残留导致逻辑异常);
- 删除 el._draggableHandlers 引用,释放内存。
3. 完整指令代码
ini
/**
* Vue 可拖动指令(draggable)
* 功能:支持元素拖动、指定拖动句柄、限制视口边界、性能优化
* 适用场景:可拖拽弹窗、自定义布局组件、轻量拖动交互
*/
export const draggable = {
/**
* 指令挂载到元素时执行(初始化逻辑)
* @param {HTMLElement} el - 绑定指令的DOM元素
* @param {Object} binding - 指令绑定信息(value为拖动句柄选择器)
*/
mounted(el, binding) {
// 1. 确定拖动句柄(默认整个元素,支持通过binding.value指定选择器)
let dragHandle = el;
if (binding.value) {
const handle = el.querySelector(binding.value);
if (handle) dragHandle = handle;
}
// 2. 初始化样式(性能优化+定位)
el.style.willChange = 'left, top'; // 告知浏览器提前优化动画
if (!el.style.position || el.style.position === 'static') {
el.style.position = 'fixed'; // 确保拖动基于视口定位
}
// 3. 核心状态变量
let isDragging = false; // 拖动状态标记
let offsetX = 0; // 鼠标与元素左边界的偏移量
let offsetY = 0; // 鼠标与元素上边界的偏移量
let animationId = null; // requestAnimationFrame ID(用于取消动画)
// 缓存元素尺寸(避免mousemove时重复计算)
const getElementSize = () => ({
width: el.offsetWidth,
height: el.offsetHeight
});
/**
* 4. 事件处理函数:鼠标按下(触发拖动开始)
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleMouseDown = (e) => {
if (e.button !== 0) return; // 仅允许鼠标左键拖动
isDragging = true;
// 计算偏移量:鼠标坐标 - 元素边界坐标(避免拖动时元素跳变)
const elementRect = el.getBoundingClientRect();
offsetX = e.clientX - elementRect.left;
offsetY = e.clientY - elementRect.top;
el.style.zIndex = 1000; // 提升层级,避免被其他元素遮挡
if (e.target === dragHandle) e.preventDefault(); // 防止句柄文本被选中
};
/**
* 事件处理函数:更新元素位置(性能优化:requestAnimationFrame)
* @param {number} x - 目标X坐标
* @param {number} y - 目标Y坐标
*/
const updatePosition = (x, y) => {
if (animationId) cancelAnimationFrame(animationId); // 取消未执行的动画
animationId = requestAnimationFrame(() => {
const { width: elementWidth, height: elementHeight } = getElementSize();
const viewportWidth = window.innerWidth; // 视口宽度
const viewportHeight = window.innerHeight; // 视口高度
// 计算边界:确保元素完全在视口内
const minX = 0;
const maxX = viewportWidth - elementWidth;
const minY = 0;
const maxY = viewportHeight - elementHeight;
// 限制坐标在边界内
const constrainedX = Math.max(minX, Math.min(x, maxX));
const constrainedY = Math.max(minY, Math.min(y, maxY));
// 应用最终位置
el.style.left = `${constrainedX}px`;
el.style.top = `${constrainedY}px`;
});
};
/**
* 事件处理函数:鼠标移动(执行拖动逻辑)
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleMouseMove = (e) => {
if (!isDragging) return; // 非拖动状态跳过
// 计算目标坐标:鼠标坐标 - 偏移量
const targetX = e.clientX - offsetX;
const targetY = e.clientY - offsetY;
updatePosition(targetX, targetY);
};
/**
* 事件处理函数:鼠标释放/离开(结束拖动)
*/
const handleMouseUp = () => {
isDragging = false;
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
// 可选:恢复z-index(根据业务需求启用)
// el.style.zIndex = '';
};
// 5. 绑定事件监听
dragHandle.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseleave', handleMouseUp); // 处理鼠标移出浏览器
// 保存 handlers 供卸载时清理
el._draggableHandlers = {
handleMouseDown,
handleMouseMove,
handleMouseUp,
dragHandle
};
},
/**
* 指令卸载时执行(清理资源)
* @param {HTMLElement} el - 绑定指令的DOM元素
*/
unmounted(el) {
const handlers = el._draggableHandlers;
if (handlers) {
// 移除所有事件监听
handlers.dragHandle.removeEventListener('mousedown', handlers.handleMouseDown);
document.removeEventListener('mousemove', handlers.handleMouseMove);
document.removeEventListener('mouseup', handlers.handleMouseUp);
document.removeEventListener('mouseleave', handlers.handleMouseUp);
// 清理引用,防止内存泄漏
delete el._draggableHandlers;
}
}
};
4. 使用案例
4.1 指令注册
首先需在 Vue 项目中注册指令,支持全局注册 或局部注册。
全局注册(main.js)
javascript
import { createApp } from 'vue';
import App from './App.vue';
import { draggable } from './directives/draggable'; // 导入指令
const app = createApp(App);
app.directive('draggable', draggable); // 全局注册指令
app.mount('#app');
局部注册(组件内)
xml
<template>
<!-- 组件模板 -->
</template>
<script setup>
import { draggable } from './directives/draggable'; // 导入指令
</script>
4.2 基础用法(整个元素可拖动)
适用于简单元素(如小面板、卡片),整个元素均可触发拖动。
xml
<template>
<div
v-draggable
class="draggable-card"
>
<p>整个卡片可拖动</p>
</div>
</template>
<style scoped>
.draggable-card {
width: 300px;
height: 200px;
background: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 16px;
cursor: move; /* 提示可拖动 */
}
</style>
4.3 高级用法(指定拖动句柄)
适用于弹窗、复杂组件(仅允许特定区域拖动,如标题栏)。
xml
<template>
<!-- 弹窗组件:仅标题栏(.modal-header)可拖动 -->
<div v-draggable=".modal-header" class="modal">
<!-- 拖动句柄 -->
<div class="modal-header">
<h3>弹窗标题(可拖动)</h3>
</div>
<!-- 不可拖动区域 -->
<div class="modal-content">
<p>弹窗内容,点击此处不可拖动</p>
<button @click="closeModal">关闭</button>
</div>
</div>
</template>
<style scoped>
.modal {
width: 400px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
.modal-header {
padding: 12px 16px;
background: #f5f5f5;
border-bottom: 1px solid #eee;
cursor: move; /* 提示可拖动 */
}
.modal-content {
padding: 16px;
cursor: default; /* 提示不可拖动 */
}
</style>
5. 注意事项
- 定位兼容性:指令默认将元素 position 设为 fixed,若业务需使用 absolute(基于父元素定位),需手动在元素样式中设置 position: absolute,并确保父元素非 static 定位;
- 边界限制范围:当前边界限制为「浏览器视口」,若需限制在父容器内,需修改 updatePosition 中的边界计算逻辑(将 window.innerWidth/window.innerHeight 替换为父容器尺寸);
- z-index 冲突:指令拖动时将 z-index 设为 1000,若业务中存在更高层级元素,需调整该值避免遮挡;
- 移动端支持:当前仅处理鼠标事件(mousedown/mousemove/mouseup),若需支持移动端,需补充触摸事件(touchstart/touchmove/touchend)。