移动端300ms延迟以及点击穿透

6/15/2021 OTHER

上周有几天是在写一个响应式网站,在写到移动端交互时.遇到一个问题,就是点击下拉框的选项时,下拉框背后的元素也被点击了.其实这个就是著名的点击穿透现象,因此趁着周末的时间把这个问题梳理了一下.然后呢,也是参考了一些文章之后整理了这篇总结,也算是自己对这个问题的一个记录吧.所有参考链接都放在文末.大家有兴趣的话,也可以去看看.

# 300ms 延迟

# 延迟产生原因

300ms 延迟的由来,是当初 07 年初苹果发布首款 iPhone 之前,苹果工程师提出的一个为了优化交互体验的操作.因为当时的网站基本都是为 PC 等大屏幕设备而写的,而现在需要用小屏幕浏览桌面端网站.当用户用手指把页面放大以后,就有了一个双击缩放(double tap to zoom)的交互.即在 iOS 自带的 Safari 浏览器中快速双击会将网页缩放到原始比例.因此在此浏览器中,用户会有单击或者双击的需求行为.而浏览器并不能立刻判断用户是想要单击还是双击.于是乎,Safari 就等待了 300ms.看看用户到底想干嘛.鉴于苹果公司这个操作的成功,后续其他的浏览器也因此借鉴了这种行为,而 300ms 也因此成为了大多数浏览器的一个约定.在当初移动端兴起的时候,300ms 是可以让人接受的,但是随着用户对交互体验的要求越来越高,300ms 就成为了用户无法忍受的一个点了.

# 解决延迟

上面简单的介绍了下 300ms 延迟产生的原因.那么在移动端开发中,该怎么解决 click 事件的 300ms 延迟呢?目前网上的解决方案也较多,我也按照那些方案做了下测试,整理如下:

# 禁用缩放

禁用浏览器的缩放,就是给我们的页面里面加个 meta 头部,表明这个网页是不可缩放的.在 App Store 下载了几个浏览器试了下,夸克浏览器和 QQ 浏览器测试是有效果的.Safari,Chrome,Firefox 都不行.这个测试结果和我在一些文章里面看的不一样,这是为啥啊?

<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />

# 改变视口宽度

更改默认的视口宽度,这下除了 safari 浏览器还有 300ms 以外,其他的浏览器都已经没有延迟了.貌似是因为这个方案还没有被 safari 通过.因为我们已经为用户适配了页面大小和阻止了用户缩放,所以浏览器就不用再判断用户双击缩放了,于是便自动取消了 click 事件的 300ms 延迟

<meta name="viewport" content="width=device-width" />

# touch-action

设置 touch-action 属性,该设置会禁用掉该元素上的浏览器代理的任何默认行为,包括缩放,移动,拖拽等.它把所有的触摸类型的交互事件都禁止掉了,导致页面也不能滚动.感觉在稍微复杂点的实际开发中,应该不会这么设置吧.

html {
  touch-action: none;
}

# 引用 fastclick 库

1fastclick原理: 在检测到 touchend 事件的时候,通过 DOM 自定义事件立即模拟一个 click 事件,并把浏览器原本 300ms 之后的 click 事件阻止掉.缺点是脚本相对较大,且有时候可能会有 bug. 项目地址 : fastclick (opens new window)

既然 click 事件屁事这么多,那能不能用 touch 事件来代替 click 事件呢?答案是不能.假如我们用 touchstart 事件来代替,一方面在用户手指触摸屏幕的时候就能触发,但有时候用户并不想点击,只想单纯的滑动屏幕而已.另一方面,就是在某些场景下可能会出现点击穿透现象(ghost click).

# 点击穿透

# 什么是点击穿透

页面俩元素 A 和 B,B 在 A 的上面.在 B 的上面注册了 touchstart 事件,回调函数中是让 B 元素隐藏.当我们点击 B 元素的时候,除了 B 被隐藏外,A 的 click 事件也被触发了.这是因为在移动端浏览器中,事件执行顺序是 touchstart => touchmove => touchend => click .click 是有 300ms 的延迟.当 B 的 touchstart 事件触发后,B 被隐藏了,300ms 之后,浏览器触发了 click 事件,此时的事件已经被派发到了 A 元素身上.

# 事件执行顺序

移动端的事件是touch事件,也叫触摸事件,因为是用手指触摸的.当然,点击事件还是存在的. 我们在上面提到了,在移动端浏览器中,事件执行顺序是 touchstart => touchmove => touchend => click.下面我们就先来介绍下这是个啥.

  • touchstart 是手指放到屏幕上就触发
  • touchmove 是手指在屏幕上滑动的时候触发
  • touchend 是手指离开屏幕的时候触发

click 事件是在最后执行的.一般情况下,click 是在手指放到屏幕上,并且没有移动过,然后离开,且这个开始触摸到手指离开屏幕的时间较短才能触发,若手指移动了,则不会触发 click 事件了(看到有的地方说某些浏览器会允许有一个很小的移动值,具体的情况不太清楚). 因此正确的触发顺序是下面两个中的一个:

  • touchstart => touchmove => touchend
  • touchstart => touchend => click

touchmove,可能不会触发,也可能触发很多次,若触发了 touchmove,则 click 就不会触发了. 我们在 Chrome 浏览器的手机模式下运行下面的代码来验证上面的结论.

