最近產品妹子提出了一個體驗issue —— 用 iOS 在手Q閱讀書友交流區發表書評時,游標點選總是不好定位到正確的位置:
如上圖,具體表現是較快點選時,游標總會跳到 textarea 內容的尾部。只有當點選停留時間較久一點(比如超過150ms)才能把游標正常定位到正確的位置。
一開始我以為是 iOS 原生的互動問題沒太在意,但後來發現訪問某些頁面又是沒有這種奇怪體驗的。
然後懷疑是否 JS 註冊了某些事件導致的問題,於是試著把業務模組移除了再跑一遍,發現問題照舊。
於是只好繼續做排除法,把頁面上的一些庫一點點移掉再執行頁面,結果發現搗亂的小鬼果然是嫌疑最大的 Fastclick。
然後呢,我試著按API所說,給 textarea 加上一個名為“needsclick”的類名,希望能繞過 fastclick 的處理直接走原生點選事件,結果訝異地發現屁用沒有。。。
對此感謝後面我們小組的 kindeng 童鞋幫忙研究了下並提供瞭解決方案,不過我還想進一步研究到底是什麼原因導致了這個坑、Fastclick 對我的頁面做了神馬~
所以昨晚花了點時間一口氣把原始碼都蹂躪了一遍。
這會是一篇很長的文章,但會是註釋非常詳盡的剖析文。
文章帶分析的原始碼我也掛在我的 github 倉庫上了,有興趣的童鞋可以去下載來看。
閒話不多說,我們們開始深入 FastClick 原始碼陣營。
我們知道,註冊一個 FastClick 事件非常簡單,它是這樣的:
1 2 3 4 5 |
if ('addEventListener' in document) { document.addEventListener('DOMContentLoaded', function() { var fc = FastClick.attach(document.body); //生成例項 }, false); } |
所以我們從這裡著手,開啟原始碼看下 FastClick .attach 方法:
1 2 3 |
FastClick.attach = function(layer, options) { return new FastClick(layer, options); }; |
這裡返回了一個 FastClick 例項,所以我們們拉到前面看看 FastClick 建構函式:
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 |
function FastClick(layer, options) { var oldOnClick; options = options || {}; //定義了一些引數... //如果是屬於不需要處理的元素型別,則直接返回 if (FastClick.notNeeded(layer)) { return; } //語法糖,相容一些用不了 Function.prototype.bind 的舊安卓 //所以後面不走 layer.addEventListener('click', this.onClick.bind(this), true); 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 ) { 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); // 相容不支援 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, 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.notNeeded 方法判斷是否需要做後續的相關處理:
1 2 3 4 |
//如果是屬於不需要處理的元素型別,則直接返回 if (FastClick.notNeeded(layer)) { return; } |
我們看下這個 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 68 69 70 71 72 73 74 75 76 77 78 |
//是否沒必要使用到 Fastclick 的檢測 FastClick.notNeeded = function(layer) { var metaViewport; var chromeVersion; var blackberryVersion; var firefoxVersion; // 不支援觸控的裝置 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) { 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; } } } } // 帶有 -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) { metaViewport = document.querySelector('meta[name=viewport]'); if (metaViewport & (metaViewport.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)事件監聽:
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); |
注意在這段程式碼上面還利用了 bind 方法做了處理,這些事件回撥中的 this 都會變成 Fastclick 例項上下文。
另外還得留意,onclick 事件以及安卓的額外處理部分都是走的捕獲監聽。
我們們分別看看這些事件回撥分別都做了什麼。
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 49 50 51 52 53 54 55 56 57 58 59 60 |
FastClick.prototype.onTouchStart = function(event) { var targetElement, touch, selection; // 多指觸控的手勢則忽略 if (event.targetTouches.length > 1) { return true; } targetElement = this.getTargetElementFromEventTarget(event.target); //一些較老的瀏覽器,target 可能會是一個文字節點,得返回其DOM節點 touch = event.targetTouches[0]; if (deviceIsIOS) { //IOS處理 // 若使用者已經選中了一些內容(比如選中了一段文字打算複製),則忽略 selection = window.getSelection(); if (selection.rangeCount & !selection.isCollapsed) { return true; } if (!deviceIsIOS4) { //是否IOS4 //怪異特性處理——若click事件回撥開啟了一個alert/confirm,使用者下一次tap頁面的其它地方時,新的touchstart和touchend //事件會擁有同一個touch.identifier(新的 touch event 會跟上一次觸發alert點選的 touch event 一樣), //為避免將新的event當作之前的event導致問題,這裡需要禁用事件 //另外chrome的開發工具啟用'Emulate touch events'後,iOS UA下的 identifier 會變成0,所以要做容錯避免除錯過程也被禁用事件了 if (touch.identifier & touch.identifier === this.lastTouchIdentifier) { event.preventDefault(); return false; } this.lastTouchIdentifier = touch.identifier; // 如果target是一個滾動容器裡的一個子元素(使用了 -webkit-overflow-scrolling: touch) ,而且滿足: // 1) 使用者非常快速地滾動外層滾動容器 // 2) 使用者通過tap停止住了這個快速滾動 // 這時候最後的'touchend'的event.target會變成使用者最終手指下的那個元素 // 所以當快速滾動開始的時候,需要做檢查target是否滾動容器的子元素,如果是,做個標記 // 在touchend時檢查這個標記的值(滾動容器的scrolltop)是否改變了,如果是則說明頁面在滾動中,需要取消fastclick處理 this.updateScrollParent(targetElement); } } this.trackingClick = true; //做個標誌表示開始追蹤click事件了 this.trackingClickStart = event.timeStamp; //標記下touch事件開始的時間戳 this.targetElement = targetElement; //標記touch起始點的頁面偏移值 this.touchStartX = touch.pageX; this.touchStartY = touch.pageY; // this.lastClickTime 是在 touchend 裡標記的事件時間戳 // this.tapDelay 為常量 200 (ms) // 此舉用來避免 phantom 的雙擊(200ms內快速點了兩次)觸發 click // 反正200ms內的第二次點選會禁止觸發其預設事件 if ((event.timeStamp - this.lastClickTime) this.tapDelay) { event.preventDefault(); } return true; }; |
順道看下這裡的 this.updateScrollParent:
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 |
/** * 檢查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 )的開頭做檢測,如果沒被賦值過,則直接忽略:
1 2 3 |
if (!this.trackingClick) { return true; } |
當然在 ontouchend 事件裡會把它重置為 false。
2. this.onTouchMove
這段程式碼量好少:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
FastClick.prototype.onTouchMove = function(event) { //不是需要被追蹤click的事件則忽略 if (!this.trackingClick) { return true; } // 如果target突然改變了,或者使用者其實是在移動手勢而非想要click // 則應該清掉this.trackingClick和this.targetElement,告訴後面的事件你們也不用處理了 if (this.targetElement !== this.getTargetElementFromEventTarget(event.target) || this.touchHasMoved(event)) { this.trackingClick = false; this.targetElement = null; } return true; }; |
看下這裡用到的 this.touchHasMoved 原型方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
//判斷是否移動了 //this.touchBoundary是常量,值為10 //如果touch已經移動了10個偏移量單位,則應當作為移動事件處理而非click事件 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; }; |
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 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
FastClick.prototype.onTouchEnd = function(event) { var forElement, trackingClickStart, targetTagName, scrollParent, touch, 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; }; |
這段比較長,我們主要看這段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
} 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事件來模擬聚焦:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
//判斷給定元素是否需要通過合成click事件來模擬聚焦 FastClick.prototype.needsFocus = function(target) { switch (target.nodeName.toLowerCase()) { case 'textarea': return true; case 'select': return !deviceIsAndroid; //iOS下的select得走穿透點選才行 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: //帶有名為“bneedsfocus”的class則返回true return (/bneedsfocusb/).test(target.className); } }; |
另外這段說明了為何稍微久按一點(超過100ms)textarea ,我們是可以把游標定位在正確的地方(會繞過後面呼叫 this.focus 的方法):
1 2 3 4 5 6 7 8 9 |
//手勢停留在元件元素時長超過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; } |
接著我們們看看這兩行很重要的程式碼:
1 2 |
this.focus(targetElement); this.sendClick(targetElement, event); //立即觸發其click事件,而無須等待300ms |
所涉及的兩個原型方法分別為:
⑴ this.focus
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
FastClick.prototype.focus = function(targetElement) { var length; // 元件建議通過setSelectionRange(selectionStart, selectionEnd)來設定游標範圍(注意這樣還沒有聚焦 // 要等到後面觸發 sendClick 事件才會聚焦) // 另外 iOS7 下有些input元素(比如 date datetime month) 的 selectionStart 和 selectionEnd 特性是沒有整型值的, // 導致會丟擲一個關於 setSelectionRange 的模糊錯誤,它們需要改用 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元素上觸發導致聚焦:
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 |
//合成一個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]; // 合成(Synthesise) 一個 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'; }; |
經過這麼一折騰,我們們輕點 textarea 後,游標就自然定位到其內容尾部去了。但是這裡有個問題——排在 touchend 後的 focus 事件為啥沒被觸發呢?
如果 focus 事件能被觸發的話,那肯定能重新定位游標到正確的位置呀。
我們們看下面這段:
1 2 3 4 5 6 |
//iOS4下的 select 元素不能禁用預設事件(要確保它能被穿透),否則不會開啟select目錄 //有時候 iOS6/7 下(VoiceOver開啟的情況下)也會如此 if (!deviceIsIOS || targetTagName !== 'select' ) { this.targetElement = null; event.preventDefault(); } |
通過 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(); } |
或者:
1 2 3 4 5 6 7 |
if (!deviceIsIOS4 || targetTagName !== 'select') { this.targetElement = null; //給textarea加上“needsclick”的class if((!/bneedsclickb/).test(targetElement.className)){ event.preventDefault(); } } |
這裡要吐槽下的是,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 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
//用於決定是否允許穿透事件(觸發layer的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 的裝置(比如Android 2)做標記, // 確保該事件回撥不會執行(見126行) 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; }; //銷燬Fastclick所註冊的監聽事件。是給外部例項去呼叫的 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); }; |
常規需要阻斷點選事件的操作,我們在 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 雖好,但也有一些坑,還是得按需求對其修改,那麼瞭解其原始碼還是很有必要的。