讀Zepto原始碼之Touch模組

對角另一面發表於2017-09-20

大家都知道,因為歷史原因,移動端上的點選事件會有 300ms 左右的延遲,Zeptotouch 模組解決的就是移動端點選延遲的問題,同時也提供了滑動的 swipe 事件。

讀 Zepto 原始碼系列文章已經放到了github上,歡迎star: reading-zepto

原始碼版本

本文閱讀的原始碼為 zepto1.2.0

GitBook

reading-zepto

實現的事件

;[`swipe`, `swipeLeft`, `swipeRight`, `swipeUp`, `swipeDown`,
  `doubleTap`, `tap`, `singleTap`, `longTap`].forEach(function(eventName){
  $.fn[eventName] = function(callback){ return this.on(eventName, callback) }
})

從上面的程式碼中可以看到,Zepto 實現了以下的事件:

  • swipe: 滑動事件
  • swipeLeft: 向左滑動事件
  • swipeRight: 向右滑動事件
  • swipeUp: 向上滑動事件
  • swipeDown: 向下滑動事件
  • doubleTap: 螢幕雙擊事件
  • tap: 螢幕點選事件,比 click 事件響應更快
  • singleTap: 螢幕單擊事件
  • longTap: 長按事件

並且為每個事件都註冊了快捷方法。

內部方法

swipeDirection

function swipeDirection(x1, x2, y1, y2) {
  return Math.abs(x1 - x2) >=
    Math.abs(y1 - y2) ? (x1 - x2 > 0 ? `Left` : `Right`) : (y1 - y2 > 0 ? `Up` : `Down`)
}

返回的是滑動的方法。

x1x軸 起點座標, x2x軸 終點座標, y1y軸 起點座標, y2y軸 終點座標。

這裡有多組三元表示式,首先對比的是 x軸y軸 上的滑動距離,如果 x軸 的滑動距離比 y軸 大,則為左右滑動,否則為上下滑動。

x軸 上,如果起點位置比終點位置大,則為向左滑動,返回 Left ,否則為向右滑動,返回 Right

y軸 上,如果起點位置比終點位置大,則為向上滑動,返回 Up ,否則為向下滑動,返回 Down

longTap

var touch = {},
    touchTimeout, tapTimeout, swipeTimeout, longTapTimeout,
    longTapDelay = 750,
    gesture
function longTap() {
  longTapTimeout = null
  if (touch.last) {
    touch.el.trigger(`longTap`)
    touch = {}
  }
}

觸發長按事件。

touch 物件儲存的是觸控過程中的資訊。

在觸發 longTap 事件前,先將儲存定時器的變數 longTapTimeout 釋放,如果 touch 物件中存在 last ,則觸發 longTap 事件, last 儲存的是最後觸控的時間。最後將 touch 重置為空物件,以便下一次使用。

cancelLongTap

function cancelLongTap() {
  if (longTapTimeout) clearTimeout(longTapTimeout)
  longTapTimeout = null
}

撤銷 longTap 事件的觸發。

如果有觸發 longTap 的定時器,清除定時器即可阻止 longTap 事件的觸發。

最後同樣需要將 longTapTimeout 變數置為 null ,等待垃圾回收。

cancelAll

function cancelAll() {
  if (touchTimeout) clearTimeout(touchTimeout)
  if (tapTimeout) clearTimeout(tapTimeout)
  if (swipeTimeout) clearTimeout(swipeTimeout)
  if (longTapTimeout) clearTimeout(longTapTimeout)
  touchTimeout = tapTimeout = swipeTimeout = longTapTimeout = null
  touch = {}
}

清除所有事件的執行。

其實就是清除所有相關的定時器,最後將 touch 物件設定為 null

isPrimaryTouch

function isPrimaryTouch(event){
  return (event.pointerType == `touch` ||
          event.pointerType == event.MSPOINTER_TYPE_TOUCH)
  && event.isPrimary
}

是否為主觸點。

pointerTypetouch 並且 isPrimarytrue 時,才為主觸點。 pointerType 可為 touchpenmouse ,這裡只處理手指觸控的情況。

