移動web風風火火幾多年,讓我這個在Pc端漂流的前端er難免心生仰慕,的確入行幾多年,也該是時候進軍移動web了。移動web中踩到的第一個坑就是事件問題,因此在吸收衆大神的經驗後,特做總結以示後來者。html
首先PC端那一堆很是happy的鼠標事件沒了,mousedown
, mouseup
, mousemove
, mouseover
, mouseout
, mouseenter
, mouseleave
全都沒了,click
也與以前有所差異。取而代之的是幾個原始的事件。前端
-touchstart
-touchmove
-touchend
-touchcancelnode
一樣事件處理函數中的event
也與pc端有着極大的差異,最典型的是增長了三個與觸摸相關的屬性:git
-touches
-changedTouches
-targetTouchesgithub
在pc端一臺機器只會有一個鼠標,因此與鼠標相關的屬性均可以放到一個event對象上,可是移動端設備大多支持多點觸控,這就意味着一個事件可能與多個觸控點相關,每一個觸控點都須要記錄本身單獨的屬性。因此event對象中與touch相關的三個屬性都是TouchList
類型,與觸控位置、目標元素、全都放到了touch對象上。web
Touch對象主要屬性以下:
-clientX / clientY:觸摸點相對瀏覽器窗口的位置
-pageX / pageY:觸摸點相對於頁面的位置
-screenX / screenY:觸摸點相對於屏幕的位置
-identifier:touch對象的ID
-target:當前的DOM元素瀏覽器
如今反過來看看幾個touch相關事件,並與pc端事件作一下對比:
-touchstart
: 觸控最開始時發生,相似於pc端的mousedown事件
-touchmove
: 觸控點在屏幕上移動時觸發,相似於mousemove。可是在當在移動設備上,觸控點從一個元素移動到另外一個元素上時,並不會像pc端同樣觸發相似mouseover
/mouseout
mouseenter
/mouseleave
的事件。
-touchend
: 在觸摸結束時觸發,相似mouseup
-touchcancel
: 當一些更高級別的事情發生時,瀏覽器會觸發該事件。好比忽然來了一個電話,這時候會觸發touchcanel事件。若是是在遊戲中,就要在touchcancel時保存當前遊戲的狀態信息。
-click
: 移動端的click事件雖然存在,但與pc端有着明顯的差別。這也就是著名的300ms問題,以及爲了解決300ms延遲帶來的點透問題。
這幾個事件的事件對象的target屬性永遠是觸控事件最早發生的那個元素微信
先把click的問題放一下,咱們先考慮如下可否在移動端模擬pc事件呢?答案是能夠的。首先咱們須要定義一下標準事件:app
press -> mousedown
release -> mouseup
move -> mousemove
cancel -> mouseleave
over -> mouseover
out -> mouseout
enter -> mouseenter
leave -> mouseleavedom
整體看來以下圖所示:
在咱們定義好標準時候就要考慮如何去實現,值得慶幸的是,事件的傳播階段並無變化,這裏要感謝微軟不來添亂。盜一張圖:
咱們先來看toucmove
,單看名字容易讓人想固然的認爲它與mousemove對應,而後上文說過了,當觸控點在不一樣元素上移動時,並不會觸發mouseover
/mouseout
mouseenter
/mouseleave
等事件,爲了實現上面所說的over
, out
, enter
, leave
咱們首先要可以在touchmove
中拿到當前位置的dom元素。
瀏覽器爲咱們提供了elementFromPoint
方法,這個函數根據clientX
/clientY
來選中最上層的dom元素,這爲咱們在touchmove中實時獲取最近的dom元素提供了保障。當觸控點從一個元素移動到另外一個元素上時,對移出元素觸發mytouchout
事件對移入元素觸發mytouchover
事件,同時對與觸摸元素當觸控點在其上移動時觸發mytouchmove
事件。
關於自定義事件,固然是使用createEvent, initEvent, dispatchEvent三個函數,這三個函數並非本文重點,請你們自行查閱《JavaScript高級程序設計第三版》13章中關於自定義事件的內容。
如此一來,咱們的move、over、out等事件就有了着落,而press也很是簡單,只須要綁定touchstart便可,一樣cancel也只須要綁定touchcancel便可。
對於release咱們不能簡單的綁定touchend。由於上文已經說過,touchend中touch的target屬性對應的是最初觸控的元素,並不會隨着觸控點位置而改變。便是最終在元素B上拿開手指,touchend仍然會發生在元素A上。因此咱們須要在touchend時,利用elementFromPoint
獲取最後觸摸元素,在它身上觸發mytouchend
事件來模擬release。
根據事件傳播的三個階段,最適合作這些事的階段應位於冒泡階段,代碼以下:
首先定義事件綁定與發射函數:
function on(node, type, listener) { node.addEventListener(type, listener); return { remove: function() { node.removeEventListener(type, listener); } }; } function emit(node, type, evt) { var ne = document.createEvent('HTMLEvents'); ne.initEvent(type, !!evt.bubbles, !!evt.canCancel); for (var p in evt) { if (!(p in ne)) { ne[p] = evt[p]; } } //The return value is false if at least one of the event handlers //which handled this event called Event.preventDefault(). Otherwise it returns true. // 若是註冊的回調事件中有的調用了preventDefault方法,dispatEvent返回false,不然都返回true return node.dispatchEvent(ne); } function elementFromPoint(evt) { var touch = evt.changedTouches[0]; return doc.elementFromPoint(touch.clientX, touch.clientY); }
而後模擬mouse事件,分別在document上添加touchstart
, touchmove
, touchend
的事件處理:
doc.addEventListener('DOMContentLoaded', function() { var hoverNode = document.body; doc.addEventListener('touchstart', function(evt) { lastTouchTime = Date.now(); var newNode = evt.target; if (hoverNode) { emit(hoverNode, 'mytouchout', { relatedTarget: newNode, bubbles: true }); } emit(newNode, 'mytouchover', { relatedTarget: hoverNode, bubbles: true }); hoverNode = newNode; }, true); //爲移出元素觸發mytouchout,爲移入元素觸發mytouchover //touchmove事件只與觸摸操做相關,不會具備mouseover、mouseout的效果 doc.addEventListener('touchmove', function(evt) { lastTouchTime = Date.now(); var newNode = elementFromPoint(evt); if (newNode) { if (newNode !== hoverNode) { emit(hoverNode, 'mytouchout', { relatedTarget: newNode, bubbles: true }); emit(newNode, 'mytouchover', { relatedTarget: hoverNode, bubbles: true }); hoverNode = newNode; } if (!emit(newNode, 'mytouchmove', copyEventProps(evt))) { evt.preventDefault(); } } }); doc.addEventListener('touchend', function(evt) { lastTouchTime = Date.now(); var newNode = elementFromPoint(evt) || doc.body; if (newNode) { emit(newNode, 'mytouchend', copyEventProps(evt)); } }); });
到目前爲止標準化事件基本完成,剩下的就是enter與leave事件。這兩個事件與over、out相似,區別就是enter與leave在touch進入或者離開子元素時並不冒泡到父元素上,而over與out會冒泡到父元素。因此咱們只要在over與out上稍加變通便可:若是evt.relatedTarget
是子元素則父元素不觸發事件,核心函數以下:
function eventHandler(type) { // return on() return function(node, listener) { return on(node, type, function(e) { if (!node.contains(e.relatedTarget, node)) { listener.apply(node, arguments); } }); }; }
綜上,咱們的標準化事件過程就所有完成了:
function dualEvent(type) { return function(node, listener) { return on(node, type, listener); }; } return root.Touch = Touch = { press: dualEvent('touchstart'), move: dualEvent('mytouchmove'), release: dualEvent('mytouchend'), cancel: dualEvent('touchcancel'), over: dualEvent('mytouchover'), out: dualEvent('mytouchout'), enter: eventHandler('mytouchover'), leave: eventHandler('mytouchout'), };
在最初移動web剛出現時,用戶雙擊時網頁會自動放大,因此爲了區分雙擊縮放與click事件,瀏覽器設置了一個間隔時間300ms。若是300ms內連續點擊2次則認爲是雙擊縮放,不然是單擊click,瀏覽器內部實現原理以下所示
在實際應用中發現,300ms並非絕對發生,當用戶設置了viewport並禁止縮放時,大部分瀏覽器會禁止300ms延遲,但在低版本安卓以及微信、qq等應用的內嵌webview中仍然會發生300ms延遲問題。
<meta name="viewport" content="width=device-width,initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
在現今分秒必爭的移動端,若是網頁在100ms以內沒有反應就會給用戶遲鈍的感受,更況且300ms,根據上文咱們能夠簡單的使用press
事件來解決問題。與click相比,press的間隔時間明顯縮短。但這也帶來了移動端另外一個經典問題:點透!
點透的經典例子是:在遮罩層下有一個button或者文本框,在遮罩層上綁定press事件,當press發生時,事件函數中清除遮罩層。這樣業務場景下,當press時,遮罩層會消失,這是正常的,可是300ms後,遮罩層下方的元素觸發了click事件。
發生這件事的緣由在於,press發生後遮罩層被清除,300ms後,瀏覽器找到當前最上層元素,觸發click事件,過程原理以下:
e = document.elementFromPoint(x, y); e.dispatchEvent('click');
若是咱們所有依賴press而不去綁定click事件,是否可行呢?答案是否認的,由於press只對應touchstart
,若是用戶一直按住不放,或者先按住在滑到別的元素上,這不能認爲是一次click事件。那麼咱們是否能夠像自定義mytouch*
等事件那樣來定義本身的click
事件呢?答案是可行的!
咱們能夠認爲當觸控點擊開始而且在結束時所通過的事件不超過300ms並且移動位置不超過4px,則此次事件就是一次完整的click事件。
這個過程涉及touchstart、touchmove和touchend三個事件,首先綁定document的touchstart事件:
doc.addEventListener('touchstart', function(evt) { doFastClick(evt, 'touchmove', 'touchend'); }, true);
整個過程核心邏輯在於doFastClick函數中:
function doFastClick(evt, moveType, endType) { // 拿到執行fastclick的元素 var markNode = marked(evt.target); var clickTracker = !evt.target.disabled && markNode && markNode.fastClick; if (clickTracker) { var useTarget = markNode && markNode.fastClick && markNode.fastClick === 'useTarget'; var clickTarget = useTarget ? markNode : evt.target; var clickX = evt.changedTouches[0].clientX; var clickY = evt.changedTouches[0].clientY; //判斷觸控點是否移出 function updateClickTracker(evt) { if (useTarget) { clickTracker = markNode.contains(elementFromPoint(evt)) ? markNode : null; } else { clickTracker = clickTarget === evt.target && (Date.now() - lastTouchTime < 1000) && Math.abs(evt.changedTouches[0].clientX - clickX) < 4 && Math.abs(evt.changedTouches[0].clientY - clickY) < 4; } } doc.addEventListener(moveType, function(evt) { updateClickTracker(evt); if (useTarget) { // evt.preventDefault(); } }, true); doc.addEventListener(endType, function(evt) { updateClickTracker(evt); if (clickTracker) { // endtype觸發時,是否touch點還在clickTarget上 clickTime = (new Date()).getTime(); var target = (useTarget ? clickTarget : evt.target); if (target.tagName === "LABEL") { // label的特殊處理,label的操做應當對應到for指定的元素上 target = dom.byId(target.getAttribute("for")) || target; } var src = (evt.changedTouches) ? evt.changedTouches[0] : evt; var clickEvt = document.createEvent("MouseEvents"); clickEvt._fastclick = true; // 標識着咱們本身的click事件 clickEvt.initMouseEvent("click", true, //bubbles true, //cancelable evt.view, evt.detail, src.screenX, src.screenY, src.clientX, src.clientY, evt.ctrlKey, evt.altKey, evt.shiftKey, evt.metaKey, 0, //button null //related target ); setTimeout(function() { emit(target, "click", clickEvt); // refresh clickTime in case app-defined click handler took a long time to run clickTime = (new Date()).getTime(); }, 0); } }, true); } }
如今咱們添加了自定義的click事件,那麼問題來了在咱們的自定義click中不會存在300ms延遲,可是如今瀏覽器存在兩個click事件,一個是咱們定義的,一個是原生的click事件。原生的click事件仍然會在300ms後執行,當你對一個元素綁定click事件時,一次click一般會觸發兩次click事件,這也是另外一個經典的鬼點擊問題
。因此咱們須要將原生的click事件完全禁止掉。根據事件的三個處理階段,最合適的處理地方在於捕獲階段,阻止原生click的繼續傳播和默認行爲。
function stopNativeEvents(type) { doc.addEventListener(type, function(evt) { if (!evt._fastclick && (Date.now() - clickTime) <= 1000) { evt.stopPropagation(); evt.stopImmediatePropagation && evt.stopImmediatePropagation(); evt.preventDefault(); } }, true); }
如今鬼點擊的問題解決了,可是實踐發現
移動瀏覽器仍然保留mousedown
與mouseup
事件,這兩個事件仍然存在300ms延遲的問題!!!當遮罩層的下方是一個文本框時,300ms後mousedown發生,鍵盤就是在mousedown
的時候彈出的!因此咱們須要把mousedown事件一塊兒禁掉。
stopNativeEvents("click"); // We also stop mousedown/up since these would be sent well after with our "fast" click (300ms), // which can confuse some dijit widgets. //移動web中文本框在mousedown中彈出鍵盤,在mousedown中preventDefault能夠阻止鍵盤彈出 //但一棒子打死,文本框永遠不會彈出鍵盤 stopNativeEvents("mousedown"); stopNativeEvents("mouseup");
那麼事情結束了麼?然並卵,若是將mousedown禁掉,你的input文本框永遠不會再彈出鍵盤!!!因此咱們須要作一下判斷,若是是文本框不能preventDefault
:
stopNativeEvents("click"); // We also stop mousedown/up since these would be sent well after with our "fast" click (300ms), // which can confuse some dijit widgets. //移動web中文本框在mousedown中彈出鍵盤,在mousedown中preventDefault能夠阻止鍵盤彈出 //但一棒子打死,文本框永遠不會彈出鍵盤 stopNativeEvents("mousedown"); stopNativeEvents("mouseup"); function stopNativeEvents(type) { doc.addEventListener(type, function(evt) { if (!evt._fastclick && (Date.now() - clickTime) <= 1000) { evt.stopPropagation(); evt.stopImmediatePropagation && evt.stopImmediatePropagation(); if (type == "click" && (evt.target.tagName != "INPUT" || evt.target.type == "radio" || evt.target.type == "checkbox") && evt.target.tagName != "TEXTAREA" && evt.target.tagName != "AUDIO" && evt.target.tagName != "VIDEO"){ evt.preventDefault(); } } }, true); } }
總結一下,目前我尚未發現完美的解決方案,也就是說若是你的移動瀏覽器沒有禁用300ms延遲,若是你的遮罩層下方是個文本框,若是你的業務恰好知足點透的業務場景。。。貌似沒有完美的方式阻止鍵盤彈出。或者可使用緩動動畫,過渡300ms。
本文全部代碼位於此處:https://github.com/vajraBodhi/Touch/blob/master/Touch.js