Skip to content

点击穿透

在很多情况下,触摸事件和鼠标事件会同时被触发(目的是让没有对触摸设备优化的代码仍然可以在触摸设备上正常工作)。如下代码:

js
document.addEventListener('touchstart', () => {
  console.log('touchstart')
})

document.addEventListener('touchend', () => {
  console.log('touchend')
})

document.addEventListener('click', () => {
  console.log('click')
})

事件触发的先后顺序是:touchstart -> touchend -> click。正是由于这种 click 事件的滞后性设计为事件穿透(点击穿透)埋下了伏笔。

什么是事件穿透

事件穿透是指触发某个目标元素的触摸事件时,会同时触发该目标元素相同位置中其他元素的鼠标点击事件。例如:

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>事件穿透</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }

      div {
        width: 100vw;
        height: 100vh;
        line-height: 100vh;
        text-align: center;
      }

      .mask {
        position: fixed;
        top: 0;
        left: 0;
        background: #333;
        opacity: 0.6;
      }
    </style>
  </head>
  <body>
    <div>事件穿透</div>
    <div class="mask"></div>

    <script>
      const $div = document.querySelector('div')
      const $mask = document.querySelector('.mask')

      $mask.addEventListener('touchstart', (e) => {
        console.log('mask touchstart')
        e.target.style.display = 'none'
      })

      $div.addEventListener('click', () => {
        console.log('div click')
      })
    </script>
  </body>
</html>

由于 mask 元素触发 touchstart 触摸事件并立即隐藏掉自身,之后应该按先后顺序触发 mask 元素的 touchend 和 click 事件。然而,当要触发 click 事件的时候由于 mask 元素已经隐藏掉了,于是触发了 div 的 click 事件。

常见的事件穿透场景:

  • 目标元素触发触摸事件时隐藏或移除自身,对应位置元素触发 click 事件或 a 链接跳转。
  • 目标元素使用触摸事件跳转至新页面,新页面中对应位置元素触发 click 事件或 a 链接跳转。

注意:a 标签的链接跳转事件属于 click 事件。

解决方案

市面上解决事件穿透的方法有很多,大致可以分为两类:第一种是禁止混用 click 和 touch 两种事件;另一种是延迟元素的隐藏或移除。

禁用 click 事件

这种方法是将页面内所有元素的 click 事件改用 touch 事件。这种方法的好处非常明显,既解决了 click 事件延迟造成体验不佳的问题又解决了事件穿透的问题,但是缺点也很明显,就是 a 标签的链接跳转的处理问题。

禁用 a 标签的点击事件,改用 touch 事件触发链接跳转。实现如下:

js
// 禁用 a 标签的点击事件
document.addEventListener('click', (e) => {
  const href = e.target.getAttribute('href')
  const nodeName = e.target.nodeName.toLowerCase()

  if (nodeName === 'a' && href) {
    e.preventDefault()
  }
})

// 改用 touch 事件触发链接跳转
document.addEventListener('touchstart', (e) => {
  const href = e.target.getAttribute('href')
  const nodeName = e.target.nodeName.toLowerCase()

  if (nodeName === 'a' && href) {
    const target = e.target.getAttribute('target')
    window.open(href, target || '_self')
  }
})

禁用 touch 事件

这种方法是将页面内所有元素的 touch 事件改用 click 事件。事件穿透不就是由于 touch 与 click 事件存在触发时间差造成的吗,全部都使用 click 事件就不会有问题。然而事实真的如此美好?当然不是的,首先要解决 click 事件延迟 300ms 的问题。解决点击事件延迟的问题可以使用以下的 CSS 代码实现:

html
html { touch-action: manipulation; }

这样已经很完美了。然而,什么是工作?工作就是不停的解决问题。当你不得不为项目添加手势功能,增加用户体验的时候(比如:左滑、右滑等等各种滑),你才会意识到完全禁用 touch 事件在实际项目中是不可能的事情。这个时候怎么办,推倒重来,全部改用 touch 事件?当然不用这么麻烦,你可以在使用 touch 事件时通过调用 preventDefault() 阻止触发 click 事件。例如:

js
const $mask = document.querySelector(".mask")

$mask.addEventListener('touchstart', (e) => {
  ...
  e.preventDefault()
})

总结

解决事件穿透还有通过设置动画过渡延迟元素消失等方法,由于这类方法影响用户体验,不一一介绍。在实际项目开发中,纯移动端项目优先推荐禁用 click 事件的方法,多端项目优先推荐禁用 touch 事件的方法。