>  기사  >  웹 프론트엔드  >  FastClick이 직면한 함정과 솔루션

FastClick이 직면한 함정과 솔루션

小云云
小云云원래의
2018-03-03 09:07:5310060검색

이 글은 주로 FastClick이 직면한 함정과 해결책을 소개합니다. 도움이 되길 바랍니다.

최근 제품소녀가 경험 문제를 제기했습니다. iOS를 사용하여 모바일 QQ 리더 통신 영역에 서평을 게시할 때 항상 커서 클릭이 올바른 위치에 위치하기가 어렵습니다.

그림과 같이 위 그림에서 특정 성능은 더 빠르게 클릭하면 커서가 항상 텍스트 영역 콘텐츠의 끝으로 이동합니다. 클릭이 오랫동안(예: 150ms 이상) 지속되는 경우에만 커서가 정상적으로 올바른 위치에 위치할 수 있습니다.

처음에는 네이티브 iOS 상호작용 문제인줄 알고 크게 신경쓰지 않았는데, 일부 페이지에 접속할 때 그런 이상한 경험은 없다는 것을 알게 되었습니다.

그런 다음 JS가 문제를 일으키는 특정 이벤트를 등록했는지 의심되어 비즈니스 모듈을 제거하고 다시 실행해 보았지만 문제는 여전히 동일했습니다.

그래서 계속해서 제거 방법을 진행하고, 페이지의 일부 라이브러리를 조금씩 제거한 후 페이지를 실행해 보니, 과연 문제의 원인이 가장 의심스러웠던 Fastclick이었습니다.

그런 다음 API가 말한 대로 텍스트 영역에 "needsclick"이라는 클래스 이름을 추가하여 fastclick 처리를 우회하고 네이티브 클릭 이벤트를 직접 사용하려고 시도했지만 작동하지 않는다는 사실에 놀랐습니다. . .

