Demo
Note
dropdown 是一種很常見的 component,一般有兩種:
- 展開 dropdown menu 後,點選任意地方都應該收起 menu。
- 展開 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,有兩種辦法:
-
使用
window.addEventLister('click', handler)
替代document.addEventListener('click', handler)
,同時在 menu 內部點選時,呼叫合成事件的event.stopPropagation()
-
不呼叫
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 發推說以後可能會統一成非同步操作,拭目以待。
其它細節:
-
只有在 menu 展開時才註冊 document click handler,收起時移除 document click handler,是動態的。
handleGlobalClick = () => { console.log('global click') this.setState({dropDownExpanded: false}) document.removeEventListener('click', this.handleGlobalClick) } 複製程式碼
-
為了實現 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> 複製程式碼
-
註冊 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) } 複製程式碼
-
在
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 方法實現。
-
這個元件庫的 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)); } 複製程式碼
-
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(); } } 複製程式碼
-
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()
}
複製程式碼