2007年,iPhone为了兼容PC网站,引入了双击缩放的操作。这个设计针对当时的情况非常人性化,其他浏览器也纷纷跟进。但是这个这个操作为了区分用户是想双击缩放还是真的单击,会在用户单击之后300ms才触发真实的click事件。这就是300ms延迟的来源。
为了让click没有这300ms延迟,FastClick诞生了。虽然有其他方案,但是FastClick是其中兼容性最好的方案。
目前绝大部分用户在移动端都不会遇到这个问题,因为现在都 2020年了。但是如果你的用户里还有人使用iOS 9,那么你就还需要关注这个问题。
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关键字具体实现了什么吗?
接着看看FastClick的构造函数(构造函数并没有检测,layer参数是否是合法的dom),在初始化参数以后,调用了一个notNeeded函数,如果这个函数返回true,就返回。这个函数实际上是检测当前环境是否存在300ms延迟问题。大致的判断逻辑如下:
如果window.ontouchstart不存在,返回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系列事件。
以下代码省略了一些
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。
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;
};
代码比较长,除了一些兼容性问题。主要考虑了如下几点:
/**
* 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函数决定)事件。并在目标元素上执行这个事件。
这个函数用来判断,目标元素是否需要原生的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模拟的事件是在
实际上主要是调用onMouse来决定是否阻止本次onClick事件。
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进行版本判断。不知道这其中是否还有啥猫腻。