移動Web觸控事件總結

移動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

整體看來以下圖所示:

99_RG_J6FE_3FE_G7_6IN

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

咱們先來看toucmove,單看名字容易讓人想固然的認爲它與mousemove對應,而後上文說過了,當觸控點在不一樣元素上移動時,並不會觸發mouseover/mouseout mouseenter/mouseleave等事件,爲了實現上面所說的over, out, enter, leave咱們首先要可以在touchmove中拿到當前位置的dom元素。
瀏覽器爲咱們提供了elementFromPoint方法,這個函數根據clientX/clientY來選中最上層的dom元素,這爲咱們在touchmove中實時獲取最近的dom元素提供了保障。當觸控點從一個元素移動到另外一個元素上時,對移出元素觸發mytouchout事件對移入元素觸發mytouchover事件,同時對與觸摸元素當觸控點在其上移動時觸發mytouchmove事件。

7H5Z_X_9TJNGN2_QNBX_5Y

關於自定義事件,固然是使用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,瀏覽器內部實現原理以下所示
412020_20160312135137491_1835042532

在實際應用中發現,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);
        }

如今鬼點擊的問題解決了,可是實踐發現
移動瀏覽器仍然保留mousedownmouseup事件,這兩個事件仍然存在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

相關文章
相關標籤/搜索