问题1:300ms延迟问题指的是?
不管在移动端还是PC端,我们都需要处理用户点击,这个最常用的事件。但在touch端click事件响应速度会比较慢,在较老的手机设备上会更为明显(300ms的延迟)。双击缩放(double tap to zoom),这也是会有上述 300 毫秒延迟的主要原因。双击缩放,顾名思义,即用手指在屏幕上快速点击两次,iOS 自带的 Safari 浏览器会将网页缩放至原始比例。
假定这么一个场景。用户在 iOS Safari 里边点击了一个链接。由于用户可以进行双击缩放或者双击滚动的操作,当用户一次点击屏幕之后,浏览器并不能立刻判断用户是确实要打开这个链接,还是想要进行双击操作。因此,iOS Safari 就等待 300 毫秒,以判断用户是否再次点击了屏幕。鉴于iPhone的成功,其他移动浏览器都复制了 iPhone Safari 浏览器的多数约定,包括双击缩放,几乎现在所有的移动端浏览器都有这个功能。之前人们刚刚接触移动端的页面,在欣喜的时候往往不会care这个300ms的延时问题,可是如今touch端界面如雨后春笋,用户对体验的要求也更高,这300ms带来的卡顿慢慢变得让人难以接受。
那么我们该如何解决这个问题,可能有的同学会想到touchstart事件,这个事件响应速度很快啊,如果说开发的界面上面可点击的地方很少,要么用户滑动下手指就触touchstart事件,也会让人崩溃的。
问题2:300ms延迟的解决方案?
在参考文献1中列出了三种方法。
第一种方法:
<meta name="viewport" content="user-scalable=no"/> <meta name="viewport" content="initial-scale=1,maximum-scale=1"/>这个方案有一个缺点,就是必须通过完全禁用缩放来达到去掉点击延迟的目的,然而完全禁用缩放并不是我们的初衷,我们只是想禁掉默认的双击缩放行为,这样就不用等待300ms来判断当前操作是否是双击。但是通常情况下,我们还是希望页面能通过 双指缩放来进行缩放操作,比如放大一张图片,放大一段很小的文字。
touch-action:none
跟300ms点击延迟相关的,是touch-action这个CSS属性。这个属性指定了相应元素上能够触发的用户代理(也就是浏览器)的默认行为。如果将该属性值设置为touch-action: none,那么表示在该元素上的操作不会触发用户代理的任何默认行为,就无需进行300ms的延迟判断。
第三种方法:
也就是fastClick解决300ms延迟的问题。
问题3:分析fastClick之前,我们看看zepto的touch.js?
解答:分析fastClick.js之前,我们先对zepto的touch.js进行的分析:
touch.js为每一个实例对象注册了swipe, tap等事件:
['swipe', 'swipeLeft', 'swipeRight', 'swipeUp', 'swipeDown', 'doubleTap', 'tap', 'singleTap', 'longTap'].forEach(function(eventName){ //调用的时候只要传入我们的回调函数就可以了,这一点要注意一下!那么内部会判断具体的事件类型,可以是如下方式的: //'swipe', 'swipeLeft', 'swipeRight', 'swipeUp', 'swipeDown','doubleTap', 'tap', 'singleTap', 'longTap' $.fn[eventName] = function(callback){ return this.on(eventName, callback) } }) })(Zepto)
也就是说可以通过下面的方式为实例注册事件:
$('body').tap(function(){console.log('taped');});
如何判断是否是pointer事件:
//是否是指针事件类型,事件类型为'pointer'+type||'mspointer'+type function isPointerEventType(e, type){ return (e.type == 'pointer'+type || e.type.toLowerCase() == 'mspointer'+type) }如何判断是否是主触点:
//@isPrimaryTouch表示是touch //表示事件是来自于手指还是手写笔还是鼠标 //MSPOINTER_TYPE_TOUCH手指;MSPOINTER_TYPE_PEN手写笔;MSPOINTER_TYPE_MOUSE鼠标 function isPrimaryTouch(event){ //必须来自于手指,因为zepto是针对touch来说 return (event.pointerType == 'touch' || event.pointerType == event.MSPOINTER_TYPE_TOUCH) //pointerType:一个整数,标识了该事件来自鼠标、手写笔还是手指 && event.isPrimary }
判断滑动的方向:
//@swipeDirection首先根据x和y的变换长度来决定是触发x还是y轴的移动,然后再决定是做滑动还是右滑动 function swipeDirection(x1, x2, y1, y2) { return Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down') }因为touch.js主要是对触屏事件进行分析,所以这里主要判断的是手指touch事件。接下来我们看看在domReady后touch.js主要做了什么:
//表示DOMContentLoaded事件 $(document).ready(function(){ var now, delta, deltaX = 0, deltaY = 0, firstTouch, _isPointerType //window是否存在MSGesture对象。MSGesture提供了一些方法和属性代表页面的一系列交互,如touch,mouse,pen等。详见IE浏览器https://msdn.microsoft.com/en-us/library/windows/apps/hh968035.aspx if ('MSGesture' in window) { gesture = new MSGesture() gesture.target = document.body //target表示:你想要触发MSGestureEvents的Element对象 } $(document) //手势完全被处理的时候触发 .bind('MSGestureEnd', function(e){ var swipeDirectionFromVelocity = //velocityX,velocityY用于判断元素的移动方向 e.velocityX > 1 ? 'Right' : e.velocityX < -1 ? 'Left' : e.velocityY > 1 ? 'Down' : e.velocityY < -1 ? 'Up' : null; if (swipeDirectionFromVelocity) { //触发swipe事件 touch.el.trigger('swipe') touch.el.trigger('swipe'+ swipeDirectionFromVelocity) } }) //触摸开始touchstart,MSPointerDown,pointerdown .on('touchstart MSPointerDown pointerdown', function(e){ if((_isPointerType = isPointerEventType(e, 'down')) && !isPrimaryTouch(e)) return //如果是往下移动,但是不是isPrimaryTouch那么我们不作处理 //http://www.w3cplus.com/css3/adapting-your-webkit-optimized-site-for-internet-explorer-10.html firstTouch = _isPointerType ? e : e.touches[0] //touches:当前位于屏幕上的所有手指的列表。 if (e.touches && e.touches.length === 1 && touch.x2) { // Clear out touch movement data if we have it sticking around // This can occur if touchcancel doesn't fire due to preventDefault, etc. //清除touchmove的数据,一般当touchcancel没有触发的时候调用(例如,preventDefault) touch.x2 = undefined touch.y2 = undefined } now = Date.now() delta = now - (touch.last || now) touch.el = $('tagName' in firstTouch.target ? firstTouch.target : firstTouch.target.parentNode) //触摸的事件的target表示当前的Element对象,如果当前对象不是Element对象,那么就获取parentNode对象 touchTimeout && clearTimeout(touchTimeout) touch.x1 = firstTouch.pageX touch.y1 = firstTouch.pageY //x1,y1存储的是pageX,pageY属性 if (delta > 0 && delta <= 250) touch.isDoubleTap = true //如果两次触屏在[0,250]表示双击 touch.last = now longTapTimeout = setTimeout(longTap, longTapDelay) //这里是长按 // adds the current touch contact for IE gesture recognition if (gesture && _isPointerType) gesture.addPointer(e.pointerId); //如果是IE浏览器,为new MSGesture()对象添加一个Pointer对象,传入的是我们的event对象的pointerId的值 //把元素上的一个触点添加到MSGesture对象上! }) .on('touchmove MSPointerMove pointermove', function(e){ if((_isPointerType = isPointerEventType(e, 'move')) && !isPrimaryTouch(e)) return firstTouch = _isPointerType ? e : e.touches[0] //这时候我们取消长按的事件处理程序,因为这里是pointermove,也就是手势移动了那么肯定不会是长按了 //但是因为我们在pointerdown中不知道是否是长按还是pointermove,所以才默认使用的长按。如果移动了,那么就知道不是长按了 cancelLongTap() touch.x2 = firstTouch.pageX touch.y2 = firstTouch.pageY deltaX += Math.abs(touch.x1 - touch.x2) deltaY += Math.abs(touch.y1 - touch.y2) //得到两者在X和Y方向移动的绝对距离 }) .on('touchend MSPointerUp pointerup', function(e){ if((_isPointerType = isPointerEventType(e, 'up')) && !isPrimaryTouch(e)) return //触摸结束,这时候我们取消掉长按的定时器,因为我们也已经知道不是长按了,所以取消长按的定时器 cancelLongTap() // swipe if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) || (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30)) //如果任意一方向移动的距离大于30,那么表示触发swipe事件。触发了swipe事件后,我们清除touch列表,也就是设置为touch={} //因为移动结束了,那么必须重置为空 swipeTimeout = setTimeout(function() { touch.el.trigger('swipe') touch.el.trigger('swipe' + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2))) touch = {} }, 0) // normal tap //这里是正常的tap事件,last表示上一次点击的时间,也就是说已经发生了点击了 //last属性是在touchstart中进行赋值的,因此如果存在那么表示已经点击过了 //但是不管是双击还是单击都会运行到这里的代码!!! else if ('last' in touch) // don't fire tap when delta position changed by more than 30 pixels, // for instance when moving to a point and back to origin //如果移动距离超过30那么我们不会触发tap事件,因为触摸事件不是移动,不能让他移动了30px了 if (deltaX < 30 && deltaY < 30) { // delay by one tick so we can cancel the 'tap' event if 'scroll' fires // ('tap' fires before 'scroll') //tap事件是在scroll之前,所以如果scroll了那么我们取消tap事件 tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) var event = $.Event('tap') event.cancelTouch = cancelAll touch.el.trigger(event) //tap会立即触发 // trigger double tap immediately //如果是双击,那么我们立即触发doubleTap事件,这时候我们的touch={}那么后面的singleTap是不会执行的 if (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {} } // trigger single tap after 250ms of inactivity //如果不是双击,那么此时tap已经触发了,等待250ms我们再次触发singleTap事件 else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} //情况touch对象,因为手指已经离开屏幕了 }, 250) } }, 0) } else { touch = {} } //注意:不管是singleTap还是doubleTap,swipe,调用后都会清空touch={},也就是这时候是重新开始的触摸事件了 deltaX = deltaY = 0 }) // when the browser window loses focus, // for example when a modal dialog is shown, // cancel all ongoing events //触摸被取消(触摸被一些事情中断,比如通知) .on('touchcancel MSPointerCancel pointercancel', cancelAll) // scrolling the window indicates intention of the user // to scroll, not tap or swipe, so cancel all ongoing events $(window).on('scroll', cancelAll) })第一步:我们首先看看对于IE浏览器的触屏事件的处理:
//window是否存在MSGesture对象。MSGesture提供了一些方法和属性代表页面的一系列交互,如touch,mouse,pen等。 详见IE浏览器https://msdn.microsoft.com/en-us/library/windows/apps/hh968035.aspx if ('MSGesture' in window) { gesture = new MSGesture() gesture.target = document.body //target表示:你想要触发MSGestureEvents的Element对象 }然后在pointerdown和MSPointerdown中为MSGesture对象添加要跟踪的触点,这样我们的gesture.target就可以响应相应的事件了。
if (gesture && _isPointerType) gesture.addPointer(e.pointerId); //如果是IE浏览器,为new MSGesture()对象添加一个Pointer对象,传入的是我们的event对象的pointerId的值 //把元素上的一个触点添加到MSGesture对象上!
同时MSGestureEnd也进行了绑定:
$(document) //手势完全被处理的时候触发 .bind('MSGestureEnd', function(e){ var swipeDirectionFromVelocity = //velocityX,velocityY用于判断元素的移动方向 e.velocityX > 1 ? 'Right' : e.velocityX < -1 ? 'Left' : e.velocityY > 1 ? 'Down' : e.velocityY < -1 ? 'Up' : null; if (swipeDirectionFromVelocity) { //触发swipe事件 touch.el.trigger('swipe') touch.el.trigger('swipe'+ swipeDirectionFromVelocity) } })其通过event对象的 velocityX,velocityY属性来判断手势的方向
//触摸开始touchstart,MSPointerDown,pointerdown .on('touchstart MSPointerDown pointerdown', function(e){ if((_isPointerType = isPointerEventType(e, 'down')) && !isPrimaryTouch(e)) return //如果是往下移动,但是不是isPrimaryTouch那么我们不作处理 //http://www.w3cplus.com/css3/adapting-your-webkit-optimized-site-for-internet-explorer-10.html firstTouch = _isPointerType ? e : e.touches[0] //touches:当前位于屏幕上的所有手指的列表。 if (e.touches && e.touches.length === 1 && touch.x2) { // Clear out touch movement data if we have it sticking around // This can occur if touchcancel doesn't fire due to preventDefault, etc. //清除touchmove的数据,一般当touchcancel没有触发的时候调用(例如,preventDefault) touch.x2 = undefined touch.y2 = undefined } now = Date.now() delta = now - (touch.last || now) touch.el = $('tagName' in firstTouch.target ? firstTouch.target : firstTouch.target.parentNode) //触摸的事件的target表示当前的Element对象,如果当前对象不是Element对象,那么就获取parentNode对象 touchTimeout && clearTimeout(touchTimeout) touch.x1 = firstTouch.pageX touch.y1 = firstTouch.pageY //x1,y1存储的是pageX,pageY属性 if (delta > 0 && delta <= 250) touch.isDoubleTap = true //如果两次触屏在[0,250]表示双击 touch.last = now longTapTimeout = setTimeout(longTap, longTapDelay) //这里是长按 // adds the current touch contact for IE gesture recognition if (gesture && _isPointerType) gesture.addPointer(e.pointerId); //如果是IE浏览器,为new MSGesture()对象添加一个Pointer对象,传入的是我们的event对象的pointerId的值 //把元素上的一个触点添加到MSGesture对象上! })首先,对于两次tap而言,如果相隔的时间小于250ms就会被认为是'doubleTap':
if (delta > 0 && delta <= 250) touch.isDoubleTap = true//相隔小于250ms然后,对于任何一次触摸事件都会首先假设是长按,也就是"longTap",并注册longTap回调函数,如果在指定的时间内 发生了move或者up类事件就清除回调:
function longTap() { longTapTimeout = null //touch.last表示是上一次触屏的时间,我们触发了longTap事件,longTap事件触发了以后我们清空touch对象为{} //之所以要清空是因为,长按后不需要马上跟踪touchstart等 if (touch.last) { touch.el.trigger('longTap') touch = {} } }下面是假设为长按事件,然后添加的回调函数:
longTapTimeout = setTimeout(longTap, longTapDelay)最后,还要记录手指放下时候的坐标:
touch.x1 = firstTouch.pageX touch.y1 = firstTouch.pageY第三步:我们看看 touchmove MSPointerMove pointermove等事件
.on('touchmove MSPointerMove pointermove', function(e){ if((_isPointerType = isPointerEventType(e, 'move')) && !isPrimaryTouch(e)) return firstTouch = _isPointerType ? e : e.touches[0] //这时候我们取消长按的事件处理程序,因为这里是pointermove,也就是手势移动了那么肯定不会是长按了 //但是因为我们在pointerdown中不知道是否是长按还是pointermove,所以才默认使用的长按。如果移动了,那么就知道不是长按了 cancelLongTap() touch.x2 = firstTouch.pageX touch.y2 = firstTouch.pageY deltaX += Math.abs(touch.x1 - touch.x2) deltaY += Math.abs(touch.y1 - touch.y2) //得到两者在X和Y方向移动的绝对距离 })首先:因为触点已经移动,所以我们要取消一开始为长按的假设
cancelLongTap()然后:判断触点移动的终点位置,然后记录触点变换的距离大小
touch.x2 = firstTouch.pageX touch.y2 = firstTouch.pageY//x2,y2为终点 deltaX += Math.abs(touch.x1 - touch.x2) deltaY += Math.abs(touch.y1 - touch.y2)//deltaX,deltaY表示移动的变化量第四步:我们看看touchend MSPointerUp pointerup事件
注意:这个事件是touch.js中最重要的事件,因为他要用于判断tap,doubleTap,singleTap等事件类型
.on('touchend MSPointerUp pointerup', function(e){ if((_isPointerType = isPointerEventType(e, 'up')) && !isPrimaryTouch(e)) return //触摸结束,这时候我们取消掉长按的定时器,因为我们也已经知道不是长按了,所以取消长按的定时器 cancelLongTap() // swipe if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) || (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30)) //如果任意一方向移动的距离大于30,那么表示触发swipe事件。触发了swipe事件后,我们清除touch列表,也就是设置为touch={} //因为移动结束了,那么必须重置为空 swipeTimeout = setTimeout(function() { touch.el.trigger('swipe') touch.el.trigger('swipe' + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2))) touch = {} }, 0) // normal tap //这里是正常的tap事件,last表示上一次点击的时间,也就是说已经发生了点击了 //last属性是在touchstart中进行赋值的,因此如果存在那么表示已经点击过了 //但是不管是双击还是单击都会运行到这里的代码!!! else if ('last' in touch) // don't fire tap when delta position changed by more than 30 pixels, // for instance when moving to a point and back to origin //如果移动距离超过30那么我们不会触发tap事件,因为触摸事件不是移动,不能让他移动了30px了 if (deltaX < 30 && deltaY < 30) { // delay by one tick so we can cancel the 'tap' event if 'scroll' fires // ('tap' fires before 'scroll') //tap事件是在scroll之前,所以如果scroll了那么我们取消tap事件 tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) var event = $.Event('tap') event.cancelTouch = cancelAll touch.el.trigger(event) //tap会立即触发 // trigger double tap immediately //如果是双击,那么我们立即触发doubleTap事件,这时候我们的touch={}那么后面的singleTap是不会执行的 if (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {} } // trigger single tap after 250ms of inactivity //如果不是双击,那么此时tap已经触发了,等待250ms我们再次触发singleTap事件 else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} //情况touch对象,因为手指已经离开屏幕了 }, 250) } }, 0) } else { touch = {} } //注意:不管是singleTap还是doubleTap,swipe,调用后都会清空touch={},也就是这时候是重新开始的触摸事件了 deltaX = deltaY = 0 })首先,因为触点已经离开界面,所以前面假设是长按的假设不成立:
cancelLongTap()//不是长按然后,如果触点变换的距离大于30,那么触发 swipe,swipeDown等类型
if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) || (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30)) //如果任意一方向移动的距离大于30,那么表示触发swipe事件。触发了swipe事件后,我们清除touch列表,也就是设置为touch={} //因为移动结束了,那么必须重置为空 swipeTimeout = setTimeout(function() { touch.el.trigger('swipe') touch.el.trigger('swipe' + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2))) touch = {} }, 0)最后,tap,doubleTap,singleTap都是有一定的触发条件的
//这里是正常的tap事件,last表示上一次点击的时间,也就是说已经发生了点击了 //last属性是在touchstart中进行赋值的,因此如果存在那么表示已经点击过了 //但是不管是双击还是单击都会运行到这里的代码!!! else if ('last' in touch) // don't fire tap when delta position changed by more than 30 pixels, // for instance when moving to a point and back to origin //如果移动距离超过30那么我们不会触发tap事件,因为触摸事件不是移动,不能让他移动了30px了 if (deltaX < 30 && deltaY < 30) { // delay by one tick so we can cancel the 'tap' event if 'scroll' fires // ('tap' fires before 'scroll') //tap事件是在scroll之前,所以如果scroll了那么我们取消tap事件 tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) var event = $.Event('tap') event.cancelTouch = cancelAll touch.el.trigger(event) //tap会立即触发 // trigger double tap immediately //如果是双击,那么我们立即触发doubleTap事件,这时候我们的touch={}那么后面的singleTap是不会执行的 if (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {} } // trigger single tap after 250ms of inactivity //如果不是双击,那么此时tap已经触发了,等待250ms我们再次触发singleTap事件 else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} //情况touch对象,因为手指已经离开屏幕了 }, 250) } }, 0) } else { touch = {} } //注意:不管是singleTap还是doubleTap,swipe,调用后都会清空touch={},也就是这时候是重新开始的触摸事件了 deltaX = deltaY = 0 })
我们从上面的代码可以看出tap,doubleTap,singleTap的区别是什么
(1)代码会通过等待250ms判断是否是双击,如果是双击那么就会触发doubleTap,否则250ms后会触发singleTap。
else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} //250ms后如果没有继续触摸,那么触发singleTap }, 250) }
如果在250ms中继续触摸了屏幕,那么在touchstart中就会清除掉这个定时器,表示不是singleTap事件。
touchTimeout && clearTimeout(touchTimeout)//这是在touchstart MSPointerDown pointerdown中的事件处理然后如果250ms中继续触摸了屏幕,同时触摸事件间隔小于250ms就会成为doubleTap:
if (delta > 0 && delta <= 250) touch.isDoubleTap = true
(2)从上面的代码可以知道,tap是立即执行的,但是singleTap,doubleTap是延迟执行的
tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) var event = $.Event('tap') event.cancelTouch = cancelAll touch.el.trigger(event) //tap会立即触发 // trigger double tap immediately //如果是双击,那么我们立即触发doubleTap事件,这时候我们的touch={}那么后面的singleTap是不会执行的 if (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {} } // trigger single tap after 250ms of inactivity //如果不是双击,那么此时tap已经触发了,等待250ms我们再次触发singleTap事件 else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} //情况touch对象,因为手指已经离开屏幕了 }, 250) } }, 0) } else { touch = {} }
(3)触屏端事件的执行顺序如下:
如果是单击的话:touchstart>touchend> tap>250ms>singleTap第五步:我们看看最后的touchcancel MSPointerCancel pointercancel事件
// when the browser window loses focus, // for example when a modal dialog is shown, // cancel all ongoing events //触摸被取消(触摸被一些事情中断,比如通知) .on('touchcancel MSPointerCancel pointercancel', cancelAll)
其实该事件很简单,就是去掉所有的事件而已
function cancelAll() { //取消touch,tap,swipe,longTap事件 //@touch 触屏事件 //@tap 轻触事件 //@swipe 滑动事件 //@longTap 长点击事件 if (touchTimeout) clearTimeout(touchTimeout) if (tapTimeout) clearTimeout(tapTimeout) if (swipeTimeout) clearTimeout(swipeTimeout) if (longTapTimeout) clearTimeout(longTapTimeout) touchTimeout = tapTimeout = swipeTimeout = longTapTimeout = null touch = {} }
第六步:我们总结一下各种事件触发的条件
doubleTap:移动的累积距离小于30;两次触摸屏幕时间区间为[0,250];
tap:移动的累积距离小于30,因为不能保证用于点击的时候没有移动;;立即触发
singleTap:移动的累积距离小于30;等待250ms后触发;
swipe:手指发生了移动,同时移动的距离在deltaX>30||deltaY>30,不过此处的deltaX和deltaY指的是手指落下的位置和最终手指的位置,而不是累积距离
longTap:手指长按超过了750ms。
下面是自己绘制的一张表,如有不正确的地方请拍砖:
也可以在空间查看
问题4:我们来看看FastClick.js的源码分析模块?
首先,我们看看那些元素需要触发原生的click事件(也就是不需要合成的click),也就是不需要我们来产生合成事件
/** * Determine whether a given element requires a native click. 如果是这下面的元素都需要触发浏览器的原生事件,button/select/textarea/input/label/iframe/video 或者含有needsclick类名的元素都是需要触发原生的click事件,而不能因为300ms的延迟就不触发这个事件 * @param {EventTarget|Element} target Target DOM element * @returns {boolean} Returns true if the element needs a native click */ FastClick.prototype.needsClick = function(target) { switch (target.nodeName.toLowerCase()) { // Don't send a synthetic click to disabled inputs (issue #62) case 'button': case 'select': case 'textarea': if (target.disabled) { //如果是disabled的元素,那么我们也是需要触发浏览器元素的click事件,而不用自己创建一个Event对象的 return true; } break; case 'input': // File inputs need real clicks on iOS 6 due to a browser bug (issue #68) //文件输入框在IOS6上需要click事件 if ((deviceIsIOS && target.type === 'file') || target.disabled) { return true; } break; case 'label': case 'iframe': // iOS8 homescreen apps can prevent events bubbling into frames //IOS8主屏幕程序能够阻止事件冒泡到frames,对于label,iframe,video都是不需要模拟一个事件的,只有用浏览器原生的click事件就ok了 case 'video': return true; } //如果是needsclick的类名,这时候我们都是需要触发浏览器的原生click事件的 return (/\bneedsclick\b/).test(target.className); };上面的方法告诉我们:
(1)如果是disabled的button/select/textarea元素,那么不需要触发合成的click事件,直接用原生的方法就可以了(因为disabled表示不能点击,那么减少300ms的延迟是没有意义的,直接使用浏览器的原生click就可以了);
(2)比如input元素,而且是disabled(因为disabled的input不能点击,那么减少300ms延迟没有意义);
(3)IOS下的file类型(single或者mutiple类型)也直接使用原生的click,其实是因为在ipad中照片选择界面存在的问题,在iphone中不存在问题,因为iphone中照片选择是全屏的;
(4)lable/iframe/video等也是应该使用原生的click的;
(5)使用了needsClick的类表示也是使用原生的click事件。
注意:如果是div等其他的元素通过这个函数返回的结果是false,表示还是会使用合成的click,那么他也是不存在300ms延迟问题的!
第二:我们看看那些元素在触发click之前需要调用focus方法
/** * Determine whether a given element requires a call to focus to simulate click into element. *判断一个元素是否需要调用focus()方法,然后才能去模拟在元素上的点击事件!如果返回true,那么在触发自己的click事件之前要手动调用focus()才行! * @param {EventTarget|Element} target Target DOM element * @returns {boolean} Returns true if the element requires a call to focus to simulate native click. */ FastClick.prototype.needsFocus = function(target) { switch (target.nodeName.toLowerCase()) { case 'textarea': return true; case 'select': return !deviceIsAndroid;//不是安卓的select需要 case 'input': switch (target.type) { case 'button': case 'checkbox': case 'file': case 'image': case 'radio': case 'submit': return false; } // No point in attempting to focus disabled inputs //如果不是disabled同时也不是readOnly,这时候才需要返回调用focus方法来模拟click方法 return !target.disabled && !target.readOnly; default: //含有class="needsfocus"的元素必须手动调用focus来模仿元素的本地click方法 return (/\bneedsfocus\b/).test(target.className); } };
(1)textarea元素;
(2)非android下的select元素;
(3)非disabled和非readOnly的input元素,同时不是button/checkbox/file/image/radio/submit;
(4)含有needFocus的元素。这些元素在触发合成的click事件之前需要手动调用focus方法才行。如下:
this.focus(targetElement); this.sendClick(targetElement, event);第三:如何让元素获取焦点的方法
/** * @param {EventTarget|Element} targetElement,也就是应该获取焦点的元素 */ FastClick.prototype.focus = function(targetElement) { var length; // Issue #160: on iOS 7, some input elements (e.g. date datetime month) throw a vague TypeError on setSelectionRange. //These elements don't have an integer value for the selectionStart and selectionEnd properties, but unfortunately that //can't be used for detection because accessing the properties also throws a TypeError. Just check the type instead. //Filed as Apple bug #15122724. //IOS7下,一些input元素,如data,time,month在调用setSelectionRange时候会抛出TypeError,这些元素的selectionStart/selectionEnd不是整数 //而且没法验证,因为直接访问这些属性就会抛错了,因此我们直接检测type if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') { length = targetElement.value.length; //setSelectionRange用于设置input元素的开始和结束位置,https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange //开始位置为length,结束位置也是length,表示光标移动到最后 targetElement.setSelectionRange(length, length); } else { targetElement.focus(); } };
(1)在IOS7中,对于date,datetime,month类型的input元素,我们采用调用focus方法来完成获取焦点,而不是采用setSelectionRange,因为方法这个方法就会报错。
(2)对于其他元素使用setSelectionRange方法
第四:通过event.target来获取元素的目标对象/** * @param {EventTarget} targetElement * @returns {Element|EventTarget} */ FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) { //在IOS4.1下,或者更老的浏览中,我们的target对象有可能是文本节点 // On some older browsers (notably Safari on iOS 4.1 - see issue #56) the event target may be a text node. if (eventTarget.nodeType === Node.TEXT_NODE) { return eventTarget.parentNode; } return eventTarget; };在老版本的IOS4.1中,event.target对象可能是 文本节点。
/** * Check whether the given target element is a child of a scrollable layer and if so, set a flag on it. * @param {EventTarget|Element} targetElement * (1)为targetElement添加一个fastClickScrollParent属性,表示当前元素所在的滚动父元素 * (2)为targetElement添加一个fastClickScrollParent属性的同时,为我们的fastClickScrollParent属性又添加一个fastClickLastScrollTop属性 * 该属性表示滚动父元素当前已经滚动的scrollTop距离 */ FastClick.prototype.updateScrollParent = function(targetElement) { var scrollParent, parentElement; //获取fastClickScrollParent属性 scrollParent = targetElement.fastClickScrollParent; // Attempt to discover whether the target element is contained within a scrollable layer. Re-check if the // target element was moved to another parent. //用于判断一个指定的元素是否在一个scrollable layer中,如果目标元素移动到另外一个父元素时候又需要重新检查 if (!scrollParent || !scrollParent.contains(targetElement)) { parentElement = targetElement; do { //如果scrollHeight>offsetHeight表示元素在垂直方向上存在滚动 if (parentElement.scrollHeight > parentElement.offsetHeight) { scrollParent = parentElement; targetElement.fastClickScrollParent = parentElement; break; } //更新parentElement元素 parentElement = parentElement.parentElement; } while (parentElement); } // Always update the scroll top tracker if possible. //获取到了滚动的父元素后,我们要更新scrollParent的fastClickLastScrollTop属性 if (scrollParent) { scrollParent.fastClickLastScrollTop = scrollParent.scrollTop; } };
通过parentElement不断获取父元素,同时通过比较scrollHeight和offsetHeight来判断元素是否有滚动条;同时需要注意的是:如果targetElement本身是可以滚动,那么targetElement的fastClickScrollParent就是本身,同时targetElement的fastClickLastScrollTop就是表示自己已经滚动的距离了!
第六:如何判断触点是否变化/** * Based on a touchmove event object, check whether the touch has moved past a boundary since it started. *判断touch是否移除了边界,在边界之外click就会被取消 * @param {Event} event * @returns {boolean} */ FastClick.prototype.touchHasMoved = function(event) { var touch = event.changedTouches[0], boundary = this.touchBoundary; //changedTouches是涉及[当前事件]的触摸点的列表 if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) { return true; } return false; };这里的touchStartX等属性在touchstart事件中被记录,同时如果触点大于我们配置的boundary,那么表示触点已经移动了。临界值是10px,如果大于10px那么表示触点已经移动了,当然这个值也可以自己设置!
第七:如何获取label元素指定的input元素
/** * Attempt to find the labelled control for the given label element. * @param {EventTarget|HTMLLabelElement} labelElement这里是labelElement元素作为参数 * @returns {Element|null} */ FastClick.prototype.findControl = function(labelElement) { // Fast path for newer browsers supporting the HTML5 control attribute //html5为我们的labelElement元素指定了一个control属性,该属性表示该lable对应的input元素 /* function setValue(){ var label=document.getElementById("label"); var textbox=label.control;//获取label元素的control属性,这时候获取到的就是我们的labelElement对应的input元素了 textbox.value="718308"; } */ if (labelElement.control !== undefined) { return labelElement.control; } // All browsers under test that support touch events also support the HTML5 htmlFor attribute if (labelElement.htmlFor) { return document.getElementById(labelElement.htmlFor); } // If no for attribute exists, attempt to retrieve the first labellable descendant element // the list of which is defined here: http://www.w3.org/TR/html5/forms.html#category-label //如果没有control,for属性,这时候我们就通过获取lable元素下面的button/keygen/meter等属性 return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); };
注意:通过control属性,htmlFor属性,或者直接querySelector来一致获取for元素指定的input元素。同时这里展示了querySelector可以指定多个选择器,如果前面的选择器没有选中元素,那么就会自动使用后面的选择器去选择:
document.querySelector("#demos,#demoh,#demo").innerHTML = "Hello World1!";//如果#demos不存在就会选择#demoh第八:那些情况下不需要FastClick来解决300ms的延迟问题?
(1)不支持touch事件,因为fastclick主要用于移动端的touch事件
(2)对于Android下的chrome浏览器,设置了user-scalable=no那么不需要fastClick;
(3)chrome32以及以上,如果有width<=device-width那么也不需要处理(也就是网页的宽度比浏览器的宽度小);
(4)chrome桌面浏览器不需要FastClick。
(5)黑莓10.3以上的系统,如果设置了meta[name=viewport],同时设置了user-scalable=no;黑莓10.3以上系统,width<=device-width都是没有延迟的;
(6)IE10以上的浏览器,同时设置了-ms-touch-action: none or manipulation,那么表示禁用了双击缩放效果,不具有延迟;
(7)IE11含有touch-action: none or manipulation也不具有300ms延迟问题
(8)FireFox浏览器大于27,同时含有meta[name=viewport]和user-scalable=no/width<device-width
注意:对于以上情况,我们不需要fastClick来解决300ms延迟问题;原因可能是本身就禁止了双击缩放,所以浏览器在第一次click后不需要等300ms判断是否是双击缩放,所以可以直接会自动触发浏览器原生的click!这种情况下,如果连续点击两次就相当于两次click!
/** * Check whether FastClick is needed. * @param {Element} layer The layer to listen on */ FastClick.notNeeded = function(layer) { var metaViewport; var chromeVersion; var blackberryVersion; var firefoxVersion; // Devices that don't support touch don't need FastClick //必须支持touch事件 if (typeof window.ontouchstart === 'undefined') { return true; } // Chrome version - zero for other browsers //如果是chrome浏览器,那么我们可以获取到chrome的版本号,否则我们获取到的就是就是0!!!! chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; if (chromeVersion) { if (deviceIsAndroid) { metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport) { // Chrome on Android with user-scalable="no" doesn't need FastClick (issue #89) //对于Android下的chrome浏览器,设置了user-scalable=no那么不需要fastClick!!! if (metaViewport.content.indexOf('user-scalable=no') !== -1) { return true; } // Chrome 32 and above with width=device-width or less don't need FastClick //chrome32上,如果有width<=device-width那么也不需要处理(width) if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) { return true; } } // Chrome desktop doesn't need FastClick (issue #15) } else { //chrome桌面浏览器不需要FastClick return true; } } if (deviceIsBlackBerry10) { blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/); // BlackBerry 10.3+ does not require Fastclick library. // https://github.com/ftlabs/fastclick/issues/251 //黑莓10.3以上的系统,如果设置了meta[name=viewport],同时设置了user-scalable=no/或者width<=device-width都是没有延迟的! if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) { metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport) { // user-scalable=no eliminates click delay. if (metaViewport.content.indexOf('user-scalable=no') !== -1) { return true; } // width=device-width (or less than device-width) eliminates click delay. if (document.documentElement.scrollWidth <= window.outerWidth) { return true; } } } } // IE10 with -ms-touch-action: none or manipulation, which disables double-tap-to-zoom (issue #97) //IE10以上的浏览器,同时设置了-ms-touch-action: none or manipulation,那么表示禁用了双击缩放效果,不具有延迟 if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') { return true; } // Firefox version - zero for other browsers firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; //FireFox浏览器大于27,同时含有meta[name=viewport]和user-scalable=no/width<device-width。 if (firefoxVersion >= 27) { // Firefox 27+ does not have tap delay if the content is not zoomable - https://bugzilla.mozilla.org/show_bug.cgi?id=922896 metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) { return true; } } // IE11: prefixed -ms-touch-action is no longer supported and it's recomended to use non-prefixed version // http://msdn.microsoft.com/en-us/library/windows/apps/Hh767313.aspx if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') { return true; } return false; };
问题5:我们看看fastClick中其他核心代码?
首先,我们要注意的就是ontouchstart方法
/** * On touch start, record the position and scroll offset. * 当触摸事件时候,我们记录下位置position和scroll滚动的距离 * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchStart = function(event) { var targetElement, touch, selection; // Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111). //不会同时跟踪两个触点的300ms问题(一次只能跟踪一个触点的点击延迟问题),否则手动放大缩小的问题就会被阻止了 if (event.targetTouches.length > 1) { return true; } //获取触点元素,如果是TEXT_NODE那么获取其父元素 targetElement = this.getTargetElementFromEventTarget(event.target); touch = event.targetTouches[0]; //目标元素,也就是target元素上的触点 if (deviceIsIOS) { // Only trusted events will deselect text on iOS (issue #49) //只有原生的Event在ISO中才会取消选择文本 selection = window.getSelection(); //如果选择了文本,我们也不会设置后面的trackingClick等 if (selection.rangeCount && !selection.isCollapsed) { return true; } if (!deviceIsIOS4) { //当alert,confirm弹窗因为click事件弹出的时候,当下次用户点击页面中任何位置的时候,那么新的touchstart/touchend事件触发时候和上次click触发的事件 //具有相同的identifier // Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23): // when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched // with the same identifier as the touch event that previously triggered the click that triggered the alert. // Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an // immediately preceeding touch event (issue #52), so this fix is unavailable on that platform. //touch.identifier当Chrome的开发者工具打开的时候为0 // Issue 120: touch.identifier is 0 when Chrome dev tools 'Emulate touch events' is set with an iOS device UA string, // which causes all touch events to be ignored. As this block only applies to iOS, and iOS identifiers are always long, // random integers, it's safe to to continue if the identifier is 0 here. if (touch.identifier && touch.identifier === this.lastTouchIdentifier) { event.preventDefault(); return false; } this.lastTouchIdentifier = touch.identifier; // If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and: //如果元素使用了-webkit-overflow-scrolling: touch事件: // 1) the user does a fling scroll on the scrollable layer // 2) the user stops the fling scroll with another tap // then the event.target of the last 'touchend' event will be the element that was under the user's finger // when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check // is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42). //当scroll滚动开始的时候,FastClick就会发送一个click事件,除非我们检查父元素在发送一个合成事件的时候并没有滚动! this.updateScrollParent(targetElement); } } //在touchstart中我们开始跟踪click事件 this.trackingClick = true; //当前时间 this.trackingClickStart = event.timeStamp; //targetElement元素 this.targetElement = targetElement; //获取pageX,pageY this.touchStartX = touch.pageX; this.touchStartY = touch.pageY; // Prevent phantom clicks on fast double-tap (issue #36) //如果两次点击之间小于200ms,那么我们阻止默认事件。不让click事件触发。这种情况出现只有可能是双击,否则不会只有几百毫秒 //如果用户双击,那么我们也会取消掉默认的click事件,而采用自己模拟的click。this.lastClickTime只会在touchend中赋值 if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { event.preventDefault(); } return true; };我们可以看到,我们是不会跟踪多个触点的,因为如果跟踪多个触点的click,那手动缩放可能会失效
// Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111). //不会同时跟踪两个触点的300ms问题(一次只能跟踪一个触点的点击延迟问题),否则手动放大缩小的问题就会被阻止了 if (event.targetTouches.length > 1) { return true; }
如果两次点击的时间间隔小于200ms那么第二次click是不会触发的:
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { event.preventDefault(); }
在IOS上,只有触发原生的click才能取消选择页面中的选中的内容,所以如果是这种情况我们直接返回而不用fastclick,而采用浏览器默认的click:
selection = window.getSelection(); if (selection.rangeCount && !selection.isCollapsed) { return true; }
如果两次indentifier是一样,那么第二次的click直接忽略,也就是采用原生的click就可以了,如alert、confirm弹窗后的click 以及ios4中两次较快的点击导致相同的identifier:
if (touch.identifier && touch.identifier === this.lastTouchIdentifier) { event.preventDefault(); return false; }
第二:我们看看touchmove
/** * Update the last position. * 更新最新的位置信息 * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchMove = function(event) { //trackingClick表示当前的click是否被跟踪,touchStart中设置为true if (!this.trackingClick) { return true; } // If the touch has moved, cancel the click tracking //(1)如果touch已经移动那么我们取消click事件跟踪,或者touch已经在boundary之外那么我们也需要去除才行 //这时候是移动,而不是点击,所以300ms延迟不需要跟踪 //(2)targetElement是在touchStart中已经被设置了,在touchmove中我们重新计算当前的target对象,如果不相同,表示已经移动了,那么不需要跟踪click了 //同时把targetElement置空 if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) { this.trackingClick = false; this.targetElement = null; } return true; };如果触点已经移动了,那么我们就不会跟踪click事件,同时目标对象也会被设置为空。因为此时表示移动而不是点击
/** * On touch end, determine whether to send a click event at once. *touch end事件中判断是否应该马上触发click事件 * @param {Event} event * @returns {boolean} */ FastClick.prototype.onTouchEnd = function(event) { var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement; //如果没有跟踪,也就是如用户点击后移动了坐标了/或者touchcancel了,那么原来的event.target的300ms就不需要处理了。 if (!this.trackingClick) { return true; } // Prevent phantom clicks on fast double-tap (issue #36) //The minimum time between tap(touchstart and touchend) events //如果两次点击时间小于200ms,那么cancelNextClick设置为true //lastClickTime只会在onTouchEnd中进行设置,而event.timeStamp表示的是这一次触摸事件发生的时间 //(1) this.lastClickTime只会在touchcancel中进行设置,因此,如果【两次touchend】触发的时候很短,那么表示双击了,因此我们就不需要跟踪后面的那一次click事件了 //return表示也不会触发后面自定义的click事件了 //(2)cancelNextClick只是用于touchEnd后用于mouseover/mousedown/mouseup等,用于判断是否调用stopPropagation/preventDefault if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { this.cancelNextClick = true; return true; } //click事件跟踪开始的时间,如果touchstart和touchEnd之间间隔的时间太久,那么也不会触发自定义click。例如长按 if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) { return true; } // Reset to prevent wrong click cancel on input (issue #156). //重置cancelNextClick,防止对于input元素click事件的错误取消. this.cancelNextClick = false; //更新lastClickTime参数 this.lastClickTime = event.timeStamp; //重置,trackingClick,trackingClickStart trackingClickStart = this.trackingClickStart; this.trackingClick = false; //手指已经抬起,这时候不需要跟踪click了 this.trackingClickStart = 0; // On some iOS devices, the targetElement supplied with the event is invalid if the layer // is performing a transition or scroll, and has to be re-detected manually. Note that // for this to function correctly, it must be called *after* the event target is checked! // See issue #57; also filed as rdar://13048589 . //IOS6-7:如果layer(也就是我们构造FastClick时候传入的DOM对象)执行transition/scroll时候,那么event对象提供的targetElement就是无效的,所以我们必须手动重新计算 if (deviceIsIOSWithBadTarget) { touch = event.changedTouches[0]; // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null //elementFromPoint是获取当前元素相对于视口的位置 targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement; // *iOS 6.0-7.*需要我们手动设置目标元素,也就是target element! targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent; } targetTagName = targetElement.tagName.toLowerCase(); if (targetTagName === 'label') { //获取for元素指定的元素 forElement = this.findControl(targetElement); if (forElement) { this.focus(targetElement); //我们让for指定的元素获取焦点 //(1)android:直接返回 // (2)IOS:修改targetElement为for元素指定的元素 if (deviceIsAndroid) { return false; } targetElement = forElement; } //如果触发自己定义的click事件之前,要手动调用focus方法才能模拟 } else if (this.needsFocus(targetElement)) { // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. //Return early and unset the target element reference so that the subsequent click will be allowed through. // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible //even though the value attribute is updated as the user types (issue #37). //Case 1:如果touch事件已经触发了,那么focus就会马上触发。马上返回,同时重置目标元素引用以便接下来的click事件能允许触发 //Case 2:当我们的input元素处于iframe中同时被点击,那么所有的文本都是不可见的,即使value属性在用户输入的时候及时更新 if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) { this.targetElement = null; return false; } //首先给这个元素获取焦点,也就是先调用focus方法或者通过setSelection完成 this.focus(targetElement); //focus后,我们在该元素上触发自定义的click事件 this.sendClick(targetElement, event); // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. // Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others) // var deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent) && !deviceIsWindowsPhone; //(1)如果不是IOS那么我们,那么我们可以阻止浏览器默认的‘click‘事件,同时把targetElement置为空 // (2)如果是IOS,同时不是select,那么我们也可以阻止浏览器默认的'click'事件。也就是说IOS下的select必须让浏览器默认的click事件触发,否则select的选择面板不会弹出 if (!deviceIsIOS || targetTagName !== 'select') { this.targetElement = null; event.preventDefault(); } return false; } if (deviceIsIOS && !deviceIsIOS4) { // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42). //(1)如果目标元素包含在一个parent layer中,而且该parent layer也被滚动了,那么我们就不会发送这个合成的click事件。这时候这个tap事件就用于阻止我们的scrolling事件 scrollParent = targetElement.fastClickScrollParent; if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) { //表示在onTouchStart后又开始滚动了,表示父元素一直在滚动,这时候我们也不需要跟踪click的延迟,因为他会用于停止滚动 return true; } } // Prevent the actual click from going though - unless the target node is marked as requiring // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. //如果needsClick返回true那么表示需要原生的click事件,于是在这里我们不会调用preventDefault方法!!! //如果不需要原生的方法,那么我们直接阻止原生的方法,阻止原生的方法的同时并调用sendClick来触发我们的模拟的方法 if (!this.needsClick(targetElement)) { //(1)如果没有needsClick的class,那么表示会调用preventDefault取消浏览器默认的click事件,取而代之的是自己创建的click事件,而且这个事件是在targetEvent对象上触发的 event.preventDefault(); //我们在网上搜索fastClick,大部分都在说他解决了zepto的点击穿透问题,他是怎么解决的呢?就是上面最后一句, //他模拟的click事件是在touchEnd获取的真实元素上触发的,而不是通过坐标计算出来的元素(因为targetElement是一开始就保存好的,而不会是tap隐藏后而出现的弹窗下面的元素)。 this.sendClick(targetElement, event); } return false; };如果触点已经移动,或者touch事件已经取消,那么我们不需要触发自定义的事件
//如果没有跟踪,也就是如用户点击后移动了坐标了/或者touchcancel了,那么原来的event.target的300ms就不需要处理了。 if (!this.trackingClick) { return true; }如果短时间点击了两次,那么我们不会跟踪第二次点击,从而直接忽略第二次
//如果两次点击时间小于200ms,那么cancelNextClick设置为true //lastClickTime只会在onTouchEnd中进行设置,而event.timeStamp表示的是这一次触摸事件发生的时间 //(1) this.lastClickTime只会在touchEnd中进行设置,因此,如果【两次touchend】触发的时候很短,那么表示双击了,因此我们就不需要跟踪后面的那一次click事件了 //return表示也不会触发后面自定义的click事件了,这时候会触发浏览器默认的click事件 //(2)cancelNextClick只是用于touchEnd后用于mouseover/mousedown/mouseup等,用于判断是否调用stopPropagation/preventDefault if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { this.cancelNextClick = true; return true; }如果touchstart和touchend之间时间太久,那么就是长按了,也不会触发自定义的click
//click事件跟踪开始的时间,如果touchstart和touchEnd之间间隔的时间太久,那么也不会触发自定义click。例如长按 if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) { return true; }如果layer在执行滚动或者动画,我们需要手动计算target
//IOS6-7:如果layer(也就是我们构造FastClick时候传入的DOM对象)执行transition/scroll时候,那么event对象提供的targetElement就是无效的 //所以我们必须手动重新计算 if (deviceIsIOSWithBadTarget) { touch = event.changedTouches[0]; // In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null //elementFromPoint是获取当前元素相对于视口的位置 targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement; // *iOS 6.0-7.*需要我们手动设置目标元素,也就是target element! targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent; }
如果是label属性,那么我们让for元素成为targetElement。如果当前点击的是label标签,我们首先需要让label标签获取焦点,同时如果是安卓那么我们不需要触发click而直接返回(在思考着)!如果是IOS更新targetElement为for指定的元素:
if (targetTagName === 'label') { //获取for元素指定的元素 forElement = this.findControl(targetElement); if (forElement) { this.focus(targetElement); //我们让for指定的元素获取焦点 //(1)android:直接返回 // (2)IOS:修改targetElement为for元素指定的元素 if (deviceIsAndroid) { return false; } targetElement = forElement; } //如果触发自己定义的click事件之前,要手动调用focus方法才能模拟 }如果在click之前需要获取焦点,那么我们先获取焦点然后触发合成的click事件。同时对于IOS下的select因为必须触发原生的click才会打开select标签,所以我们不会调用preventDefault方法
if (this.needsFocus(targetElement)) { // Case 1: If the touch started a while ago (best guess is 100ms based on tests for issue #36) then focus will be triggered anyway. //Return early and unset the target element reference so that the subsequent click will be allowed through. // Case 2: Without this exception for input elements tapped when the document is contained in an iframe, then any inputted text won't be visible //even though the value attribute is updated as the user types (issue #37). //Case 1:如果touch事件已经触发了,那么focus就会马上触发。马上返回,同时重置目标元素引用以便接下来的click事件能允许触发 //Case 2:当我们的input元素处于iframe中同时被点击,那么所有的文本都是不可见的,即使value属性在用户输入的时候及时更新 if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) { this.targetElement = null; return false; } //首先给这个元素获取焦点,也就是先调用focus方法或者通过setSelection完成 this.focus(targetElement); //focus后,我们在该元素上触发自定义的click事件 this.sendClick(targetElement, event); // Select elements need the event to go through on iOS 4, otherwise the selector menu won't open. // Also this breaks opening selects when VoiceOver is active on iOS6, iOS7 (and possibly others) //var deviceIsIOS = /iP(ad|hone|od)/.test(navigator.userAgent) && !deviceIsWindowsPhone; //在IOS下的select【必须】允许原生的click触发,否则select的菜单栏不会打开,同时把targetElement置为空(逆否命题) if (!deviceIsIOS || targetTagName !== 'select') {//这里是逆否命题的结果 this.targetElement = null; event.preventDefault(); } return false; }
如果非IOS4的iOS下,目标元素处于滚动的元素之中,那么第二次点击也会被忽略,因为这可能是停止滚动或者加速滚动而已。这可能也是为什么不用touchstart或者touchend来替代click的原因,你可以阅读后面的参考文献:
if (deviceIsIOS && !deviceIsIOS4) { // Don't send a synthetic click event if the target element is contained within a parent layer that was scrolled // and this tap is being used to stop the scrolling (usually initiated by a fling - issue #42). scrollParent = targetElement.fastClickScrollParent; if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) { return true; } }
触发自定义的click事件
// Prevent the actual click from going though - unless the target node is marked as requiring // real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted. //如果needsClick返回true那么表示需要原生的click事件,于是在这里我们不会调用preventDefault方法!!! //如果不需要原生的方法,那么我们直接阻止原生的方法,阻止原生的方法的同时并调用sendClick来触发我们的模拟的方法 if (!this.needsClick(targetElement)) { //(1)如果没有needsClick的class,那么表示会调用preventDefault取消浏览器默认的click事件,取而代之的是自己创建的click事件,而且这个事件是在targetEvent对象上触发的 event.preventDefault(); //我们在网上搜索fastClick,大部分都在说他解决了zepto的点击穿透问题,他是怎么解决的呢?就是上面最后一句, //他模拟的click事件是在touchEnd获取的真实元素上触发的,而不是通过坐标计算出来的元素(因为targetElement是一开始就保存好的,而不会是tap隐藏后而出现的弹窗下面的元素)。 this.sendClick(targetElement, event); }注意: 从这里你就会发现,fastClick是如何解决300ms的延迟问题的,其是通过取消默认事件后然后调用自己click事件来完成的。那么fastClick是如何 解决点击穿透问题的呢,其实就是下面的一句:
this.sendClick(targetElement, event);//target对象是保存好的,用来触发click事件的元素,而不是隐藏后位于底部的元素
下面是触发自定义事件的关键代码:
/** * Send a click event to the specified element. *为特定元素触发一个指定的click事件 * @param {EventTarget|Element} targetElement * @param {Event} event */ FastClick.prototype.sendClick = function(targetElement, event) { var clickEvent, touch; // On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24) //在一些安卓设备上,activeElement需要blur,否则同步的click事件无效。如果【当前具有焦点的元素和目标元素】不一致,那么要把焦点元素blur掉,否则 //直接调用目标元素的sendClick是无效的 if (document.activeElement && document.activeElement !== targetElement) { document.activeElement.blur(); } // changedTouches:是涉及[当前事件]的触摸点的列表。 touch = event.changedTouches[0]; // Synthesise a click event, with an extra attribute so it can be tracked clickEvent = document.createEvent('MouseEvents'); //直接调用dispatchEvent就可以了,但是需要获取到当前touch事件的screenX,screenY,clientX,clientY等属性 clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null); //fastclick的内部变量,用来识别click事件是原生还是模拟 clickEvent.forwardedTouchEvent = true; targetElement.dispatchEvent(clickEvent); };
总结:
下面我对那些情况下不会触发click进行了总结,你也可以仔细阅读上面的内容:
(1) 如果点击的时候移动了触点,那么不会触发click事件。其中移动的临界值是10px
if (!this.trackingClick) { returntrue; }
(2) 如果是双击(两次点击小于200ms),那么第二次的click是不会触发的,其中delay是200ms
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) { event.preventDefault(); }
(3)如果点击后持续了700ms不放开,那么不会触发fastclick的click,而是由浏览器自己处理,可能是弹出菜单栏:
if ((event.timeStamp -this.trackingClickStart) > this.tapTimeout) { returntrue; }
(4)IOS下,目标元素的父元素在滚动,那么第二次点击忽略
if(deviceIsIOS && !deviceIsIOS4) { scrollParent= targetElement.fastClickScrollParent; if(scrollParent && scrollParent.fastClickLastScrollTop !==scrollParent.scrollTop) { returntrue; } }(5)上面源码分析部分提到的8中情况(可以参考上面分析)
参考资源:
移动端click事件延迟300ms到底是怎么回事,该如何解决?
移动端300ms点击延迟和点击穿透问题
[Sencha ExtJS & Touch] singletap 和 tap的区别
tap事件是怎么模拟出来的?移动端触摸事件是怎么一个流程?
HTML5 手势检测原理和实现
突然发现一个问题,如果用touchstart替换了click 问题大了!?
在手持设备上使用 touchstart 事件代替 click 事件是不是个好主意?
也来说说touch事件与点击穿透问题