從 Dropdown 的 React 實現中學習到的

YouKnowNothing發表於2018-10-08

Demo

Demo Link

從 Dropdown 的 React 實現中學習到的

從 Dropdown 的 React 實現中學習到的

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 處理。總之,邏輯變得複雜了。

從 Dropdown 的 React 實現中學習到的

後來用 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 的 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 的 React 實現中學習到的

即使只有一個 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()
}
複製程式碼

從 Dropdown 的 React 實現中學習到的

相關文章