最近调查了一个 bug,鼠标从 disabled 状态的 checkbox 上移走时 tooltip 没有消失:
简单查了之后发现是由于给父组件绑定的 mouseleave 事件没有触发引起的,用到的框架是 React,所以代码大概就是这样:
// 触发了mouseenter, 没有触发mouseleave
<div
onMouseEnter={() => console.log('mouseenter')}
onMouseLeave={() => console.log("mouseLeave")}
>
<input type="checkbox" disabled />
</div>
因为这是 React 的事件处理机制,所以我先试了下原生,看看同样的场景 mouseleave 有没有触发:
<-- 在原生写法中 -->
<div
onmouseenter="(() => { console.log('mouseenter') })()"
onmouseleave="(() => { console.log('mouseleave') })()"
>
<input type="checkbox" disabled />
</div>
可以看到,使用原生的事件监听方式,父组件绑定的 mouseleave 事件是会触发的。
接下来就可以缩小到是 React 事件处理机制的问题,我们去看下 React 的 onMouseLeave 具体是怎么实现的。
React 自定义了一套 事件插件系统,贴了一些主要流程的代码:
// EnterLeaveEventPlugin 是处理 mouseenter / mouseleave 两个事件的插件
EnterLeaveEventPlugin.registerEvents();
/*
注册事件
*/
function registerEvents() {
registerDirectEvent('onMouseEnter', ['mouseout', 'mouseover']);
registerDirectEvent('onMouseLeave', ['mouseout', 'mouseover']);
registerDirectEvent('onPointerEnter', ['pointerout', 'pointerover']);
registerDirectEvent('onPointerLeave', ['pointerout', 'pointerover']);
}
/*
给 React 事件和原生事件绑定依赖关系
用来之后通过原生事件来模拟 React 事件
*/
function registerDirectEvent(
registrationName: string,
dependencies: Array<DOMEventName>,
) {
if (__DEV__) {
// 不能重复绑定
if (registrationNameDependencies[registrationName]) {
console.error(
'EventRegistry: More than one plugin attempted to publish the same ' +
'registration name, `%s`.',
registrationName,
);
}
}
// 绑定依赖关系
registrationNameDependencies[registrationName] = dependencies;
for (let i = 0; i < dependencies.length; i++) {
// 将依赖的原生事件保存起来,之后会订阅这些事件
allNativeEvents.add(dependencies[i]);
}
}
所以 React 其实是通过 mouseover / mouseout 事件来模拟的 mouseenter 和 mouseleave 事件,当触发了订阅的事件之后会去遍历依赖关系,然后触发用户绑定的 React 事件。
然后我们再试一下同样的场景能不能触发原生
mouseout 事件吧。
emmm,果然没有触发。
我们去看下 mouseout 和 mouseleave 有什么区别吧:
MDN上面讲的是:
-
mouseleave- 指点设备(通常是鼠标)的指针移出某个元素时,会触发
mouseleave事件。 mouseleave不会冒泡。
- 指点设备(通常是鼠标)的指针移出某个元素时,会触发
-
mouseout- 当移动指针设备(通常是鼠标),使指针不再包含在这个元素或其子元素中时,**
mouseout**事件被触发。 mouseout会冒泡。
- 当移动指针设备(通常是鼠标),使指针不再包含在这个元素或其子元素中时,**
写个简单的例子试下:
- 父子组件都绑定了
mouseleave事件的情况下,先触发子组件,因为不会产生冒泡,所以父组件触发的target是其本身。
<div id="container">
<input id="checkbox" type="checkbox" />
</div>
<script>
function onMouseLeave(e) {
console.log(e.target)
}
document
.getElementById("container")
.addEventListener("mouseleave", onMouseLeave);
document
.getElementById("checkbox")
.addEventListener("mouseleave", onMouseLeave);
</script>
- 父子组件都绑定了
mouseout事件的情况下,先触发子组件,因为会产生冒泡,所以父组件触发的target还是其子组件。
所以当子元素遮盖了父元素的可视区域之后,父元素绑定的 mouseout 事件只能通过子元素触发事件之后的冒泡来触发,并且现在 disabled 状态的表单元素还不能触发 mouse event,所以正如开头发生的 mouseout 事件没有被触发,相反,不会冒泡的 mouseleave 事件不会受到影响。
最后解决这个 bug 的方法是在禁用的表单元素上添加样式:point-events: none,让禁用的表单元素永远不会成为鼠标事件的 target。所以鼠标再次移走时相当于是从父元素上面移走的。
总结
调查这个 bug,先怀疑是 React mouseleave 事件的问题,然后发现底层调用的是 mouseout 事件,通过对比 mouseleave 和 mouseout 的不同找到问题原因。