isPointerEventType

function isPointerEventType(e, type){
  return (e.type == `pointer`+type ||
          e.type.toLowerCase() == `mspointer`+type)
}

觸發的是否為 pointerEvent

在低版本的移動端 IE 瀏覽器中,只實現了 PointerEvent ,並沒有實現 TouchEvent ,所以需要這個來判斷。

事件觸發

整體分析

$(document).ready(function(){
    var now, delta, deltaX = 0, deltaY = 0, firstTouch, _isPointerType

    $(document)
      .bind(`MSGestureEnd`, function(e){
        ...
      })
      .on(`touchstart MSPointerDown pointerdown`, function(e){
        ...
      })
      .on(`touchmove MSPointerMove pointermove`, function(e){
        ...
      })
      .on(`touchend MSPointerUp pointerup`, function(e){
        ...
      })
      
      .on(`touchcancel MSPointerCancel pointercancel`, cancelAll)

    $(window).on(`scroll`, cancelAll)

先來說明幾個變數,now 用來儲存當前時間, delta 用來儲存兩次觸控之間的時間差, deltaX 用來儲存 x軸 上的位移, deltaY 來用儲存 y軸 上的位移, firstTouch 儲存初始觸控點的資訊, _isPointerType 儲存是否為 pointerEvent 的判斷結果。

從上面可以看到, Zepto 所觸發的事件,是從 touchpointer 或者 IE 的 guesture 事件中,根據不同情況計算出來的。這些事件都繫結在 document 上。

IE Gesture 事件的處理

IE 的手勢使用,需要經歷三步:

  1. 建立手勢物件
  2. 指定目標元素
  3. 指定手勢識別時需要處理的指標
if (`MSGesture` in window) {
  gesture = new MSGesture()
  gesture.target = document.body
}

這段程式碼包含了前兩步。

on(`touchstart MSPointerDown pointerdown`, function(e){
  ...
  if (gesture && _isPointerType) gesture.addPointer(e.pointerId)
}

這段是第三步,用 addPointer 的方法,指定需要處理的指標。

bind(`MSGestureEnd`, function(e){
  var swipeDirectionFromVelocity =
      e.velocityX > 1 ? `Right` : e.velocityX < -1 ? `Left` : e.velocityY > 1 ? `Down` : e.velocityY < -1 ? `Up` : null
  if (swipeDirectionFromVelocity) {
    touch.el.trigger(`swipe`)
    touch.el.trigger(`swipe`+ swipeDirectionFromVelocity)
  }
})

接下來就是分析手勢了,Gesture 裡只處理 swipe 事件。

velocityXvelocityY 分別為 x軸y軸 上的速率。這裡以 1-1 為臨界點,判斷 swipe 的方向。

如果 swipe 的方向存在,則觸發 swipe 事件,同時也觸發帶方向的 swipe 事件。

start

on(`touchstart MSPointerDown pointerdown`, function(e){
  if((_isPointerType = isPointerEventType(e, `down`)) &&
     !isPrimaryTouch(e)) return
  firstTouch = _isPointerType ? e : e.touches[0]
  if (e.touches && e.touches.length === 1 && touch.x2) {
    touch.x2 = undefined
    touch.y2 = undefined
  }
  now = Date.now()
  delta = now - (touch.last || now)
  touch.el = $(`tagName` in firstTouch.target ?
               firstTouch.target : firstTouch.target.parentNode)
  touchTimeout && clearTimeout(touchTimeout)
  touch.x1 = firstTouch.pageX
  touch.y1 = firstTouch.pageY
  if (delta > 0 && delta <= 250) touch.isDoubleTap = true
  touch.last = now
  longTapTimeout = setTimeout(longTap, longTapDelay)
  if (gesture && _isPointerType) gesture.addPointer(e.pointerId)
})

過濾掉非觸屏事件

if((_isPointerType = isPointerEventType(e, `down`)) &&
   !isPrimaryTouch(e)) return
firstTouch = _isPointerType ? e : e.touches[0]

這裡還將 isPointerEventType 的判斷結果儲存到了 _isPointerType 中,用來判斷是否為 PointerEvent

這裡的判斷其實就是隻處理 PointerEventTouchEvent ,並且 TouchEventisPrimary 必須為 true

因為 TouchEvent 支援多點觸碰,這裡只取觸碰的第一點存入 firstTouch 變數。

重置終點座標

if (e.touches && e.touches.length === 1 && touch.x2) {
  touch.x2 = undefined
  touch.y2 = undefined
}

如果還需要記錄,終點座標是需要更新的。

正常情況下,touch 物件會在 touchEnd 或者 cancel 的時候清空,但是如果使用者自己呼叫了 preventDefault 等,就可能會出現沒有清空的情況。

這裡有一點不太明白,為什麼只會在 touches 單點操作的時候才清空呢?多個觸碰點的時候不需要清空嗎?

記錄觸碰點的資訊

now = Date.now()
delta = now - (touch.last || now)
touch.el = $(`tagName` in firstTouch.target ?
             firstTouch.target : firstTouch.target.parentNode)
touchTimeout && clearTimeout(touchTimeout)
touch.x1 = firstTouch.pageX
touch.y1 = firstTouch.pageY

now 用來儲存當前時間。

delta 用來儲存兩次點選時的時間間隔,用來處理雙擊事件。

touch.el 用來儲存目標元素,這裡有個判斷,如果 target 不是標籤節點時,取父節點作為目標元素。這會在點選偽類元素時出現。

如果 touchTimeout 存在,則清除定時器,避免重複觸發。

touch.x1touch.y1 分別儲存 x軸 座標和 y軸 座標。

雙擊事件

if (delta > 0 && delta <= 250) touch.isDoubleTap = true

可以很清楚地看到, Zepto 將兩次點選的時間間隔小於 250ms 時,作為 doubleTap 事件處理,將 isDoubleTap 設定為 true

長按事件

touch.last = now
longTapTimeout = setTimeout(longTap, longTapDelay)

touch.last 設定為當前時間。這樣就可以記錄兩次點選時的時間差了。

同時開始長按事件定時器,從上面的程式碼可以看到,長按事件會在 750ms 後觸發。

move

on(`touchmove MSPointerMove pointermove`, function(e){
  if((_isPointerType = isPointerEventType(e, `move`)) &&
     !isPrimaryTouch(e)) return
  firstTouch = _isPointerType ? e : e.touches[0]
  cancelLongTap()
  touch.x2 = firstTouch.pageX
  touch.y2 = firstTouch.pageY

  deltaX += Math.abs(touch.x1 - touch.x2)
  deltaY += Math.abs(touch.y1 - touch.y2)
})

move 事件處理了兩件事,一是記錄終點座標,一是計算起點到終點之間的位移。

要注意這裡還呼叫了 cancelLongTap 清除了長按定時器,避免長按事件的觸發。因為有移動,肯定就不是長按了。

end

on(`touchend MSPointerUp pointerup`, function(e){
  if((_isPointerType = isPointerEventType(e, `up`)) &&
     !isPrimaryTouch(e)) return
  cancelLongTap()

  if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) ||
      (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30))

    swipeTimeout = setTimeout(function() {
      if (touch.el){
        touch.el.trigger(`swipe`)
        touch.el.trigger(`swipe` + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2)))
      }
      touch = {}
    }, 0)

  else if (`last` in touch)
  
    if (deltaX < 30 && deltaY < 30) {
    
      tapTimeout = setTimeout(function() {
        
        var event = $.Event(`tap`)
        event.cancelTouch = cancelAll
        
        if (touch.el) touch.el.trigger(event)

        if (touch.isDoubleTap) {
          if (touch.el) touch.el.trigger(`doubleTap`)
          touch = {}
        }

        else {
          touchTimeout = setTimeout(function(){
            touchTimeout = null
            if (touch.el) touch.el.trigger(`singleTap`)
            touch = {}
          }, 250)
        }
      }, 0)
    } else {
      touch = {}
    }
  deltaX = deltaY = 0

})

