Demo

Demo Link

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 处理。总之,逻辑变得复杂了。

后来用 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,有两种办法:

  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 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 发推说以后可能会统一成异步操作,拭目以待。

其它细节:

  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 tickdocument.addEventListener('click', this.handleGlobalClick)}, 0)}
    复制代码
  4. 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 方法实现。

  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-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));}
    复制代码
  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/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();}}
    复制代码
  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)) 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 实现中学习到的相关推荐

  1. 从React和React Native中学习Facebook在开源项目中的行为准则【code of conduct】

    作为程序员, 在开发工作中难免会遇到一些问题或分歧,本文是一篇关于facebook公司对参与社区活动中的行为准则(code of conduct)的译文,希望大家都能够互相尊重和理解,共创一个文明高效 ...

  2. react项目中的参数解构_一天入门React学习心得

    一天入门React学习心得 阅读前必读 本文写的仓促,算是一个学习笔记吧,算是一个入门级的学习文章.如果你刚刚入门,那么可能一些入门的视频可能更加适合你.但如果具备一些知识,比如Vue,那么视频就不适 ...

  3. react 渲染道具_在React中学习分解道具的基础

    react 渲染道具 by Evelyn Chan 通过伊芙琳·陈 在React中学习分解道具的基础 (Learn the basics of destructuring props in React ...

  4. jsx 调用php,JavaScript_JavaScript的React框架中的JSX语法学习入门教程,什么是JSX? 在用React写组件的 - phpStudy...

    JavaScript的React框架中的JSX语法学习入门教程 什么是JSX? 在用React写组件的时候,通常会用到JSX语法,粗看上去,像是在Javascript代码里直接写起了XML标签,实质上 ...

  5. 我在React Native中构建时获得的经验教训

    by Amanda Bullington 通过阿曼达·布林顿(Amanda Bullington) 我在React Native中构建时获得的经验教训 (Lessons I learned while ...

  6. 从Facebook的React框架事件学习一下开源协议

    前言 前一阵子由于Facebook BSD+PATENTS License的原因,Apache项目禁止使用带该license的代码,引人注目的就是Facebook的React前端框架. 后来在知乎上看 ...

  7. react native中一次错误排查 Error:Error: Duplicate resources

    最近一直在使用react native中,遇到了很多的坑,同时也学习到了一些移动端的开发经验. 今天在做一个打包的测试时,遇到了一个问题,打包过程中报错"Error:Error: Dupli ...

  8. 从 Demo 中学习 Solidity

    从 Demo 中学习 Solidity [注解译文] 前 (全文参考) Solidity官方文档 以太坊白皮书_ZH 以太坊白皮书_EN 发现网上的资料太过琐碎, 惊奇的发现官方有详细的教程, 和例子 ...

  9. React hook 中的数据获取

    相关说明: 对于hook相关词不翻译,感觉翻译后怪怪的. effect hook 效果钩子,用于执行一些副作用例如获取数据 . state hook 状态钩子. 使用----------- 和 --- ...

最新文章

  1. cmake java_JNI系列之AS支持CMake了
  2. 【学习笔记】超简单的多项式开方
  3. IOS中类和对象还有,nil/Nil/NULL的区别
  4. 如何分配和释放存储空间
  5. c语言枚举变量自增报错,C_数据结构与算法(1):C语言基础
  6. TListBox的项目个数
  7. 只靠开源的时代已经过去,BAT都在这样做!
  8. centos7.0 没有netstat 和 ifconfig命令问题
  9. mysql 磁盘利用率100_磁盘空间使用率100%的故障处理
  10. WordPress4.8.1版本存在XSS跨站攻击漏洞
  11. 手机ppt怎么添加页码_全网超详细的操作教程,手把手教你使用高效PPT小技巧!...
  12. pheatmap, gplots heatmap.2和ggplot2 geom_tile实现数据聚类和热图plot
  13. 智能搜索推荐模型预估框架的建设及在美团点评的实践
  14. LAMP兄弟连打造免费视频教程
  15. 用计算机模仿真实系统的技术叫,计算机模拟技术.pdf
  16. Mike and Cellphone
  17. EViews10.0程序安装及注意事项
  18. Oracle pmon是什么,oracle 11g pmon工作内容系列二
  19. pytorch求解高维空间PDE
  20. css朗逸保险丝盒机舱,【朗逸保险盒】朗逸保险盒位置图解、拆卸方法_车主指南...

热门文章

  1. 网络编程中的缓冲区溢出
  2. socket实现进程间通信
  3. 实时摄像头数据传输丢包问题
  4. char s[] 和 char *s 的区别
  5. 用一个例子告诉你gdb调试工具如何使用
  6. 能否把指针变量本身传递给一个函数?
  7. springmvc十七:自定义视图和自定义视图解析器
  8. jvm七:数组创建本质
  9. 剑指Offer 56 数组中数字出现的次数
  10. 为Feign设置Header信息