前言
平时开发过程中, 出于各种原因模拟原生 slect 的要求并不算少见.
在实现的过程中, 点击其他区域隐藏下拉列表, 又是一个必备的功能,
最近在一次开发的过程中引发了点思考, 做下总结.
现象
实际中的实现比较复杂, 列表中还要增删改查等操作. 这里就只放个最简单的 demo.
目的是点击 select 以外的其他区域, 隐藏下拉列表.
效果大概这个样子 (简单粗暴纯演示用):
首先这确实不难实现, 上来像方法一一样撸袖子干就完了
开始之前, 先列下基本结构, 待会好描述:
外层一个 warper, 里面是 Input, 下面就是 ul,li 绑定点击事件.
- <div className="match-select-warper" name={`this.idName`}>
- <Input></Input>
- <ul className={`${showOption ? '':'hidden'}`}>
- <li onClick={this.clickHanler}>{问题 1}</li>
- <li onClick={this.clickHanler}>{问题 1}</li>
- </ul>
- </div>
- // 点击列表, 提示并隐藏弹框
- clickHanler(){
- alert('1')
- this.changeShow(false)
- }
实现方式有下面这么几种:
实现一: 全局监听点击事件, 判断是否为 select 区域的子元素.
这是原本比较熟悉和一直在使用的方式:
- // 组件挂载之后添加事件
- componentDidMount(){
- // 非匿名函数的目的在于移除时解除事件
- this.clickTriggerHandler = ((idName) => {
- let id = idName;
- return (event) => {
- // 是否属于子元素
- !isParent(id, event.target) && (this.changeShow(false));
- }
- })(this.idName)
- document.addEventListener('click', this.clickTriggerHandler)
- }
- componentWillUnmount() {
- // 若绑定事件, 则移除该事件
- if(this.clickTriggerHandler){
- document.removeEventListener('click', this.clickTriggerHandler)
- }
- }
至于如何判断事件元素的归属也比较常见:
判断当前元素的父元素是否为置顶元素, 不满足则循环上溯祖先元素, 直到 document.
- /**
- * 判断是否属于指定元素的子元素
- * @param {*} id 指定元素的标识
- * @param {*} dom 触发事件的 dom
- */
- const isParent=(id, dom)=>{
- let tempNode = dom.parentNode;
- while (tempNode && tempNode !== document) {
- // 满足则返回 true
- if (tempNode.getAttribute('name') == id) {
- return true;
- } else {
- // 否则继续获取祖先元素
- tempNode = tempNode.parentNode;
- }
- }
- // 最终返回 false
- return false;
- }
这样达到了我们的目的, 不过是有些缺点的.
缺点一: 性能消耗
每次都溯源去判断, 性能消耗是个问题, 特别是稍微复杂页面, 展示多个组件时.
缺点二: 受其他 dom 元素行为影响
假如有元素阻止了冒泡, 如果点到了这个元素, 那么全局就监听不到该事件了.
- <button onClick={(e) => {
- e.nativeEvent.stopImmediatePropagation();
- alert('我就是来阻止冒泡的')
}}> 测试 </button>
那么效果就如下图所示了:
此外实现方式总感觉不够优雅, 所以我们应该考虑其他实现方式.
实现二: select 元素的焦点事件
可能一开始思维固话之后, 就不太好转变, 因为上面的方式是一直所熟悉的, 一时想不到其他方法.
这时候可以去跟别人交流一下 (这里的交流包括但不限于老司机面谈, 搜索某种实现思路, 优秀开源框架).
得到了另一个方向: 点击其他区域的时候, 意味着当前区域失去了焦点,
基于这一点可以从 input 操作了.
- <div className="match-select-warper" name={`${this.idName}`}>
- <Input
- onFocus={(e) => {
- // 聚焦或者失焦时, 完全可以操作
- this.changeShow(true)
- }}
- onBlur={(e) => {
- this.changeShow(false)
- }}
- ></Input>
- <ul className={`${showOption ? '':'hidden'}`}>
- <li onClick={this.clickHanler}>{问题 1}</li>
- <li onClick={this.clickHanler}>{问题 1}</li>
- </ul>
- </div>
这样看起来很美好, 但是点击列表的时候, 直接关闭了, 没有执行 this.clickHanler 回调.
因为下拉列表操作点击的时候, 其实对于 Input 而言也是失去焦点.
所以先执行了 input 的 onBlur, 隐藏列表, state 更新之后,
列表的 click 操作并没有得到相应.
既然是执行顺序的问题, 那么我们可以有下面两种解决思路:
2.1 事件执行顺序不变, 修改回调事件执行时机
既然 blur 执行顺序在前, 重新渲染后会影响后续执行, 那么我们将 blur 事件的回调延迟执行, 即不立即去 setState, 那么 li 的 click 事件就会执行, 然后再去隐藏列表.
至于如何延迟执行, 显然就是我们的万能 setTimeout 了:
- <div className="match-select-warper" name={`${this.idName}`}>
- <Input
- onFocus={(e) => {
- // 聚焦或者失焦时, 完全可以操作
- this.changeShow(true)
- }}
- onBlur={(e) => {
- // 延迟执行 blur 的回调, 先执行
- setTimeout(this.changeShow.bind(this,false),200)
- }}
- ></Input>
- <ul className={`${showOption ? '':'hidden'}`}>
- <li onClick={this.clickHanler}>{问题 1}</li>
- <li onClick={this.clickHanler}>{问题 1}</li>
- </ul>
- </div>
这样可以满足我们的需求, 此外还有另一种方式
2.2 改变事件执行顺序, 即使用触发时机在 blur 之前的事件来替换 click, 即 mouseDown
大致说下几个事件的执行顺序 (毕竟我对这方面掌握的也不是很不足, 所以后面也会专门总结下相关内容).
- // 这里也顺便解释了下问题出现的原因
- mousedown->blur->mouseup->click
既然 click 触发时机晚于 blur, 那我们换成 mouseDown 不就绕过去了.
- <div className="match-select-warper" name={`${this.idName}`}>
- <Input
- onFocus={(e) => {
- // 聚焦或者失焦时, 完全可以操作
- this.changeShow(true)
- }}
- onBlur={(e) => {
- // 延迟执行 blur 的回调, 先执行
- setTimeout(this.changeShow.bind(this,false),200)
- }}
- ></Input>
- // 列表的选择回调在 mousedown 时执行
- <ul className={`${showOption ? '':'hidden'}`}>
- <li onMouseDown={this.clickHanler}>{问题 1}</li>
- <li onMouseDown={this.clickHanler}>{问题 1}</li>
- </ul>
- </div>
效果同上, 这里就不重复放图了.
如果我们的目的是点击列表的时候, 完全不触发 blur 事件, 可以在 clickHanler 回调里加上 event.preventDefault(), 这样就不会按照原来的顺序出发 blur 事件了. 例如这里:
- // 本身自行处理了列表显示, 就不用调用 blur 事件了
- clickHanler(event){
- event.preventDefault()
- alert('1')
- this.changeShow(false)
- }
具体是否阻止默认事件, 就看具体应用了, 示例代码这里就没有阻止默认事件,
而是将列表的显示隐藏全交给焦点事件来处理.
- // 只关注点击的逻辑, 公共逻辑交给 blur 统一管理
- clickHanler(){
- alert('1')
- }
方式三: 下拉列表显示时增加背景遮罩
即点击其他区域时, 点击的是背景 mask, 交给他来统一处理.
因为这样点击存在一个比较明显的问题, 如果想要点击其他元素例如 radio 时, 需要二次点击.
所以这里就不去折腾这种实现了.
结束语
参考文章和组件
浏览器点击屏幕事件触发顺序
- https://github.com/future-team/eagle-ui
- https://segmentfault.com/q/1010000004950602
本文是自己的一篇学习总结记录, 不过我感觉最有用的还是对自己的触动. 因为平时都习惯于第一种方式去实现功能, 特别是在业务开发过程中, 第一选择肯定是自己常用的. 还是在空闲时候才有心情去优化.
这时候才清晰的理解我们所谓的读优秀开源作品源码, 学习的是什么, 不要为了读源码而读源码, 有目的有思维的读才能学习更多. 望诸君共勉, 再次对参考文章表示感谢.
来源: https://www.cnblogs.com/pqjwyn/p/10549109.html