通过fastclick源码分析彻底解决tap“点透”

在移动端开发过程中,常常会遇到“点透”的问题。例如,当一个元素的click事件和另一个元素的touchend事件同时被触发时,就会发生“点透”,相当于用户点了下下一层的元素。为了避免这种问题的出现,我们可以使用第三方库 fastclick 来解决这

通过fastclick源码分析彻底解决tap“点透”

背景

在移动端开发过程中,常常会遇到“点透”的问题。例如,当一个元素的click事件和另一个元素的touchend事件同时被触发时,就会发生“点透”,相当于用户点了下下一层的元素。为了避免这种问题的出现,我们可以使用第三方库 fastclick 来解决这一问题,此处将通过 fastclick 的源码分析来解决这个问题。

fastclick 原理

fastclick 的核心是通过对 click 事件在移动设备上的延迟处理机制进行封装,启用 touch 事件模拟 click 事件并在合适的时候阻止默认行为从而彻底解决移动端的点透问题。在 fastclick 的实现中,主要依赖了 touchstart、touchend 和 click 事件,具体实现可以参考下面的代码:

var FastClick = function(layer, options) {
    var oldOnClick;

    options = options || {};

    // If necessary, set options to the global default settings
    for (var key in FastClick.defaults) {
        if (typeof options[key] === 'undefined') {
            options[key] = FastClick.defaults[key];
        }
    }

    // ......

    function onTouchStart(event) {
        if (event.targetTouches.length > 1) {
            return true;
        }

        if (options.disableInput) {
            event.preventDefault();
        }

        downEl = hasTouch ? event.target : (event.srcElement || element);
        addClass(downEl, options.activeClass);

        lastTouchStartTime = Date.now();

        clearTimeout(activeTimeout);

        return true;
    }

    // ......

    function onClick(event) {
        if (deviceIsIOS && !event.metaKey && !event.ctrlKey) {
            event.preventDefault();
        }
    }

    // ......

    function onMouseUp(event) {
        if (trackingClick) {
            targetElement = event.target || event.srcElement;
            targetTagName = targetElement.tagName.toLowerCase();
            removeClass(downEl, options.activeClass);
            trackingClick = false;

            if (targetTagName === 'label') {
                forElement = findControl(targetElement);
                if (forElement) {
                    focus(targetElement);
                    if (deviceIsAndroid) {
                        return false;
                    }
                    targetElement = forElement;
                }
            } else if (shouldUseClick(targetElement)) {
                doClick(targetElement, event);
                event.preventDefault();
            }

            return false;
        }

        return true;
    }

    // ......

    function onDoubleClick(event) {

        // Certain devices have specific user agent strings that require even more
        // hacky solutions to detect whether they are mobile. These are provided
        // in the big if clause which follows.
        if (deviceIsAndroid && (Date.now() - lastTouchEndTime) < 200) {
            // Trick the browser into believing that the user had actually
            // tapped on the target element by manually setting up touch coordinates
            // for it
            resetOnTouchEnd();

            targetElement = event.target || event.srcElement;
            oldTargetElement = targetElement;
            simulateEvent(targetElement, 'mousedown');

            // 延迟触发移除 class and preventDefault
            addClass(targetElement, options.activeClass);
            activeTimeout = setTimeout(function() {
                removeClass(targetElement, options.activeClass);
            }, options.activeTimeout);

            simulateEvent(targetElement, 'mouseup');
            simulateEvent(targetElement, 'click');

        }
    }

    // 为某个元素绑定事件
    function fastClick() {
        var layer = this;

        // 为特定 layer 添加 fastclick 特性
        addEvent(layer, 'touchstart', onTouchStart);
        addEvent(layer, 'touchmove', onTouchMove);
        addEvent(layer, 'touchend', onTouchEnd);
        addEvent(layer, 'touchcancel', onTouchCancel);
        addEvent(layer, 'mousedown', onMouseDown);
        addEvent(layer, 'mouseup', onMouseUp);
        addEvent(layer, 'mousemove', onMouseMove);
        addEvent(layer, 'click', onClick);
        addEvent(layer, 'dblclick', onDoubleClick);

        // ....

    }

    // Attach FastClick to object
    // 将 fastclick 挂载在某一个元素上
    FastClick.attach = function(layer, options) {
        return new FastClick(layer, options);
    };

    // ....

    // 绑定 fastclick
    if (typeof window.ontouchstart === 'undefined' && !deviceIsAndroid) {
        document.addEventListener('DOMContentLoaded', function() {
            FastClick.attach(document.body);
        }, false);
    }

    // ....

};

fastclick 实现

fastclick 本身比较简单,通过给一个对象的 layer 属性绑定事件,然后在事件处理的过程中阻止默认事件从而达到消除点透的目的。我们常常使用:

if ('addEventListener' in document) {
  document.addEventListener('DOMContentLoaded', function() {
      FastClick.attach(document.body);
  }, false);
}

将 fastclick 应用到页面上来消除点透。但是,这并不能解决所有的问题。我们还需要解决:为什么在移动端环境下快速触发2次click事件时,会出现1次“点透”的问题。fastclick 的原理的概括如下:

  • 处理 touchstart 事件,如果目标元素是表单控件元素,就不处理,默认使用系统点击
  • 处理 touchmove 事件,记录当前不发布 click 事件,防止滑动时有误点击
  • 处理 touchend 事件,触发 click 事件,如果时间大于 300ms,认为不是快速点击,不处理不阻止
  • 处理 click 事件,阻止事件默认行为

