Fork me on GitHub

移动端300ms点击延迟与点透问题

 个人接触移动端的经验还是比较欠缺的,结合网上一些博文作了这方面的总结。

300ms 点击延迟

  先说这大概是一种什么现象:我们先将点击动作分为两类,一种是单击,另一种则是双击;由于苹果厂的历史原因,移动端的浏览器需要300ms的响应延迟来判断用户动作是属于单击还是双击。由于苹果那个时候还是老大哥(虽然现在也还勉强),它有着双击缩放和滚动的特性,所以我们经常看到以下的这种HTML的头部媒体标签设置:

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

  作用是禁止缩放,但是这个用度有点过猛,会把所有的缩放特性全部抹掉,除了前面说的双击问题还包括你双指缩放操作,这肯定不是我们所期望的,也影响用户的使用体验,所以可以通过如下的媒体设置,这种方式通过指定浏览器视窗宽度为移动端设备的视窗宽度,按照移动端的等比缩放,即所谓的响应式,当移动设备通过该媒体标签识别出是响应式网站后,就会自动禁用掉前面的双击缩放和延迟问题,并且不会禁止双手的正常缩放操作。

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

点透问题

  什么是点透问题呢?在以前没有研究前,我一直以为就是两个容器A和B,A在B在上面,点击A但是B的某些事件监听也被触发了,但是仔细一想,这nm不就是事件冒泡么,肯定不是这么一回事,后面看了几篇相关博文发现点透还是有几个条件的首先A和B不是后代继承关系,其次A在B的层叠流之上,最后也是最为关键的一点:上层的A在点击后消失或者移开覆盖B的区域,B本身有默认(a标签)或者绑定的click事件。

  那么点透的本质是啥呢,根据我的理解:由于在移动端触摸屏幕进行点击动作的时候,其实是有2个事件触发的一个是touch,另一个则是click,后者我们已经很熟悉了,主要是前面这个touch,它会先于click事件前被触发完成,而click在移动端前文中已经讲到过有300ms的响应延迟,实际触发顺序是touchstart > touchmove > touchend > click。在touchstart时将覆盖在上面的层级处理掉,300ms后下面的层级就会触发click事件,若下面的这个是一个链接,就会发生跳转。

  解决方案:
  ①在touch阶段通过e.preventDefault()来阻止后面的click触发。
  ②通过setTimeout使上层在300ms后再移除。
  ③使用FastClick库,本质是检测到touchend时,通过DOM自定义事件立即触发模拟一个click事件,并把浏览器在300ms之后真正的click事件阻止掉。

简单模拟一个FastClick

实践

  开始简单模拟前,有几个构建自定义事件的API我们需要学习一下:

  Document.createEvent(Event type),该API能够协助我们创建自定义的事件,其中Event type的选取只能是事件模块中定义的值,如UIEventsMouseEventsMutationEvents等。具体见Mozilla

  模拟FastClick的一个关键环节就是自定义MouseEvents事件,然后使用initMouseEventAPI去初始化事件内容,最后使用dispatchEvent发布这个事件:

1
2
3
4
event.initMouseEvent(type, canBubble, cancelable, view,
detail, screenX, screenY, clientX, clientY,
ctrlKey, altKey, shiftKey, metaKey,
button, relatedTarget);

  阅读FastClick源码,有几个额外的细节:
  ① 对屏幕双击的情景,我们需要设置一个监听时长tapDelay,默认大小为200ms。如果从第一次触碰结束到第二次触碰开始的间隔小于这个区间,我们可以认为这是一次双击事件,不再走后续的自定义派发。
  ② 对屏幕长按的情景,我们同样要设置一个监听时长tapTimeout,默认大小为700ms。如果从触碰开始到触碰结束的间隔大于该区间,我们认为本次点击是一次长按操作,将不会进行后续的自定义派发。
  ③ 对触屏移动的情景,我们需要设置一个移动范围去评估手指按下去是在进行移动事件还是点击事件,参考源码,命名boundary,默认大小为10px。同时结合event.changedTouches去获取对应的定位信息加以判断。
  ④ Android和IOS的事件监听有所差异,IOS只需配置clicktouchstarttouchmovetouchendtouchcancel;Android除了IOS配置的还需配置mouseovermousedownmouseup

  根据以上,我们能够整理出如下的简易版FastClick

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class SimpleFastClick {
static evtEle = null;
static tapDelay = 200;
static tapTimeout = 700;
static boundary = 10;
static touchStartX = 0;
static touchStartY = 0;
static hasMoved = false;
static attach(layerDOM) {
layerDOM.addEventListener('touchstart', e => {
if (e.timeStamp - SimpleFastClick.lastClickTime < SimpleFastClick.tapDelay) {
// console.log('这是一次双击');
if (e.stopImmediatePropagation) e.stopImmediatePropagation(); // 如果同一个事件有多个监听函数,触发顺序按代码绑定顺序来,如果调用该API则剩余函数不会被触发同时阻止冒泡
e.preventDefault();
e.stopPropagation();
return;
}
SimpleFastClick.evtEle = e.target;
let touch = e.changedTouches[0];
SimpleFastClick.touchStartX = touch.pageX;
SimpleFastClick.touchStartY = touch.pageY;
SimpleFastClick.trackingClickStart = e.timeStamp; // 开始跟踪
});
layerDOM.addEventListener('touchmove', e => {
let touch = e.changedTouches[0];
if (Math.abs(touch.pageX - SimpleFastClick.touchStartX) > boundary || Math.abs(touch.pageY - SimpleFastClick.touchStartY) > boundary) {
SimpleFastClick.hasMoved = true;
}
SimpleFastClick.hasMoved = false;
})
layerDOM.addEventListener('touchend', e => {
if (SimpleFastClick.hasMoved) return; // 如果判定是移动事件,则不进行以下自定义事件派发
// console.log('非移动事件');
if (e.timeStamp - SimpleFastClick.trackingClickStart > SimpleFastClick.tapTimeout) return; // 如果判定为长按,则不进行自定义事件派发
// console.log('非长按点击');
SimpleFastClick.lastClickTime = e.timeStamp;
e.preventDefault(); // 阻止后面的默认click 由我们派发自定义click
let touch = e.changedTouches[0]; // 获取触摸点的具体位置 可以通过.screenX .screenY访问坐标信息
let clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent('click', true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
SimpleFastClick.evtEle.dispatchEvent(clickEvent);
})
// layerDOM.addEventListener('click', e => {
// console.log('被点击');
// })
}
}

// 使用:挂载到BODY上

SimpleFastClick.attach(document.body);

结论

  经过实测发现,其实touch的默认触发事件就是click。所以我们在任何一个touch的事件监听中进行Event.preventDefault()return都能够阻止click的触发。也基于这个原理,我们可以在touchend中阻止其后的click触发,并使用我们自定义的click派发替代。这么做其实就是规避那300ms带来的一系列问题,使得我们的事件触发严格有序。