swipe

cancelLongTap()
if ((touch.x2 && Math.abs(touch.x1 - touch.x2) > 30) ||
    (touch.y2 && Math.abs(touch.y1 - touch.y2) > 30))

  swipeTimeout = setTimeout(function() {
    if (touch.el){
      touch.el.trigger(`swipe`)
      touch.el.trigger(`swipe` + (swipeDirection(touch.x1, touch.x2, touch.y1, touch.y2)))
    }
    touch = {}
  }, 0)

進入 end 時,立刻清除 longTap 定時器的執行。

可以看到,起點和終點的距離超過 30 時,會被判定為 swipe 滑動事件。

在觸發完 swipe 事件後,立即觸發對應方向上的 swipe 事件。

注意,swipe 事件並不是在 end 系列事件觸發時立即觸發的,而是設定了一個 0ms 的定時器,讓事件非同步觸發,這個有什麼用呢?後面會講到。

tap

else if (`last` in touch)
  
  if (deltaX < 30 && deltaY < 30) {

    tapTimeout = setTimeout(function() {

      var event = $.Event(`tap`)
      event.cancelTouch = cancelAll

      if (touch.el) touch.el.trigger(event)

    }, 0)
  } else {
    touch = {}
  }
deltaX = deltaY = 0

終於看到重點了,首先判斷 last 是否存在,從 start 中可以看到,如果觸發了 startlast 肯定是存在的,但是如果觸發了長按事件,touch 物件會被清空,這時不會再觸發 tap 事件。

