外观
事件冲突 - 禁止拖动时触发点击事件(Mousedown 与 Click 冲突)
1596字约5分钟
2024-11-27
父元素 Mousedown 事件 拖拽结束后 触发 子元素 Click 事件 问题研究与解决
日常开发中,我们经常实现全局悬浮菜单。一般情况下,悬浮菜单的父元素会绑定 mousedown
事件,用于拖拽移动菜单。而菜单内部会有一些导航按钮,这些按钮会绑定 click
事件,用于触发一些操作。
但是,当我们进行拖拽时发现,在拖拽结束时,会同时触发子元素的 click
事件。
按照惯例,先说最终解决方案,在 mousedown
事件中,通过事件修饰符 self
阻止拖动事件传递给子元素,从而避免触发子元素的 click
事件。
<div @mousedown.self="startDrag"></div>
还可以通过触发时间长短,来区分是点击事件还是拖拽事件。
clickItem(item) {
if (this.isDragging) return
console.log(item);
},
startDrag(event) {
// 重置状态
if(this.timerId)clearTimeout(this.timerId);
this.isDragging = false;
// 大于100ms,判断为拖拽操作 否则为点击事件
this.timerId = setTimeout(() => {
this.isDragging = true; // 开始拖拽
// ...
},100);
}
stopDrag() {
if(this.timerId)clearTimeout(this.timerId);
setTimeout(() => { // 设置异步,确保在拖拽结束的时候,已经停止拖拽
this.isDragging = false; // 停止拖拽
},0);
// ...
}
问题复现
我们使用 vue2 实现一个简单的拖拽父元素,里面包含一些列表子元素,并添加上 click
事件。
<div class="draggable" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown="startDrag" @touchstart="startDrag">
Drag me!
<ul>
<li @click="clickItem(item)" v-for="item in 10" :key="item">{{ item }}</li>
</ul>
</div>
具体实现托拽代码如下,点击展开查看:
详情
export default {
data() {
return {
position: { x: 100, y: 100 }, // 初始位置
isDragging: false,
offset: { x: 0, y: 0 },
};
},
methods: {
clickItem(item) {
console.log(item);
},
startDrag(event) {
this.isDragging = true;
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
this.offset.x = clientX - this.position.x;
this.offset.y = clientY - this.position.y;
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('touchmove', this.onDrag);
document.addEventListener('mouseup', this.stopDrag);
document.addEventListener('touchend', this.stopDrag);
},
onDrag(event) {
if (!this.isDragging) return;
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
const clientY = event.touches ? event.touches[0].clientY : event.clientY;
this.position.x = clientX - this.offset.x;
this.position.y = clientY - this.offset.y;
},
stopDrag() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('touchmove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
document.removeEventListener('touchend', this.stopDrag);
},
},
};
如上代码,测试之后得知,每次进行拖动结束的时候,都会触发子元素的 click
事件,打印出子元素的索引。
问题研究
遇到这个问题第一时间,想到的是,在 mousedown
事件中阻止默认行为,或者使用 event.preventDefault()
阻止默认行为。在加上 event.stopPropagation()
阻止事件冒泡。
我们在 Vue 组件中 直接通过 stop
和 prevent
修饰符,阻止默认行为和事件冒泡。
但是发现,并没有什么用。
<div class="draggable" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown.stop.prevent="startDrag" @touchstart.stop.prevent="startDrag">
Drag me!
<ul>
<li @click="clickItem(item)" v-for="item in 10" :key="item">{{ item }}</li>
</ul>
</div>
搜索了相关问题解决方案,有人提到通过 pointer-events
属性,将子元素设置为 none
,这样在拖拽的时候,子元素不会触发点击事件。
让我想到可以通过是否拖动来动态设置 pointer-events
属性。
<div class="draggable" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown.stop.prevent="startDrag" @touchstart.stop.prevent="startDrag">
Drag me!
<ul :style="{'pointer-events': isDragging ? 'none':'unset'}">
<li @click="clickItem(item)" v-for="item in 10" :key="item">{{ item }}</li>
</ul>
</div>
如上面实现方法,在拖拽的时候,将子元素设置为 pointer-events: none
,这样在拖拽的时候,子元素不会触发点击事件。但是,子元素的 click
事件单独点击也不会触发了。
除此之外,还有通过设置 isDragging
字段的解决方案,在触发 click
事件的时候,判断是否在拖拽中,如果是拖拽中,则不触发 click
事件。和上面一样,子元素的 click
事件单独点击也不会触发了。
clickItem(item) {
if (this.isDragging) return
console.log(item);
},
startDrag(event) {
this.isDragging = true; // 开始拖拽
// ...
}
stopDrag() {
setTimeout(() => { // 设置异步,确保在拖拽结束的时候,已经停止拖拽
this.isDragging = false; // 停止拖拽
},0);
}
解决方案
问题没有解决,我们尝试 打印 每次触发 startDrag
事件的参数 event
。
startDrag(event) {
console.log(event)
this.isDragging = true; // 开始拖拽
// ...
}
从打印结果中,我们发现,不只是元素会触发 mousedown
事件,子元素也会触发 mousedown
事件。
我们可以通过 Vue 的事件修饰符 self
,来限制只有父元素触发事件,子元素不触发事拖拽事件。
测试发现,成功 通过父元素 拖动移动,并且子元素点击事件正常触发。
<div class="draggable" :style="{ left: position.x + 'px', top: position.y + 'px' }" @mousedown.self="startDrag"@touchstart.stop.prevent="startDrag">
Drag me!
<ul :style="{'pointer-events': isDragging ? 'none':'unset'}">
<li @click="clickItem(item)" v-for="item in 10" :key="item">{{ item }}</li>
</ul>
</div>
父元素设置 拖动鼠标样式 cursor: move;
,子元素设置 点击鼠标样式 cursor: pointer;
,优化最终实现效果。
需要注意的是,在拖拽的时候,只能拖拽未被子元素覆盖的区域,否则无法拖拽。
其他方案
如果需要实现,父元素所有区域都可以拖拽,子元素点击事件正常触发。我们可以通过设置延迟来判断当前操作是否为拖拽操作。
如下,我们通过 isDragging
存放当前是否为拖拽操作,通过 setTimeout
延迟 100ms 判断当前操作是否为拖拽操作,如果是拖拽操作,则不触发 click
事件。
具体实现如下:
clickItem(item) {
if (this.isDragging) return
console.log(item);
},
startDrag(event) {
// 重置状态
if(this.timerId)clearTimeout(this.timerId);
this.isDragging = false;
// 大于100ms,判断为拖拽操作 否则为点击事件
this.timerId = setTimeout(() => {
this.isDragging = true; // 开始拖拽
// ...
},100);
}
stopDrag() {
if(this.timerId)clearTimeout(this.timerId);
setTimeout(() => { // 设置异步,确保在拖拽结束的时候,已经停止拖拽
this.isDragging = false; // 停止拖拽
},0);
//...
}
该方案存在一个缺陷,当用户按下鼠标后立即拖动,由于 setTimeout
延迟 100ms,判单为拖动后会导致悬浮窗闪动一次,到当前鼠标位置。
总结
上面提供了两种解决方案,第一种方案通过设置 Vue 的事件修饰符 self
来实现。第二种方案通过设置 isDragging
字段 和 设定100ms 延迟 来判断当前操作是否为拖拽操作,如果是拖拽操作,则不触发 click
事件。两种方案都可以实现父元素拖拽,子元素点击事件正常触发。
如果你有更好的解决方案,欢迎在评论区留言探讨。