移动端300ms点击延迟和点击穿透

20,646 阅读15分钟

这个不是我原创,整理的资料方便学习积累知识

移动端300ms点击延迟由来:

故事:2007 年初。苹果公司在发布首款 iPhone 前夕,遇到一个问题:当时的网站都是为大屏幕设备所设计的。于是苹果的工程师们做了一些约定,应对 iPhone 这种小屏幕浏览桌面端站点的问题。

这当中最出名的,当属双击缩放(double tap to zoom),这也是会有上述 300 毫秒延迟的主要原因。

双击缩放,顾名思义,即用手指在屏幕上快速点击两次,iOS 自带的 Safari 浏览器会将网页缩放至原始比例。 那么这和 300 毫秒延迟有什么联系呢? 假定这么一个场景。用户在 iOS Safari 里边点击了一个链接。由于用户可以进行双击缩放或者双击滚动的操作,当用户一次点击屏幕之后,浏览器并不能立刻判断用户是确实要打开这个链接,还是想要进行双击操作。因此,iOS Safari 就等待 300 毫秒,以判断用户是否再次点击了屏幕。 鉴于iPhone的成功,其他移动浏览器都复制了 iPhone Safari 浏览器的多数约定,包括双击缩放,几乎现在所有的移动端浏览器都有这个功能。之前人们刚刚接触移动端的页面,在欣喜的时候往往不会care这个300ms的延时问题,可是如今touch端界面如雨后春笋,用户对体验的要求也更高,这300ms带来的卡顿慢慢变得让人难以接受。

也就是说,移动端浏览器会有一些默认的行为,比如双击缩放、双击滚动。这些行为,尤其是双击缩放,主要是为桌面网站在移动端的浏览体验设计的。而在用户对页面进行操作的时候,移动端浏览器会优先判断用户是否要触发默认的行为。

重点:由于移动端会有双击缩放的这个操作,因此浏览器在click之后要等待300ms,看用户有没有下一次点击,也就是这次操作是不是双击。

浏览器开发商的解决方案

方案一:禁用缩放
当HTML文档头部包含如下meta标签时:

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

表明这个页面是不可缩放的,那双击缩放的功能就没有意义了,此时浏览器可以禁用默认的双击缩放行为并且去掉300ms的点击延迟。

缺点:就是必须通过完全禁用缩放来达到去掉点击延迟的目的,然而完全禁用缩放并不是我们的初衷,我们只是想禁掉默认的双击缩放行为,这样就不用等待300ms来判断当前操作是否是双击。但是通常情况下,我们还是希望页面能通过双指缩放来进行缩放操作,比如放大一张图片,放大一段很小的文字。

方案二:更改默认的视口宽度

一开始,为了让桌面站点能在移动端浏览器正常显示,移动端浏览器默认的视口宽度并不等于设备浏览器视窗宽度,而是要比设备浏览器视窗宽度大,通常是980px。我们可以通过以下标签来设置视口宽度为设备宽度。

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

因为双击缩放主要是用来改善桌面站点在移动端浏览体验的,而随着响应式设计的普及,很多站点都已经对移动端坐过适配和优化了,这个时候就不需要双击缩放了,如果能够识别出一个网站是响应式的网站,那么移动端浏览器就可以自动禁掉默认的双击缩放行为并且去掉300ms的点击延迟。如果设置了上述meta标签,那浏览器就可以认为该网站已经对移动端做过了适配和优化,就无需双击缩放操作了。
这个方案相比方案一的好处在于,它没有完全禁用缩放,而只是禁用了浏览器默认的双击缩放行为,但用户仍然可以通过双指缩放操作来缩放页面。

方案三:CSS touch-action

touch-action这个CSS属性。这个属性指定了相应元素上能够触发的用户代理(也就是浏览器)的默认行为。如果将该属性值设置为touch-action: none,那么表示在该元素上的操作不会触发用户代理的任何默认行为,就无需进行300ms的延迟判断。

现有的解决方案

