从 Dropdown 的 React 实现中学习到的
Demo
Demo Link
Note
dropdown 是一種很常見的 component,一般有兩種:
在 jQuery 時(shí)代,dropdown 是很好實(shí)現(xiàn)的,直接用 document.addEventListener('click', handler),監(jiān)聽 document 的 click 事件,然后讓 dropdown 的 menu 隱藏起來。如果想讓 menu 內(nèi)部的點(diǎn)擊不收起 menu,則讓 menu 內(nèi)部的點(diǎn)擊事件執(zhí)行 event.stopPropagation()。
剛開始做 React 開發(fā)的時(shí)候,不知道是從哪接收到的思想,覺得 document.addEventListener() 的 API 不那么 React,很排斥使用。這樣,在實(shí)現(xiàn) dropdown component 時(shí),怎么處理在 menu 以外點(diǎn)擊時(shí)讓 menu 收起來成了一個(gè)頭疼的問題。
我查了文檔,覺得可以用 onBlur 這個(gè)事件,但為了能夠接收到 onBlur 事件,menu 內(nèi)部必須是 input 類型的 component,或者是有 tabIndex 屬性,然后加上 tabIndex 后,當(dāng) component 處于 onFocus 時(shí),會(huì)額外在邊框上加上陰影的樣式,像下圖所示,必須額外再加 css 處理。總之,邏輯變得復(fù)雜了。
后來用 React 做音樂播放器,看別人的實(shí)現(xiàn)源碼,發(fā)現(xiàn)他們都大都使用了 audioElement.addEventListener('play', handler) 這種原生 API,而且,有些邏輯如果不用原生事件就沒法處理,比如監(jiān)聽 window 的 resize 事件,似乎除了用 window.addEventListener('resize', handler) 就沒有其它辦法了。因此再回過頭來看 dropdown 的實(shí)現(xiàn),如果也用 document.addEventListener('click', handler) 處理 menu 以后的點(diǎn)擊的話,邏輯就簡單多了。
但是,也還是有坑的。
坑之一,React 的 event.stopPropagation() 無法阻止原生事件冒泡到 document。
看這篇文章的詳細(xì)介紹:
- React 合成事件和原生事件的阻止冒泡
React 的 issue:
- e.stopPropagation() seems to not be working as expect.
React 有兩套事件系統(tǒng),一套是原生事件系統(tǒng),就是 document.addEventListener() 這種 API,另一套是 React 自己定義的,叫 SyntheticEvent (合成事件),比如下例中的 onClick。
<a onClick={this.clickLink}>Open</a> 復(fù)制代碼實(shí)際 React 的所有合成事件都是綁定在 document 上的 (所謂的代理方式),而不是單獨(dú)綁在各個(gè) component 上,當(dāng)你執(zhí)行合成事件中的 event.stopPropagation() 時(shí),實(shí)際原生事件已經(jīng)到達(dá) document 了。
所以 React 的 event.stopPropagation() 只能阻止合成事件繼續(xù)往上冒泡,卻不能阻止原生事件往上冒泡到 document。
所以你會(huì)發(fā)現(xiàn),為什么我已經(jīng)在 menu 內(nèi)部的點(diǎn)擊事件 handler 中 stopPropagation 了,為什么全局的 click handler 還是會(huì)執(zhí)行,這就是原因。
但是! React 的合成事件的 stopPropagation 雖然不能阻止事件冒泡到 document,但它可以阻止事件冒泡到 window。
(這件事讓我想起,在某個(gè)項(xiàng)目中,我用了 React 的 event.stopPropagation(),導(dǎo)致 turbolinks 不工作了,當(dāng)時(shí)覺得很理所當(dāng)然,現(xiàn)在回想,不對(duì),turoblinks 綁定的是原生事件,如果它是綁在 <a> tag 上的話,不應(yīng)該不工作的啊,由此我推斷 turbolinks 的 click 事件是綁定在 window 上的,后來看了源碼,的確是這樣的)
所以,為了在 React 的 dropdown 中實(shí)現(xiàn)點(diǎn)擊 menu 外部收起 menu,點(diǎn)擊內(nèi)部不收起 menu,有兩種辦法:
使用 window.addEventLister('click', handler) 替代 document.addEventListener('click', handler),同時(shí)在 menu 內(nèi)部點(diǎn)擊時(shí),調(diào)用合成事件的 event.stopPropagation()
不調(diào)用 event.stopPropagation(),讓事件冒泡到 document 的 click handler 中,在 handler 中判斷 event.target 中在 menu 內(nèi)部還是外部,使用 DOMNode.contains() 方法判斷。這種方法需要用 React 的 ref 屬性把 menu 的引用保存下來,如下所示:
<div className="dropdown-body" ref={ref=>this._dropdown_body=ref}> 復(fù)制代碼判斷:
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)} 復(fù)制代碼坑之二,在原生事件的 handler 中,this.setState() 是同步的,不是異步的,讓我很驚訝。之前一直以為 this.setState() 肯定是異步的。
具體的分析可以看這篇文章 - 你真的理解 setState 嗎?
總結(jié):
setState 只在合成事件和生命周期函數(shù)中是 "異步" 的,在原生事件和 setTimeout 中都是同步的。
但在 twitter 上看 Dan 發(fā)推說以后可能會(huì)統(tǒng)一成異步操作,拭目以待。
其它細(xì)節(jié):
只有在 menu 展開時(shí)才注冊(cè) document click handler,收起時(shí)移除 document click handler,是動(dòng)態(tài)的。
handleGlobalClick = () => {console.log('global click')this.setState({dropDownExpanded: false})document.removeEventListener('click', this.handleGlobalClick)} 復(fù)制代碼為了實(shí)現(xiàn) toggle 的效果,即點(diǎn)擊按鈕,展開 dropdown menu,再點(diǎn)擊按鈕,則收到 menu,最簡單的辦法是,只有在 menu 收起的時(shí)候,才給按鈕綁定 click handler,menu 展開的時(shí)候,按鈕沒有 click handler,讓 document click handler 處理。否則,同時(shí)在合成事件的 handler 和原生事件的 handler 中調(diào)用 this.setState(),一個(gè)異步,一個(gè)同步,可能會(huì)引起麻煩。
<div className="dropdown-head">{dropDownExpanded ?<button>Collapse dropdown menu - 1</button> :<button onClick={this.handleHeadClick}>Open dropdown menu - 1</button>}</div> 復(fù)制代碼注冊(cè) document 的 click handler 時(shí),必須在 setTimeout 回調(diào)中執(zhí)行。
handleHeadClick = () => {console.log('head click')this.setState({dropDownExpanded: true})setTimeout(()=>{// must run in the next tickdocument.addEventListener('click', this.handleGlobalClick)}, 0)} 復(fù)制代碼在 componentWillUnmount() 中要移除 document 的 click handler,以免造成內(nèi)存泄漏。
componentWillUnmount() {// important! we need remove global click handler when unmoutdocument.removeEventListener('click', this.handleGlobalClick)} 復(fù)制代碼Update
自從發(fā)現(xiàn)用 window.addEventListener('click', handler) 可以很方便地用來實(shí)現(xiàn)收起 React 中的 Dropdown 后,我就不亦樂乎的到處用起來了。為了避免寫無數(shù)遍的 window.addEventLister('click', handler),我封裝了一個(gè) 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} } 復(fù)制代碼使用:
<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() } 復(fù)制代碼后來我想,那其它開源的 React 組件庫中的 Dropdown 都是怎么實(shí)現(xiàn)的呢,于是探究了一下,果然不出意外,也是用的原生的 addEventListener 實(shí)現(xiàn)的,但也有點(diǎn)意外的是,它們并沒有用 window.addEventListener,而都是用了 document.addEventListener 和 node.contains 方法實(shí)現(xiàn)。
Material Kit React
這個(gè)組件庫的 Dropdown 用到了 @material-ui/core/ClickAwayListener,來看看它的實(shí)現(xiàn)。
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>);} 復(fù)制代碼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));} 復(fù)制代碼Ant Design 中的 Dropdown 的實(shí)現(xiàn)最終可以追溯到 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();}} 復(fù)制代碼JetBrain 的 ring-ui 的 Dropdown 并沒有實(shí)現(xiàn)在其它地方點(diǎn)擊后讓 Dropdown 收起的功能,有點(diǎn)意外...
一開始不是很理解,不過后來我發(fā)現(xiàn),如果用 window.addEventListener('click', handler) 的方式收起 Dropdown,在一個(gè)頁面中,如果有多個(gè) Dropdown,我先展開一個(gè) Dropdown menu (稱之為 A),再點(diǎn)擊另一個(gè) Dropdown (稱之為 B),因?yàn)樵?Dropdown B 的點(diǎn)擊事件中調(diào)用了 event.stopPropagation(),因此 Dropdown A 的 global click handler 將無法觸發(fā),因此 Dropdown A 無法收起。
即使只有一個(gè) Dropdown,如果頁面中有其它任意地方的 event handler 中調(diào)用了 event.stopPropagation() 都會(huì)導(dǎo)致此 Dropdown 有可能無法收起。
但是用 document.addEventListener('click', handler) 配合 node.contains() 方法卻不會(huì)有這個(gè)問題,因此恍然大悟,終于明白了為什么那些開源組件庫并沒有采用 window.addEventListener() 的方式。
于是實(shí)現(xiàn) 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>)} } 復(fù)制代碼使用:
<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() } 復(fù)制代碼總結(jié)
以上是生活随笔為你收集整理的从 Dropdown 的 React 实现中学习到的的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 关于不同的MySQL复制解决方案概述
- 下一篇: LaTex 使用特殊章节符号 (§)