一个由 mouseleave 引发的 bug

2,688 阅读2分钟

最近调查了一个 bug,鼠标从 disabled 状态的 checkbox 上移走时 tooltip 没有消失:

1.gif

简单查了之后发现是由于给父组件绑定的 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>

2.gif

可以看到,使用原生的事件监听方式,父组件绑定的 mouseleave 事件是会触发的。

接下来就可以缩小到是 React 事件处理机制的问题,我们去看下 ReactonMouseLeave 具体是怎么实现的。

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 事件来模拟的 mouseentermouseleave 事件,当触发了订阅的事件之后会去遍历依赖关系,然后触发用户绑定的 React 事件。

3.gif 然后我们再试一下同样的场景能不能触发原生 mouseout 事件吧。

emmm,果然没有触发。

我们去看下 mouseoutmouseleave 有什么区别吧:

MDN上面讲的是:

  1. mouseleave

    • 指点设备(通常是鼠标)的指针移出某个元素时,会触发mouseleave事件。
    • mouseleave不会冒泡。
  2. mouseout

    • 当移动指针设备(通常是鼠标),使指针不再包含在这个元素或其子元素中时,**mouseout**事件被触发。
    • mouseout会冒泡。

写个简单的例子试下:

  1. 父子组件都绑定了 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>

1.png

  1. 父子组件都绑定了 mouseout 事件的情况下,先触发子组件,因为会产生冒泡,所以父组件触发的 target 还是其子组件。

2.png

所以当子元素遮盖了父元素的可视区域之后,父元素绑定的 mouseout 事件只能通过子元素触发事件之后的冒泡来触发,并且现在 disabled 状态的表单元素还不能触发 mouse event,所以正如开头发生的 mouseout 事件没有被触发,相反,不会冒泡的 mouseleave 事件不会受到影响。

最后解决这个 bug 的方法是在禁用的表单元素上添加样式:point-events: none,让禁用的表单元素永远不会成为鼠标事件的 target。所以鼠标再次移走时相当于是从父元素上面移走的。

总结

调查这个 bug,先怀疑是 React mouseleave 事件的问题,然后发现底层调用的是 mouseout 事件,通过对比 mouseleavemouseout 的不同找到问题原因。