以下是 fastclick 的使用示例:

<body>
  <div class="button">Click me!</div>
  <script src="//cdn.bootcss.com/fastclick/1.0.6/fastclick.min.js"></script>
  <script>
    window.addEventListener('load', function() {
      FastClick.attach(document.body);
    }, false);
  </script>
</body>

解决方案

针对“为什么在移动端环境下快速触发2次click事件时,会出现1次“点透”的问题”,我们需要加入一个遮罩层来解决。点击一次时,在 300ms 内尝试拦截所有的后续点击事件,此时使用一个类为 in-acting 的遮罩层将当前点击目标元素覆盖,拦截所有的 click 事件,然后在 300ms 后移除遮罩层,释放 click 事件到目标元素。修改的具体实现可以参考下面的代码:

// 自定义 fastclick
var FC = function(layer){
  // ..
  var self = this;

  self.threshold = options.threshold || 10; // px
  self.layer = layer;

  self.reset = function(){
    self.trackingClick = false;
    self.trackingClickStart = 0;
    self.targetElement = null;
    self.lastTouchIdentifier = self.touchIdentifier;
    self.touchStartX = 0;
    self.touchStartY = 0;
    self.lastTouchMove = 0;
    self.touchBoundary = options.touchBoundary || 10;
    self.layer.removeEventListener('touchend', self, false);
    self.layer.removeEventListener('touchcancel', self, false);
    self.layer.removeEventListener('touchmove', self, false);
    document.removeEventListener('scroll', self, true);
  }
  // ..
  self.layer.addEventListener('touchstart', self, false);
  self.layer.addEventListener('touchmove', self, false);
  self.layer.addEventListener('touchend', self, false);
  self.layer.addEventListener('touchcancel', self, false);
};
FC.prototype.handleEvent = function(event){
  switch(event.type){
    case 'touchstart': return this.onTouchStart(event);
    case 'touchmove': return this.onTouchMove(event);
    case 'touchend': return this.onTouchEnd(event);
    case 'touchcancel': return this.onTouchCancel(event);
  }
}
FC.prototype.onTouchStart = function(event){
  console.log(event);
  if(this.trackingClick) return;
  this.trackingClick = true;
  this.targetElement = event.target;
  this.touchIdentifier = event.touches[0].identifier;
  this.trackingClickStart = Date.now();

  var self = this;
  this.layer.addEventListener('touchmove', self, false);
  this.layer.addEventListener('touchend', self, false);
  this.layer.addEventListener('touchcancel', self, false);
}
FC.prototype.onTouchEnd = function(event){
  console.log(event);
  if(!this.trackingClick) return;
  if((Date.now() - this.trackingClickStart) > 300) return this.reset();
  this.preventDefault(event);
  this.stopEventPropagation(event);
  this.reset();
  return false;
}
FC.prototype.onTouchMove = function(event){
  // console.log(event);
  if(!this.trackingClick) return true;
  if(this.lastTouchIdentifier !== event.touches[0].identifier) return this.reset();
  var dx = event.touches[0].pageX - this.touchStartX;
  var dy = event.touches[0].pageY - this.touchStartY;
  if((dx * dx + dy * dy) > (this.threshold * this.threshold)) return this.reset();
}
FC.prototype.onTouchCancel = function(event){
  console.log(event);
  this.preventDefault(event);
  this.stopEventPropagation(event);
  this.reset();
  return false;
}

FC.prototype.preventDefault = function(event){
  event.preventDefault();
}
FC.prototype.stopEventPropagation = function(event){
  event.stopPropagation();
}
var addCustomFastClick = function(){
  var inAction = false,
      isTouch = false;
  $('body').on('touchend', function(e){
    if(!isTouch){
      return;
    }
    inAction = true;
    $(e.target).addClass('in-acting');
    setTimeout(function(){
      $('body .in-acting').removeClass('in-acting');
      inAction = false;
    }, 300);
  }).on('touchstart', function(e){
    isTouch = true;
  }).on('touchmove', function(e){
    isTouch = false;
  });
  return;
};
addCustomFastClick();

我们自定义了一个 fastclick,实质类似于解决方案1,即阻止后续 300ms 内的事件(除了第一个事件)并添加遮罩层,此处的遮罩层使用的类是 in-acting。

解决方案1示例

<body>
  <div class="button">Click me!</div>
  <script src="//cdn.bootcss.com/fastclick/1.0.6/fastclick.min.js"></script>
  <script>
    if ('addEventListener' in document) {
      document.addEventListener('DOMContentLoaded', function() {
          FastClick.attach(document.body);
      }, false);
    }

    // 自定义的处理遮罩层
    function addCustomFastClick() {
      var inAction = false,
          isTouch = false;
      $('body').on('touchend', function(e) {
          if (!isTouch) {
              return;
          }
          inAction = true;
          $(e.target).addClass('in-acting');
          setTimeout(function() {
              $('body .in-acting').removeClass('in-acting');
              inAction = false;
          }, 300);
      }).on('touchstart', function(e) {
          isTouch = true;
      }).on('touchmove', function(e) {
          isTouch = false;
      });
      return;
    }
    addCustomFastClick();
  </script>
</body>

解决方案2示例

<body>
  <div class="button">Click me!</div>
  <script>
    var fc = new FC(document.querySelector('.button'));
  </script>
</body>

本文标题为:通过fastclick源码分析彻底解决tap“点透”

基础教程推荐