從 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()
}
複製代碼

相關文章
相關標籤/搜索