方案一:指针事件的polyfill
现在除了IE,其他大部分浏览器都还不支持指针事件。有一些JS库,可以让我们提前使用指针事件,比如

然而,我们现在关心的不是指针事件,而是与300ms延迟相关的CSS属性touch-action。由于除了IE之外的大部分浏览器都不支持这个新的CSS属性,所以这些指针事件的polyfill必须通过某种方式去模拟支持这个属性。一种方案是JS去请求解析所有的样式表,另一种方案是将touch-action作为html标签的属性。

方案二:FastClick
FastClickFT Labs 专门为解决移动端浏览器 300 毫秒点击延迟问题所开发的一个轻量级的库。FastClick的实现原理是在检测到touchend事件的时候,会通过DOM自定义事件立即出发模拟一个click事件,并把浏览器在300ms之后的click事件阻止掉。

二、点击穿透问题

说完移动端点击300ms延迟的问题,还不得不提一下移动端点击穿透的问题。可能有人会想,既然click点击有300ms的延迟,那对于触摸屏,我们直接监听touchstart事件不就好了吗?
使用touchstart去代替click事件有两个不好的地方。
第一:touchstart是手指触摸屏幕就触发,有时候用户只是想滑动屏幕,却触发了touchstart事件,这不是我们想要的结果;
第二:使用touchstart事件在某些场景下可能会出现点击穿透的现象。

什么是点击穿透
假如页面上有两个元素A和B。B元素在A元素之上。我们在B元素的touchstart事件上注册了一个回调函数,该回调函数的作用是隐藏B元素。我们发现,当我们点击B元素,B元素被隐藏了,随后,A元素触发了click事件。

这是因为在移动端浏览器,事件执行的顺序是touchstart > touchend > click。而click事件有300ms的延迟,当touchstart事件把B元素隐藏之后,隔了300ms,浏览器触发了click事件,但是此时B元素不见了,所以该事件被派发到了A元素身上。如果A元素是一个链接,那此时页面就会意外地跳转。

注:浏览器事件触发的顺序

touchstart --> mouseover(有的浏览器没有实现) --> mousemove(一次) -->mousedown --> mouseup --> click -->touchend

Touch 事件中,常用的为 touchstart, touchmove, touchend 三种。除此之外还有touchcancel。 注意,原生事件中并没有tap事件。下面会解释tap事件怎么产生的。

事件描述如下:

事件描述触发时机
touchstart开始触摸手指接触屏幕时立即触发
touchmove移动或拖拽取决于系统和浏览器
touchend触摸结束手指离开屏幕时立即出发

而Touch事件的触发一般通过手指,还会存在多点触控,拖拽方向等情况。列出几个重要参数如下:

参数含义
touches屏幕中每根手指信息列表
targetTouches和touches类似,把同一节点的手指信息过滤掉
changedTouches响应当前事件的每根手指的信息列表

代码获取如下:

elemenrRef.addEventListener('touchstart', function(e) {   
    console.log(e.touches, e.targetTouches, e.changedTouches);}
);

手指触发触摸事件的过程如下:

touchstart --> mouseover(有的浏览器没有实现) --> mousemove(一次) -->mousedown --> 

mouseup --> click -->touchend

由此,我们可以在 ontouchstart 事件上记录开始触摸开始,ontouchend 记录触摸结束信息。 通过上述这些参数,很容易的去计算幽冥点击的时间,以及点击穿透的相关信息,包括响应的坐标情况。

现象:

1) 点击穿透问题:点击蒙层(mask)上的关闭按钮,蒙层消失后发现触发了按钮下面元素的click事件,

蒙层的关闭按钮绑定的是touch事件,而按钮下面元素绑定的是click事件,touch事件触发之后,蒙层消失了,300ms后这个点的click事件fire,event的target自然就是按钮下面的元素,因为按钮跟蒙层一起消失了

2) 跨页面点击穿透问题:如果按钮下面恰好是一个有href属性的a标签,那么页面就会发生跳转

因为a标签跳转默认是click事件触发,所以原理和上面的完全相同

