亲宝软件园·资讯

展开

浅谈FastClick 填坑及源码解析

VaJoy Larn 人气:0

最近产品妹子提出了一个体验issue —— 用 iOS 在手Q阅读书友交流区发表书评时,光标点击总是不好定位到正确的位置:

如上图,具体表现是较快点击时,光标总会跳到 textarea 内容的尾部。只有当点击停留时间较久一点(比如超过150ms)才能把光标正常定位到正确的位置。

一开始我以为是 iOS 原生的交互问题没太在意,但后来发现访问某些页面又是没有这种奇怪体验的。

然后怀疑是否 JS 注册了某些事件导致的问题,于是试着把业务模块移除了再跑一遍,发现问题照旧。

于是只好继续做排除法,把页面上的一些库一点点移掉再运行页面,结果发现捣乱的小鬼果然是嫌疑最大的 Fastclick。

然后呢,我试着按API所说,给 textarea 加上一个名为“needsclick”的类名,希望能绕过 fastclick 的处理直接走原生点击事件,结果讶异地发现屁用没有。。。

对此感谢后面我们小组的 kindeng 童鞋帮忙研究了下并提供了解决方案,不过我还想进一步研究到底是什么原因导致了这个坑、Fastclick 对我的页面做了神马~

所以昨晚花了点时间一口气把源码都蹂躏了一遍。

这会是一篇很长的文章,但会是注释非常详尽的剖析文。

文章带分析的源码我也挂在我的 github 仓库上了,有兴趣的童鞋可以去下载来看。

闲话不多说,咱们开始深入 FastClick 源码阵营。

我们知道,注册一个 FastClick 事件非常简单,它是这样的:

if ('addEventListener' in document) {
  document.addEventListener('DOMContentLoaded', function() {
    var fc = FastClick.attach(document.body); //生成实例
  }, false);
}

所以我们从这里着手,打开源码看下 FastClick .attach 方法:

  FastClick.attach = function(layer, options) {
    return new FastClick(layer, options);
  };

这里返回了一个 FastClick 实例,所以咱们拉到前面看看 FastClick 构造函数:

function FastClick(layer, options) {
    var oldOnClick;
    options = options || {};
    //定义了一些参数...
    //如果是属于不需要处理的元素类型,则直接返回
    if (FastClick.notNeeded(layer)) {
      return;
    }
    //语法糖,兼容一些用不了 Function.prototype.bind 的旧安卓
    //所以后面不走 layer.addEventListener('click', this.onClick.bind(this), true);
    function bind(method, context) {
      return function() { return method.apply(context, arguments); };
    }
    var methods = ['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel'];
    var context = this;
    for (var i = 0, l = methods.length; i < l; i++) {
      context[methods[i]] = bind(context[methods[i]], context);
    }

    //安卓则做额外处理
    if (deviceIsAndroid) {
      layer.addEventListener('mouseover', this.onMouse, true);
      layer.addEventListener('mousedown', this.onMouse, true);
      layer.addEventListener('mouseup', this.onMouse, true);
    }
    layer.addEventListener('click', this.onClick, true);
    layer.addEventListener('touchstart', this.onTouchStart, false);
    layer.addEventListener('touchmove', this.onTouchMove, false);
    layer.addEventListener('touchend', this.onTouchEnd, false);
    layer.addEventListener('touchcancel', this.onTouchCancel, false);
    // 兼容不支持 stopImmediatePropagation 的浏览器(比如 Android 2)
    if (!Event.prototype.stopImmediatePropagation) {
      layer.removeEventListener = function(type, callback, capture) {
        var rmv = Node.prototype.removeEventListener;
        if (type === 'click') {
          rmv.call(layer, type, callback.hijacked || callback, capture);
        } else {
          rmv.call(layer, type, callback, capture);
        }
      };
      layer.addEventListener = function(type, callback, capture) {
        var adv = Node.prototype.addEventListener;
        if (type === 'click') {
          //留意这里 callback.hijacked 中会判断 event.propagationStopped 是否为真来确保(安卓的onMouse事件)只执行一次
          //在 onMouse 事件里会给 event.propagationStopped 赋值 true
          adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
              if (!event.propagationStopped) {
                callback(event);
              }
            }), capture);
        } else {
          adv.call(layer, type, callback, capture);
        }
      };
    }

    // 如果layer直接在DOM上写了 onclick 方法,那我们需要把它替换为 addEventListener 绑定形式
    if (typeof layer.onclick === 'function') {
      oldOnClick = layer.onclick;
      layer.addEventListener('click', function(event) {
        oldOnClick(event);
      }, false);
      layer.onclick = null;
    }
  }

