TouchEnd事件处理中的那些坑我替你踩过了

♫仙仙 交互 阅读 813
赞 15 收藏
二维码
手机扫码查看
反馈

TouchEnd事件处理的那些坑

昨天遇到一个奇怪的问题,移动端的点击效果怎么都触发不了,折腾了半天发现原来是touchend事件被preventDefault给阻止了。这里我把整个踩坑过程记录一下,免得以后再犯同样的错误。

TouchEnd事件处理中的那些坑我替你踩过了

事情是这样的,我在做一个移动端的滑动菜单组件,需要在touchstart的时候记录起始位置,touchmove的时候计算偏移量,touchend的时候判断是否达到阈值来决定是否切换菜单状态。按理说这应该是很基础的功能,但偏偏touchend就是不触发点击事件。

折腾半天才发现的真相

一开始我以为是事件冲突,试了各种event.stopPropagation()和stopImmediatePropagation(),都不管用。后来打印event对象发现了问题所在:touchend事件确实触发了,但是后面的click事件被阻断了。

查了一下资料才知道,在移动端,浏览器有个”fast click”机制,当touchend之后很快就会触发click事件。但如果在touchend里调用了preventDefault(),就会阻止这个click事件的触发。而我为了防止页面滚动,确实在touchmove里面加了preventDefault(),这就导致了连锁反应。

核心代码就这几行

我的原始代码大概是这样:

let startX = 0;
let startY = 0;
let isScrolling = false;

element.addEventListener('touchstart', function(e) {
    const touch = e.touches[0];
    startX = touch.clientX;
    startY = touch.clientY;
    isScrolling = undefined; // 重置滚动状态
});

element.addEventListener('touchmove', function(e) {
    if (isScrolling === undefined) {
        const touch = e.touches[0];
        const deltaX = Math.abs(touch.clientX - startX);
        const deltaY = Math.abs(touch.clientY - startY);
        
        // 判断是垂直滚动还是水平滚动
        isScrolling = deltaY > deltaX ? 'vertical' : 'horizontal';
    }
    
    // 关键问题在这里,如果判定为垂直滚动就要preventDefault
    if (isScrolling === 'vertical') {
        e.preventDefault(); // 这里阻止了默认行为
    }
});

上面这段代码看起来没问题,但在实际使用中会导致touchend后的click事件失效。后来我修改了一下逻辑:

let startX = 0;
let startY = 0;
let isScrolling = false;
let shouldPreventClick = false;

element.addEventListener('touchstart', function(e) {
    const touch = e.touches[0];
    startX = touch.clientX;
    startY = touch.clientY;
    isScrolling = undefined;
    shouldPreventClick = false; // 重置点击阻止标志
});

element.addEventListener('touchmove', function(e) {
    if (isScrolling === undefined) {
        const touch = e.touches[0];
        const deltaX = Math.abs(touch.clientX - startX);
        const deltaY = Math.abs(touch.clientY - startY);
        
        isScrolling = deltaY > deltaX ? 'vertical' : 'horizontal';
    }
    
    if (isScrolling === 'vertical') {
        e.preventDefault();
        shouldPreventClick = true; // 标记需要阻止click
    }
});

element.addEventListener('touchend', function(e) {
    // 触发业务逻辑
    handleTouchEnd();
    
    // 在touchend事件中手动处理点击逻辑
    setTimeout(() => {
        if (!shouldPreventClick && !isScrolling) {
            // 如果不需要阻止点击且没有滚动,则触发点击效果
            element.click();
        }
        // 重置状态
        isScrolling = false;
        shouldPreventClick = false;
    }, 0);
});

function handleTouchEnd() {
    // 这里处理touchend的具体业务逻辑
    console.log('touchend triggered');
}

踩坑提醒:这里注意我踩过好几次坑

上面的解决方案看似完美,但实际上还有个小问题。setTimeout里的逻辑可能会在某些情况下执行时机不对,特别是在快速连续操作的时候。后来我又优化了一下:

let startX = 0;
let startY = 0;
let isScrolling = false;
let touchStartTime = 0;

element.addEventListener('touchstart', function(e) {
    const touch = e.touches[0];
    startX = touch.clientX;
    startY = touch.clientY;
    isScrolling = undefined;
    touchStartTime = Date.now();
});

element.addEventListener('touchmove', function(e) {
    if (isScrolling === undefined) {
        const touch = e.touches[0];
        const deltaX = Math.abs(touch.clientX - startX);
        const deltaY = Math.abs(touch.clientY - startY);
        
        isScrolling = deltaY > deltaX ? 'vertical' : 'horizontal';
    }
    
    if (isScrolling === 'vertical') {
        e.preventDefault();
    }
});

element.addEventListener('touchend', function(e) {
    const touchDuration = Date.now() - touchStartTime;
    
    // 如果移动距离很小且持续时间短,认为是点击
    const touch = e.changedTouches[0];
    const endX = touch.clientX;
    const endY = touch.clientY;
    const moveDistance = Math.sqrt(
        Math.pow(endX - startX, 2) + 
        Math.pow(endY - startY, 2)
    );
    
    // 距离小于10像素且时间小于200ms认为是点击
    if (moveDistance < 10 && touchDuration < 200 && !isScrolling) {
        // 延迟触发点击,给浏览器时间处理原生click
        setTimeout(() => {
            element.click();
        }, 10);
    } else {
        // 处理滑动逻辑
        handleSwipe();
    }
    
    // 重置状态
    isScrolling = false;
});

还有个隐藏的bug

你以为这就完了?还没完呢!还有一个更隐蔽的问题:在某些Android机型上,touchend事件可能会延迟触发或者根本不触发。为了避免这种情况,我还加上了touchcancel事件的处理:

element.addEventListener('touchcancel', function(e) {
    // 处理触摸取消的情况
    isScrolling = false;
    shouldPreventClick = false;
});

另外,考虑到性能问题,建议在不需要的时候及时移除这些事件监听器,特别是在SPA应用中页面切换的时候,避免内存泄漏。

最后的小贴士

经过这次折腾,我总结了一些经验:

  • touchmove事件中的preventDefault()会影响后续的click事件,这是浏览器的行为而不是bug
  • 判断滑动方向时不要立即preventDefault,而是先标记状态
  • touchend事件的触发条件判断要综合考虑移动距离和时间
  • 记得处理touchcancel事件,防止异常情况

虽然现在有了Pointer Events API可以统一处理各种输入设备,但在实际项目中我还是更倾向于用传统的touch系列事件,因为兼容性更好,而且控制更精确。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论