ahooks 的 useClickAway 在 React 17 中不工作了!

最近公司的前端项目从 React 16 升级到了 React 17,导致 ahooks 的 useClickAway 不能按预期工作。

下面西瓜哥我就来说说到底发生了什么事。

ahooks 中的 useClickAway

ahooks 是阿里巴巴维护的第三方 React Hook 库,里面封装了很多好用的 hook。

比如经常用到的组件挂载以及卸载的 useMount、useUnmount,还有支持自动请求、手动请求、防抖等各种功能请求 useRequest,以及可以将状态同步存取到 localStorage 的 useLocalStorageState。

当你想要写一个与业务无关的第三方 ahooks,你可以去 ahooks 里面找找,大概率能够找到,是比较优秀的 hook 库。

其中,useClickAway 的作用是 监听目标元素外的点击事件

useClickAway 接受的第一个参数是一个事件回调函数。

第二个参数是被排除的目标元素,可以是 ref 或 DOM 元素,或者是它们组成的数组,

第三个是需要监听的事件类型字符串或事件字符串数组。第三个参数是可选的,不使用的话默认用点击事件 'click'

下面是一个常用的写法:

useClickAway(() => {
  console.log('点击到元素外的地方');
}, ref);

useClickAway 的核心底层原理

核心底层原理是,是在 document 上绑定了一个冒泡事件。当事件冒泡到  document 时,会判断事件目标元素是否为传入的 ref 下的子元素。

如果是,什么都不做。如果不是,执行回调函数。

这里给出 useClickAway 的源码地址,感兴趣的话可以研究一下:

https://github.com/alibaba/hooks/blob/v3.5.0/packages/hooks/src/useClickAway/index.ts

useClickAway 的问题

如果你在 React 16 中使用 useClickAway,一切都表现良好。

但如果是 React 17 及以上版本使用,在一些情况下会有问题。

我们有这么一个场景。

点击一个搜索按钮,会出现一个输入框,此时用户需要在这个输入框内输入文字来搜索。如果点击到搜索按钮外的地方,输入框会消失。

核心实现如下:

function App() {
  const [visible, setVisible] = useState(false);

  const inputRef = useRef();

  useClickAway(() => {
    setVisible(false);
  }, inputRef);

  return (
    <div>
      <button onClick={() => setVisible(true)}>搜索</button>
      {visible && <input ref={inputRef} autoFocus />}
    </div>
  );
}

这里提供一个线上 demo(用的是 React 17 版本):

https://codesandbox.io/s/f54siy

在 React 16 的时候,上面的写法是正常的。但升级到 17 后,你会发现点击 button 后什么事情都没有发生。

React 17 的事件系统改造

原因在于 React 17 对事件系统进行了改造。

16 升级到 17 后,React 将事件委托到 ReactDOM 挂载的根节点上,比如 div#app,而不再是原来 document

首先,我们要知道的是,当调用 setVisible(true) 改变组件状态时,组件就立即被重新渲染了,然后调用了 useClickAway。状态更新后的组件重渲染是同步的,此时我们的事件流其实还没有结束

需要注意的是,更新状态后的组件重新渲染,可能是同步,也可能是异步的。

在 React 16 中,事件都委托到了 document 上。

我们点击 button 元素,产生了一个事件流,当点击事件流动到 document 时,我们将 visible 设置为 true,组件进行了一次同步的重新渲染,并调用 useClickAway,做了个 document 上的冒泡事件绑定。

就像下面这样:

document.addEventListener('click', () => {
  console.log('显示输入框')
  // React 16 中 useClickAway 绑定事件的时机
  document.addEventListener('click', () => {
    console.log('隐藏输入框');
  });
});

// 点击后的输出内容为:
// 显示输入框

在一个元素的事件触发过程中,往这个元素上注册新的相同类型的事件响应函数,这个新的响应函数不会在此次事件流上立即触发。

所以,前面的 useClickAway 写法在 React 16 是正常的。

但。

在 React 17 中就不同了,事件委托下放到了 div#app 中。

点击按钮,事件流冒泡到  div#app  元素,执行事件回调函数将 visible 设置为了 true,并重新渲染组件,执行 useClickAway 再给 document 绑定了新的事件响应函数。

此时事件流没有结束,继续冒泡到 document,将 visible 又设置回了 false。

所以,visible 在短暂地变成 true 后,又变回了 false,无事发生。

document.querySelector('#app').addEventListener('click', () => {
  console.log('显示输入框')
  // React 17 中 useClickAway 绑定事件的时机
  document.addEventListener('click', () => {
    console.log('隐藏输入框');
  });
});

// 点击后的输出内容为:
// 显示输入框
// 隐藏输入框

解决方案

方案 1:阻止冒泡

<button
  onClick={(e) => {
    e.stopPropagation();
    setVisible(true);
  }}
>

我们给按钮加上阻止事件冒泡,提前结束事件流,使其不流到 document 上,就不会触发 document 的点击事件。

但这样也是有隐患的,e.stopPropagation 是破坏性的。

如果我们在其他的地方要写一些特殊的判断失焦逻辑,也要用到类似 useClickAway 的做法,我们点到这个 button 上就会让其他地方的逻辑走不通。

CSS 中的 overflow: hidden; 也具有破坏性,如果设置了该属性的容器内部的元素超出了容器范围,会被截断。

方案 2:修改绑定事件类型为 mousedown / touchstart

useClickAway(
  () => setVisible(false),
  inputRef,
  ['mousedown', 'touchstart']
);

mousedown 在 click 事件之前就结束了,所以在 click 事件流过程中不会触发它。

touchstart 是为了兼容移动设备的情况。因为触屏时,touchstart 一定会触发,mousedown 不一定,顺带一提,click 也不一定。

其他的比较优秀的第三方 React Hooks 库,比如 react-use 的 useClickAway,其实就是用 mousedown 和 touchstart 作为默认事件类型。

还有百度的 react-hooks 库,其下的 useClickOutside 不支持自定义事件类型,但也是用的 mousedown 和 touchstart。

方案 3:将 button 元素也传给 useClickAway

useClickAway(
  () => setVisible(false),
  [inputRef, buttonRef]
);

这样就可以把 button 也排除在触发条件外。

但这样写很繁琐。如果输入框要封装成一个组件,你还得把 buttonRef 传入到这个组件中。

方案 4:延迟输入框出现时机

<button
  onClick={() => {
    setTimeout(() => {
      setVisible(true);
    });
  }}
>

通过 setTimeout 的方式,确保输入框的出现在同步的事件流之后才出现,然后才触发 useClickAway 绑定逻辑。

结尾

React 16 升级为 17 后,React 中混合事件托管绑定到了 React 组件树挂载的 div#app 上,不再是之前的 document。

这让默认注册为 click 事件类型的 useClickAway 在一些场景下,表现上和 React 16 有一些不同。

对于上面的场景以及解决方案,我认为最好的是第二种:给 useClickAway 的事件类型设置为 mousedown 和 touchstart。这种方法更有普适性。

我是前端西瓜哥,欢迎关注我,一起学习前端知识。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容