在初始通过 FastClick.notNeeded 方法判断是否需要做后续的相关处理:

    //如果是属于不需要处理的元素类型,则直接返回
    if (FastClick.notNeeded(layer)) {
      return;
    }

我们看下这个 FastClick.notNeeded 都做了哪些判断:

  //是否没必要使用到 Fastclick 的检测
  FastClick.notNeeded = function(layer) {
    var metaViewport;
    var chromeVersion;
    var blackberryVersion;
    var firefoxVersion;

    // 不支持触摸的设备
    if (typeof window.ontouchstart === 'undefined') {
      return true;
    }
    // 获取Chrome版本号,若非Chrome则返回0
    chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
    if (chromeVersion) {
      if (deviceIsAndroid) { //安卓
        metaViewport = document.querySelector('meta[name=viewport]');
        if (metaViewport) {
          // 安卓下,带有 user-scalable="no" 的 meta 标签的 chrome 是会自动禁用 300ms 延迟的,所以无需 Fastclick
          if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
            return true;
          }
          // 安卓Chrome 32 及以上版本,若带有 width=device-width 的 meta 标签也是无需 FastClick 的
          if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
            return true;
          }
        }

        // 其它的就肯定是桌面级的 Chrome 了,更不需要 FastClick 啦
      } else {
        return true;
      }
    }

    if (deviceIsBlackBerry10) { //黑莓,和上面安卓同理,就不写注释了
      blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/);
      if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) {
        metaViewport = document.querySelector('meta[name=viewport]');
        if (metaViewport) {
          if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
            return true;
          }
          if (document.documentElement.scrollWidth <= window.outerWidth) {
            return true;
          }
        }
      }
    }
    // 带有 -ms-touch-action: none / manipulation 特性的 IE10 会禁用双击放大,也没有 300ms 时延
    if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
      return true;
    }

    // Firefox检测,同上
    firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
    if (firefoxVersion >= 27) {
      metaViewport = document.querySelector('meta[name=viewport]');
      if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) {
        return true;
      }
    }
 
    // IE11 推荐使用没有“-ms-”前缀的 touch-action 样式特性名
    if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
      return true;
    }
    return false;
  };

基本上都是一些能禁用 300ms 时延的浏览器嗅探,它们都没必要使用 Fastclick,所以会返回 true 回构造函数停止下一步执行。

由于安卓手Q的 ua 会被匹配到 /Chrome\/([0-9]+)/,故带有 'user-scalable=no' meta 标签的安卓手Q页会被 FastClick 视为无需处理页。

这也是为何在安卓手Q里没有开头提及问题的原因。

我们继续看构造函数,它直接给 layer(即body)添加了click、touchstart、touchmove、touchend、touchcancel(若是安卓还有 mouseover、mousedown、mouseup)事件监听:

    //安卓则做额外处理
    if (deviceIsAndroid) {
      layer.addEventListener('mouseover', this.onMouse, true);
      layer.addEventListener('mousedown', this.onMouse, true);
      layer.addEventListener('mouseup', this.onMouse, true);
    }

    layer.addEventListener('click', this.onClick, true);
    layer.addEventListener('touchstart', this.onTouchStart, false);
    layer.addEventListener('touchmove', this.onTouchMove, false);
    layer.addEventListener('touchend', this.onTouchEnd, false);
    layer.addEventListener('touchcancel', this.onTouchCancel, false);

注意在这段代码上面还利用了 bind 方法做了处理,这些事件回调中的 this 都会变成 Fastclick 实例上下文。

另外还得留意,onclick 事件以及安卓的额外处理部分都是走的捕获监听。

咱们分别看看这些事件回调分别都做了什么。

