讀FastClick源碼理清移動端click事件300ms延遲問題

移動端爲什麼會有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進行版本判斷。不知道這其中是否還有啥貓膩。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章