Skip to content

事件冲突 - 禁止拖动时触发点击事件(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 组件中 直接通过 stopprevent 修饰符,阻止默认行为和事件冒泡。

但是发现,并没有什么用。

<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; // 开始拖拽
    // ...
}
PNG

从打印结果中,我们发现,不只是元素会触发 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 事件。两种方案都可以实现父元素拖拽,子元素点击事件正常触发。

如果你有更好的解决方案,欢迎在评论区留言探讨。