<<button id="btn">click me</button>
<div id="app"></div>
let btn = document.querySelector('#btn')
let app = document.querySelector('#app')
let s = ''

btn.addEventListener('click', function() {
  s += 'click '
  app.innerText = s
})

btn.addEventListener('touchstart', function(e) {
  s += 'touchstart '
  app.innerText = s
  // e.preventDefault()
})

btn.addEventListener('touchmove', function() {
  s += 'touchmove '
  app.innerText = s
})

btn.addEventListener('touchend', function() {
  s += 'touchend '
  app.innerText = s
})

我们在按钮上单纯的点击一下,会打印出touchstart touchend click.在按钮上快速的从左滑到右,会打印出touchstart touchmove touchmove touchmove touchmove touchend,这里的touchmove的数量不定.

click 等事件一样,touch 事件也是有事件对象的.touchstarttouchmove 通过 event.touched 来获取手指信息(比如触摸的点的位置等信息).但是 touchend 不能通过 event.touched 来获取.因为此时的手指已经离开了.但是可以通过 event.changedTouches 来获取手指信息.

# 解决点击穿透

解决点击穿透的方案也有好几个,大家可以根据自己项目的实际情况选择对应的解决方案.

# 元素阻挡

新增一个看不见的元素阻止事件穿透.解决思路基本就是在触发事件的位置动态生成一个新的透明元素,这样当 300ms 之后的 click 事件来临时,点到的就是这个透明元素,然后再把这个元素删除即可.缺点就是写法麻烦,而且有时候用户快速点击的时候,下面元素的事件有可能不会触发,因为此时的透明元素还没有被定时器清理掉.当然还有一个方案和这个很像,那就是弄一个隐藏动画,时间大于 300ms,在延迟之后的点击事件来临时,上面的元素还没有消失,这样就不会点到下面的元素了.

<div class="div1"></div>
<div class="div2"></div>
.div1 {
  width: 200px;
  height: 200px;
  background-color: pink;
}
.div2 {
  width: 200px;
  height: 200px;
  background-color: orange;
  position: relative;
  top: -100px;
  display: block;
  opacity: 1;
}
let div1 = document.querySelector('.div1')
let div2 = document.querySelector('.div2')

div1.addEventListener('click', function() {
  console.log('div1')
})

div2.addEventListener('touchstart', function(e) {
  console.log('div2')
  let el = document.createElement('div')
  el.style.width = '200px'
  el.style.height = '200px'
  el.style.opacity = '0'
  el.style.position = 'relative'
  el.style.top = '-100px'
  document.body.appendChild(el)
  this.style.display = 'none'
  setTimeout(() => {
    document.body.removeChild(el)
  }, 400)
})

# 阻止默认事件

使用 event.preventDefault() ,把上面的代码改成

div2.addEventListener('touchstart', function() {
  console.log('div2')
  this.style.display = 'none'
  e.preventDefault()
})

# pointer-events

使用 pointer-events,这是一个 css3 中的属性.其中我们用的比较多的属性值有autonone.其他的属性基本都是为 SVG 服务的,戳我查看该属性详细介绍 (opens new window).

初始值就是auto,适用于所有的元素,其表现效果和 pointer-events 属性未指定时一样.鼠标不会穿透当前层. none 则该元素不会成为鼠标事件的目标.鼠标不再监听当前层而去监听下面层中的元素.但是若它的子元素设置了 pointer-events 为其他值,则鼠标还是会监听这个子元素的.运行下面代码,观察效果.

.div1 {
  width: 200px;
  height: 200px;
  background-color: orange;
}
.div2 {
  width: 100px;
  height: 100px;
  background-color: pink;
  pointer-events: none;
}
.div3 {
  width: 50px;
  height: 50px;
  background-color: #00bfff;
  pointer-events: auto;
}
<div class="div1">
  div1
  <div class="div2">
    div2
    <div class="div3">
      div3
    </div>
  </div>
</div>
let div1 = document.querySelector('.div1')
let div2 = document.querySelector('.div2')
let div3 = document.querySelector('.div3')

div1.addEventListener('click', function() {
  console.log('div1')
})
div2.addEventListener('click', function() {
  console.log('div2')
})
div3.addEventListener('click', function() {
  console.log('div3')
})

所以这里我们解决点击穿透的方法就是,先设置下层元素的 pointer-eventsnone,然后设置一个定时器,在一定的时间后将下层元素的属性值改为auto 即可.

# fastclick 库

引入 fastclick 库.

<script src="https://lib.baomitu.com/fastclick/1.0.6/fastclick.min.js"></script>

下面是原生 JS 中的写法,在不同的库(如 jQuery)或者框架(如 vue)中的写法都不太一样.不过基本都是大同小异,网上一搜一大堆,大家用到的时候去搜索下就行了.

if ('addEventListener' in document) {
  document.addEventListener(
    'DOMContentLoaded',
    function() {
      FastClick.attach(document.body)
    },
    false
  )
}

# 总结

既然这回遇到了 300ms 以及点击穿透这样的问题,那就索性研究记录下大家的解决方案是啥.下次再遇到类似的问题基本就是胸有成竹了.下面是我参考的一些文章链接列表:

    希望像星光一样闪烁
    文雀