閱讀優秀的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的分析文章,感覺自己還有很遠的路要走~