从 Dropdown 的 React 实现中学习到的

Demo

Demo Linkcss

Note

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

  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()node

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

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

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

可是,也仍是有坑的。github

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

看这篇文章的详细介绍:异步

React 的 issue:ide

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 的 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)
     }
    复制代码

Update

自从发现用 window.addEventListener('click', handler) 能够很方便地用来实现收起 React 中的 Dropdown 后,我就不亦乐乎的处处用起来了。为了不写无数遍的 window.addEventLister('click', handler),我封装了一个 NativeClickListener 的 Component,代码没几行,以下所示:

export default class NativeClickListener extends React.Component {
  static propTypes = {
    onClick: PropTypes.func
  }

  clickHandler = (event) => {
    console.log('NativeClickListener click')
    const { onClick } = this.props
    onClick && onClick(event)
  }

  componentDidMount() {
    window.addEventListener('click', this.clickHandler)
  }

  componentWillUnmount() {
    window.removeEventListener('click', this.clickHandler)
  }

  render() {
    return this.props.children
  }
}
复制代码

使用:

<div className="dropdown-container">
  <div className="dropdown-head">
    <button onClick={this.handleHeadClick}>
      {dropDownExpanded ? 'Collapse' : 'Open'} dropdown menu - 5
    </button>
  </div>
  {
    dropDownExpanded &&
    <NativeClickListener onClick={()=>this.setState({dropDownExpanded: false})}>
      <div className="dropdown-body"
          onClick={this.handleBodyClick}>
          ...
      </div>
    </NativeClickListener>
  }
</div>

handleHeadClick = (event) => {
  console.log('head click')
  this.setState(prevState => ({dropDownExpanded: !prevState.dropDownExpanded}))
  event.stopPropagation()
}
handleBodyClick = (event) => {
  console.log('body click')
  // just can stop event propagate from document to window
  event.stopPropagation()
}
复制代码

后来我想,那其它开源的 React 组件库中的 Dropdown 都是怎么实现的呢,因而探究了一下,果真不出意外,也是用的原生的 addEventListener 实现的,但也有点意外的是,它们并无用 window.addEventListener,而都是用了 document.addEventListener 和 node.contains 方法实现。

  1. Material Kit React

    这个组件库的 Dropdown 用到了 @material-ui/core/ClickAwayListener,来看看它的实现。

    handleClickAway = event => {
       ...
       if (
         doc.documentElement &&
         doc.documentElement.contains(event.target) &&
         !this.node.contains(event.target)
       ) {
         this.props.onClickAway(event);
       }
     }
    
     render() {
       const { children, mouseEvent, touchEvent, onClickAway, ...other } = this.props;
       const listenerProps = {};
       if (mouseEvent !== false) {
         listenerProps[mouseEvent] = this.handleClickAway;
       }
       if (touchEvent !== false) {
         listenerProps[touchEvent] = this.handleClickAway;
       }
    
       return (
         <React.Fragment>
           {children}
           <EventListener target="document" {...listenerProps} {...other} />
         </React.Fragment>
       );
     }
    复制代码

    addEventListener 的逻辑看来在 EventListener 中,来自 react-event-listener 库。并且从 target="document" 来看,event 是绑在 document 上的。

    class EventListener extends React.PureComponent {
       componentDidMount() {
         this.applyListeners(on);
       }
       applyListeners(onOrOff, props = this.props) {
         const { target } = props;
    
         if (target) {
           let element = target;
    
           if (typeof target === 'string') {
             element = window[target];
           }
    
           forEachListener(props, onOrOff.bind(null, element));
       }
       ...
     }
     function on(target, eventName, callback, options) {
       // eslint-disable-next-line prefer-spread
       target.addEventListener.apply(target, getEventListenerArgs(eventName, callback, options));
     }
     function off(target, eventName, callback, options) {
       // eslint-disable-next-line prefer-spread
       target.removeEventListener.apply(target, getEventListenerArgs(eventName, callback, options));
     }
    复制代码
  2. Ant Design 中的 Dropdown 的实现最终能够追溯到 react-component/trigger 组件。

    // We must listen to `mousedown` or `touchstart`, edge case:
     // https://github.com/ant-design/ant-design/issues/5804
     // https://github.com/react-component/calendar/issues/250
     // https://github.com/react-component/trigger/issues/50
     if (state.popupVisible) {
       let currentDocument;
       if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextMenuToShow())) {
         currentDocument = props.getDocument();
         this.clickOutsideHandler = addEventListener(currentDocument,
           'mousedown', this.onDocumentClick);
       }
       // always hide on mobile
       if (!this.touchOutsideHandler) {
         currentDocument = currentDocument || props.getDocument();
         this.touchOutsideHandler = addEventListener(currentDocument,
           'touchstart', this.onDocumentClick);
       }
       // close popup when trigger type contains 'onContextMenu' and document is scrolling.
       if (!this.contextMenuOutsideHandler1 && this.isContextMenuToShow()) {
         currentDocument = currentDocument || props.getDocument();
         this.contextMenuOutsideHandler1 = addEventListener(currentDocument,
           'scroll', this.onContextMenuClose);
       }
       // close popup when trigger type contains 'onContextMenu' and window is blur.
       if (!this.contextMenuOutsideHandler2 && this.isContextMenuToShow()) {
         this.contextMenuOutsideHandler2 = addEventListener(window,
           'blur', this.onContextMenuClose);
       }
       return;
     }
    
     onDocumentClick = (event) => {
       if (this.props.mask && !this.props.maskClosable) {
         return;
       }
    
       const target = event.target;
       const root = findDOMNode(this);
       if (!contains(root, target) && !this.hasPopupMouseDown) {
         this.close();
       }
     }
    复制代码
  3. JetBrain 的 ring-ui 的 Dropdown 并无实如今其它地方点击后让 Dropdown 收起的功能,有点意外...