1. this.onTouchStart

  FastClick.prototype.onTouchStart = function(event) {
    var targetElement, touch, selection;
    // 多指触控的手势则忽略
    if (event.targetTouches.length > 1) {
      return true;
    }
    targetElement = this.getTargetElementFromEventTarget(event.target); //一些较老的浏览器,target 可能会是一个文本节点,得返回其DOM节点
    touch = event.targetTouches[0];
    if (deviceIsIOS) { //IOS处理
      // 若用户已经选中了一些内容(比如选中了一段文本打算复制),则忽略
      selection = window.getSelection();
      if (selection.rangeCount && !selection.isCollapsed) {
        return true;
      }
      if (!deviceIsIOS4) { //是否IOS4
        //怪异特性处理——若click事件回调打开了一个alert/confirm,用户下一次tap页面的其它地方时,新的touchstart和touchend
        //事件会拥有同一个touch.identifier(新的 touch event 会跟上一次触发alert点击的 touch event 一样),
        //为避免将新的event当作之前的event导致问题,这里需要禁用事件
        //另外chrome的开发工具启用'Emulate touch events'后,iOS UA下的 identifier 会变成0,所以要做容错避免调试过程也被禁用事件了
        if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
          event.preventDefault();
          return false;
        }
        this.lastTouchIdentifier = touch.identifier;
        // 如果target是一个滚动容器里的一个子元素(使用了 -webkit-overflow-scrolling: touch) ,而且满足:
        // 1) 用户非常快速地滚动外层滚动容器
        // 2) 用户通过tap停止住了这个快速滚动
        // 这时候最后的'touchend'的event.target会变成用户最终手指下的那个元素
        // 所以当快速滚动开始的时候,需要做检查target是否滚动容器的子元素,如果是,做个标记
        // 在touchend时检查这个标记的值(滚动容器的scrolltop)是否改变了,如果是则说明页面在滚动中,需要取消fastclick处理
        this.updateScrollParent(targetElement);
      }
    }
    this.trackingClick = true; //做个标志表示开始追踪click事件了
    this.trackingClickStart = event.timeStamp; //标记下touch事件开始的时间戳
    this.targetElement = targetElement;
    //标记touch起始点的页面偏移值
    this.touchStartX = touch.pageX;
    this.touchStartY = touch.pageY;
    // this.lastClickTime 是在 touchend 里标记的事件时间戳
    // this.tapDelay 为常量 200 (ms)
    // 此举用来避免 phantom 的双击(200ms内快速点了两次)触发 click
    // 反正200ms内的第二次点击会禁止触发其默认事件
    if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
      event.preventDefault();
    }
    return true;
  };

顺道看下这里的 this.updateScrollParent:


  /**
   * 检查target是否一个滚动容器里的子元素,如果是则给它加个标记
   */
  FastClick.prototype.updateScrollParent = function(targetElement) {
    var scrollParent, parentElement;
    scrollParent = targetElement.fastClickScrollParent;
    if (!scrollParent || !scrollParent.contains(targetElement)) {
      parentElement = targetElement;
      do {
        if (parentElement.scrollHeight > parentElement.offsetHeight) {
          scrollParent = parentElement;
          targetElement.fastClickScrollParent = parentElement;
          break;
        }
        parentElement = parentElement.parentElement;
      } while (parentElement);
    }
    // 给滚动容器加个标志fastClickLastScrollTop,值为其当前垂直滚动偏移
    if (scrollParent) {
      scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
    }
  };

另外要注意的是,在 onTouchStart 里被标记为 true 的 this.trackingClick 属性,都会在其它事件回调(比如 ontouchmove )的开头做检测,如果没被赋值过,则直接忽略:

    if (!this.trackingClick) {
      return true;
    }

当然在 ontouchend 事件里会把它重置为 false。

2. this.onTouchMove

这段代码量好少:

  FastClick.prototype.onTouchMove = function(event) {
    //不是需要被追踪click的事件则忽略
    if (!this.trackingClick) {
      return true;
    }
    // 如果target突然改变了,或者用户其实是在移动手势而非想要click
    // 则应该清掉this.trackingClick和this.targetElement,告诉后面的事件你们也不用处理了
    if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
      this.trackingClick = false;
      this.targetElement = null;
    }
    return true;
  };

