前言
移动端原生支持touchstart
、touchmove
、touchend
等事件,但是在平常业务中我们经常需要使用swipe
、tap
、doubleTap
、longTap
等事件去实现想要的效果,对于这种自定义事件他们底层是如何实现的呢?让我们从Zepto.js
的touch
模块去分析其原理。您也可以直接查看
事件简述
Zepto的touch模块实现了很多与手势相关的自定义事件,分别是swipe
,swipeLeft
,swipeRight
,swipeUp
,swipeDown
,doubleTap
,tap
,singleTap
,longTap
事件名称 | 事件描述 |
---|---|
swipe | 滑动事件 |
swipeLeft | ←左滑事件 |
swipeRight | →右滑事件 |
swipeUp | ↑上滑事件 |
swipeDown | ↓下滑事件 |
doubleTap | 双击事件 |
tap | 点击事件(非原生click事件) |
singleTap | 单击事件 |
longTap | 长按事件 |
;['swipe', 'swipeLeft', 'swipeRight', 'swipeUp', 'swipeDown', 'doubleTap', 'tap', 'singleTap', 'longTap'].forEach(function(eventName){ $.fn[eventName] = function(callback){ return this.on(eventName, callback) }})
可以看到Zepto把这些方法都挂载到了原型上,这意味着,你可以直接用简写的方式例如$('body').tap(callback)
前置条件
在开始分析这些事件如何实现之前,我们先了解一些前置条件
- 部分内部变量
var touch = {}, touchTimeout, tapTimeout, swipeTimeout, longTapTimeout, // 长按事件定时器时间 longTapDelay = 750, gesture
touch
: 用以存储手指操作的相关信息,例如手指按下时的位置,离开时的坐标等。
touchTimeout
,tapTimeout
, swipeTimeout
,longTapTimeout
分别存储singleTap、tap、swipe、longTap事件的定时器。
longTapDelay
:longTap事件定时器延时时间
gesture
: 存储ieGesture事件对象
- 滑动方向判断(swipeDirection)
我们根据下图以及对应的代码来理解滑动的时候方向是如何判定的。需要注意的是浏览器中的“坐标系”和数学中的坐标系还是不太一样,Y轴有点反过来的意思。
/** * 判断移动的方向,结果是Left, Right, Up, Down中的一个 * @param {} x1 起点的横坐标 * @param {} x2 终点的横坐标 * @param {} y1 起点的纵坐标 * @param {} y2 终点的纵坐标 */function swipeDirection(x1, x2, y1, y2) { /** * 1. 第一个三元运算符得到如果x轴滑动的距离比y轴大,那么是左右滑动,否则是上下滑动 * 2. 如果是左右滑动,起点比终点大那么往左滑动 * 3. 如果是上下滑动,起点比终点大那么往上滑动 * 需要注意的是这里的坐标和数学中的有些不一定 纵坐标有点反过来的意思 * 起点p1(1, 0) 终点p2(1, 1) */ return Math.abs(x1 - x2) >= Math.abs(y1 - y2) ? (x1 - x2 > 0 ? 'Left' : 'Right') : (y1 - y2 > 0 ? 'Up' : 'Down')}
- 触发长按事件
function longTap() { longTapTimeout = null if (touch.last) { // 触发el元素的longTap事件 touch.el.trigger('longTap') touch = {} }}
在触发长按事件之前先将longTapTimeout
定时器取消,如果touch.last
还存在则触发之,为什么要判断touch.last
呢,因为swip
, doubleTap
,singleTap
会将touch对象置空,当这些事件发生的时候,自然不应该发生长按事件。
- 取消长按,以及取消所有事件
// 取消长按function cancelLongTap() { if (longTapTimeout) clearTimeout(longTapTimeout) longTapTimeout = null}// 取消所有事件function cancelAll() { if (touchTimeout) clearTimeout(touchTimeout) if (tapTimeout) clearTimeout(tapTimeout) if (swipeTimeout) clearTimeout(swipeTimeout) if (longTapTimeout) clearTimeout(longTapTimeout) touchTimeout = tapTimeout = swipeTimeout = longTapTimeout = null touch = {}}
方式都是类似,先调用clearTimeout取消定时器,然后释放对应的变量,等候垃圾回收。
整体结构分析
$(document).ready(function(){ /** * now 当前触摸时间 * delta 两次触摸的时间差 * deltaX x轴变化量 * deltaY Y轴变化量 * firstTouch 触摸点相关信息 * _isPointerType 是否是pointerType */ var now, delta, deltaX = 0, deltaY = 0, firstTouch, _isPointerType $(document) .bind('MSGestureEnd', function(e){ // xxx 先不看这里 }) .on('touchstart MSPointerDown pointerdown', function(e){ // xxx 关注这里 }) .on('touchmove MSPointerMove pointermove', function(e){ // xxx 关注这里 }) .on('touchend MSPointerUp pointerup', function(e){ // xxx 关注这里 }) .on('touchcancel MSPointerCancel pointercancel', cancelAll) $(window).on('scroll', cancelAll) })
这里将详细代码暂时省略了,留出整体框架,可以看出Zepto在dom,ready的时候在document
上添加了MSGestureEnd
,touchstart MSPointerDown pointerdown
,touchmove MSPointerMove pointermove
,touchcancel MSPointerCancel pointercancel
等事件,最后还给在window上加了scroll
事件。我们将目光聚焦在touchstart
,touchmove
,touchend
对应的逻辑,其他相对少见的事件在暂不讨论
touchstart
if((_isPointerType = isPointerEventType(e, 'down')) && !isPrimaryTouch(e)) return
要走到touchstart
事件处理程序后续逻辑中,需要先满足一些条件。到底是哪些条件呢?先来看看isPointerEventType
, isPrimaryTouch
两个函数做了些什么。
**isPointerEventType
function isPointerEventType(e, type){ return (e.type == 'pointer'+type || e.type.toLowerCase() == 'mspointer'+type)}
Pointer Event相关知识
isPrimaryTouch
function isPrimaryTouch(event){ return (event.pointerType == 'touch' || event.pointerType == event.MSPOINTER_TYPE_TOUCH) && event.isPrimary}
根据,其类型可以是mouse
,pen
,touch
,这里只处理其值为touch并且isPrimary为true的情况。
接着回到
if((_isPointerType = isPointerEventType(e, 'down')) && !isPrimaryTouch(e)) return
其实就是过滤掉非触摸事件。
触摸点信息兼容处理
// 如果是pointerdown事件则firstTouch保存为e,否则是e.touches第一个firstTouch = _isPointerType ? e : e.touches[0]
这里只清楚e.touches[0]
的处理逻辑,另一种不太明白,望有知晓的同学告知一下,感谢感谢。
复原终点坐标
// 一般情况下,在touchend或者cancel的时候,会将其清除,如果用户调阻止了默认事件,则有可能清空不了,但是为什么要将终点坐标清除呢?if (e.touches && e.touches.length === 1 && touch.x2) { // Clear out touch movement data if we have it sticking around // This can occur if touchcancel doesn't fire due to preventDefault, etc. touch.x2 = undefined touch.y2 = undefined}
存储触摸点部分信息
// 保存当前时间now = Date.now()// 保存两次点击时候的时间间隔,主要用作双击事件delta = now - (touch.last || now)// touch.el 保存目标节点// 不是标签节点则使用该节点的父节点,注意有伪元素touch.el = $('tagName' in firstTouch.target ? firstTouch.target : firstTouch.target.parentNode)// touchTimeout 存在则清除之,可以避免重复触发touchTimeout && clearTimeout(touchTimeout)// 记录起始点坐标(x1, y1)(x轴,y轴)touch.x1 = firstTouch.pageXtouch.y1 = firstTouch.pageY
判断双击事件
// 两次点击的时间间隔 > 0 且 < 250 毫秒,则当做doubleTap事件处理if (delta > 0 && delta <= 250) touch.isDoubleTap = true
处理长按事件
// 将now设置为touch.last,方便上面可以计算两次点击的时间差touch.last = now// longTapDelay(750毫秒)后触发长按事件longTapTimeout = setTimeout(longTap, longTapDelay)
touchmove
.on('touchmove MSPointerMove pointermove', function(e){ if((_isPointerType = isPointerEventType(e, 'move')) && !isPrimaryTouch(e)) return firstTouch = _isPointerType ? e : e.touches[0] // 取消长按事件,都移动了,当然不是长按了 cancelLongTap() // 终点坐标 (x2, y2) touch.x2 = firstTouch.pageX touch.y2 = firstTouch.pageY // 分别记录X轴和Y轴的变化量 deltaX += Math.abs(touch.x1 - touch.x2) deltaY += Math.abs(touch.y1 - touch.y2)})
手指移动的时候,做了三件事情。
- 取消长按事件
- 记录终点坐标
- 记录x轴和y轴的移动变化量
touchend
.on('touchend MSPointerUp pointerup', function(e){ if((_isPointerType = isPointerEventType(e, 'up')) && !isPrimaryTouch(e)) return // 取消长按事件 cancelLongTap() // 滑动事件,只要X轴或者Y轴的起始点和终点的距离超过30则认为是滑动,并触发滑动(swip)事件, // 紧接着马上触发对应方向的swip事件(swipLeft, swipRight, swipUp, swipDown) // swipe 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) // touch对象的last属性,在touchstart事件中添加,所以触发了start事件便会存在 // normal tap else if ('last' in touch) // don't fire tap when delta position changed by more than 30 pixels, // for instance when moving to a point and back to origin // 只有当X轴和Y轴的变化量都小于30的时候,才认为有可能触发tap事件 if (deltaX < 30 && deltaY < 30) { // delay by one tick so we can cancel the 'tap' event if 'scroll' fires // ('tap' fires before 'scroll') tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) // 创建自定义事件 var event = $.Event('tap') // 往自定义事件中添加cancelTouch回调函数,这样使用者可以通过该方法取消所有的事件 event.cancelTouch = cancelAll // [by paper] fix -> "TypeError: 'undefined' is not an object (evaluating 'touch.el.trigger'), when double tap // 当目标元素存在,触发tap自定义事件 if (touch.el) touch.el.trigger(event) // trigger double tap immediately // 如果是doubleTap事件,则触发之,并清除touch if (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {} } // trigger single tap after 250ms of inactivity // 否则在250毫秒之后。触发单击事件 else { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} }, 250) } }, 0) } else { // 不是tap相关的事件 touch = {} } // 最后将变化量信息清空 deltaX = deltaY = 0})
touchend事件触发时,相应的注释都在上面了,但是我们来分解一下这段代码。
swip事件相关
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)
手指离开后,通过判断x轴或者y轴的位移,只要其中一个跨度大于30便会触发swip
及其对应方向的事件。
tap,doubleTap,singleTap
这三个事件可能触发的前提条件是touch对象中还存在last属性,从touchstart事件处理程序中知道last在其中记录,而在touchend之前被清除的时机是长按事件被触发longTap
,取消所有事件被调用cancelAll
if (deltaX < 30 && deltaY < 30) { // delay by one tick so we can cancel the 'tap' event if 'scroll' fires // ('tap' fires before 'scroll') tapTimeout = setTimeout(function() { // trigger universal 'tap' with the option to cancelTouch() // (cancelTouch cancels processing of single vs double taps for faster 'tap' response) var event = $.Event('tap') event.cancelTouch = cancelAll // [by paper] fix -> "TypeError: 'undefined' is not an object (evaluating 'touch.el.trigger'), when double tap if (touch.el) touch.el.trigger(event) } }
只有当x轴和y轴的变化量都小于30的时候才会触发tap
事件,注意在触发tap事件之前,Zepto还将往事件对象上添加了cancelTouch属性,对应的也就是cancelAll方法,即你可以通过他取消所有的touch相关事件。
// trigger double tap immediatelyif (touch.isDoubleTap) { if (touch.el) touch.el.trigger('doubleTap') touch = {}}// trigger single tap after 250ms of inactivityelse { touchTimeout = setTimeout(function(){ touchTimeout = null if (touch.el) touch.el.trigger('singleTap') touch = {} }, 250)}
在发生触发tap事件之后,如果是doubleTap,则会紧接着触发doubleTap事件,否则250毫秒之后触发singleTap事件,并且都会讲touch对象置为空对象,以便下次使用
// 最后将变化量信息清空deltaX = deltaY = 0
touchcancel
.on('touchcancel MSPointerCancel pointercancel', cancelAll)
当touchcancel
被触发的时候,取消所有的事件。
scroll
$(window).on('scroll', cancelAll)
当滚动事件被触发的时候,取消所有的事件(这里有些不解,滚动事件触发,完全有可能是要触发tap或者swip等事件啊)。
结尾
最后说一个面试中经常会问的问题,touch击穿现象。如果对此有兴趣可以查看 , [新年第一发--深入不浅出zepto的Tap击穿问题 ](
参考
- [新年第一发--深入不浅出zepto的Tap击穿问题
](
- [[翻译]整合鼠标、触摸 和触控笔事件的Html5 Pointer Event Api](
文章目录
-
touch.js
- (2017-12-22)
-
ie.js
- (2017-11-03)
-
data.js
- (2017-10-03)
-
form.js
- (2017-10-01)
-
zepto.js
- (2017-08-26)
- (2017-08-30)
- (2017-10-2)
- (2017-11-13)
- (2017-12-10)
-
event.js
- (2017-06-05)
- (2017-06-07)
- (2017-06-08)
-
ajax.js
- (2017-06-11)