读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进行版本判断。不知道这其中是否还有啥猫腻。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值