移動web風風火火幾多年,讓我這個在Pc端漂流的前端er不免心生仰慕,的確入行幾多年,也該是時候進軍移動web了。移動web中踩到的第一個坑就是事件問題,所以在吸取眾大神的經驗后,特作總結以示后來者。
移動端事件的變化
首先PC端那一堆非常happy的鼠標事件沒了,mousedown, mouseup, mousemove, mouseover, mouseout, mouseenter, mouseleave全都沒了,click也與之前有所差別。取而代之的是幾個原始的事件。
-touchstart
-touchmove
-touchend
-touchcancel
同樣事件處理函數中的event也與pc端有着極大的差別,最典型的是增加了三個與觸摸相關的屬性:
-touches
-changedTouches
-targetTouches
在pc端一台機器只會有一個鼠標,所以與鼠標相關的屬性都可以放到一個event對象上,但是移動端設備大多支持多點觸控,這就意味着一個事件可能與多個觸控點相關,每個觸控點都需要記錄自己單獨的屬性。所以event對象中與touch相關的三個屬性都是TouchList類型,與觸控位置、目標元素、全都放到了touch對象上。
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事件呢?答案是可以的。首先我們需要定義一下標准事件:
press -> mousedown
release -> mouseup
move -> mousemove
cancel -> mouseleave
over -> mouseover
out -> mouseout
enter -> mouseenter
leave -> mouseleave
總體看來如下圖所示:

在我們定義好標准時候就要考慮如何去實現,值得慶幸的是,事件的傳播階段並沒有變化,這里要感謝微軟不來添亂。盜一張圖:

我們先來看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'),
};
click的300ms延遲與點透、鬼點擊問題
在最初移動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
