组件概述
最近实现了一个基于 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
-
优势:
-
统一处理鼠标、触摸和触控笔输入。
-
提供更连续的事件流。
-
更好的跨设备兼容性。
事件捕获机制
-
功能:通过
setPointerCapture
和releasePointerCapture
方法确保事件捕获。 -
优点:
-
解决快速拖动时事件丢失的问题。
-
即使指针移出元素区域也能继续捕获事件。
-
// 设置指针捕获
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
/touchend
和mousedown
/mouseup
等事件,Pointer Events不仅简化了跨平台兼容性问题,还提高了代码的可维护性和灵活性。例如,您可以使用pointerdown
、pointermove
以及pointerup
来替代多个特定于设备的事件处理器。