3) 另一种跨页面点击穿透问题:这次没有mask了,直接点击页内按钮跳转至新页,然后发现新页面中对应位置元素的click事件被触发了

和蒙层的道理一样,js控制页面跳转的逻辑如果是绑定在touch事件上的,而且新页面中对应位置的元素绑定的是click事件,而且页面在300ms内完成了跳转,三个条件同时满足,就出现这种情况了
非要细分的话还有第四种,不过概率很低,就是新页面中对应位置元素恰好是a标签,然后就发生连续跳转了。。。诸如此类的,都是点击穿透问题

解决方案:

  1. 只用touch

    最简单的解决方案,完美解决点击穿透问题

    把页面内所有click全部换成touch事件(touchstart、’touchend’、’tap’),

    需要特别注意
    a标签,a标签的href也是click,需要去掉换成js控制的跳转,或者直接改成span + tap控制跳转。如果要求不高,不在乎滑走或者滑进来触发事件的话,span + touchend就可以了,毕竟tap需要引入第三方库

    不用a标签其实没什么,移动app开发不用考虑SEO,即便用了a标签,一般也会去掉所有默认样式,不如直接用span

  2. 只用click

    下下策
    ,因为会带来300ms延迟,页面内任何一个自定义交互都将增加300毫秒延迟,想想都慢

    不用touch就不会存在touch之后300ms触发click的问题,如果交互性要求不高可以这么做,

    强烈不推荐
    ,快一点总是好的
  3. tap后延迟350ms再隐藏mask

    改动最小,缺点是隐藏mask变慢了,350ms还是能感觉到慢的

    只需要针对mask做处理就行,改动非常小,如果要求不高的话,用这个比较省力

  4. pointer-events

    比较麻烦且有缺陷,

    不建议使用

    mask隐藏后,给按钮下面元素添上pointer-events: none;样式,让click穿过去,350ms后去掉这个样式,恢复响应

    缺陷是mask消失后的的350ms内,用户可以看到按钮下面的元素点着没反应,如果用户手速很快的话一定会发现

  5. 在下面元素的事件处理器里做检测(配合全局flag)

    比较麻烦,

    不建议使用:

    全局flag记录按钮点击的位置(坐标点),在下面元素的事件处理器里判断event的坐标点,如果相同则是那个可恶的click,拒绝响应

    上面说的只是想法,没测试过,实在不行就用记录时间戳判断,等待350ms,这样就和pointer-events差不多

  6. fastclick

    好用的解决方案,不介意多加载几KB的话,

    不建议使用:
    ,因为有人遇到了bug,首先引入fastclick库,再把页面内所有touch事件都换成click,其实稍微有点麻烦,建议引入这几KB就为了解决点透问题不值得,不如用第一种方法呢


注:代码上处理建议如下:

在touchend事件上调用 preventDefault()


在一次成功的点击后,建议接下来的 500ms 以内取消所有的 click 事件。


分析点击事件,判断如果是慢速点击穿透,则取消所有 click 事件,如果是快速点击穿透,取消触摸事件 50ms以内的 click 事件即可。

别的参考思路(开源库fastclick),取消 click 事件,用touchend 模拟 快速点击行为。


Why

问题来了,click 事件什么时候触发?

浏览器在 touchend 之后会等待约 300ms ,如果没有 tap 行为,则触发 click 事件。 而浏览器等待约 300ms 的原因是,判断用户是否是双击(double tap)行为,双击过程中就不适合触发 click 事件了。 由此可以看出 click 事件触发代表一轮触摸事件的结束。

上面说到原生事件中并没有 tap 事件,可以参考经典的 zepto.js 对 singleTap 事件的处理(遗憾的是在部分浏览器中,依然存在点击穿透的问题)。可以看出,singleTap 事件的触发时机 —— 在 touchend 事件响应 250ms 无操作后,触发singleTap。因此,点击穿透的现象就容易理解了,在这 300ms 以内,因为上层元素隐藏或消失了,由于 click 事件的滞后性,同样位置的 DOM 元素触发了 click 事件(如果是 input 则触发了 focus 事件)。在代码中,给我们的感觉就是 target 发生了飘移。

