移動端爲什麼會有300ms的延遲
2007年,iPhone爲了兼容PC網站,引入了雙擊縮放的操作。這個設計針對當時的情況非常人性化,其他瀏覽器也紛紛跟進。但是這個這個操作爲了區分用戶是想雙擊縮放還是真的單擊,會在用戶單擊之後300ms才觸發真實的click事件。這就是300ms延遲的來源。
爲了讓click沒有這300ms延遲,FastClick誕生了。雖然有其他方案,但是FastClick是其中兼容性最好的方案。
目前絕大部分用戶在移動端都不會遇到這個問題,因爲現在都 2020年了。但是如果你的用戶裏還有人使用iOS 9,那麼你就還需要關注這個問題。
讀FastClick源碼
FastClick源碼:https://github.com/ftlabs/fastclick/blob/master/lib/fastclick.js
代碼沒有使用ES6編寫,是一份典型的JS庫,使用下面的代碼完成加載,兼容AMD、CommonJs加載和全局引入。
/**
* Factory method for creating a FastClick object
*
* @param {Element} layer The layer to listen on
* @param {Object} [options={}] The options to override the defaults
*/
FastClick.attach = function(layer, options) {
return new FastClick(layer, options);
};
if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {
// AMD. Register as an anonymous module.
define(function() {
return FastClick;
});
} else if (typeof module !== 'undefined' && module.exports) {
module.exports = FastClick.attach;
module.exports.FastClick = FastClick;
} else {
window.FastClick = FastClick;
}
實際使用,只有一個函數,attach函數,這個函數內部實例化了一個FastClick函數。還記得new關鍵字具體實現了什麼嗎?
構造函數和notNeeded函數
接着看看FastClick的構造函數(構造函數並沒有檢測,layer參數是否是合法的dom),在初始化參數以後,調用了一個notNeeded函數,如果這個函數返回true,就返回。這個函數實際上是檢測當前環境是否存在300ms延遲問題。大致的判斷邏輯如下:
-
如果window.ontouchstart不存在,返回true。
- 如果是手機裏chrome,viewport裏設置了user-scalable=no或者版本大於31並且設置了width=device-width,返回true。
- 如果是黑莓10.3+,和上面手機chrome一樣的配置,返回true。
- 如果layer的樣式裏包含touchAction === 'none' || touchAction === 'manipulation' || msTouchAction === 'none',返回true。
- 如果firefox版本大於等於27,和上面手機chrome配置一樣,返回true。
這裏並沒有判斷是否是iOS,網上有文章說其實iOS 9.3以後,蘋果修復了這個問題。文章鏈接:https://segmentfault.com/a/1190000019281808
如果notNeeded函數返回false,那麼FastClick在layer上綁定一系列的事件,而且使用bind函數修改了這些事件觸發後執行函數的this爲FastClick本身。事件類型包括['onMouse', 'onClick', 'onTouchStart', 'onTouchMove', 'onTouchEnd', 'onTouchCancel']。其中onMouse事件僅在Android設備中生效。
這些事件綁定的時候。onClick和onMouse是在捕獲階段執行,touch事件是在冒泡階段執行。這樣做的目的是爲了阻止原生的onClick事件觸發,具體代碼片段如下:
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);
綁定這些事件以後,就能在捕獲階段阻止事件,在冒泡階段跟蹤事件了。接下來我們先看touch系列事件。
冒泡階段
onTouchStart
以下代碼省略了一些
FastClick.prototype.onTouchStart = function(event) {
var targetElement, touch, selection;
// Ignore multiple touches, otherwise pinch-to-zoom is prevented if both fingers are on the FastClick element (issue #111).
if (event.targetTouches.length > 1) {
return true;
}
targetElement = this.getTargetElementFromEventTarget(event.target);
touch = event.targetTouches[0];
if (deviceIsIOS) {
// Only trusted events will deselect text on iOS (issue #49)
selection = window.getSelection();
if (selection.rangeCount && !selection.isCollapsed) {
return true;
}
if (!deviceIsIOS4) {
// ...
}
}
this.trackingClick = true;
this.trackingClickStart = event.timeStamp;
this.targetElement = targetElement;
this.touchStartX = touch.pageX;
this.touchStartY = touch.pageY;
// Prevent phantom clicks on fast double-tap (issue #36)
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
event.preventDefault();
}
return true;
};
這裏看到標記了要追蹤click,並且記錄了start開始事件,目前元素和具體位置。如果現在的事件減去上一次的事件小於tapDelay,則會阻止掉這個事件,默認的tapDelay是200ms。
onTouchMove
move函數主要判斷,如果事件沒有移動,上次記錄了追蹤,且元素沒有改變。就繼續事件。
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;
};
onTouchEnd
代碼比較長,除了一些兼容性問題。主要考慮瞭如下幾點:
- 如果目標元素是label標籤,則讓目標元素對應的控制元素聚焦。
- 如果目標元素是需要聚焦的類型,則觸發元素聚焦,並且執行sendClick函數
- 如果元素不需要原生的雙擊事件(needsClick函數),執行sendClick函數。
sendClick
/**
* Send a click event to the specified element.
*
* @param {EventTarget|Element} targetElement
* @param {Event} event
*/
FastClick.prototype.sendClick = function(targetElement, event) {
var clickEvent, touch;
// On some Android devices activeElement needs to be blurred otherwise the synthetic click will have no effect (#24)
if (document.activeElement && document.activeElement !== targetElement) {
document.activeElement.blur();
}
touch = event.changedTouches[0];
// Synthesise a click event, with an extra attribute so it can be 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;
targetElement.dispatchEvent(clickEvent);
};
sendClick函數使用createEvent模擬了一個完整的click或者是mousedown(由determineEventType函數決定)事件。並在目標元素上執行這個事件。
needsClick
這個函數用來判斷,目標元素是否需要原生的click
/**
* Determine whether a given element requires a native click.
*
* @param {EventTarget|Element} target Target DOM element
* @returns {boolean} Returns true if the element needs a native click
*/
FastClick.prototype.needsClick = function(target) {
switch (target.nodeName.toLowerCase()) {
// Don't send a synthetic click to disabled inputs (issue #62)
case 'button':
case 'select':
case 'textarea':
if (target.disabled) {
return true;
}
break;
case 'input':
// File inputs need real clicks on iOS 6 due to a browser bug (issue #68)
if ((deviceIsIOS && target.type === 'file') || target.disabled) {
return true;
}
break;
case 'label':
case 'iframe': // iOS8 homescreen apps can prevent events bubbling into frames
case 'video':
return true;
}
return (/\bneedsclick\b/).test(target.className);
};
FastClick允許使用者在目標原生上寫needclick類名告訴FasClick元素需要元素的click事件。
捕獲階段
需要阻止的其實是touchend觸發後300ms後的那次原生的click事件,FastClick模擬的事件是在
onClick
實際上主要是調用onMouse來決定是否阻止本次onClick事件。
onMouse
FastClick.prototype.onMouse = function(event) {
// If a target element was never set (because a touch event was never fired) allow the event
if (!this.targetElement) {
return true;
}
if (event.forwardedTouchEvent) {
return true;
}
// Programmatically generated events targeting a specific element should be permitted
if (!event.cancelable) {
return true;
}
// Derive and check the target element to see whether the mouse event needs to be permitted;
// unless explicitly enabled, prevent non-touch click events from triggering actions,
// to prevent ghost/doubleclicks.
if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
// Prevent any user-added listeners declared on FastClick element from being fired.
if (event.stopImmediatePropagation) {
event.stopImmediatePropagation();
} else {
// Part of the hack for browsers that don't support Event#stopImmediatePropagation (e.g. Android 2)
event.propagationStopped = true;
}
// Cancel the event
event.stopPropagation();
event.preventDefault();
return false;
}
// If the mouse event is permitted, return true for the action to go through.
return true;
};
如果當前元素不需要原生的click,那麼阻止掉了所有的監聽事件,行爲和冒泡。如果這個click是FastClick自己模擬的,則事件通過。
流程總結
1、在捕獲階段阻止原生click事件,這個事件是在touchend觸發300ms後觸發的。
2、在touchend觸發後,給目標元素模擬一個click事件。
3、整個流程爲了兼容性,引入了很多判斷和修復bug方案。
遺留的問題
1、爲什麼判斷width=device-width的代碼是:document.documentElement.scrollWidth <= window.outerWidth。
2、FastClick的代碼其實還有很多的改進空間。
3、網上的文章(上文中有鏈接)說iOS只要使用了9.3+或者webview使用了WKWebView就不存在這個問題了,但是FastClick裏併爲對iOS進行版本判斷。不知道這其中是否還有啥貓膩。