看下这里用到的 this.touchHasMoved 原型方法:

  //判断是否移动了
  //this.touchBoundary是常量,值为10
  //如果touch已经移动了10个偏移量单位,则应当作为移动事件处理而非click事件
  FastClick.prototype.touchHasMoved = function(event) {
    var touch = event.changedTouches[0], boundary = this.touchBoundary;
    if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) {
      return true;
    }
    return false;
  };

3. onTouchEnd


  FastClick.prototype.onTouchEnd = function(event) {
    var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
    if (!this.trackingClick) {
      return true;
    }
    // 避免 phantom 的双击(200ms内快速点了两次)触发 click
    // 我们在 ontouchstart 里已经做过一次判断了(仅仅禁用默认事件),这里再做一次判断
    if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
      this.cancelNextClick = true; //该属性会在 onMouse 事件中被判断,为true则彻底禁用事件和冒泡
      return true;
    }
    //this.tapTimeout是常量,值为700
    //识别是否为长按事件,如果是(大于700ms)则忽略
    if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
      return true;
    }
    // 得重置为false,避免input事件被意外取消
    // 例子见 https://github.com/ftlabs/fastclick/issues/156
    this.cancelNextClick = false;
    this.lastClickTime = event.timeStamp; //标记touchend时间,方便下一次的touchstart做双击校验
    trackingClickStart = this.trackingClickStart;
    //重置 this.trackingClick 和 this.trackingClickStart
    this.trackingClick = false;
    this.trackingClickStart = 0;
    // iOS 6.0-7.*版本下有个问题 —— 如果layer处于transition或scroll过程,event所提供的target是不正确的
    // 所以咱们得重找 targetElement(这里通过 document.elementFromPoint 接口来寻找)
    if (deviceIsIOSWithBadTarget) { //iOS 6.0-7.*版本
      touch = event.changedTouches[0]; //手指离开前的触点
      // 有些情况下 elementFromPoint 里的参数是预期外/不可用的, 所以还得避免 targetElement 为 null
      targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
      // target可能不正确需要重找,但fastClickScrollParent是不会变的
      targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
    }
    targetTagName = targetElement.tagName.toLowerCase();
    if (targetTagName === 'label') { //是label则激活其指向的组件
      forElement = this.findControl(targetElement);
      if (forElement) {
        this.focus(targetElement);
        //安卓直接返回(无需合成click事件触发,因为点击和激活元素不同,不存在点透)
        if (deviceIsAndroid) {
          return false;
        }
        targetElement = forElement;
      }
    } else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
      //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
      //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
      //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
      //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
      //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
      if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
        this.targetElement = null;
        return false;
      }
      this.focus(targetElement);
      this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms
      //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
      //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
      if (!deviceIsIOS || targetTagName !== 'select') {
        this.targetElement = null;
        event.preventDefault();
      }

      return false;
    }
    if (deviceIsIOS && !deviceIsIOS4) {
      // 滚动容器的垂直滚动偏移改变了,说明是容器在做滚动而非点击,则忽略
      scrollParent = targetElement.fastClickScrollParent;
      if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
        return true;
      }
    }
    // 查看元素是否无需处理的白名单内(比如加了名为“needsclick”的class)
    // 不是白名单的则照旧预防穿透处理,立即触发合成的click事件
    if (!this.needsClick(targetElement)) {
      event.preventDefault();
      this.sendClick(targetElement, event);
    }
    return false;
  };

这段比较长,我们主要看这段:


    } else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
      //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
      //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
      //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
      //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
      //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
      if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
        this.targetElement = null;
        return false;
      }
      this.focus(targetElement);
      this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms

      //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
      //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
      if (!deviceIsIOS || targetTagName !== 'select') {
        this.targetElement = null;
        event.preventDefault();
      }
      return false;
    }

其中 this.needsFocus 用于判断给定元素是否需要通过合成click事件来模拟聚焦:

  //判断给定元素是否需要通过合成click事件来模拟聚焦
  FastClick.prototype.needsFocus = function(target) {
    switch (target.nodeName.toLowerCase()) {
      case 'textarea':
        return true;
      case 'select':
        return !deviceIsAndroid; //iOS下的select得走穿透点击才行
      case 'input':
        switch (target.type) {
          case 'button':
          case 'checkbox':
          case 'file':
          case 'image':
          case 'radio':
          case 'submit':
            return false;
        }
        return !target.disabled && !target.readOnly;
      default:
        //带有名为“bneedsfocus”的class则返回true
        return (/\bneedsfocus\b/).test(target.className);
    }
  };