一开始不是很理解,不事后来我发现,若是用 window.addEventListener('click', handler) 的方式收起 Dropdown,在一个页面中,若是有多个 Dropdown,我先展开一个 Dropdown menu (称之为 A),再点击另外一个 Dropdown (称之为 B),由于在 Dropdown B 的点击事件中调用了 event.stopPropagation(),所以 Dropdown A 的 global click handler 将没法触发,所以 Dropdown A 没法收起。

即便只有一个 Dropdown,若是页面中有其它任意地方的 event handler 中调用了 event.stopPropagation() 都会致使此 Dropdown 有可能没法收起。

可是用 document.addEventListener('click', handler) 配合 node.contains() 方法却不会有这个问题,所以恍然大悟,终于明白了为何那些开源组件库并无采用 window.addEventListener() 的方式。

因而实现 NativeClickListener2:

export default class NativeClickListener extends React.Component {
  static propTypes = {
    onClick: PropTypes.func
  }

  clickHandler = (event) => {
    console.log('NativeClickListener click')
    if(this._container.contains(event.target)) return

    const { onClick } = this.props
    onClick && onClick(event)
  }

  componentDidMount() {
    document.addEventListener('click', this.clickHandler)
  }

  componentWillUnmount() {
    document.removeEventListener('click', this.clickHandler)
  }

  render() {
    return (
      <div ref={ref=>this._container=ref}>
        {this.props.children}
      </div>
    )
  }
}
复制代码

使用:

<div className="dropdown-container">
  <div className="dropdown-head">
    <button onClick={this.handleHeadClick}>
      {dropDownExpanded ? 'Collapse' : 'Open'} dropdown menu - 5
    </button>
  </div>
  {
    dropDownExpanded &&
    <NativeClickListener2 onClick={()=>this.setState({dropDownExpanded: false})}>
      <div className="dropdown-body"
          onClick={this.handleBodyClick}>
          ...
      </div>
    </NativeClickListener2>
  }
</div>

handleHeadClick = (event) => {
  console.log('head click')
  this.setState(prevState => ({dropDownExpanded: !prevState.dropDownExpanded}))
  // no need
  // event.stopPropagation()
}
handleBodyClick = (event) => {
  console.log('body click')
  // no need
  // event.stopPropagation()
}
复制代码

相关文章
相关标签/搜索