你真的了解FastClick嗎?


你真的了解FastClick嗎?

前段時間在做公司官網手機端菜單部分的時候,遇到一些很詭異的點擊問題。比如菜單點擊無效/雙擊才有效、在手指滑動的時候會觸發點擊事件、以及同樣的事件處理在微信跟瀏覽器會有不一樣的表現等等,這些問題我一直試圖用一些移動端事件的hack來解決,到最后還是有兩個問題沒有解決掉。后來意識到可能是引入的插件導致的事件沖突引起,因為一直都在全局引入了fastclick,以及最初偷懶引入的一個菜單功能插件(插件中有引入iScroll)。經過排查最后得出結論是fastclick與插件 沖突所致,只能去除插件重寫菜單功能。而這個小插曲也讓我有興趣閱讀一下它的源碼來深究一下fastclick到底做了什么?

FastClick的使用場景及背景:

  • 移動端的開發經常需要監聽用戶的雙擊行為,事件的發生順序是這樣的:touchstart---touchmove---touchend,然后大約過300ms觸發click事件,用來判斷是否有雙擊事件。
  • 在混合使用touch與click時,會導致點擊穿透!(此處不展開討論)
  • FastClick的思路就是利用touch來模擬tap(觸碰),如果認為是一次有效的tap,則在touchend時立即模擬一個click事件,分發到事件源(相當於主動觸發一次click),同時阻止掉瀏覽器300ms后產生的click。自然也不存在點擊穿透的問題。

眾所周知的FastClick用法:

 

Javascript原生

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

 

jQuery

$(function() {
    FastClick.attach(document.body);
});

 

類似Common JS的模塊系統方式

var attachFastClick = require('fastclick');
attachFastClick(document.body);

 

needsclick

對於頁面上不需要使用fastclick來立刻觸發點擊事件的元素在元素標簽的class上添加needsclick

 

不需要使用fastclick的情況

  • PC端,FastClick只在移動端監聽;

  • Android版Chrome 32+瀏覽器,如果設置viewport meta的值為width=device-width,這種情況下瀏覽器會馬上出發點擊事件,不會延遲300毫秒。

<meta name="viewportcontent="width=device-widthinitial-scale=1">

 

  • 所有版本的Android Chrome瀏覽器,如果設置viewport meta的值有user-scalable=no,瀏覽器也是會立即觸發點擊事件。
  • IE11+瀏覽器設置了css的屬性touch-action: manipulation,它會在某些標簽(a,button等)禁止雙擊事件,IE10的為-ms-touch-action: manipulation

FastClick的實現原理

Fastclick的源碼中除了對舊版本瀏覽器的polyfill以及特殊版本瀏覽器的的bug解決,主要綁定了以下原型方法:

/*構造函數*/
function FastClick(layer, options)

/*判斷是否需要瀏覽器原生的click事件(針對一些特殊元素比如表單)*/
FastClick.prototype.needsClick = function(target)

/*判斷給定元素是否需要通過合成click事件來模擬聚焦*/
FastClick.prototype.needsFocus = function(target)

/*合成click事件並在指定元素上觸發*/
FastClick.prototype.sendClick = function(targetElement, event)

/* touchstart */
FastClick.prototype.onTouchStart = function(event)

/* touchmove*/
FastClick.prototype.onTouchMove = function(event)

/* touchend*/
FastClick.prototype.onTouchEnd = function(event)

/*判斷這次tap是否有效*/
FastClick.prototype.onMouse = function(event

/*click handler 捕獲階段監聽*/
FastClick.prototype.onClick = function(event)

/*移出fastlick事件綁定*/
FastClick.prototype.destroy = function()

/*調用FastClick*/
FastClick.attach = function(layer, options
{
    return new FastClick(layer, options);
};

先用一張圖來解構FastClick源碼(圖片來自其他博客):

 

初始化的時候主要都做了什么呢?

/*不需要處理的元素類型,則直接返回(這些情況上面已經提到)*/
if (FastClick.notNeeded(layer)) {   
    return;
}

/*Some old versions of Android don't have Function.prototype.bind*/
/*對安卓老版本不支持bind的polyfill*/
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);
}

