以前听到前辈们说移动端尽量不要使用click,click会比较迟钝,能用touchstart还是用touchstart。但是用touchstart会有一个问题,用户在滑动页面的时候要是不小心碰到了相关元素也会触发touchstart,所以两者都有缺点。那怎么办呢?


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.

大意是说因为移动端要判断是否是双击,所以单击之后不能够立刻触发click,要等300ms,直到确认不是双击了才触发click。所以就导致了click有延迟。

更为重要的是,文档里面还提到在2014年的Chrome 32版本已经把这个延迟去掉了,如果有一个meta标签:

<meta name="viewport" content="width=device-width">复制代码
<meta name="viewport" content="width=device-width">复制代码

即把viewport设置成设备的实际像素,那么就不会有这300ms的延迟,并且这个举动受到了IE/Firefox/Safari(IOS 9.3)的支持,也就是说现在的移动端开发可以不用顾虑click会比较迟钝的问题。

如果设置initial-scale=1.0,在chrome上是可以生效,但是Safari不会:

<meta name="viewport" content="initial-scale=1.0">复制代码
<meta name="viewport" content="initial-scale=1.0">复制代码

还有第三种办法就是设置CSS:

html{
    touch-action: manipulation;
}复制代码
html{
    touch-action: manipulation;
}复制代码

这样也可以取消掉300ms的延迟,Chrome和Safari都可以生效。

click是在什么时候触发的呢?来研究一下click/touch事件的触发先后顺序。

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>复制代码
<!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;复制代码
  /**
   * 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;
}复制代码
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);复制代码
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.$ = $;复制代码
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 = {}; 
            }   
        }   
    } 
}复制代码
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;复制代码
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]);
        }       
    }
}复制代码
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(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); 
        }       
    }
}复制代码
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){

    });
}复制代码
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;
});复制代码
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;
});复制代码
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;
});复制代码
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;
    }   
}复制代码
class $Event{
    constructor(pageX, pageY){
        this.pageX = pageX;
        this.pageY = pageY;
    }   
}复制代码

然后就可以使用这个tap事件了,如下代码所示:

$("#target").on("tap", function(event){
    console.log("tap", event.pageX, event.pageY);
});复制代码
$("#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));
});复制代码
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);
});复制代码
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);复制代码
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);
}复制代码
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");
});复制代码
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事件。