文章出處

最近產品妹子提出了一個體驗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 雖好,但也有一些坑,還是得按需求對其修改,那么了解其源碼還是很有必要的。


文章列表




Avast logo

Avast 防毒軟體已檢查此封電子郵件的病毒。
www.avast.com


arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()