FastClick 填坑及源碼解析

最近產品妹子提出了一個體驗issue —— 用 iOS 在手Q閱讀書友交流區發表書評時,光標點擊老是很差定位到正確的位置:node

如上圖,具體表現是較快點擊時,光標總會跳到 textarea 內容的尾部。只有當點擊停留時間較久一點(好比超過150ms)才能把光標正常定位到正確的位置。git

一開始我覺得是 iOS 原生的交互問題沒太在乎,但後來發現訪問某些頁面又是沒有這種奇怪體驗的。github

而後懷疑是否 JS 註冊了某些事件致使的問題,因而試着把業務模塊移除了再跑一遍,發現問題照舊。web

因而只好繼續作排除法,把頁面上的一些庫一點點移掉再運行頁面,結果發現搗亂的小鬼果真是嫌疑最大的 Fastclick。chrome

而後呢,我試着按API所說,給 textarea 加上一個名爲「needsclick」的類名,但願能繞過 fastclick 的處理直接走原生點擊事件,結果訝異地發現屁用沒有。。。編程

對此感謝後面咱們小組的 kindeng 童鞋幫忙研究了下並提供瞭解決方案,不過我還想進一步研究究竟是什麼緣由致使了這個坑、Fastclick 對個人頁面作了神馬~瀏覽器

因此昨晚花了點時間一口氣把源碼都蹂躪了一遍。app

這會是一篇很長的文章,但會是註釋很是詳盡的剖析文。ide

文章帶分析的源碼我也掛在個人 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 雖好,但也有一些坑,仍是得按需求對其修改,那麼瞭解其源碼仍是頗有必要的。

相關文章
相關標籤/搜索