阅读优秀的js插件和库源码,可以加深我们对web开发的理解和提高js能力,本人能力有限,只能粗略读懂一些小型插件,这里带来对fastclick源码的解读,望各位大神不吝指教~!
fastclick诞生背景与使用
在解读源码前,还是简单介绍下fastclick:
诞生背景
我们都知道,在移动端页面开发上,会出现一个问题,click事件会有300ms的延迟,这让用户感觉很不爽,感觉像是网页卡顿了一样,实际上,这是浏览器为了更好的判断用户的双击行为,移动浏览器都支持双击缩放或双击滚动的操作,比如一个链接,当用户第一次点击后,浏览器不能立刻判断用户确实要打开这个链接,还是想要进行双击的操作,因此几乎现在所有浏览器都效仿Safari当年的约定,在点击事件上加了300毫秒的延迟。
就因为这300ms的延迟,催生了fastclick的诞生~
使用方法
1.引入fastclick到自己的开发环境(源码第829~840行,后面都采用简写了哈,如:829~840)
//优先兼容AMD方式
if (typeof define === 'function' && typeof define.amd === 'object' && define.amd) {
define(function() {
return FastClick;
});
} else if (typeof module !== 'undefined' && module.exports) {
//兼容commonJs风格
module.exports = FastClick.attach;
module.exports.FastClick = FastClick;
} else {
//最后兼容原生Js
window.FastClick = FastClick;
}
fastclick的引入兼容AMD、commonJs风格、原生Js的方式,在本人的大半年开发过程中,只接触过commonJs的风格,这里就不多做介绍了,根据自己项目技术栈选择吧~
2.入口函数(824~826)
//layer参数:要监听的dom对象,一般是document.body
//options参数:用来覆盖自定义参数,个人建议不去覆盖,
//因为里面的参数设定都是FastClick的精华,不要着急,参数在后面会详细介绍
FastClick.attach = function(layer, options) {
return new FastClick(layer, options);
};
我们如果要使用fastclick的话,只需要在自己的js上写上FastClick.attach(document.body),这样就可以了,没错,就是这么简单!
fastclick源码解读
判断是否需要调用FastClick(105~107)
fastclick在某些情况下是不需要的,当然fastclick的开发者早已经替我们想到了,在官网上有详细的解释,如果你想详细了解,请点击这里。
//所有在不需要FastClick的浏览器会直接return掉,不会执行fastclick.js后面的代码。
if (FastClick.notNeeded(layer)) {
return;
}
参数解读(23~103)
上面提到了入口函数中的options参数,这里不得不赞一下fastclick的源码,对每个参数都做出了详细的解释(虽然都是英文,但很容易懂),这里介绍几个我认为比较精华的参数,如下代码:
//比如这几个参数,上面提到不建议自定义覆盖,
//这些参数正是FastClick的精华所在,
//大幅度修改数值可能让整个库的功效大打折扣。
this.touchBoundary = options.touchBoundary || 10;
this.tapDelay = options.tapDelay || 200;
this.tapTimeout = options.tapTimeout || 700;
touchBoundary: 这个参数是用于判断用户触摸屏幕后,移动的距离,如果大于10px,那么就不被看做是一次点击事件(具体实现后面介绍,下面的参数也同样会解释)。
tapDelay: 这个参数规定了touchstart和touchend事件之间的200毫秒最小间隔,如果在这段时间内,发生了第二次点击将会被阻止。
tapTimeout: 这个参数规定了一次tap事件(源码解释为tap事件)最长的事件,即touchstart和touchend事件之间的700毫秒最大间隔,超过这个时间,将不会被视作tap事件。
当然还有很多参数,因为篇幅的关系,这里就不一一解释了,也不贴出源码,如果你想了解更多,请下载并阅读源码23~103行,每个参数都有详细的解释,只要学过高中英语都能读得懂- -(我四级没过都能读得懂。。。)
主干部分解读(23~174)
function FastClick(layer, options) {
var oldOnClick;
options = options || {};
//这里本来是定义了一些参数的,但我在之前讲过了,这里的代码被我删掉了
//如果是属于不需要处理的元素类型,则直接返回,notNeeded方法已在上方提到
if (FastClick.notNeeded(layer)) {
return;
}
//语法糖,兼容一些用不了 Function.prototype.bind 的旧安卓
//所以后面不走 layer.addEventListener('click', this.onClick.bind(this), true);而是调用的这里的bind方法
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);//调用上面定义的bind()方法
}
//绑定事件,安卓需要做额外处理
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);
// 兼容不支持 stopImmediatePropagation 的浏览器
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') {
//留意这里 callback.hijacked 中会判断 event.propagationStopped 是否为真来确保(安卓的onMouse事件)只执行一次
//在 onMouse 事件里会给 event.propagationStopped 赋值 true
adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) {
if (!event.propagationStopped) {
callback(event);
}
}), capture);
} else {
adv.call(layer, type, callback, capture);
}
};
}
// 如果layer直接在DOM上写了 onclick 方法,那我们需要把它替换为 addEventListener 绑定形式
if (typeof layer.onclick === 'function') {
oldOnClick = layer.onclick;
layer.addEventListener('click', function(event) {
oldOnClick(event);
}, false);
layer.onclick = null;
}
}
在fastclick的主干部分,主要做了这么几件事情:
1.定义一些参数,在后面的代码中会用到,作用已在前面提过。
2.判断是否需要使用fastclick。
3.绑定了事件:注意,这里绑定的都是fastclick中定义的事件,并不是原生事件,因为使用bind()方法做了处理,事件回调中的this都是fastclick实例上下文。
4.兼容不支持 stopImmediatePropagation 的浏览器。
5.将dom上写的onclick方法替换为addEventListener绑定形式
核心部分解读(包括核心部分涉及到的方法)
下面代码中的注释是我自己的理解,如有不对的地方请各位阅读者指出~~
1.onTouchStart(391-450):
FastClick.prototype.onTouchStart = function(event) {
var targetElement, touch, selection;
// 如果是多点触摸,将被忽略,直接返回true,不会执行后面代码
if (event.targetTouches.length > 1) {
return true;
}
//获得触摸对象,这个getTargetElementFromEventTarget方法将稍后讲解
targetElement = this.getTargetElementFromEventTarget(event.target);
touch = event.targetTouches[0];
//判断系统是否为ios
if (deviceIsIOS) {
// 在ios中,受信任的事件将会被取消,返回true。相关知识:如果一个事件是由设备本身(如浏览器)触发的,而不是通过JavaScript模拟合成的,那个这个事件被称为可信任的(trusted)
//获得激活选中区
selection = window.getSelection();
//判断是否有range被选中&&选中“起点”和“结束点”是否重合,这一部分我猜测应该是ios自带的复制文字效果,为了防止用户意图复制文字时触发tap事件。
if (selection.rangeCount && !selection.isCollapsed) {
return true;
}
//这一部分应该是对ios4中的bug进行处理吧,不过现在也没什么人用ios4这种古董系统,所以注释我就不翻译了,有兴趣自己去了解吧~
if (!deviceIsIOS4) {
// Weird things happen on iOS when an alert or confirm dialog is opened from a click event callback (issue #23):
// when the user next taps anywhere else on the page, new touchstart and touchend events are dispatched
// with the same identifier as the touch event that previously triggered the click that triggered the alert.
// Sadly, there is an issue on iOS 4 that causes some normal touch events to have the same identifier as an
// immediately preceeding touch event (issue #52), so this fix is unavailable on that platform.
// Issue 120: touch.identifier is 0 when Chrome dev tools 'Emulate touch events' is set with an iOS device UA string,
// which causes all touch events to be ignored. As this block only applies to iOS, and iOS identifiers are always long,
// random integers, it's safe to to continue if the identifier is 0 here.
if (touch.identifier && touch.identifier === this.lastTouchIdentifier) {
event.preventDefault();
return false;
}
this.lastTouchIdentifier = touch.identifier;
// If the target element is a child of a scrollable layer (using -webkit-overflow-scrolling: touch) and:
// 1) the user does a fling scroll on the scrollable layer
// 2) the user stops the fling scroll with another tap
// then the event.target of the last 'touchend' event will be the element that was under the user's finger
// when the fling scroll was started, causing FastClick to send a click event to that layer - unless a check
// is made to ensure that a parent layer was not scrolled before sending a synthetic click (issue #42).
this.updateScrollParent(targetElement);
}
}
//记录click已经发生,这也是一个参数哟!
this.trackingClick = true;
//记录click发生的时间戳,参数一员
this.trackingClickStart = event.timeStamp;
//记录click的目标对象,参数一员
this.targetElement = targetElement;
//这里不解释,你们懂得
this.touchStartX = touch.pageX;
this.touchStartY = touch.pageY;
//防止200ms内的多次点击,tapDelay这个参数在上面提到过
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
event.preventDefault();
}
return true;
};
FastClick.prototype.getTargetElementFromEventTarget = function(eventTarget) {
// 在一些旧的浏览器(尤其是Safari浏览器在iOS4.1)事件目标可能是一个文本节点。那么这个时候返回它的父节点。(⊙o⊙)…涨知识,不过目前来看可能这种情况很少了。
if (eventTarget.nodeType === Node.TEXT_NODE) {
return eventTarget.parentNode;
}
return eventTarget;
};
onTouchStart这个单词,很容易让我们知道fastclick中的tap仍然是通过touch事件进行模拟的,在touchStart时,fastclick主要做了这么几件事:
1.忽略了多点触摸的情况
2.解决了一些兼容性问题(ios4 和 ios复制文字效果)
3.追踪click事件,获得click对象,记录了发生click事件时的时间戳
4.防止200ms内的多次点击
这里其实有点乱,因为其实是touch事件,但是为什么记作click事件呢(有的时候又说是tap事件),我们可以这样理解:本质上发生是touch事件,而fastclick要根据touch事件模拟click(tap)事件,这有一些条件,当该次触摸事件符合条件时,便可以认为是一次click事件,tap事件就是相对于pc端的click事件,所以移动端tap事件==pc端click事件。恩,因为源码中用到了trackingClickStart和一些带click的参数,所以你们懂的。tap事件本身是不存在的,是一种合成事件。
2.onTouchMove(476~488)
FastClick.prototype.onTouchMove = function(event) {
if (!this.trackingClick) {
return true;
}
// 如果touchMove超过了规定距离(10px),那么取消追踪这次touch事件,不会被模拟为tap,可以理解为:用户手指在滑动屏幕。。
if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) {
this.trackingClick = false;
this.targetElement = null;
}
return true;
};
FastClick.prototype.touchHasMoved = function(event) {
var touch = event.changedTouches[0], boundary = this.touchBoundary;
//这里就是判断touchMove移动的距离(x轴和y轴)是否超过boundary(10px),超过返回true
if (Math.abs(touch.pageX - this.touchStartX) > boundary || Math.abs(touch.pageY - this.touchStartY) > boundary) {
return true;
}
return false;
};
onTouchMove很明显就是在触摸过程中手指发生位移触发的事件,fastclick在这里主要做了两件事:
1.首先判断是否有符合条件的tranckingClick,tranck意思是追踪,就是在onTouchStart阶段提供的判断条件,条件通过那么该次touch事件将被追踪,记作tranckingClick。
2.如果touchMove超过了规定距离(x轴或y轴10px),那么取消追踪这次touch事件,不会被模拟为tap,可以理解为:用户手指在滑动屏幕。。
3.onTouchEnd(521~610)
FastClick.prototype.onTouchEnd = function(event) {
var forElement, trackingClickStart, targetTagName, scrollParent, touch, targetElement = this.targetElement;
//不多说了,你们懂的
if (!this.trackingClick) {
return true;
}
// 还是为了防止多次点击,不过这里多了一个参数cancleNextClick,该属性会在onMouse事件中被判断,为true则彻底禁用事件和冒泡
if ((event.timeStamp - this.lastClickTime) < this.tapDelay) {
this.cancelNextClick = true;
return true;
}
//识别长按事件,tapTimeOut默认为700ms
if ((event.timeStamp - this.trackingClickStart) > this.tapTimeout) {
return true;
}
// 重置为false避免input事件意外取消
this.cancelNextClick = false;
//标记touchEnd时间戳,方便下一次touchStart判定双击
this.lastClickTime = event.timeStamp;
trackingClickStart = this.trackingClickStart;
//重置这两个参数
this.trackingClick = false;
this.trackingClickStart = 0;
//这里又修复了一个ios的bug,啪啪啪一大串英文实在读不懂,解决的是ios6的bug,没兴趣详细了解。。
if (deviceIsIOSWithBadTarget) {
touch = event.changedTouches[0];
// In certain cases arguments of elementFromPoint can be negative, so prevent setting targetElement to null
targetElement = document.elementFromPoint(touch.pageX - window.pageXOffset, touch.pageY - window.pageYOffset) || targetElement;
targetElement.fastClickScrollParent = this.targetElement.fastClickScrollParent;
}
targetTagName = targetElement.tagName.toLowerCase();
if (targetTagName === 'label') {//是lable的话激活其指向的组件
//findControl这个方法将在后面介绍,大概就是点击label的时候,找到他指向的元素,并获取焦点。
forElement = this.findControl(targetElement);
//如果找到了对应的元素
if (forElement) {
this.focus(targetElement);
if (deviceIsAndroid) {//安卓直接返回
return false;
}
targetElement = forElement;
}
} else if (this.needsFocus(targetElement)) {//needsFocus方法我将稍后说明,用于判断目标元素是否需要获得焦点
//触摸在元素上的事件超过100ms,则置空targetElement并返回false,也就是去走原生的focus方法去了,至于为什么这么做,目前还不是太明白
// 后面这里又解决了ios5、6上的两个兼容性bug,(⊙o⊙)…不多做研究了,因为这个情况已经太少了。
if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) {
this.targetElement = null;
return false;
}
//获得焦点(这里不是原生的)
this.focus(targetElement);
//sendClick是重点,将在后面讲解,我们需要知道的是这里将立即触发,并没有300ms延迟
this.sendClick(targetElement, event);
// 这个地方是为了防止ios4、6、7上面select展开的问题
if (!deviceIsIOS || targetTagName !== 'select') {
this.targetElement = null;
event.preventDefault();
}
return false;
}
if (deviceIsIOS && !deviceIsIOS4) {
//又是ios的hack代码,貌似是解决滚动区域的点击问题
scrollParent = targetElement.fastClickScrollParent;
if (scrollParent && scrollParent.fastClickLastScrollTop !== scrollParent.scrollTop) {
return true;
}
}
//确定目标元素是否需要原生click,方法后面会介绍
if (!this.needsClick(targetElement)) {
//如果这不是一个需要使用原生click的元素,则屏蔽原生事件,避免触发两次click
event.preventDefault();
//触发一次模拟的click事件
this.sendClick(targetElement, event);
}
return false;
};
FastClick.prototype.findControl = function(labelElement) {
// 支持html5 control属性的话,返回其指向的元素
if (labelElement.control !== undefined) {
return labelElement.control;
}
// 支持html5 htmlFor属性的话,返回其指向的元素
if (labelElement.htmlFor) {
return document.getElementById(labelElement.htmlFor);
}
// 如果以上属性都不支持,尝试返回lable的后代元素
return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea');
};
//判断是否需要获得焦点
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://目标元素如果有'needsfocus'的类,那么返回true
return (/\bneedsfocus\b/).test(target.className);
}
};
//一看名字就知道是判断是否需要原生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':
//hack代码,ios6浏览器的bug,input[type='file']需要原生click事件
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);
};
onTouchEnd这个方法的代码量比较多一些,因为解决了很多稀奇古怪的兼容性问题,写一个好的js插件还真是不容易,就解决个点击事件300ms延迟问题,hack代码我并没有非常认真的研究到底,也看的晕乎乎的。好了废话不多说,这一部分主要是做了这么几件事情:
1.首先判断这次touch事件是否还是处于追踪状态,如果不是,那么什么都不做了。
2.防止多次点击问题
3.如果是长按事件不予理会
4.如果目标元素是lable,那么找到其指向的元素并获取焦点,如果不是,那么判断元素是否需要获取焦点,最后确认目标是否需要原生click事件,如果不需要那么屏蔽掉原生click事件,并触发一次模拟的click事件(tap事件)。
5.解决了一大推兼容性问题。
4.sendClick
//合成一个click事件并在指定元素上触发
FastClick.prototype.sendClick = function(targetElement, event) {
var clickEvent, touch;
// 在一些安卓机器中,得让页面所存在的 activeElement(聚焦的元素,比如input)失焦,否则合成的click事件将无效
if (document.activeElement && document.activeElement !== targetElement) {
document.activeElement.blur();
}
touch = event.changedTouches[0];
// 合成(自定义事件) 一个 click 事件
// 通过一个额外属性确保它能被追踪(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; // fastclick的内部变量,用来识别click事件是原生还是合成的
targetElement.dispatchEvent(clickEvent); //立即触发其click事件
};
FastClick.prototype.determineEventType = function(targetElement) {
//安卓设备下 Select 无法通过合成的 click 事件被展开,得改为 mousedown
if (deviceIsAndroid && targetElement.tagName.toLowerCase() === 'select') {
return 'mousedown';
}
return 'click';
};
终于走到这一步,这里合成了一个click事件,并且合成的click事件立即触发,是没有300ms的延迟的~~~
5.onMouse 和 onClick(630~704)
//用于决定是否采用原生click事件
FastClick.prototype.onMouse = function(event) {
// touch事件一直没触发
if (!this.targetElement) {
return true;
}
if (event.forwardedTouchEvent) { //触发的click事件是合成的
return true;
}
// 确保其没执行过 preventDefault 方法(event.cancelable 不为 true)即可
if (!event.cancelable) {
return true;
}
// 需要做预防穿透处理的元素,或者做了快速(200ms)双击的情况
if (!this.needsClick(this.targetElement) || this.cancelNextClick) {
//停止当前默认事件和冒泡
if (event.stopImmediatePropagation) {
event.stopImmediatePropagation();
} else {
// 不支持 stopImmediatePropagation 的设备做标记,
// 确保该事件回调不会执行
event.propagationStopped = true;
}
// 取消事件和冒泡
event.stopPropagation();
event.preventDefault();
return false;
}
return true;
};
//click事件常规都是touch事件衍生来的,也排在touch后面触发。
//对于那些我们在touch事件过程没有禁用掉默认事件的event来说,我们还需要在click的捕获阶段进一步
//做判断决定是否要禁掉点击事件
FastClick.prototype.onClick = function(event) {
var permitted;
// 如果还有 trackingClick 存在,可能是某些UI事件阻塞了touchEnd 的执行
if (this.trackingClick) {
this.targetElement = null;
this.trackingClick = false;
return true;
}
// 依旧是对 iOS 怪异行为的处理 —— 如果用户点击了iOS模拟器里某个表单中的一个submit元素
// 或者点击了弹出来的键盘里的“Go”按钮,会触发一个“伪”click事件(target是一个submit-type的input元素)
if (event.target.type === 'submit' && event.detail === 0) {
return true;
}
permitted = this.onMouse(event);
if (!permitted) { //如果点击是被允许的,将this.targetElement置空可以确保onMouse事件里不会阻止默认事件
this.targetElement = null;
}
//没有什么意义返回这个
return permitted;
};
常规需要阻断点击事件的操作,在touch 监听事件回调中已经做了处理,这里主要是针对那些 touch 过程(有些设备甚至可能并没有touch事件触发)没有禁用默认事件的 event 做进一步处理,从而决定是否触发原生的 click 事件(如果禁止是在 onMouse 方法里做的处理)。
总结
新知识get:
stopImmediatePropagation与stopPropagation区别:
-
-
他们都可以阻止事件冒泡到父元素
-
stopImmediatePropagation多做了一件事:比如某个元素绑定多个相同类型事件监听函数,如果执行了stopImmediatePropagation,将按照顺序执行第一个事件监听函数,其余相同类型事件监听函数被阻止。
-
zepto“点透”现象被解决是为什么?
这一点因为我还没有去阅读zepto的源码,所以暂时不能解答。。等待之后再去挖掘。
第一次阅读源码,感觉很困难,很多东西都不知道,去github上面找问题,但英语太渣,有些看不懂,连蒙带猜加翻译,最终还是求助于百度和谷歌,看到了很多大神的对fastclick的分析文章,感觉自己还有很远的路要走~