另外这段说明了为何稍微久按一点(超过100ms)textarea ,我们是可以把光标定位在正确的地方(会绕过后面调用 this.focus 的方法):

      //手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
      //(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
      //这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
      //另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
      //会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
      if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
        this.targetElement = null;
        return false;
      }

接着咱们看看这两行很重要的代码:

this.focus(targetElement);
this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms

所涉及的两个原型方法分别为:

⑴ this.focus

  FastClick.prototype.focus = function(targetElement) {
    var length;

    // 组件建议通过setSelectionRange(selectionStart, selectionEnd)来设定光标范围(注意这样还没有聚焦
    // 要等到后面触发 sendClick 事件才会聚焦)
    // 另外 iOS7 下有些input元素(比如 date datetime month) 的 selectionStart 和 selectionEnd 特性是没有整型值的,
    // 导致会抛出一个关于 setSelectionRange 的模糊错误,它们需要改用 focus 事件触发
    if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
      length = targetElement.value.length;
      targetElement.setSelectionRange(length, length);
    } else {
      //直接触发其focus事件
      targetElement.focus();
    }
  };

注意,我们点击 textarea 时调用了该方法,它通过 targetElement.setSelectionRange(length, length) 决定了光标的位置在内容的尾部(但注意,这时候还没聚焦!!!)。

⑵ this.sendClick

真正让 textarea 聚焦的是这个方法,它合成了一个 click 方法立刻在textarea元素上触发导致聚焦:

  //合成一个click事件并在指定元素上触发
  FastClick.prototype.sendClick = function(targetElement, event) {
    var clickEvent, touch;

    // 在一些安卓机器中,得让页面所存在的 activeElement(聚焦的元素,比如input)失焦,否则合成的click事件将无效
    if (document.activeElement && document.activeElement !== targetElement) {
      document.activeElement.blur();
    }

    touch = event.changedTouches[0];

    // 合成(Synthesise) 一个 click 事件
    // 通过一个额外属性确保它能被追踪(tracked)
    clickEvent = document.createEvent('MouseEvents');
    clickEvent.initMouseEvent(this.determineEventType(targetElement), true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);
    clickEvent.forwardedTouchEvent = true; // fastclick的内部变量,用来识别click事件是原生还是合成的
    targetElement.dispatchEvent(clickEvent); //立即触发其click事件
  };

  FastClick.prototype.determineEventType = function(targetElement) {

    //安卓设备下 Select 无法通过合成的 click 事件被展开,得改为 mousedown
    if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
      return 'mousedown';
    }

    return 'click';
  };

经过这么一折腾,咱们轻点 textarea 后,光标就自然定位到其内容尾部去了。但是这里有个问题——排在 touchend 后的 focus 事件为啥没被触发呢?

如果 focus 事件能被触发的话,那肯定能重新定位光标到正确的位置呀。

咱们看下面这段:

      //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
      //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
      if (!deviceIsIOS || targetTagName !== 'select' ) {
        this.targetElement = null;
        event.preventDefault();
      }

通过 preventDefault 的阻挡,textarea 自然再也无法拥抱其 focus 宝宝了~

于是乎,我们在这里做个改动就能修复这个问题:

      var _isTextInput = function(){
        return targetTagName === 'textarea' || (targetTagName === 'input' && targetElement.type === 'text');
      };
      
      if ((!deviceIsIOS || targetTagName !== 'select') && !_isTextInput()) {
        this.targetElement = null;
        event.preventDefault();
      }

或者:

if (!deviceIsIOS4 || targetTagName !== 'select') {
  this.targetElement = null;
  //给textarea加上“needsclick”的class
  if((!/\bneedsclick\b/).test(targetElement.className)){
    event.preventDefault(); 
  }
}

这里要吐槽下的是,Fastclick 把 this.needsClick 放到了 ontouchEnd 末尾去执行,才导致前面说的加上了“needsclick”类名也无效的问题。

