从 Dropdown 的 React 实现中学习到的

Demo

Demo Link

Note

dropdown 是一种很常见的 component,一般有两种:

  1. 展开 dropdown menu 后,点击任意地方都应该收起 menu。
  2. 展开 dropdown menu 后,点击 menu 内部,不会收起 menu,只有点击 menu 外部,才收起 menu。

在 jQuery 时代,dropdown 是很好实现的,直接用 document.addEventListener('click', handler),监听 document 的 click 事件,然后让 dropdown 的 menu 隐藏起来。如果想让 menu 内部的点击不收起 menu,则让 menu 内部的点击事件执行 event.stopPropagation()

刚开始做 React 开发的时候,不知道是从哪接收到的思想,觉得 document.addEventListener() 的 API 不那么 React,很排斥使用。这样,在实现 dropdown component 时,怎么处理在 menu 以外点击时让 menu 收起来成了一个头疼的问题。

我查了文档,觉得可以用 onBlur 这个事件,但为了能够接收到 onBlur 事件,menu 内部必须是 input 类型的 component,或者是有 tabIndex 属性,然后加上 tabIndex 后,当 component 处于 onFocus 时,会额外在边框上加上阴影的样式,像下图所示,必须额外再加 css 处理。总之,逻辑变得复杂了。

后来用 React 做音乐播放器,看别人的实现源码,发现他们都大都使用了 audioElement.addEventListener('play', handler) 这种原生 API,而且,有些逻辑如果不用原生事件就没法处理,比如监听 window 的 resize 事件,似乎除了用 window.addEventListener('resize', handler) 就没有其它办法了。因此再回过头来看 dropdown 的实现,如果也用 document.addEventListener('click', handler) 处理 menu 以后的点击的话,逻辑就简单多了。

但是,也还是有坑的。

坑之一,React 的 event.stopPropagation() 无法阻止原生事件冒泡到 document。

看这篇文章的详细介绍:

React 的 issue:

React 有两套事件系统,一套是原生事件系统,就是 document.addEventListener() 这种 API,另一套是 React 自己定义的,叫 SyntheticEvent (合成事件),比如下例中的 onClick

<a onClick={this.clickLink}>Open</a>
复制代码

实际 React 的所有合成事件都是绑定在 document 上的 (所谓的代理方式),而不是单独绑在各个 component 上,当你执行合成事件中的 event.stopPropagation() 时,实际原生事件已经到达 document 了。

所以 React 的 event.stopPropagation() 只能阻止合成事件继续往上冒泡,却不能阻止原生事件往上冒泡到 document。

所以你会发现,为什么我已经在 menu 内部的点击事件 handler 中 stopPropagation 了,为什么全局的 click handler 还是会执行,这就是原因。

但是! React 的合成事件的 stopPropagation 虽然不能阻止事件冒泡到 document,但它可以阻止事件冒泡到 window。

(这件事让我想起,在某个项目中,我用了 React 的 event.stopPropagation(),导致 turbolinks 不工作了,当时觉得很理所当然,现在回想,不对,turoblinks 绑定的是原生事件,如果它是绑在 <a> tag 上的话,不应该不工作的啊,由此我推断 turbolinks 的 click 事件是绑定在 window 上的,后来看了源码,的确是这样的)

所以,为了在 React 的 dropdown 中实现点击 menu 外部收起 menu,点击内部不收起 menu,有两种办法:

  1. 使用 window.addEventLister('click', handler) 替代 document.addEventListener('click', handler),同时在 menu 内部点击时,调用合成事件的 event.stopPropagation()

  2. 不调用 event.stopPropagation(),让事件冒泡到全局 (document 或 window 皆可) 的 click handler 中,在 handler 中判断 event.target 中在 menu 内部还是外部,使用 DOMNode.contains() 方法判断。这种方法需要用 React 的 ref 属性把 menu 的引用保存下来,如下所示:

     <div className="dropdown-body" ref={ref=>this._dropdown_body=ref}>
    复制代码

    判断:

     handleGlobalClick = (event) => {
       console.log('global click')
    
       // use DOMNode.contains() method to judge click target is in or out of the dropdown body
       if (this._dropdown_body && this._dropdown_body.contains(event.target)) return
    
       this.setState({dropDownExpanded: false})
       document.removeEventListener('click', this.handleGlobalClick)
     }
    复制代码

坑之二,在原生事件的 handler 中,this.setState() 是同步的,不是异步的,让我很惊讶。之前一直以为 this.setState() 肯定是异步的。

具体的分析可以看这篇文章 - 你真的理解 setState 吗?

总结:

setState 只在合成事件和生命周期函数中是 "异步" 的,在原生事件和 setTimeout 中都是同步的。

但在 twitter 上看 Dan 发推说以后可能会统一成异步操作,拭目以待。

其它细节:

  1. 只有在 menu 展开时才注册 document click handler,收起时移除 document click handler,是动态的。

     handleGlobalClick = () => {
       console.log('global click')
    
       this.setState({dropDownExpanded: false})
       document.removeEventListener('click', this.handleGlobalClick)
     }
    复制代码
  2. 为了实现 toggle 的效果,即点击按钮,展开 dropdown menu,再点击按钮,则收到 menu,最简单的办法是,只有在 menu 收起的时候,才给按钮绑定 click handler,menu 展开的时候,按钮没有 click handler,让 document click handler 处理。否则,同时在合成事件的 handler 和原生事件的 handler 中调用 this.setState(),一个异步,一个同步,可能会引起麻烦。

     <div className="dropdown-head">
       {
         dropDownExpanded ?
         <button>Collapse dropdown menu - 1</button> :
         <button onClick={this.handleHeadClick}>Open dropdown menu - 1</button>
       }
     </div>
    复制代码
  3. 注册 document 的 click handler 时,必须在 setTimeout 回调中执行。

     handleHeadClick = () => {
       console.log('head click')
    
       this.setState({dropDownExpanded: true})
       setTimeout(()=>{
         // must run in the next tick
         document.addEventListener('click', this.handleGlobalClick)
       }, 0)
     }
    复制代码
  4. componentWillUnmount() 中要移除 document 的 click handler,以免造成内存泄漏。

     componentWillUnmount() {
       // important! we need remove global click handler when unmout
       document.removeEventListener('click', this.handleGlobalClick)
     }
    复制代码

猜你喜欢

转载自juejin.im/post/5bb1812a6fb9a05d082a3361