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

4,795 阅读9分钟

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

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

既然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服务的,戳我查看该属性详细介绍.

初始值就是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以及点击穿透这样的问题,那就索性研究记录下大家的解决方案是啥.下次再遇到类似的问题基本就是胸有成竹了.下面是我参考的一些文章链接列表: