移动端开发基本知识之touch.js,FastClick.js源码分析

问题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中列出了三种方法。

第一种方法:


这个方案有一个缺点,就是必须通过完全禁用缩放来达到去掉点击延迟的目的,然而完全禁用缩放并不是我们的初衷,我们只是想禁掉默认的双击缩放行为,这样就不用等待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事件绑定

//触摸开始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
如果是双击的话:touchstart>touchend> tap>touchstart>touchend> tap>doubleTap

第五步:我们看看最后的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。

下面是自己绘制的一张表,如有不正确的地方请拍砖:

移动端开发基本知识之touch.js,FastClick.js源码分析_第1张图片

也可以在空间查看

问题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对象可能是 文本节点
第五:为targetElement元素添加 滚动的父元素作为属性,同时为滚动的父元素添加一个已经滚动的高度的属性

	/**
	 * 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

注意:对于以上情况,我们不需要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= 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事件,同时目标对象也会被设置为空。因为此时表示移动而不是点击
第三:我们看看touchend,其决定是否马上触发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事件与点击穿透问题

你可能感兴趣的:(移动端开发)