最近产品妹子提出了一个体验issue —— 用 iOS 在手Q阅读书友交流区发表书评时,光标点击总是不好定位到正确的位置:
如上图,具体表现是较快点击时,光标总会跳到 textarea 内容的尾部。只有当点击停留时间较久一点(比如超过150ms)才能把光标正常定位到正确的位置。
一开始我以为是 iOS 原生的交互问题没太在意,但后来发现访问某些页面又是没有这种奇怪体验的。
然后怀疑是否 JS 注册了某些事件导致的问题,于是试着把业务模块移除了再跑一遍,发现问题照旧。
于是只好继续做排除法,把页面上的一些库一点点移掉再运行页面,结果发现捣乱的小鬼果然是嫌疑最大的 Fastclick。
然后呢,我试着按API所说,给 textarea 加上一个名为“needsclick”的类名,希望能绕过 fastclick 的处理直接走原生点击事件,结果讶异地发现屁用没有。。。
对此感谢后面我们小组的 kindeng 童鞋帮忙研究了下并提供了解决方案,不过我还想进一步研究到底是什么原因导致了这个坑、Fastclick 对我的页面做了神马~
所以昨晚花了点时间一口气把源码都蹂躏了一遍。
这会是一篇很长的文章,但会是注释非常详尽的剖析文。
文章带分析的源码我也挂在我的 github 仓库上了,有兴趣的童鞋可以去下载来看。
闲话不多说,咱们开始深入 FastClick 源码阵营。
我们知道,注册一个 FastClick 事件非常简单,它是这样的:
所以我们从这里着手,打开源码看下 FastClick .attach 方法:
这里返回了一个 FastClick 实例,所以咱们拉到前面看看 FastClick 构造函数:
//安卓则做额外处理
if (deviceIsAndroid) {
layer.addEventListener('mou<a href="https://www.jb51.cc/tag/SEO/" title="SEO">SEO</a>ver',this.onMouse,true);
layer.addEventListener('mousedown',true);
layer.addEventListener('mouseup',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);
// 兼容<a href="https://www.jb51.cc/tag/buzhichi/" target="_blank" class="keywords">不支持</a> stopImmediatePropagation 的浏览器(比如 Android 2)
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,capture);
}
};
layer.addEventListener = function(type,capture) {
var adv = Node.prototype.addEventListener;
if (type === 'click') {
//留意这里 callback.hijacked 中会判断 event.propagationStopped 是否为真来确保(安卓的onMouse事件)只执行一次
//在 onMouse 事件里会给 event.propagationStopped 赋值 true
adv.call(layer,callback.hijacked || (callback.hijacked = function(event) {
if (!event.propagationStopped) {
callback(event);
}
}),capture);
} else {
adv.call(layer,capture);
}
};
}
// 如果layer直接在DOM上写了 onclick <a href="https://www.jb51.cc/tag/fangfa/" target="_blank" class="keywords">方法</a>,那我们需要把它替换为 addEventListener 绑定形式
if (typeof layer.onclick === 'function') {
oldOnClick = layer.onclick;
layer.addEventListener('click',function(event) {
oldOnClick(event);
},false);
layer.onclick = null;
}
}
在初始通过 FastClick.notNeeded 方法判断是否需要做后续的相关处理:
我们看下这个 FastClick.notNeeded 都做了哪些判断:
// 不支持触摸的设备
if (typeof window.ontouchstart === 'undefined') {
return true;
}
// 获取Chrome版本号,若非Chrome则返回0
chromeVersion = +(/Chrome\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
if (chromeVersion) {
if (deviceIsAndroid) { //安卓
metaViewport = document.querySelector('meta[name=viewport]');
if (metaViewport) {
// 安卓下,带有 user-scalable="no" 的 meta 标签的 chrome 是会自动禁用 300ms 延迟的,所以无需 Fastclick
if (metaViewport.content.indexOf('user-scalable=no') !== -1) {
return true;
}
// 安卓Chrome 32 及以上版本,若带有 width=device-width 的 meta 标签也是无需 FastClick 的
if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) {
return true;
}
}
// 其它的就肯定是桌面级的 Chrome 了,更不需要 FastClick 啦
} else {
return true;
}
}
if (deviceIsBlackBerry10) { //黑莓,和上面安卓同理,就不写注释了
blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/);
if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) {
<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport = document.querySelector('<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>[name=viewport]');
if (<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport) {
if (<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport.content.indexOf('user-scalable=no') !== -1) {
return true;
}
if (document.documentElement.scrollWidth <= window.outerWidth) {
return true;
}
}
}
}
// 带有 -ms-touch-action: none / manipulation 特性的 IE10 会禁用双击放大,也没有 300ms 时延
if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') {
return true;
}
// Firefox检测,同上
firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1];
if (firefoxVersion >= 27) {
<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport = document.querySelector('<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>[name=viewport]');
if (<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport && (<a href="https://www.jb51.cc/tag/Meta/" target="_blank" class="keywords">Meta</a>Viewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) {
return true;
}
}
// IE11 推荐使用没有“-ms-”前缀的 touch-action 样式特性名
if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') {
return true;
}
return false;
};
基本上都是一些能禁用 300ms 时延的浏览器嗅探,它们都没必要使用 Fastclick,所以会返回 true 回构造函数停止下一步执行。
由于安卓手Q的 ua 会被匹配到 /Chrome\/([0-9]+)/,故带有 'user-scalable=no' Meta 标签的安卓手Q页会被 FastClick 视为无需处理页。
这也是为何在安卓手Q里没有开头提及问题的原因。
我们继续看构造函数,它直接给 layer(即body)添加了click、touchstart、touchmove、touchend、touchcancel(若是安卓还有 mouSEOver、mousedown、mouseup)事件监听:
layer.addEventListener('click',false);
注意在这段代码上面还利用了 bind 方法做了处理,这些事件回调中的 this 都会变成 Fastclick 实例上下文。
另外还得留意,onclick 事件以及安卓的额外处理部分都是走的捕获监听。
咱们分别看看这些事件回调分别都做了什么。
1. this.onTouchStart
顺道看下这里的 this.updateScrollParent:
- 检查target是否一个滚动容器里的子元素,如果是则给它加个标记
*/
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);
}
// 给滚动容器加个标志fastClickLastScrollTop,值为其当前垂直滚动偏移
if (scrollParent) {
scrollParent.fastClickLastScrollTop = scrollParent.scrollTop;
}
};
另外要注意的是,在 onTouchStart 里被标记为 true 的 this.trackingClick 属性,都会在其它事件回调(比如 ontouchmove )的开头做检测,如果没被赋值过,则直接忽略:
当然在 ontouchend 事件里会把它重置为 false。
2. this.onTouchMove
这段代码量好少:
看下这里用到的 this.touchHasMoved 原型方法:
3. onTouchEnd
FastClick.prototype.onTouchEnd = function(event) {
var forElement,trackingClickStart,targetTagName,scrollParent,targetElement = this.targetElement;
if (!this.trackingClick) {
return true;
}
// 避免 phantom 的双击(200ms内快速点了两次)触发 click
// 我们在 ontouchstart 里已经做过一次判断了(仅仅禁用默认事件),这里再做一次判断
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
this.cancelNextClick = true; //该属性会在 onMouse 事件中被判断,为true则彻底禁用事件和冒泡
return true;
}
//this.tapTimeout是常量,值为700
//识别是否为长按事件,如果是(大于700ms)则忽略
if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
return true;
}
// 得重置为false,避免input事件被意外取消
// 例子见 https://github.com/ftlabs/fastclick/issues/156
this.cancelNextClick = false;
this.lastClickTime = event.timeStamp; //标记touchend时间,方便下一次的touchstart做双击校验
trackingClickStart = this.trackingClickStart;
//重置 this.trackingClick 和 this.trackingClickStart
this.trackingClick = false;
this.trackingClickStart = 0;
// iOS 6.0-7.版本下有个问题 —— 如果layer处于transition或scroll过程,event所提供的target是不正确的
// 所以咱们得重找 targetElement(这里通过 document.elementFromPoint 接口来寻找)
if (deviceIsIOSWithBadTarget) { //iOS 6.0-7.版本
touch = event.changedTouches[0]; //手指离开前的触点
// 有些情况下 elementFromPoint 里的参数是预期外/不可用的,所以还得避免 targetElement 为 null
targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset,touch.pageY - window.pageYOffset) || targetElement;
// target可能不正确需要重找,但fastClickScrollParent是不会变的
targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
}
targetTagName = targetElement.tagName.toLowerCase();
if (targetTagName === 'label') { //是label则激活其指向的组件
forElement = this.findControl(targetElement);
if (forElement) {
this.focus(targetElement);
//安卓直接返回(无需合成click事件触发,因为点击和激活元素不同,不存在点透)
if (deviceIsAndroid) {
return false;
}
targetElement = forElement;
}
} else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
//手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
//(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
//这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
//另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
//会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
this.focus(targetElement);
this.sendClick(targetElement,event); //立即触发其click事件,而无须等待300ms
//iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
//有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
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;
}
}
// 查看元素是否无需处理的白名单内(比如加了名为“needsclick”的class)
// 不是白名单的则照旧预防穿透处理,立即触发合成的click事件
if (!this.needsClick(targetElement)) {
event.preventDefault();
this.sendClick(targetElement,event);
}
return false;
};
这段比较长,我们主要看这段:
} else if (this.needsFocus(targetElement)) { //非label则识别是否需要focus的元素
//手势停留在组件元素时长超过100ms,则置空this.targetElement并返回
//(而不是通过调用this.focus来触发其聚焦事件,走的原生的click/focus事件触发流程)
//这也是为何文章开头提到的问题中,稍微久按一点(超过100ms)textarea是可以把光标定位在正确的地方的原因
//另外iOS下有个意料之外的bug——如果被点击的元素所在文档是在iframe中的,手动调用其focus的话,
//会发现你往其中输入的text是看不到的(即使value做了更新),so这里也直接返回
if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
this.focus(targetElement);
this.sendClick(targetElement,event); //立即触发其click事件,而无须等待300ms
//iOS4下的 select 元素不能禁用默认事件(要确保它能被穿透),否则不会打开select目录
//有时候 iOS6/7 下(VoiceOver开启的情况下)也会如此
if (!deviceIsIOS || targetTagName !== 'select') {
this.targetElement = null;
event.preventDefault();
}
return false;
}
其中 this.needsFocus 用于判断给定元素是否需要通过合成click事件来模拟聚焦:
另外这段说明了为何稍微久按一点(超过100ms)textarea ,我们是可以把光标定位在正确的地方(会绕过后面调用 this.focus 的方法):
接着咱们看看这两行很重要的代码:
所涉及的两个原型方法分别为:
⑴ this.focus
// 组件建议通过setSelectionRange(selectionStart,selectionEnd)来设定光标范围(注意这样还没有聚焦
// 要等到后面触发 sendClick 事件才会聚焦)
// 另外 iOS7 下有些input元素(比如 date datetime month) 的 selectionStart 和 selectionEnd 特性是没有整型值的,
// 导致会抛出一个关于 setSelectionRange 的模糊<a href="https://www.jb51.cc/tag/cuowu/" target="_blank" class="keywords">错误</a>,它们需要改用 focus 事件触发
if (deviceIsIOS && targetElement.setSelectionRange && targetElement.type.indexOf('date') !== 0 && targetElement.type !== 'time' && targetElement.type !== 'month') {
length = targetElement.value.length;
targetElement.setSelectionRange(length,length);
} else {
//直接触发其focus事件
targetElement.focus();
}
};
注意,我们点击 textarea 时调用了该方法,它通过 targetElement.setSelectionRange(length,length) 决定了光标的位置在内容的尾部(但注意,这时候还没聚焦!!!)。
⑵ this.sendClick
真正让 textarea 聚焦的是这个方法,它合成了一个 click 方法立刻在textarea元素上触发导致聚焦:
// 在一些安卓机器中,得让<a href="https://www.jb51.cc/tag/yemian/" target="_blank" class="keywords">页面</a>所存在的 activeElement(聚焦的元素,比如input)失焦,否则合成的click事件将无效
if (document.activeElement && document.activeElement !== targetElement) {
document.activeElement.blur();
}
touch = event.changedTouches[0];
// 合成(Synthesise) 一个 click 事件
// 通过一个额外<a href="https://www.jb51.cc/tag/shuxing/" target="_blank" class="keywords">属性</a>确保它能被追踪(tracked)
clickEvent = document.createEvent('MouseEvents');
clickEvent.initMouseEvent(this.determineEventType(targetElement),true,window,1,touch.screenX,touch.screenY,touch.clientX,touch.clientY,false,null);
clickEvent.forwardedTouchEvent = true; // fastclick的内部变量,用来识别click事件是原生还是合成的
targetElement.dispatchEvent(clickEvent); //立即触发其click事件
};
FastClick.prototype.determineEventType = function(targetElement) {
//安卓设备下 Select 无法通过合成的 click 事件被展开,得改为 mousedown
if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
return 'mousedown';
}
return 'click';
};
经过这么一折腾,咱们轻点 textarea 后,光标就自然定位到其内容尾部去了。但是这里有个问题——排在 touchend 后的 focus 事件为啥没被触发呢?
如果 focus 事件能被触发的话,那肯定能重新定位光标到正确的位置呀。
咱们看下面这段:
通过 preventDefault 的阻挡,textarea 自然再也无法拥抱其 focus 宝宝了~
于是乎,我们在这里做个改动就能修复这个问题:
if ((!deviceIsIOS || targetTagName !== 'select') && !_isTextInput()) {
this.targetElement = null;
event.preventDefault();
}</pre>
或者:
这里要吐槽下的是,Fastclick 把 this.needsClick 放到了 ontouchEnd 末尾去执行,才导致前面说的加上了“needsclick”类名也无效的问题。
虽然问题原因找到也解决了,但咱们还是继续看剩下的部分吧。
4. onMouse 和 onClick
//销毁Fastclick所注册的监听事件。是给外部实例去调用的
FastClick.prototype.destroy = function() {
var layer = this.layer;
if (deviceIsAndroid) {
layer.removeEventListener('mouSEOver',true);
layer.removeEventListener('mousedown',true);
layer.removeEventListener('mouseup',true);
}
layer.removeEventListener('click',true);
layer.removeEventListener('touchstart',false);
layer.removeEventListener('touchmove',false);
layer.removeEventListener('touchend',false);
layer.removeEventListener('touchcancel',false);
};
常规需要阻断点击事件的操作,我们在 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 虽好,但也有一些坑,还是得按需求对其修改,那么了解其源码还是很有必要的。
原文链接:https://www.f2er.com/js/33432.html