This article mainly introduces a brief discussion of FastClick pit filling and source code analysis. Now I will share it with you and give you a reference.
Recently, the product girl raised an experience issue - when using iOS to post a book review in the mobile QQ reader communication area, the cursor click is always difficult to locate the correct position:

As shown in the picture above, the specific performance is that when clicking quickly, the cursor will always jump to the end of the textarea content. Only when the click stays for a longer time (for example, more than 150ms) can the cursor be positioned to the correct position normally.
At first I thought it was a native iOS interaction problem and didn’t pay much attention to it, but later I found that there was no such strange experience when accessing some pages.
Then I suspected whether JS registered certain events that caused the problem, so I tried to remove the business module and run it again, and found that the problem remained the same.
So I had to continue to do the troubleshooting method, remove some libraries on the page bit by bit, and then run the page. It turned out that the troublemaker was indeed Fastclick, the most suspected one.
Then, I tried to add a class name called "needsclick" to the textarea as the API said, hoping to bypass the processing of fastclick and directly use the native click event, but I was surprised to find that fart Use no. . .
Thanks to kindeng children's shoes from our team for helping to research and provide solutions, but I would like to further study what caused this pit, and Fastclick did something to my page~
So I spent some time last night and ravaged the source code in one breath.
This will be a long article, but it will be a very detailed analysis.
I have also posted the source code of the article with analysis on my github warehouse. Interested children can download it and have a look.
Without further ado, let’s start digging into the FastClick source code camp.
We know that registering a FastClick event is very simple, it goes like this:
1 2 3 4 5 | if ('addEventListener' in document) {
document.addEventListener('DOMContentLoaded', function () {
var fc = FastClick.attach(document.body);
}, false);
}
|
Copy after login
So let’s start here and open the source code to take a look. FastClick .attach method:
1 2 3 | FastClick.attach = function (layer, options) {
return new FastClick(layer, options);
};
|
Copy after login
A FastClick instance is returned here, so let’s pull it to the front and take a look at the FastClick constructor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | function FastClick(layer, options) {
var oldOnClick;
options = options || {};
if (FastClick.notNeeded(layer)) {
return ;
}
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);
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') {
adv.call(layer, type, callback.hijacked || (callback.hijacked = function (event) {
if (!event.propagationStopped) {
callback(event);
}
}), capture);
} else {
adv.call(layer, type, callback, capture);
}
};
}
if (typeof layer.onclick === ' function ') {
oldOnClick = layer.onclick;
layer.addEventListener('click', function (event) {
oldOnClick(event);
}, false);
layer.onclick = null;
}
}
|
Copy after login
In the initial stage, the FastClick.notNeeded method is used to determine whether subsequent related processing is required:
1 2 3 4 | if (FastClick.notNeeded(layer)) {
return ;
}
|
Copy after login
Let’s take a look at this What judgments have been made on FastClick.notNeeded:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 | FastClick.notNeeded = function (layer) {
var metaViewport;
var chromeVersion;
var blackberryVersion;
var firefoxVersion;
if (typeof window.ontouchstart === 'undefined') {
return true;
}
chromeVersion = +(/Chrome\/([0-9]+)/. exec (navigator.userAgent) || [,0])[1];
if (chromeVersion) {
if (deviceIsAndroid) {
metaViewport = document.querySelector('meta[name=viewport]');
if (metaViewport) {
if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
return true;
}
if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
return true;
}
}
} 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;
}
}
}
}
if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
return true;
}
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;
}
}
if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
return true;
}
return false;
};
|
Copy after login
Basically they are browser sniffers that can disable the 300ms delay. There is no need to use Fastclick for them. , so it will return true to the constructor to stop the next step of execution.
Since the ua of Android mobile Q will be matched to /Chrome\/([0-9] )/, the Android mobile Q page with the 'user-scalable=no' meta tag will be viewed by FastClick Pages that do not require processing.
This is also the reason why there is no mention of the problem at the beginning in Android QQ.
Let's continue to look at the constructor, which directly adds click, touchstart, touchmove, touchend, touchcancel (if Android also has mouseover, mousedown, mouseup) event monitoring to the layer (i.e. body):
1 2 3 4 5 6 7 8 9 10 11 12 | 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);
|
Copy after login
Note that the bind method is also used in this code. This in these event callbacks will become the Fastclick instance context.
Also note that the onclick event and the additional processing part of Android are all captured and monitored.
Let’s take a look at what these event callbacks do respectively.
1. this.onTouchStart
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 | FastClick.prototype.onTouchStart = function (event) {
var targetElement, touch, selection;
if (event.targetTouches.length > 1) {
return true;
}
targetElement = this.getTargetElementFromEventTarget(event.target);
touch = event.targetTouches[0];
if (deviceIsIOS) {
selection = window.getSelection();
if (selection.rangeCount && !selection.isCollapsed) {
return true;
}
if (!deviceIsIOS4) {
if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
event.preventDefault();
return false;
}
this.lastTouchIdentifier = touch.identifier;
this.updateScrollParent(targetElement);
}
}
this.trackingClick = true;
this.trackingClickStart = event.timeStamp;
this.targetElement = targetElement;
this.touchStartX = touch.pageX;
this.touchStartY = touch.pageY;
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
event.preventDefault();
}
return true;
};
|
Copy after login
Take a look at this.updateScrollParent here:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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);
}
if (scrollParent) {
scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
}
};
|
Copy after login
It should also be noted that the this.trackingClick attribute marked as true in onTouchStart will be detected at the beginning of other event callbacks (such as ontouchmove). If it is not If assigned, it will be ignored directly:
1 2 3 | if (!this.trackingClick) {
return true;
}
|
Copy after login
Of course, it will be reset to false in the ontouchend event.
2. this.onTouchMove
This code is so small:
1 2 3 4 5 6 7 8 9 10 11 12 13 | FastClick.prototype.onTouchMove = function (event) {
if (!this.trackingClick) {
return true;
}
if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
this.trackingClick = false;
this.targetElement = null;
}
return true;
};
|
Copy after login
Look The prototype method of this.touchHasMoved used here:
1 2 3 4 5 6 7 8 9 10 | 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;
};
|
Copy after login
3. onTouchEnd
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | FastClick.prototype.onTouchEnd = function (event) {
var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
if (!this.trackingClick) {
return true;
}
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
this.cancelNextClick = true;
return true;
}
if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
return true;
}
this.cancelNextClick = false;
this.lastClickTime = event.timeStamp;
trackingClickStart = this.trackingClickStart;
this.trackingClick = false;
this.trackingClickStart = 0;
if (deviceIsIOSWithBadTarget) {
touch = event.changedTouches[0];
targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
}
targetTagName = targetElement.tagName.toLowerCase();
if (targetTagName === 'label') {
forElement = this.findControl(targetElement);
if (forElement) {
this.focus(targetElement);
if (deviceIsAndroid) {
return false;
}
targetElement = forElement;
}
} else if (this.needsFocus(targetElement)) {
if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
this.focus(targetElement);
this.sendClick(targetElement, event);
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;
}
}
if (!this.needsClick(targetElement)) {
event.preventDefault();
this.sendClick(targetElement, event);
}
return false;
};
|
Copy after login
This paragraph is relatively long, we mainly look at this paragraph:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | } else if (this.needsFocus(targetElement)) {
if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
this.focus(targetElement);
this.sendClick(targetElement, event);
if (!deviceIsIOS || targetTagName !== 'select') {
this.targetElement = null;
event.preventDefault();
}
return false;
}
|
Copy after login
where this.needsFocus is used to determine whether a given element is needed Simulate focus by synthesizing click events:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | FastClick.prototype.needsFocus = function (target) {
switch (target.nodeName.toLowerCase()) {
case 'textarea':
return true;
case 'select':
return !deviceIsAndroid;
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 :
return (/\bneedsfocus\b/).test(target.className);
}
};
|
Copy after login
In addition, this paragraph explains why if you press the textarea for a little longer (more than 100ms), we can position the cursor at The correct place (will bypass the method of calling this.focus later):
1 2 3 4 5 6 7 8 9 | if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
|
Copy after login
Then let’s take a look at these two very important lines of code:
1 2 | this.focus(targetElement);
this.sendClick(targetElement, event);
|
Copy after login
The two prototype methods involved are:
⑴ this.focus
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | FastClick.prototype.focus = function (targetElement) {
var length;
if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf(' date ') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
length = targetElement.value.length;
targetElement.setSelectionRange(length, length);
} else {
targetElement.focus();
}
};
|
Copy after login
注意,我们点击 textarea 时调用了该方法,它通过 targetElement.setSelectionRange(length, length) 决定了光标的位置在内容的尾部(但注意,这时候还没聚焦!!!)。
⑵ this.sendClick
真正让 textarea 聚焦的是这个方法,它合成了一个 click 方法立刻在textarea元素上触发导致聚焦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | FastClick.prototype.sendClick = function (targetElement, event) {
var clickEvent, touch;
if (document.activeElement && document.activeElement !== targetElement) {
document.activeElement.blur();
}
touch = event.changedTouches[0];
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);
};
FastClick.prototype.determineEventType = function (targetElement) {
if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
return 'mousedown';
}
return 'click';
};
|
Copy after login
经过这么一折腾,咱们轻点 textarea 后,光标就自然定位到其内容尾部去了。但是这里有个问题——排在 touchend 后的 focus 事件为啥没被触发呢?
如果 focus 事件能被触发的话,那肯定能重新定位光标到正确的位置呀。
咱们看下面这段:
1 2 3 4 5 6 | if (!deviceIsIOS || targetTagName !== 'select' ) {
this.targetElement = null;
event.preventDefault();
}
|
Copy after login
通过 preventDefault 的阻挡,textarea 自然再也无法拥抱其 focus 宝宝了~
于是乎,我们在这里做个改动就能修复这个问题:
1 2 3 4 5 6 7 8 | var _isTextInput = function (){
return targetTagName === 'textarea' || (targetTagName === 'input' && targetElement.type === 'text');
};
if ((!deviceIsIOS || targetTagName !== 'select') && !_isTextInput()) {
this.targetElement = null;
event.preventDefault();
}
|
Copy after login
或者:
1 2 3 4 5 6 7 | if (!deviceIsIOS4 || targetTagName !== 'select') {
this.targetElement = null;
if ((!/\bneedsclick\b/).test(targetElement.className)){
event.preventDefault();
}
}
|
Copy after login
这里要吐槽下的是,Fastclick 把 this.needsClick 放到了 ontouchEnd 末尾去执行,才导致前面说的加上了“needsclick”类名也无效的问题。
虽然问题原因找到也解决了,但咱们还是继续看剩下的部分吧。
4. onMouse 和 onClick
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | FastClick.prototype.onMouse = function (event) {
if (!this.targetElement) {
return true;
}
if (event.forwardedTouchEvent) {
return true;
}
if (!event.cancelable) {
return true;
}
if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
if (event.stopImmediatePropagation) {
event.stopImmediatePropagation();
} else {
event.propagationStopped = true;
}
event.stopPropagation();
event.preventDefault();
return false;
}
return true;
};
FastClick.prototype.onClick = function (event) {
var permitted;
if (this.trackingClick) {
this.targetElement = null;
this.trackingClick = false;
return true;
}
if (event.target.type === 'submit' && event.detail === 0) {
return true;
}
permitted = this.onMouse(event);
if (!permitted) {
this.targetElement = null;
}
return permitted;
};
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);
};
|
Copy after login
常规需要阻断点击事件的操作,我们在 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 虽好,但也有一些坑,还是得按需求对其修改,那么了解其源码还是很有必要的。
上面是我整理给大家的,希望今后会对大家有帮助。
相关文章:
如何关闭Vue计算属性自带的缓存功能,具体步骤有哪些?
利用vue2.0中swiper组件实现轮播(详细教程)
有关在Vue中使用Compass的具体方法?
The above is the detailed content of Detailed explanation of FastClick source code (detailed tutorial). For more information, please follow other related articles on the PHP Chinese website!