虽然问题原因找到也解决了,但咱们还是继续看剩下的部分吧。

4. onMouse 和 onClick

  //用于决定是否允许穿透事件(触发layer的click默认事件)
  FastClick.prototype.onMouse = function(event) {
    // touch事件一直没触发
    if (!this.targetElement) {
      return true;
    }
    if (event.forwardedTouchEvent) { //触发的click事件是合成的
      return true;
    }
    // 编程派生的事件所对应元素事件可以被允许
    // 确保其没执行过 preventDefault 方法(event.cancelable 不为 true)即可
    if (!event.cancelable) {
      return true;
    }
    // 需要做预防穿透处理的元素,或者做了快速(200ms)双击的情况
    if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
      //停止当前默认事件和冒泡
      if (event.stopImmediatePropagation) {
        event.stopImmediatePropagation();
      } else {
        // 不支持 stopImmediatePropagation 的设备(比如Android 2)做标记,
        // 确保该事件回调不会执行(见126行)
        event.propagationStopped = true;
      }
      // 取消事件和冒泡
      event.stopPropagation();
      event.preventDefault();
      return false;
    }
    //允许穿透
    return true;
  };
  //click事件常规都是touch事件衍生来的,也排在touch后面触发。
  //对于那些我们在touch事件过程没有禁用掉默认事件的event来说,我们还需要在click的捕获阶段进一步
  //做判断决定是否要禁掉点击事件(防穿透)
  FastClick.prototype.onClick = function(event) {
    var permitted;
    // 如果还有 trackingClick 存在,可能是某些UI事件阻塞了touchEnd 的执行
    if (this.trackingClick) {
      this.targetElement = null;
      this.trackingClick = false;
      return true;
    }
    // 依旧是对 iOS 怪异行为的处理 —— 如果用户点击了iOS模拟器里某个表单中的一个submit元素
    // 或者点击了弹出来的键盘里的“Go”按钮,会触发一个“伪”click事件(target是一个submit-type的input元素)
    if (event.target.type === 'submit' && event.detail === 0) {
      return true;
    }
    permitted = this.onMouse(event);
    if (!permitted) { //如果点击是被允许的,将this.targetElement置空可以确保onMouse事件里不会阻止默认事件
      this.targetElement = null;
    }
    //没有多大意义
    return permitted;
  };

  //销毁Fastclick所注册的监听事件。是给外部实例去调用的
  FastClick.prototype.destroy = function() {
    var layer = this.layer;
    if (deviceIsAndroid) {
      layer.removeEventListener('mouseover', this.onMouse, true);
      layer.removeEventListener('mousedown', this.onMouse, true);
      layer.removeEventListener('mouseup', this.onMouse, true);
    }
    layer.removeEventListener('click', this.onClick, true);
    layer.removeEventListener('touchstart', this.onTouchStart, false);
    layer.removeEventListener('touchmove', this.onTouchMove, false);
    layer.removeEventListener('touchend', this.onTouchEnd, false);
    layer.removeEventListener('touchcancel', this.onTouchCancel, false);
  };

常规需要阻断点击事件的操作,我们在 touch 监听事件回调中已经做了处理,这里主要是针对那些 touch 过程(有些设备甚至可能并没有touch事件触发)没有禁用默认事件的 event 做进一步处理,从而决定是否触发原生的 click 事件(如果禁止是在 onMouse 方法里做的处理)。

小结

1. 在 fastclick 源码的 addEventListener 回调事件中有很多的 return false/true。它们其实主要用于绕过后面的脚本逻辑,并没有其它意义(它是不会阻止默认事件的)。

所以千万别把 jQuery 事件、或者 DOM0 级事件回调中的 return false 概念,跟 addEventListener 的混在一起了。

2. fastclick 的源码其实很简单,有很大部分不外乎对一些怪异行为做 hack,其核心理念不外乎是——捕获 target 事件,判断 target 是要解决点透问题的元素,就合成一个 click 事件在 target 上触发,同时通过 preventDefault 禁用默认事件。

3. fastclick 虽好,但也有一些坑,还是得按需求对其修改,那么了解其源码还是很有必要的。

加载全部内容

相关教程
猜你喜欢
用户评论