如何处理点击穿透(思路)

1. 触摸开始时 touchstart 事件触发时,preventDefault()。毫无疑问,很容易想到这一点,而且也从根本上解决了这个问题。但是,它有一个避免不了或者说引入了很大的缺陷,页面中DOM 元素无法再进行滚动了。这个方法显然不能满足我们的需求,但是这个思路其实可以给我们更多的启发,比如说 iscroll 只允许横向滚动的实现,相关实现这里暂且不表。

2. 触摸结束时 touchend 事件触发时,preventDefault()。看上去好像没有什么问题,但是,很遗憾的是不是所有的浏览器都支持。

3. 禁止页面缩放 通过设置meta标签,可以禁止页面缩放,部分浏览器不再需要等待 300ms,导致点击穿透。点击事件仍然会触发,但相对较快,所以 click 事件从某种意义上来说可以取代点击事件, 而代价是牺牲少数用户(click 事件触发仍然较慢)的体验。

<meta name="viewport" content="width=device-width, user-scalable=no">

移动端chromiun 和 iOS 9.3+ 可以用 CSS 属性来阻止元素的双击缩放进而取消点击穿透的延迟:

html {    -ms-touch-action: manipulation;    touch-action: manipulation;}  

4. CSS3 的方法 虽然主要讲的是事件,但是有必要介绍一个 CSS3 的属性 —— pointer-events。

pointer-events:  auto | none | visiblePainted | visibleFill | visibleStroke | visible | painted | fill | stroke | all | inherit;

pointer-events 属性有很多值,有用的主要是 auto 和 none,其他属性为 SVG 服务。

可见移动端开发还是可以用的。

属性含义
auto默认值,鼠标或触屏事件不会穿透当前层
none元素不再是target,监听的元素变成了下层的元素(如果子元素设置成 auto,点击子元素会继续监听事件)

5. 处理点击事件 —— Touch to Click 最靠谱的方案还是从点击事件的根源上解决问题。用 js 去判断幽冥点击,然后阻止点击穿透。这种方式显然可以实现,缺点是阻止点击穿透时需要小心,不要导致原生的 HTML 元素(如:链接,多选框,单选框)无法正常运行。

通过上文中介绍的 touches,targetTouches,changedTouches 参数,我们可以构建出这样的测试页面,可以统计出点击穿透的时间,以及已经响应的情况。

preventDefault()点击穿透时间点击穿透区域
touchstarttouchend缩放页面禁止缩放页面缩放页面禁止缩放页面
Safari Mobile iOS 5.1.1YesYes370ms after end370msafter endtouchstarttouchstart
Safari Mobile iOS 6.1.3YesYes370ms after end370msafter endtouchstarttouchstart
Safari Mobile iOS 7.1.1YesYes370ms after end370msafter endtouchstarttouchstart
Android 2.3.7YesNo410ms after end410msafter endtouchstarttouchstart
Android 4.0.4YesNo300ms after end10ms after endtouchstarttouchstart
Android 4.1.2YesNo300ms after end300msafter endtouchstarttouchstart
Android 4.2.2YesNo300ms after start10ms after endtouchstarttouchend
IE10 Windows Phone 8NoNo310ms after end10ms after endtouchendtouchend
Blackberry 10YesYes260ms after end10ms after endtouchstarttouchstart
Chrome for iOSYesYes360ms after end360msafter endtouchstarttouchstart
Chrome for AndroidYesYes300ms after start10ms after endtouchstarttouchend
Firefox for AndroidYesNo300ms after end10ms after endtouchstarttouchend

由此可以看出: 1. 点击穿透受浏览器和页面是否缩放影响 2. 点击穿透有两种情况:快速情况有 10ms 慢速情况有 300ms 3. 在 touchend 时间上调用 preventDefault() 可以阻止多数情况的点击穿透