依然先说结论:目前为止所有的点击问题都是移动端事件触发顺序混乱导致的!

关于捕获和冒泡→

我们首先要知道的是:当我们鼠标按下一个按钮时,并不是“点击了一个按钮”,而是在这个区域内,鼠标(上的按键)被按下,操作系统和浏览器把这个信息对应到了“按钮”所在区域并触发其逻辑。
事实上鼠标点击并没有位置信息,是操作系统一直在监听鼠标移动,根据累积的位移计算出来的坐标,将其传给浏览器。

那么,把这个坐标转换为具体的元素上的事件的过程,就可称作“捕获”。那“冒泡”呢?这个不好直观解释,但有一点想必你是明白的:当你按下电视开关时,你也按到了电视!

这就是很多文章会讲到的“冒泡过程由内向外,捕获过程由外向内”,或者说是“洋葱模型”。

还有一点就是:事件​​addEventListener​​​的第三个参数 ​​true/false​​ ,即为“是捕获/冒泡”。(别多想,这只是浏览器提供的事件模型之一。无论是否监听,在一个事件发生时,捕获和冒泡总是先后发生的)

点击穿透

​touch​​​ 事件结束后会默认触发元素的 ​​click​​ 事件,如没有设置完美视口,则事件触发的时间间隔为 300ms 左右,如设置完美视口则时间间隔为 30ms 左右(备注:具体的时间也看设备的特性)。

如果 touch 事件隐藏了元素,则 click 动作将作用到新的元素上,触发新元素的 click 事件或页面跳转,此现象称为点击穿透。

点击穿透会导致什么问题?

最常见的情况可能也就是发生在弹层中了 —— 你点击某一个元素结果触发了下面元素的事件,导致某些“诡异”现象的发生:
(以下截自我司微店APP某项目,代码已做脱敏处理)

移动端点击穿透之谜_vue.js

在这个场景中可以看到:我在点开小黑弹框的时候在其层级之下、列表卡片组件层级之上新增了一个全屏弹层,试图让触摸到弹层的时候就关掉弹层和小黑弹框 —— 以便让再次点击或唤起其余弹框。

这里笔者封装的卡片组件中模仿elementUI给弹框加了‘底部校验’,即弹框的展开并不一定是在下部。这也就导致了“唤起以后不管他只有在再次点击‘管理’按钮时才消失”这个想法不能使用,因为会导致重叠!

通过上面的描述你可能感觉到了一丝问题:“触摸到弹层”意味着我使用的不是​​click​​ 事件!

<div class="single-card" ref="singleRef">
<div class="single-mkt-card">
<!-- 一些结构 -->

<ul class="single-controller">
<li class="single-c-control" :class="{'single-li': list_data.status===2}" @click="handleControl">管理
<div class="single-c-tip" :class="singleTop" v-show="isBindControl">
<!-- 弹框里的结构 -->
<slot name="cardEdit" />
</div>
</li>
<li :class="xxx" @click="xxx">预览</li>
<li :class="xxx" @click="xxx">数据</li>
<li :class="xxx" v-if="list_data.status !== 2" @click="xxx"><i class="single-icon"></i>推广</li>
</ul>
</div>
<!-- bg弹层 -->
<div v-show="isBindControl" class="single-fx-tip" @touchstart="handleControlEdit"></div>
</div>
handleControl(){
if(this.$refs.singleRef.getBoundingClientRect().bottom > 515){
this.isBoundingTop = true
}else{
this.isBoundingTop = false
}
this.isBindControl = !this.isBindControl;
},
handleControlEdit(e){
this.isBindControl = false;
},

结合上面说的“事件捕获”和“事件冒泡”,你应该也想到了这样一条过程:为了用户体验,在触摸到弹层的时候,弹层已经消失了;然后手指按到了别的按钮,移动端浏览器执行了click事件,于是别的按钮绑定的事件被触发了,你以为的“bug”就产生了。

怎么解决

点击穿透的产生原因既然是“移动端事件触发顺序”。那么就分为几种情况:

  1. 不同事件挂载在不同&非父子祖孙元素上,但是这些元素之间有关联
  2. 不同事件挂载在相同/父子祖孙元素上
  3. 相同事件作用在父子祖孙元素上

方案1:css pointer-events属性

css3的​​pointer-events​​​属性在这方面可算是“风光独盛”。当值为​​none​​时表示禁止穿透。当绑定元素的后代元素的​​pointer-events​​属性指定其他值时,鼠标事件可以指向后代元素,在这种情况下,鼠标事件将在捕获或冒泡阶段触发父元素的事件侦听器。

.single-fx-tip {
pointer-events: none;
}

但很显然。这种方式不能作用在上面第一个场景中,因为事件的发生顺序问题,它并不能很好的监听到(但是这个属性在别的场景下非常有用!)。那么我们可以用另一种“取巧的方法”实现:

方案2:模拟click-300ms

上面提到了“设置完美视口”,也就是移动端常用的

<meta name="viewport" content="width=device-width">

如果没有,因为移动端要判断是否是双击,所以单击之后不能立刻触发click,要等上个300ms,知道确认了是不是双击 —— 事实上,即使设置了,也会有一定的延迟!

所以我们可以主动让元素在触发了某个事件后延迟 300ms 再走下面的流程:

handleControlEdit(e){
setTimeout(()=>{
this.xxx = false; // 这时候就需要给浮层换一个控制变量了
},300)
},

方案3:阻止默认行为

除了上面说的“​​touch​​​ 事件结束后会默认触发元素的 ​​click​​ 事件”,还有就是,在微信自带的浏览器中,有一个“触顶下拉回弹”的操作,这其实是不应该的。它也属于浏览器默认事件。

一般我们需要禁止这种行为:

handleControlEdit(e){
this.isBindControl = false;
},

在原生代码中,考虑到兼容性问题,可以这么写:

// 全局阻止浏览器默认行为
document.addEventListener("touchstart",function(e){
//xxx
if(e.cancelable){
e.preventDefault();
}
},{passive: false})

如果你用了“事件代理”,则可以使用

e.stopPropagation();

阻止事件冒泡。

点击和页面跳转

移动端页面跳转可以使用 a 链接,也可以使用 ​​touchstart​​ 事件来触发 JS 代码完成跳转

  • 效率上,touchstart 速度更快
  • SEO 优化上, a 链接效果更好

尤其是在webview中,不必关注SEO效果,除非有一个统一且硬性的规范,否则,我个人还是推荐第一个的。