從移動端 click 到搖一搖

之前聽到前輩們說移動端儘可能不要使用click,click會比較遲鈍,能用touchstart仍是用touchstart。可是用touchstart會有一個問題,用戶在滑動頁面的時候要是不當心碰到了相關元素也會觸發touchstart,因此二者都有缺點。那怎麼辦呢?javascript

首先爲何移動端的click會遲鈍呢?從谷歌的開發者文檔《300ms tap delay, gone away》能夠找到答案:css

For many years, mobile browsers applied a 300-350ms delay between touchend 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.html

大意是說由於移動端要判斷是不是雙擊,因此單擊以後不可以馬上觸發click,要等300ms,直到確認不是雙擊了才觸發click。因此就致使了click有延遲。html5

更爲重要的是,文檔裏面還提到在2014年的Chrome 32版本已經把這個延遲去掉了,若是有一個meta標籤:java

<meta name="viewport" content="width=device-width">複製代碼

即把viewport設置成設備的實際像素,那麼就不會有這300ms的延遲,而且這個舉動受到了IE/Firefox/Safari(IOS 9.3)的支持,也就是說如今的移動端開發能夠不用顧慮click會比較遲鈍的問題。web

若是設置initial-scale=1.0,在chrome上是能夠生效,可是Safari不會:算法

<meta name="viewport" content="initial-scale=1.0">複製代碼

還有第三種辦法就是設置CSS:chrome

html{
    touch-action: manipulation;
}複製代碼

這樣也能夠取消掉300ms的延遲,Chrome和Safari均可以生效。設計模式

click是在何時觸發的呢?來研究一下click/touch事件的觸發前後順序。api

1. click/touch觸發順序

用如下的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)的手機鏈接電腦的Safari作實驗,以下圖所示:

而後點擊灰色的target區域,用電腦的Safari進行檢查,能夠看到輸出結果:

能夠看到click事件是在最後觸發的,而且還看到300ms的延遲,實際的執行延遲要比這個大,由於瀏覽器的內核運行也須要消耗時間。如今加上viewport的meta標籤,再觀察結果,以下圖所示:

能夠看到,300ms的延遲沒有了。

知道了click是在touchend以後觸發的,如今咱們來嘗試一下實現一個tap事件。

2. tap事件的實現

雖然已經沒有太大的必要自行實現一個tap事件,可是咱們仍是很好奇能夠怎麼實現一個可以快速觸發的tap的事件?有兩個庫,一個是zepto,另外一個是fastclick,它們均可以解決點擊延遲的問題。其中,zepto有一個自定義事件tap,它是一個沒有延遲的click事件。而fastclick是在touchend以後生成一個click事件,並當即觸發這個click,再取消本來的click事件。這二者的原理都是同樣的,都是在touchend以後觸發,一個是觸發它本身定義的tap事件,一個是觸發原生click。

這裏有一個關鍵的問題,就是touchend以後不可以每次都觸發tap,由於有可能用戶是在上下滑並非在點擊,否則的話直接監聽touchstart就行了。因此怎麼斷定用戶是點擊仍是在上下滑呢?Zepto是用的位移誤差,即記錄下touchstart的時候的初始位移,而後用touchend的時候的位移減掉初始位移的誤差,若是這個差值在30之內,則認爲用戶是點擊,大於30則認爲是滑動。而fastclick是用的時間誤差,分別記錄touchstart和touchend的時間戳,若是它們的時間差大於700毫秒,則認爲是滑動操做,不然是點擊操做。

Chrome又是怎麼判斷用戶是點擊仍是滑動呢,筆者沒有去看安卓或者IOS Chrome的源碼,找了下Chromium的源碼,它裏面有一個resources的目錄,是Chrome本身頁面的代碼,如chrome://setting頁,它是用html寫的。在這個裏面有一個touch_handler.js,它裏面封裝了一些移動端的手勢實現如tap,tap是根據時間位移判斷是否要觸發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,就把一個標誌位disableTap_設置爲true,而後在touchend裏面,這個flag爲true就不會觸發tap:

if (!this.disableTap_)
    this.dispatchEvent_(TouchHandler.EventType.TAP, touch);複製代碼

相對於fastclick用兩個時間戳的方式,我感受源碼的實現更爲複雜,由於要啓動一個計時器。

如今咱們來實現一個按位移誤差判斷的tap。

要實現一個自定義事件,有兩種方式,第一種是像jQuery/Zepto同樣,本身封裝一個事件機制,第二種是調用原生的document.createEvent,而後再執行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.$ = $;複製代碼

選擇器的名稱用$,它是一個函數,傳進來的參數爲選擇器或者dom元素,若是是字符串的選擇器,則調用querySelectorAll去獲取dom元素,若是它已是一個dom則不用處理,最後返回一個$Element的封裝的實例,相似於jQuery對象。

如今來實現這個$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的構造函數裏面,先判斷參數的類型,若是它不是一個數組或者是用querySelectorAll返回的NodeList類型,則構造一個dom數組。而後給這些dom對象添加一個listeners的屬性,用來存放事件的回調函數。注意這裏不是一個好的實踐,由於通常不推薦給原生對象添加東西。可是從簡單考慮,這裏先用這樣的方法。

第8行代碼比較有趣,把this看成一個數組,dom元素看成這個數組的元素。這樣就能夠經過索引獲取dom元素:

var value = $("input")[0].value;複製代碼

可是它又不是一個數組,它沒有數組的sort/indexOf等函數,它是一個$Element實例,另外一方面它又有length,能夠經過index獲取元素,因此它是一個僞數組,這樣你就知道了arguments實例、jQuery對象這種僞數組是怎麼來的。

上面代碼還調了一個init,這個init函數用來添加tap事件:

init(){
    for(var i = 0; i < this.doms.length; i++){
        if(!this.doms[i].listeners){
            this.initTapEvent(this.doms[i]);
        }       
    }
}複製代碼

在說tap事件以前,須要提供事件綁定和觸發的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函數會給dom的listeners屬性添加相應事件的回調,每種事件類型都用一個數組存儲。而觸發的代碼以下所示:

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); 
        }       
    }
}複製代碼

這段代碼也好理解,根據不一樣的事件類型去取回調函數的數組,依次執行。

如今重點來講一下怎麼添加一個tap事件,即上面的initTapEvent函數,以下代碼所示:

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,進而分別得到到每根手指的位置,因爲咱們是單點,因此就獲取第一個手指的位置便可。pageX/pageY是至關於當前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,則認爲是tap事件,並觸發這個事件。這裏封裝了一個自定義事件:

class $Event{
    constructor(pageX, pageY){
        this.pageX = pageX;
        this.pageY = pageY;
    }   
}複製代碼

而後就可使用這個tap事件了,以下代碼所示:

$("#target").on("tap", function(event){
    console.log("tap", event.pageX, event.pageY);
});複製代碼

接着在手機瀏覽器上運行,當點擊目標區域的時候就會執行tap回調,而上下滑動的時候則不會觸發,以下圖所示:

再比較一下tap和原生click的觸發時間的差異,須要給自定義事件添加一個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,多是由於它前面還要觸發mouse的事件。

這樣咱們就實現了一個自定義tap事件,是本身封裝了一個事件機制,fastclick是使用原生的Event,以下fastclick的源碼,在touchend的回調函數裏面執行:

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);複製代碼

而後再調event.preventDefault禁掉本來的click事件的觸發。它裏面還作了其它一些的兼容性的處理。

這個時候若是要作一個放大的事件,你應該不難想到實現的方法。能夠在touchstart裏面獲取event.touches兩根手指的初始位置,保存初始化手指的距離,而後在touchmove裏面再次獲取新位置,計算新的距離減掉老的距離,若是是正數則說明是放大,反之縮小,放大和縮小的尺度也是能夠取到一個相對值。手機Safari有一個gesturestart/gesturechange/gestureend事件,在gesturechange的event裏面有一個放大比例scale的屬性。讀者能夠本身嘗試實現一個放大和縮小的手勢事件。

當知道了怎麼實現一個自定義事件以後,如今來實現一個更爲複雜的「搖一搖」事件。

3. 搖一搖事件

html5新增了一個devicemotion的事件,可使用手機的重力感應。以下代碼所示:

window.ondevicemotion = function(event){
    var gravity = event.accelerationIncludingGravity;
    console.log(gravity.x, gravity.y, gravity.z);
}複製代碼

x,y,z表示三個方向的重力加速度,以下圖所示:

x是手機短邊,y是長邊,z是和手機屏幕垂直的方向,當把手機平着放的時候,因爲x、y和地平線平行,因此g(x) = g(y) = 0,而z和地平線垂直,因此g(z) = 9.8左右,同理當把手機豎着放的時候,g(x) = g(z) = 0,而g(y) = -9.8.

devicemotion事件會不斷地觸發,並且觸發得很快。

當咱們把手機拿起來搖一搖的時候,這個場景應該是這樣的:

y軸和x軸的變化範圍從-45o到+45o,即這個區間是:

delta = 9.8 * sin(45o) * 2 = 13.8

即只要x軸和y軸的g值變化超過13.8,咱們就認爲發生了搖一搖事件。

根據上面的分析,不難寫出如下的代碼:

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觸發的時候就判斷當前的g值與最小值的差值是否超過了閾值,若是是的話就建立一個CustomEvent的實例,而後disatch給window,window上兼聽的onshake事件就會觸發了。

如今拿起手機搖一搖,控制檯就會輸出:

這樣就實現了一個搖一搖shake事件。還有一個問題就是:這個shake會不會很容易觸發,即便不是搖一搖操做它也觸發了?根據實驗上面代碼若是不搖不容易觸發shake,同時搖的時候比較容易觸發。若是太難觸發能夠把閾值改小點。

固然判斷是否搖一搖的算法不止上面一個,你還能夠想出其它更好的方法。


綜上,本文討論了怎麼去掉移動端click事件遲鈍的300ms延遲,怎麼實現一個快速響應的tap事件,怎麼封裝和觸發自定義事件,以及搖一搖的原理是怎麼樣的,怎麼實現一個搖一搖的shake事件。

相信閱讀了本文,你就知道了怎麼用一些基本事件進行組合觸發一些高級事件。一般把這些基本事件封裝起來,如上面用一個$Element的類,由它負責決定是否觸發tap,而高層的調用者不須要關心tap事件觸發的細節,這個$Element就至關於一個事件代理,或者也能夠把tap看成一個門面。因此它是一個代理模式或者門面模式。更多設計模式能夠查看本文《JS與面向對象

相關文章
相關標籤/搜索