如果不是 swipe 事件,也不存在 last ,則只將 touch 清空,不觸發任何事件。

在最後會將 deltaXdeltaY 重置為 0

觸發 tap 事件時,會在 event 中加了 cancelTouch 方法,外界可以通過這個方法取消所有事件的執行。

這裡同樣用了 setTimeout 非同步觸發事件。

doubleTap

if (touch.isDoubleTap) {
  if (touch.el) touch.el.trigger(`doubleTap`)
  touch = {}
}

這個 isDoubleTapstart 時確定的,上面已經分析過了,在 end 的時候觸發 doubleTap 事件。

因此,可以知道,在觸發 doubleTap 事件之前會觸發兩次 tap 事件。

singleTap

touchTimeout = setTimeout(function(){
  touchTimeout = null
  if (touch.el) touch.el.trigger(`singleTap`)
  touch = {}
}, 250)

如果不是 doubleTap ,會在 tap 事件觸發的 250ms 後,觸發 singleTap 事件。

cancel

.on(`touchcancel MSPointerCancel pointercancel`, cancelAll)

在接受到 cancel 事件時,呼叫 cancelAll 方法,取消所有事件的觸發。

scroll

$(window).on(`scroll`, cancelAll)

從前面的分析可以看到,所有的事件觸發都是非同步的。

因為在 scroll 的時候,肯定是隻想響應滾動的事件,非同步觸發是為了在 scroll 的過程中和外界呼叫 cancelTouch 方法時, 可以將事件取消。

系列文章

  1. 讀Zepto原始碼之程式碼結構
  2. 讀Zepto原始碼之內部方法
  3. 讀Zepto原始碼之工具函式
  4. 讀Zepto原始碼之神奇的$
  5. 讀Zepto原始碼之集合操作
  6. 讀Zepto原始碼之集合元素查詢
  7. 讀Zepto原始碼之操作DOM
  8. 讀Zepto原始碼之樣式操作
  9. 讀Zepto原始碼之屬性操作
  10. 讀Zepto原始碼之Event模組
  11. 讀Zepto原始碼之IE模組
  12. 讀Zepto原始碼之Callbacks模組
  13. 讀Zepto原始碼之Deferred模組
  14. 讀Zepto原始碼之Ajax模組
  15. 讀Zepto原始碼之Assets模組
  16. 讀Zepto原始碼之Selector模組

參考

License

署名-非商業性使用-禁止演繹 4.0 國際 (CC BY-NC-ND 4.0)

最後,所有文章都會同步傳送到微信公眾號上,歡迎關注,歡迎提意見:

作者:對角另一面

相關文章