연구에 도움을 주고 해결책을 제공해준 kindeng Children's Shoes에게 감사드립니다. 하지만 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(&#39;mouseover&#39;, this.onMouse, true);
      layer.addEventListener(&#39;mousedown&#39;, this.onMouse, true);
      layer.addEventListener(&#39;mouseup&#39;, this.onMouse, true);
    }
    layer.addEventListener(&#39;click&#39;, this.onClick, true);
    layer.addEventListener(&#39;touchstart&#39;, this.onTouchStart, false);
    layer.addEventListener(&#39;touchmove&#39;, this.onTouchMove, false);
    layer.addEventListener(&#39;touchend&#39;, this.onTouchEnd, false);
    layer.addEventListener(&#39;touchcancel&#39;, this.onTouchCancel, false);
    // 兼容不支持 stopImmediatePropagation 的浏览器(比如 Android 2)
    if (!Event.prototype.stopImmediatePropagation) {
      layer.removeEventListener = function(type, callback, capture) {
        var rmv = Node.prototype.removeEventListener;
        if (type === &#39;click&#39;) {
          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 === &#39;click&#39;) {
          //留意这里 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 === &#39;function&#39;) {
      oldOnClick = layer.onclick;
      layer.addEventListener(&#39;click&#39;, function(event) {
        oldOnClick(event);
      }, false);
      layer.onclick = null;
    }
  }

초기 단계에서는 FastClick.notNeeded 메소드를 사용하여 후속 관련 처리가 필요한지 여부를 결정합니다. done:


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

이것을 살펴보겠습니다. FastClick.notNeeded가 완료되었습니다. 어떤 판단:


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

    // 不支持触摸的设备
    if (typeof window.ontouchstart === &#39;undefined&#39;) {
      return true;
    }
    // 获取Chrome版本号,若非Chrome则返回0
    chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
    if (chromeVersion) {
      if (deviceIsAndroid) { //安卓
        metaViewport = document.querySelector(&#39;meta[name=viewport]&#39;);
        if (metaViewport) {
          // 安卓下,带有 user-scalable="no" 的 meta 标签的 chrome 是会自动禁用 300ms 延迟的,所以无需 Fastclick
          if (metaViewport.content.indexOf(&#39;user-scalable=no&#39;) !== -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(&#39;meta[name=viewport]&#39;);
        if (metaViewport) {
          if (metaViewport.content.indexOf(&#39;user-scalable=no&#39;) !== -1) {
            return true;
          }
          if (document.documentElement.scrollWidth <= window.outerWidth) {
            return true;
          }
        }
      }
    }
    // 带有 -ms-touch-action: none / manipulation 特性的 IE10 会禁用双击放大,也没有 300ms 时延
    if (layer.style.msTouchAction === &#39;none&#39; || layer.style.touchAction === &#39;manipulation&#39;) {
      return true;
    }

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

은 기본적으로 300ms 지연을 비활성화할 수 있는 브라우저 스니퍼이므로 Fastclick을 사용할 필요가 없습니다. 실행의 다음 단계를 중지하기 위해 생성자에 true를 반환합니다.

안드로이드 QQ의 ua는 /Chrome/([0-9]+)/에 매칭되므로, 'user-scalable=no' 메타태그가 있는 안드로이드 QQ 페이지는 필요하지 않은 페이지로 간주됩니다. FastClick에 의해 처리됩니다.

안드로이드 QQ에서 해당 질문이 처음에 언급되지 않는 이유이기도 합니다.

계속해서 click, touchstart, touchmove, touchend, touchcancel(Android에 mouseover, mousedown, mouseup이 있는 경우) 이벤트 모니터링을 레이어(예: 본문)에 직접 추가하는 생성자를 살펴보겠습니다.


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

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

Pay 여기에 주의하세요. 위의 코드는 처리를 위해 바인딩 메서드도 사용합니다. 이는 이러한 이벤트 콜백에서 Fastclick 인스턴스 컨텍스트가 됩니다.

또한 onclick 이벤트와 Android의 추가 처리 부분이 캡처되어 모니터링된다는 점에 유의하세요.

이러한 이벤트 콜백의 기능을 각각 살펴보겠습니다.

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的开发工具启用&#39;Emulate touch events&#39;后,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停止住了这个快速滚动
        // 这时候最后的&#39;touchend&#39;的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.

  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 === &#39;label&#39;) { //是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 === &#39;input&#39;)) {
        this.targetElement = null;
        return false;
      }
      this.focus(targetElement);
      this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms
      //iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
      //有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
      if (!deviceIsIOS || targetTagName !== &#39;select&#39;) {
        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 === &#39;input&#39;)) {
        this.targetElement = null;
        return false;
      }
      this.focus(targetElement);
      this.sendClick(targetElement, event); //立即触发其click事件,而无须等待300ms

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

this.needsFocus는 클릭 이벤트를 합성하여 특정 요소가 포커스를 시뮬레이션해야 하는지 여부를 결정하는 데 사용됩니다.

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

또한, 이 설명 텍스트 영역을 조금 더 길게(100ms 이상) 누르는 이유를 이해한 후 커서를 올바른 위치에 배치할 수 있습니다(나중에 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 === &#39;input&#39;)) {
        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(&#39;date&#39;) !== 0 && targetElement.type !== &#39;time&#39; && targetElement.type !== &#39;month&#39;) {
      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(&#39;MouseEvents&#39;);
    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() === &#39;select&#39;) {
      return &#39;mousedown&#39;;
    }

    return &#39;click&#39;;
  };

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

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

咱们看下面这段:


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

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

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


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

或者:


if (!deviceIsIOS4 || targetTagName !== &#39;select&#39;) {
  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 === &#39;submit&#39; && 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(&#39;mouseover&#39;, this.onMouse, true);
      layer.removeEventListener(&#39;mousedown&#39;, this.onMouse, true);
      layer.removeEventListener(&#39;mouseup&#39;, this.onMouse, true);
    }
    layer.removeEventListener(&#39;click&#39;, this.onClick, true);
    layer.removeEventListener(&#39;touchstart&#39;, this.onTouchStart, false);
    layer.removeEventListener(&#39;touchmove&#39;, this.onTouchMove, false);
    layer.removeEventListener(&#39;touchend&#39;, this.onTouchEnd, false);
    layer.removeEventListener(&#39;touchcancel&#39;, 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 虽好,但也有一些坑,还是得按需求对其修改,那么了解其源码还是很有必要的。

相关推荐:

js原生实现FastClick事件的实例

위 내용은 FastClick이 직면한 함정과 솔루션의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.