/* 如果layer直接在DOM上寫了 onclick 方法,那我們需要把它替換為 addEventListener 綁定形式*/
if (typeof layer.onclick === 'function') {
    oldOnClick = layer.onclick;
    layer.addEventListener('click'function(event{
        oldOnClick(event);
    }, false);
    layer.onclick = null;
}

 

在FastClick.prototype.needsClick中有如下一行代碼即是對 needsclick的判斷:

 /*元素帶了名為“needsclick”的class返回true*/
 return (/\bneedsclick\b/).test(target.className);

 

在FastClick.prototype.needsFocus 中有如下一行代碼即是對 needsfocus 的判斷:

 /*帶有名為“needsfocus”的class則返回true*/
 return (/\bneedsfocus\b/).test(target.className);

 

touchstart事件

FastClick.prototype.onTouchStart = function(event{
    var targetElement, touch, selection;

    /*多指觸控手勢則忽略*/
    if (event.targetTouches.length > 1) {
        return true;
    }
    /*某些舊瀏覽器,如果target是一個文本節點,得返回其DOM節點*/
    targetElement = this.getTargetElementFromEventTarget(event.target);
    touch = event.targetTouches[0];

    if (deviceIsIOS) {
        /*若用戶已經選中了一些內容(比如選中了一段文本打算復制),則忽略*/
        selection = window.getSelection();
        if (selection.rangeCount && !selection.isCollapsed) {
            return true;
        }

        if (!deviceIsIOS4) { 

            /*
怪異特性處理——若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 里標記的事件時間戳

     his.tapDelay 為常量 200 (ms)
      此舉用來避免 phantom 的雙擊(200ms內快速點了兩次)觸發 click
      反正200ms內的第二次點擊會禁止觸發點擊的默認事件
    */
  if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
        event.preventDefault();
    }

    return true;
};

 

touchmove事件

FastClick.prototype.onTouchMove = function(event{
    if (!this.trackingClick) {
        return true;
    }

    /* If the touch has moved, cancel the click tracking*/
    if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
        this.trackingClick = false;
        this.targetElement = null;
    }
    return true;
};

 

touchend事件

FastClick.prototype.onTouchEnd = function(event{
        var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;

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

        /*避免雙擊(200ms內快速點了兩次)觸發 click*/
        if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
            /*該屬性會在 onMouse 事件中被判斷,為true則徹底禁用事件和冒泡*/
            this.cancelNextClick = true
            return true;
        }

        /*識別是否為長按事件,如果是(大於700ms)則忽略*/
        if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
            return true;
        }

        /*得重置為false,避免input事件被意外取消*/
        this.cancelNextClick = false;
        /*標記touchend時間,方便下一次的touchstart做雙擊校驗*/
        this.lastClickTime = event.timeStamp;
        trackingClickStart = this.trackingClickStart;
        /*重置 this.trackingClick 和 this.trackingClickStart*/
        this.trackingClick = false;
        this.trackingClickStart = 0;

        /* iOS 6.0-7.*版本下有個問題 —— 如果layer處於transition或scroll過程,event所提供的target是不正確的*/
        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事件觸發流程)
             另外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);
            /*立即觸發其click事件*/
            this.sendClick(targetElement, event);

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

 

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在IOS11.3以上版本的bug及解決方案

 

1:文本框在內容區點擊不會立即定位到相應位置,而是在文本末尾。而長摁超過100ms則無此問題。

首先交互肯定是在focus的時候發生,讓我們看下FastClick里的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();
        }

在IOS下是通過targetElement.setSelectionRange來定位位置,至於在iOS11.3下為什么會出現這個bug,仍未知,解決的方法簡單暴力,直接改寫此方法:

FastClick.prototype.focus = function(targetElement{
    targetElement.focus();
};

 

2:ios11.3支持了Web API:允許對事件支持被動模式,減少滾動屏幕的性能損耗和奔潰,並且針對document的touch事件監聽添加被動模式的配置,因此document將不再調用preventDefault方法。這些改動會引起fastclick的另一個bug,當靜置app或鎖屏幾秒后頁面將無法響應任何點擊操作。

解決方法:


layer.addEventListener('touchstart'this.onTouchStart, {passive:false}); /*支持設置passive的,將被動模式顯式設置為false*/

layer.addEventListener('touchstart'this.onTouchStart, false);// 否則,去除默認的被動模式

學到的知識點

 

event.stopImmediatePropagation

我們都知道stopPropagation是阻止默認事件,那stopImmediatePropagation跟stopPropagation最大的區別在於它能夠阻止當前元素剩下的監聽函數的執行。

 

EventTarget.addEventListener(type, listener[, options])的option參數的passive屬性(es6第三個參數可以是對象)

說實話,事件監聽用到現在,我一直以為options參數只有一個Boolean來判斷在捕獲/冒泡階段監聽事件。直到今天看到。 passive(默認false)表示listener永遠不會調用preventDefault(),如果仍然調用此函數,客戶端會拋出一個控制台警告。而且設置此屬性可以改善滾屏性能,具體見MDN。

參考文檔:

1: https://github.com/ftlabs/fastclick/blob/master/lib/fastclick.js (fastclick源碼)

2: https://github.com/VaJoy/fastclick-analysis/blob/master/fastclick.js

3: fastclick解析與ios11.3相關bug原因分析

4: event.stopImmediatePropagation----MDN;

5: EventTarget.addEventListener()-----MDN

6:移動端Click 300ms點擊延遲的來龍去脈


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM