以前聽到前輩們說移動端盡量不要使用click,click會比較遲鈍,能用touchstart還是用touchstart。但是用touchstart會有一個問題,用戶在滑動頁面的時候要是不小心碰到了相關(guān)元素也會觸發(fā)touchstart,所以兩者都有缺點。那怎么辦呢?
首先為什么移動端的click會遲鈍呢?從谷歌的開發(fā)者文檔《300ms tap delay, gone away》可以找到答案:
For many years, mobile browsers applied a 300-350ms delay between touch end and click while they waited to see if this was going to be a double-tap or not, since double-tap was a gesture to zoom into text.
大意是說因為移動端要判斷是否是雙擊,所以單擊之后不能夠立刻觸發(fā)click,要等300ms,直到確認(rèn)不是雙擊了才觸發(fā)click。所以就導(dǎo)致了click有延遲。
更為重要的是,文檔里面還提到在2014年的Chrome 32版本已經(jīng)把這個延遲去掉了,如果有一個meta標(biāo)簽:
<meta name="viewport" content="width=device-width">
即把viewport設(shè)置成設(shè)備的實際像素,那么就不會有這300ms的延遲,并且這個舉動受到了IE/Firefox/Safari(IOS 9.3)的支持,也就是說現(xiàn)在的移動端開發(fā)可以不用顧慮click會比較遲鈍的問題。
如果設(shè)置initial-scale=1.0,在chrome上是可以生效,但是Safari不會:
<meta name="viewport" content="initial-scale=1.0">
還有第三種辦法就是設(shè)置CSS:
html{
touch-action: manipulation;
}
這樣也可以取消掉300ms的延遲,Chrome和Safari都可以生效。
click是在什么時候觸發(fā)的呢?來研究一下click/touch事件的觸發(fā)先后順序。
1. click/touch觸發(fā)順序
用以下的html代碼來實驗:
<!DOCType html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1.0">
</head>
<body>
<div id="target" style="margin-top:20px;width:200px;height:200px;background-color:#ccc">hello, world</div>
<script>
!function(){
var target = document.getElementById("target");
var body = document.querySelector("body");
var touchstartBeginTime = 0;
function log(event){
if(event.type === "touchstart") touchstartBeginTime = Date.now();
console.log(event.type, Date.now() - touchstartBeginTime);
}
target.onclick = log;
target.ontouchstart = log;
target.ontouchend = log;
target.ontouchmove = log;
target.onmouseover = log;
target.onmousedown = log;
target.onmouseup = log;
}();
</script>
</body>
</html>
用一臺iPhone6 (IOS 10)的手機(jī)連接電腦的Safari做實驗,如下圖所示:
然后點擊灰色的target區(qū)域,用電腦的Safari進(jìn)行檢查,可以看到輸出結(jié)果:
可以看到click事件是在最后觸發(fā)的,并且還看到300ms的延遲,實際的執(zhí)行延遲要比這個大,因為瀏覽器的內(nèi)核運行也需要消耗時間。現(xiàn)在加上viewport的meta標(biāo)簽,再觀察結(jié)果,如下圖所示:
可以看到,300ms的延遲沒有了。
知道了click是在touchend之后觸發(fā)的,現(xiàn)在我們來嘗試一下實現(xiàn)一個tap事件。
2. tap事件的實現(xiàn)
雖然已經(jīng)沒有太大的必要自行實現(xiàn)一個tap事件,但是我們還是很好奇可以怎么實現(xiàn)一個能夠快速觸發(fā)的tap的事件?有兩個庫,一個是zepto,另一個是fastclick,它們都可以解決點擊延遲的問題。其中,zepto有一個自定義事件tap,它是一個沒有延遲的click事件。而fastclick是在touchend之后生成一個click事件,并立即觸發(fā)這個click,再取消原本的click事件。這兩者的原理都是一樣的,都是在touchend之后觸發(fā),一個是觸發(fā)它自己定義的tap事件,一個是觸發(fā)原生click。
這里有一個關(guān)鍵的問題,就是touchend之后不能夠每次都觸發(fā)tap,因為有可能用戶是在上下滑并不是在點擊,不然的話直接監(jiān)聽touchstart就好了。所以怎么判定用戶是點擊還是在上下滑呢?Zepto是用的位移偏差,即記錄下touchstart的時候的初始位移,然后用touchend的時候的位移減掉初始位移的偏差,如果這個差值在30以內(nèi),則認(rèn)為用戶是點擊,大于30則認(rèn)為是滑動。而fastclick是用的時間偏差,分別記錄touchstart和touchend的時間戳,如果它們的時間差大于700毫秒,則認(rèn)為是滑動操作,否則是點擊操作。
Chrome又是怎么判斷用戶是點擊還是滑動呢,筆者沒有去看安卓或者IOS Chrome的源碼,找了下Chromium的源碼,它里面有一個resources的目錄,是Chrome自己頁面的代碼,如chrome://setting頁,它是用html寫的。在這個里面有一個touch_handler.js,它里面封裝了一些移動端的手勢實現(xiàn)如tap,tap是根據(jù)時間位移判斷是否要觸發(fā)tap,如下所示:
/**
* The time, in milliseconds, that a touch must be held to be considered
* 'long'.
* @type {number}
* @private
*/
TouchHandler.TIME_FOR_LONG_PRESS_ = 500;
定義的時間為長時間按壓long press的時間閾值為500ms,在touchstart里面啟動一個計時器:
this.longPressTimeout_ = window.setTimeout(
this.onLongPress_.bind(this), TouchHandler.TIME_FOR_LONG_PRESS_);
onLongPress_: function() {
this.disableTap_ = true;
}
如果超過了閾值500ms,就把一個標(biāo)志位disableTap_設(shè)置為true,然后在touchend里面,這個flag為true就不會觸發(fā)tap:
if (!this.disableTap_)
this.dispatchEvent_(TouchHandler.EventType.TAP, touch);
相對于fastclick用兩個時間戳的方式,我感覺源碼的實現(xiàn)更為復(fù)雜,因為要啟動一個計時器。
現(xiàn)在我們來實現(xiàn)一個按位移偏差判斷的tap。
要實現(xiàn)一個自定義事件,有兩種方式,第一種是像jQuery/Zepto一樣,自己封裝一個事件機(jī)制,第二種是調(diào)用原生的document.createEvent,然后再執(zhí)行div.dispatchEvent(event),這里我們使用第一種。
為此先寫一個選擇器。如下代碼所示:
var $ = function(selector){
var dom = null;
if(typeof selector === "string"){
dom = document.querySelectorAll(selector);
} else if(selector instanceof HTMLElement){
dom = selector;
}
return new $Element(dom);
}
window.$ = $;
選擇器的名稱用$,它是一個函數(shù),傳進(jìn)來的參數(shù)為選擇器或者dom元素,如果是字符串的選擇器,則調(diào)用querySelectorAll去獲取dom元素,如果它已經(jīng)是一個dom則不用處理,最后返回一個$Element的封裝的實例,類似于jQuery對象。
現(xiàn)在來實現(xiàn)這個$Element的類,如下代碼所示:
class $Element{
constructor(_doms){
var doms = _doms.constructor === Array || _doms.constructor === NodeList ?
_doms : [_doms];
this.doms = doms;
this.init();
for(var i = 0; i < doms.length; i++){
this[i] = doms[i];
if(!doms[i].listeners){
doms[i].listeners = {};
}
}
}
}
$Element的構(gòu)造函數(shù)里面,先判斷參數(shù)的類型,如果它不是一個數(shù)組或者是用querySelectorAll返回的NodeList類型,則構(gòu)造一個dom數(shù)組。然后給這些dom對象添加一個listeners的屬性,用來存放事件的回調(diào)函數(shù)。注意這里不是一個好的實踐,因為一般不推薦給原生對象添加?xùn)|西。但是從簡單考慮,這里先用這樣的方法。
第8行代碼比較有趣,把this當(dāng)作一個數(shù)組,dom元素當(dāng)作這個數(shù)組的元素。這樣就可以通過索引獲取dom元素:
var value = $("input")[0].value;
但是它又不是一個數(shù)組,它沒有數(shù)組的sort/indexOf等函數(shù),它是一個$Element實例,另一方面它又有l(wèi)ength,可以通過index獲取元素,所以它是一個偽數(shù)組,這樣你就知道了arguments實例、jQuery對象這種偽數(shù)組是怎么來的。
上面代碼還調(diào)了一個init,這個init函數(shù)用來添加tap事件:
init(){
for(var i = 0; i < this.doms.length; i++){
if(!this.doms[i].listeners){
this.initTapEvent(this.doms[i]);
}
}
}
在說tap事件之前,需要提供事件綁定和觸發(fā)的api,如下所示:
on(eventType, callback){
for(var i = 0; i < this.doms.length; i++){
var dom = this.doms[i];
if(!dom.listeners[eventType]){
dom.listeners[eventType] = [];
}
dom.listeners[eventType].push(callback);
}
}
上面的on函數(shù)會給dom的listeners屬性添加相應(yīng)事件的回調(diào),每種事件類型都用一個數(shù)組存儲。而觸發(fā)的代碼如下所示:
trigger(eventType, event){
for(var i = 0; i < this.doms.length; i++){
$Element.dispatchEvent(this.doms[i], eventType, event);
}
}
static dispatchEvent(dom, eventType, event){
var listeners = dom.listeners[eventType];
if(listeners){
for(var i = 0; i < listeners.length; i++){
listeners[i].call(dom, event);
}
}
}
這段代碼也好理解,根據(jù)不同的事件類型去取回調(diào)函數(shù)的數(shù)組,依次執(zhí)行。
現(xiàn)在重點來說一下怎么添加一個tap事件,即上面的initTapEvent函數(shù),如下代碼所示:
initTapEvent(dom){
var x1 = 0, x2 = 0, y1 = 0, y2 = 0;
dom.addEventListener("touchstart", function(event){
});
dom.addEventListener("touchmove", function(event){
});
dom.addEventListener("touchend", function(event){
});
}
思路是這樣的,在touchstart的時候記錄x1和y1的位置:
dom.addEventListener("touchstart", function(event){
var touch = event.touches[0];
x1 = x2 = touch.pageX;
y1 = y2 = touch.pageY;
});
如果你用兩根手指的話,那么event.touches.length就是2,如果是3根則為3,進(jìn)而分別獲得到每根手指的位置,由于我們是單點,所以就獲取第一個手指的位置即可。pageX/pageY是相當(dāng)于當(dāng)前html頁面的位置,而clientX和clientY是相對于視圖窗口的位置。
然后在touchmove的時候獲取到最新的移動位置:
dom.addEventListener("touchmove", function(event){
var touch = event.touches[0];
x2 = touch.pageX;
y2 = touch.pageY;
});
最后touchend的時候,比較位移偏差:
dom.addEventListener("touchend", function(event){
if(Math.abs(x2 - x1) < 10 && Math.abs(y2 - y1) < 10){
$Element.dispatchEvent(dom, "tap", new $Event(x1, y1));
}
y2 = x2 = 0;
});
如果兩者的位移差小于10,則認(rèn)為是tap事件,并觸發(fā)這個事件。這里封裝了一個自定義事件:
class $Event{
constructor(pageX, pageY){
this.pageX = pageX;
this.pageY = pageY;
}
}
然后就可以使用這個tap事件了,如下代碼所示:
$("#target").on("tap", function(event){
console.log("tap", event.pageX, event.pageY);
});
接著在手機(jī)瀏覽器上運行,當(dāng)點擊目標(biāo)區(qū)域的時候就會執(zhí)行tap回調(diào),而上下滑動的時候則不會觸發(fā),如下圖所示:
再比較一下tap和原生click的觸發(fā)時間的差別,需要給自定義事件添加一個click:
dom.addEventListener("click", function(event){
$Element.dispatchEvent(dom, "click", new $Event(event.pageX, event.pageY));
});
接著用一個tapTime記錄下時間:
var tapTime = 0;
$("div").on("tap", function(event){
console.log("tap", event.pageX, event.pageY);
tapTime = Date.now();
});
$("div").on("click", function(event){
console.log("time diff", Date.now() - tapTime);
});
點擊后,觀察控制臺的輸出:
click會大概慢20ms,可能是因為它前面還要觸發(fā)mouse的事件。
這樣我們就實現(xiàn)了一個自定義tap事件,是自己封裝了一個事件機(jī)制,fastclick是使用原生的Event,如下fastclick的源碼,在touchend的回調(diào)函數(shù)里面執(zhí)行:
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);
然后再調(diào)event.preventDefault禁掉原本的click事件的觸發(fā)。它里面還做了其它一些的兼容性的處理。
這個時候如果要做一個放大的事件,你應(yīng)該不難想到實現(xiàn)的方法。可以在touchstart里面獲取event.touches兩根手指的初始位置,保存初始化手指的距離,然后在touchmove里面再次獲取新位置,計算新的距離減掉老的距離,如果是正數(shù)則說明是放大,反之縮小,放大和縮小的尺度也是可以取到一個相對值。手機(jī)Safari有一個gesturestart/gesturechange/gestureend事件,在gesturechange的event里面有一個放大比例scale的屬性。讀者可以自己嘗試實現(xiàn)一個放大和縮小的手勢事件。
當(dāng)知道了怎么實現(xiàn)一個自定義事件之后,現(xiàn)在來實現(xiàn)一個更為復(fù)雜的“搖一搖”事件。
3. 搖一搖事件
html5新增了一個devicemotion的事件,可以使用手機(jī)的重力感應(yīng)。如下代碼所示:
window.ondevicemotion = function(event){
var gravity = event.accelerationIncludingGravity;
console.log(gravity.x, gravity.y, gravity.z);
}
x,y,z表示三個方向的重力加速度,如下圖所示:
x是手機(jī)短邊,y是長邊,z是和手機(jī)屏幕垂直的方向,當(dāng)把手機(jī)平著放的時候,由于x、y和地平線平行,所以g(x) = g(y) = 0,而z和地平線垂直,所以g(z) = 9.8左右,同理當(dāng)把手機(jī)豎著放的時候,g(x) = g(z) = 0,而g(y) = -9.8.
devicemotion事件會不斷地觸發(fā),而且觸發(fā)得很快。
當(dāng)我們把手機(jī)拿起來搖一搖的時候,這個場景應(yīng)該是這樣的:
y軸和x軸的變化范圍從-45o到+45o,即這個區(qū)間是:
delta = 9.8 * sin(45o) * 2 = 13.8
即只要x軸和y軸的g值變化超過13.8,我們就認(rèn)為發(fā)生了搖一搖事件。
根據(jù)上面的分析,不難寫出以下的代碼:
const EMPTY_VALUE = 100;
const THREAD_HOLD = 13.8;
var minX = EMPTY_VALUE,
minY = EMPTY_VALUE;
window.ondevicemotion = function(event){
var gravity = event.accelerationIncludingGravity,
x = gravity.x,
y = gravity.y;
if(x < minX) minX = x;
if(y < minY) minY = y;
if(Math.abs(x - minX) > THREAD_HOLD &&
Math.abs(y - minY) > THREAD_HOLD){
console.log("shake");
var event = new CustomEvent("shake");
window.dispatchEvent(event);
minX = minY = EMPTY_VALUE;
}
}
window.addEventListener("shake", function(){
console.log("window shake callback was called");
});
用一個minX和minY記錄最小的值,每次devicemotion觸發(fā)的時候就判斷當(dāng)前的g值與最小值的差值是否超過了閾值,如果是的話就創(chuàng)建一個CustomEvent的實例,然后disatch給window,window上兼聽的onshake事件就會觸發(fā)了。
現(xiàn)在拿起手機(jī)搖一搖,控制臺就會輸出:
這樣就實現(xiàn)了一個搖一搖shake事件。還有一個問題就是:這個shake會不會很容易觸發(fā),即使不是搖一搖操作它也觸發(fā)了?根據(jù)實驗上面代碼如果不搖不容易觸發(fā)shake,同時搖的時候比較容易觸發(fā)。如果太難觸發(fā)可以把閾值改小點。
當(dāng)然判斷是否搖一搖的算法不止上面一個,你還可以想出其它更好的方法。
綜上,本文討論了怎么去掉移動端click事件遲鈍的300ms延遲,怎么實現(xiàn)一個快速響應(yīng)的tap事件,怎么封裝和觸發(fā)自定義事件,以及搖一搖的原理是怎么樣的,怎么實現(xiàn)一個搖一搖的shake事件。
相信閱讀了本文,你就知道了怎么用一些基本事件進(jìn)行組合觸發(fā)一些高級事件。通常把這些基本事件封裝起來,如上面用一個$Element的類,由它負(fù)責(zé)決定是否觸發(fā)tap,而高層的調(diào)用者不需要關(guān)心tap事件觸發(fā)的細(xì)節(jié),這個$Element就相當(dāng)于一個事件代理,或者也可以把tap當(dāng)作一個門面。所以它是一個代理模式或者門面模式。更多設(shè)計模式可以查看本文《JS與面向?qū)ο?/a>》