玩过移动端web开发的同学应该都了解过,移动端上的click事件都会有300毫秒的延迟,这300毫秒主要是浏览器为了判断你当前的点击时单击还是双击,但有时候为了更快的对用户的操作做出更快的响应,越过这个300毫秒的延迟是有点必要的,FastClick做的就是这件事,这篇文章会理清FastClick的整体思路,分析主要的代码,但不会贴出所有的代码,仅分析主干,由于历史原因,FastClick对旧版本的机型做了很多兼容性适配,例如ios4,这部分代码到现在显然已经没有什么分析的意义了,所以贴出的代码会将这部分代码删除。
首先,我们分析一下总体的实现思路,其实FastClick做的事情很简单,首先判断当前浏览器需不需要使用FastClick,例如桌面浏览器,那就不需要,直接绕过,接着,如果需要,则在click事件中拦截事件,取消所有绑定事件的操作,接着用一系列touch事件(touchstart,touchmove,touchend)来模拟click事件,由于touch事件不会延迟,从而达到绕过300毫秒延迟的效果。
先看看FastClick是如何判断浏览器是否需要FastClick的
FastClick.notNeeded = function(layer) { var metaViewport; var chromeVersion; var blackberryVersion; var firefoxVersion; // Devices that don't support touch don't need FastClick //不支持用于模拟的touchstart事件,无法模拟 if (typeof window.ontouchstart === 'undefined') { return true; } // 探测chome浏览器 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; } //chome32以上带有 width=device-width的meta标签的也唔需要使用FastClick if (chromeVersion > 31 && document.documentElement.scrollWidth <= window.outerWidth) { return true; } } // 桌面设备自然无需使用 } else { return true; } } //黑莓浏览器,这个。。。了解就好 if (deviceIsBlackBerry10) { //检测黑莓浏览器 blackberryVersion = navigator.userAgent.match(/Version\/([0-9]*)\.([0-9]*)/); // 黑莓10.3以上部分可以不适用FastClick if (blackberryVersion[1] >= 10 && blackberryVersion[2] >= 3) { metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport) { // 跟chome一样 if (metaViewport.content.indexOf('user-scalable=no') !== -1) { return true; } // 跟chome一样 if (document.documentElement.scrollWidth <= window.outerWidth) { return true; } } } } //ie10带有msTouchAction,touchAction相关样式的不需要FastClick if (layer.style.msTouchAction === 'none' || layer.style.touchAction === 'manipulation') { return true; } //firefox,跟chome差不多 firefoxVersion = +(/Firefox\/([0-9]+)/.exec(navigator.userAgent) || [,0])[1]; if (firefoxVersion >= 27) { // Firefox 27+ does not have tap delay if the content is not zoomable - https://bugzilla.mozilla.org/show_bug.cgi?id=922896 metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport && (metaViewport.content.indexOf('user-scalable=no') !== -1 || document.documentElement.scrollWidth <= window.outerWidth)) { return true; } } //ie11检测,跟ie10一样,只是ie11废弃了msTouchAction,改为touchAction,依旧是检测样式,检测到相关样式不用FastClick if (layer.style.touchAction === 'none' || layer.style.touchAction === 'manipulation') { return true; } //黑名单之外放行,都使用FastClick return false; };
长长的一大段,基本上采用黑名单策略,分别检测了chome,黑莓,firefox,ie10,ie11,基本上都是检测对应的meta标签,检测到对应的值的话,弃用FastClick,黑名单之外启用FastClick,仅仅是一个检测函数,看看就好,没什么研究的价值
主体流程,看看FastClick的构造函数,此处仅贴出主要代码,删除了一些兼容的代码
function FastClick(layer, options) { //不需要fastClick时直接返回 if (FastClick.notNeeded(layer)) { return; } //简单的兼容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); } //捕获阶段做拦截事件处理 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); //处理通过标签属性绑定事件的方式,转化为通过addEventListener绑定事件,确保fastclick的各种兼容能顺利执行 if (typeof layer.onclick === 'function') { oldOnClick = layer.onclick; layer.addEventListener('click', function(event) { oldOnClick(event); }, false); layer.onclick = null; } }
FastClick会在执行FastClick.attach操作时被实例化,从代码我们可以看到,做了几件事,检测是否需要使用FastClick,之后注册了一些列的内部方法(onmouse,onclik,ontouchstart等等)并绑定当前作用域,捕获阶段处理onclick事件,冒泡阶段处理touch相关事件并定义相关的内部处理函数,最后对于用标签绑定事件的方式修改为用addEventListener的方式绑定。至于为什么为什么要在捕获阶段处理onclick,我们都知道,现代浏览器对于事件的处理都是先发生捕获,之后再发生冒泡,而为了兼容旧版本浏览器,默认的做法都是将事件绑定在冒泡阶段,在冒泡阶段处理click事件,我们就可以拦截到click事件,并把后续的click绑定操作全都取消掉。
所以,我们大概可以看到,FastClick里面最主要的几个主要方法:onMouse,onClick,onTouchStart,onTouchMoce,onTouchEnd,onTouchMove,onTouchCancel,接下来我们将会逐个分析这些方法
首先,onClick方法
FastClick.prototype.onClick = function(event) { var permitted; // 标记未被取消,直接取消 if (this.trackingClick) { this.targetElement = null; this.trackingClick = false; return true; } //submit控件不做处理 if (event.target.type === 'submit' && event.detail === 0) { return true; } permitted = this.onMouse(event); if (!permitted) { this.targetElement = null; } return permitted; };
此处有必要解释一下trackingClick和targetElement这两个标记,trackingClick是一个追踪标志,用touch事件模拟时,正常情况下,开始时(touchstart)会被设置为true,模拟结束(touchend)会被设置为false,而click事件会在touchend事件中被模拟发出,这个后面分析代码的时候我们会看到,很明显,这个时候trackingClick如果检测到为true,是一种不正常的现象,这里FastClick的作者解释为you可能使用了类似的第三方库,导致click事件比FastClick更快的发出,所以此处就不再对结果进行处理,并将内部变量重现修改为默认状态。接着,我们看到,onclick方法其实在内部调用了onmouse方法,事实上主要的操作也都是在onmouse里面执行的,接下来我们看看onMouse
FastClick.prototype.onMouse = function(event) { //当前target缺失,有可能模拟触发已经被取消,没有必要阻止 ,直接触发原生事件 if (!this.targetElement) { return true; } //模拟事件标识符 if (event.forwardedTouchEvent) { return true; } // 事件无法阻止 if (!event.cancelable) { return true; } //需要fastclick是阻止所有事件触发,快速点击时亦如此 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; } // 阻止冒泡,阻止默认操作 event.stopPropagation(); event.preventDefault(); return false; } // If the mouse event is permitted, return true for the action to go through. return true; };
首先,进入onMouse之后,会通过函数needClick判断当前点击的控件是否需要原生点击的支持,避免出现一些bug,然后判断this.cancelNextClick是否为true,cancelNextClick是用于判断当前操作是否要取消的一个标识符,当两次点击的间隔小于配置的值时,cancelNextClick会被设置为true,这个操作在touchend中进行,稍后会进行分析。当条件满足时,执行阻止事件的操作,具体是执行event.stopImmediatePropagation方法,他能阻止此操作之后绑定在这个节点上的所有其他操作,对于不支持的浏览器,会在event中添加一个propagationStopped的属性,用于兼容操作,这个兼容操作后面再说,接着就是各种阻止冒泡,阻止默认操作,至此,整个阻止操作就完成了,接下来就是如何不延迟300毫秒来触发click事件了,上面说了,用touch事件进行模拟,具体如何,往下走
首先,onTouchStart
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]; //记录跟踪状态 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; };
onTouchStart做的事情其实比较少,上面的代码去掉了一些兼容性操作,剩下的只是记录一些基础性的信息,唯一做的事情就是阻止了双击事件的默认操作,如何判断是双击的,event.timeStamp记录了当前点击的时间戳,this.lastClickTime为上一次onTouchEnd时记录的值,记录最后一次点击完成的时间,两者相减小于配置值,则认为是双击,FastClick默认配置的this.tapDelay为200毫秒
接着是onTouchMove
FastClick.prototype.onTouchMove = function(event) { //没有触发过touchstart事件,直接返回 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; };
操作也是比较简单,trackingClick是一个跟踪字段,在onTouchStart中设置为true,如此处发现不为true,则发生了错误,直接会返回,接着就是判断当前是否有移动,主要就是获取当前手指的位置跟触发控件的位置进行比较,具体方法由于篇幅关系就不解释了,本篇博文仅解释主干内容,当触摸点移动了,则将trackingClcik和targetElement恢复为默认,之后在touchEnd中就不会发出模拟事件触发click
接着对于特殊原因取消的情况,绑定了touchcancel事件
FastClick.prototype.onTouchCancel = function() { this.trackingClick = false; this.targetElement = null; };
这个并没有什么特别的地方,特殊情况发生了,如手指戳下的时候突然来电话了各种情况导致触摸中断,则将所有跟踪变量恢复到初始状态。
最关键的onTouchEnd
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; targetTagName = targetElement.tagName.toLowerCase(); //处理组件为label时的状况,获取label对应绑定的控件 if (targetTagName === 'label') { forElement = this.findControl(targetElement); if (forElement) { this.focus(targetElement); if (deviceIsAndroid) { return false; } targetElement = forElement; } } else if (this.needsFocus(targetElement)) { //第一个判断作者认为如果按下的时间超过了100毫秒,此时已经没有必要再执行模拟操作了,按原生的click执行操作即可,第二个判断则是处理ios相关的一个bug if ((event.timeStamp - trackingClickStart) > 100 || (deviceIsIOS && window.top !== window && targetTagName === 'input')) { this.targetElement = null; return false; } this.focus(targetElement); this.sendClick(targetElement, event); return false; } //不需要原生点击时,触发模拟click事件 if (!this.needsClick(targetElement)) { event.preventDefault(); this.sendClick(targetElement, event); } return false; };
此处,ontouchEnd,首先忽略快速点击和长按,然后恢复所有的初始化变量,之后会判断当前控件是不是label,是的话利用findControl函数找到label关联的组件,并赋值给当前的targetElement 统一处理,具体杂七杂八的函数会在后面再解释,接着会判断当前组件触发click时需不需要获取焦点,如果需要,则获取焦点后,触发模拟事件,此处关注两个函数focus和sendClick,focus函数帮助当前target获取焦点,sendClick则发送模拟事件,focus函数关键代码如下
/** * 兼容写法,获取焦点,光标放置到末尾 */ 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(); } };
此处,对于ios浏览器,采用兼容的写法,用setSelectionRange来获取焦点,setSelectionRange可以用来选取输入框的值,此处将选取的开始和结束都设置为value的length,则可以把光标放到组件的末尾并且获得焦点
接下来是sendClick,这也是整个fastclick的关键,用于模拟事件的发生,主要实现如下:
FastClick.prototype.sendClick = function(targetElement, event) { var clickEvent, touch; //兼容操作,部分安卓机当前焦点所在的节点如果不是模拟节点,需要把焦点去除,否则影响效果 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); };
至此,我们的所有主流程已经讲完了,接下来我们说一下里面涉及到的一些杂七杂八的函数
首先,如何兼容event.stopImmediatePropagation,上面我们说了,这个函数可以解除当前绑定操作之后的所有绑定到此节点上的操作,但存在部分浏览器不兼容,对于一些不兼容的浏览器,上面说到绑定事件fastclick会手动给event对象添加一个propagationStopped属性,那这个属性有什么用呢,我们看看下面的代码
layer.addEventListener = function(type, callback, capture) { var adv = Node.prototype.addEventListener; if (type === 'click') { adv.call(layer, type, callback.hijacked || (callback.hijacked = function(event) { //通过对event对象添加属性来控制事件的触发 if (!event.propagationStopped) { callback(event); } }), capture); } else { adv.call(layer, type, callback, capture); } };
这段函数出现在fastclick的构造函数中,为了主干代码的清晰,在上面我把它删掉了,对于不兼容event.stopImmediatePropagation的浏览器,它重写了addEventListener方法,增加了对stopImmediatePropagation属性的判断,这样当上面的propagationStopped被设置为true的时候,后续的绑定操作就都不会继续进行了。
接下来一个方法是获取label关联控件的方法,findControl
FastClick.prototype.findControl = function(labelElement) { //通过control属性获取 if (labelElement.control !== undefined) { return labelElement.control; } //通过获取for属性 if (labelElement.htmlFor) { return document.getElementById(labelElement.htmlFor); } //如各种不兼容,则获取label标签中的第一个 return labelElement.querySelector('button, input:not([type=hidden]), keygen, meter, output, progress, select, textarea'); };
首先,findControl会通过html5的control属性来获取label包含的表单元素,如果失败,转而获取label的for属性对应的表单元素,因为for属性也是html5的,旧浏览器可能不兼容,最后如果获取不了,则会获取label元素的子元素中的第一个表单元素,进而来获取label对应的表单元素。
嗯,啰啰嗦嗦大概说完了,如有说错的地方,欢迎评论区指出