大家好,我是老纪。
前两天遇到一个小Bug
,感觉有点儿意思,简单记录下。
这是一个原生的checkbox
,功能就是勾选或取消:
代码很简单,左边是原生的input
框,右侧是一个label
标签:
vue
<div class="uno-bg-color mt-1" v-show="!isFold">
<div v-for="(item, index) in menuItems" :key="item.name" class="flex flex-col flex-items-start">
<span class="text-sm color-white ml-2" :class="index === 0 ? 'mt-3' : ''"> {{ item.name }}</span>
<ul class="list-none ml--5">
<li v-for="name in item.children" :key="name" class="text-align-left text-sm text-white mb-2">
<input class="mr-2" type="checkbox" :id="name" :name="name" :value="name"
:checked="isSelected(name)" @click="change" />
<label :for="name" class="cursor-pointer">{{ name }}</label>
</li>
</ul>
</div>
</div>
谁成想与其他组同事的代码集成(动态加载他们的代码)后,点了死活没有反应了。
这时有两种可能,一种是 JS 层面,点击事件被拦截了;另一种是 CSS,原生的勾选样式被覆盖了。
从道理上讲,JS 的概率更大些(因为后者我也不知道怎么做)。
经过断点追踪,发现我的click
居然没有触发!
在浏览器里找到一个input
元素,在右侧Event Listeners
里查看绑定的事件:
click
事件的第二个,是我们的代码,而第一个则是集成的代码。展开看下是某个组件的处理:
也就是说,我的点击被这个全局事件坑了:
ts
document.addEventListener("click", (e) => {
e.preventDefault();
// xxx
}, false);
浏览器控制台界面上有个Remove
的按钮,点击后就移除了这个事件,我的功能就正常了。
于是给写这段代码的同事说,『不要这么做啊,能不能把它去掉』?正好这个业务在与我们集成时并不需要,同事很爽快地去掉了。
问题解决!
但我回头一想,不对劲啊!
我们知道,正常的点击事件是冒泡的,我点击checkbox
时,理论上应该先触发它的onclick
事件,再往上层冒泡。那么为什么还会出现我的代码被拦截的情况呢?
事件委托
我起始的想法是:Vue
应该与React
一样,用事件委托(代理),在根元素上绑定事件,以优化性能;当我加载同事代码时,因为某种顺序问题,先执行了他的代码,导致我的代码被拦截了。
但再看这个截图,明显这个事件是绑定在具体元素(这里是input
)上的啊,难道浏览器会出错?
所以,
Vue3
并没有使用事件委托进行事件合成。
我在知乎这个问题下看的答案,表示现代浏览器不需要再做这方面优化,不知道靠不靠谱:
言归正传。
我们在元素的事件处理里右键菜单:
选中显示函数定义的地方,会跳转到Vue
的源码:
打断点追踪也确实只是普通元素的事件绑定。
坑在何处
既然Vue3
并没有使用事件委托,那么为什么会出现这种冲突的情况呢?
我心中窃喜,又一个探(装)案(B)的机会来了!
我有种预感,真相可能并不复杂。在我的辛苦努力下,终于发现了华点。
大家仔细看这段代码:
vue
<input class="mr-2" type="checkbox" :id="name" :name="name" :value="name"
:checked="isSelected(name)" @click="change" />
<label :for="name" class="cursor-pointer">{{ name }}</label>
我为每个input
框设置了一个id
,原因是为了点击label
时也能起到选中checkbox
一样的效果,for
属性是关键。
为什么不将click
事件放在父级div
上呢?因为我在change
事件中要获取input
元素当前的选中状态:
ts
function change(e: MouseEvent) {
const target = e.target as HTMLInputElement;
const value = target.value;
const checked = target.checked;
// do else
}
正是这样一个处理,引发了此次的问题。
为方便大家理解,我写个更简单的示例:
vue
<script setup lang="ts">
function handleClick() {
console.log('--111----');
}
</script>
<template>
<div>
<input type="checkbox" id="test" @click="handleClick" />
<label for="test">点击我</label>
</div>
</template>
再来一段全局事件注册:
ts
document.addEventListener(
"click",
(e) => {
console.log("----222---");
e.preventDefault();
// xxx
},
false
);
页面是这样的朴实无华:
注意点击左侧checkbox
:
这时打印结果符合预期------由于preventDefault
的存在,checkbox
的选中状态没有变化。
但当点击label
时,坑爹的来了!
只触发了document
的事件!连checkbox
上的click
事件都没有触发!
这也正是这个问题的狡猾之处,因为大部分情况我都是点击的
label
,而非checkbox
本身!如果仅仅是
checkbox click
执行后,样式没有变化,那么问题反倒简单了,阻止事件冒泡就可以了。
如果去除document
事件中的e.preventDefault()
,打印就有趣了:
顺序居然是:document
-> input
-> document
!
也就是说,
label
与checkbox
的绑定,会先触发全局点击事件,如果没有被阻止,会再触发绑定元素的事件,继而冒泡。而checkbox
本身的点击则遵循正常的冒泡逻辑。
不吃一堑,难长一智啊!
优化方案
找到了罪魁祸首,下来的处理就非常简单了。
- 去除
label
与input
的绑定,老老实实往父级div
上注册事件,至于checkbox
的状态,完全可以在内存中记录。 - 事件停止冒泡,添加
.stop
修饰符即可。
vue
<script setup lang="ts">
import { ref } from 'vue';
const checked = ref(false);
function handleClick() {
checked.value = !checked.value;
console.log('--111----');
}
</script>
<template>
<div @click.stop="handleClick">
<input type="checkbox" id="test" v-model="checked" />
<label>点击我</label>
</div>
</template>
这时再怎么点击,都不会触发document
的事件了。
总结
本文讲述了我在开发过程中遇到的一个小Bug
以及解决过程。
首先,我展示了一个使用原生checkbox
和label
标签的简单Vue
代码,并描述了在与同事代码集成后出现点击无响应的问题。经过分析,发现是由于全局事件监听器中的preventDefault
导致的点击事件被拦截。虽然我让同事将全局拦截的代码去掉了解决了问题,但同时引发思考,正常来说不应该是先冒泡吗?为什么checkbox
的click
事件没有触发?
接下来,我们简单看了下Vue3
与React
在事件处理上的不同,Vue3
并没有使用事件委托,将事件绑定在根元素上。这可能与现代浏览器的优化有关,毕竟事件委托各有利弊。因为并非本文重点,笔者没有深究。
我通过简单示例和调试过程展示了问题根源:在document
事件preventDefault
的情况下,label
与checkbox
的绑定,会先触发全局点击事件,如果没有被阻止,会再触发绑定元素的事件,继而冒泡。而checkbox
本身的点击则遵循正常的冒泡逻辑。
最终的解决方案是将点击事件直接绑定在父级div
上,并使用.stop
修饰符停止事件冒泡,以确保点击div
时不会触发全局事件。
事后想想,我的事件处理中,使用target.checked
来获取状态,还是jQuery
时代的遗毒啊。拥抱数据驱动不够彻底,合该有此一劫!