从 Dropdown 的 React 实现中学习到的
Demo
Demo Link
Note
dropdown 是一種很常見的 component,一般有兩種:
在 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 合成事件和原生事件的阻止冒泡
React 的 issue:
- e.stopPropagation() seems to not be working as expect.
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 bodyif (this._dropdown_body && this._dropdown_body.contains(event.target)) returnthis.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 tickdocument.addEventListener('click', this.handleGlobalClick)}, 0)} 復制代碼在 componentWillUnmount() 中要移除 document 的 click handler,以免造成內存泄漏。
componentWillUnmount() {// important! we need remove global click handler when unmoutdocument.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.propsonClick && 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 windowevent.stopPropagation() } 復制代碼后來我想,那其它開源的 React 組件庫中的 Dropdown 都是怎么實現的呢,于是探究了一下,果然不出意外,也是用的原生的 addEventListener 實現的,但也有點意外的是,它們并沒有用 window.addEventListener,而都是用了 document.addEventListener 和 node.contains 方法實現。
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-spreadtarget.addEventListener.apply(target, getEventListenerArgs(eventName, callback, options));}function off(target, eventName, callback, options) {// eslint-disable-next-line prefer-spreadtarget.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/50if (state.popupVisible) {let currentDocument;if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextMenuToShow())) {currentDocument = props.getDocument();this.clickOutsideHandler = addEventListener(currentDocument,'mousedown', this.onDocumentClick);}// always hide on mobileif (!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)) returnconst { onClick } = this.propsonClick && 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 实现中学习到的的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于不同的MySQL复制解决方案概述
- 下一篇: [转] 视频直播前端方案