大佬,怎么办?升级React17,Toast组件不能用了

开发 前端
今天,我们来追查一个棘手的React bug,知名组件库material-ui就受其影响。

[[405755]]

大家好,我是卡颂,人称卡尔摩斯。

今天,我们来追查一个棘手的React bug,知名组件库material-ui就受其影响。

这个bug的产生涉及多方因素,包括:

  • useEffect执行时机(很可能与你想的不一样)
  • 合成事件原理
  • v17源码中对合成事件的改动
  • Portal原理

这篇文章很长很长,有非常多源码细节。

你可以用如下Demo和我一起debug源码,更有破案的感觉

在线Demo地址

相信整篇文章过完,你能对如上知识点有更深的理解。

接下来,让我们复现案发现场吧。

只在v17下复现的bug

假设,我们有个ToastButton组件,代码如下:

  1. function ToastButton() { 
  2.   const [show, setShow] = useState(false); 
  3.  
  4.   useEffect(() => { 
  5.     if (!show) return
  6.  
  7.     function clickHandler(e) { 
  8.       setShow(false); 
  9.     } 
  10.  
  11.     document.addEventListener("click", clickHandler); 
  12.     return () => { 
  13.       document.removeEventListener("click", clickHandler); 
  14.     }; 
  15.   }, [show]); 
  16.  
  17.   return ( 
  18.     <div> 
  19.       <button type="button" onClick={() => setShow(true)}>Show Toast</button> 
  20.       {show && <div className="toast">Hey, Ka Song~</div>} 
  21.     </div> 
  22.   ); 

 点击button后,show状态变为true,展示toast。

同时在useEffect回调中,在document上注册「点击事件」。

触发点击事件会让show状态置为false,达到「点击页面任意区域关闭toast」的效果。

入口函数如下:

  1. function App() { 
  2.   return ( 
  3.     <ToastButton /> 
  4.   ); 
  5.  
  6. ReactDOM.render(<App />, document.getElementById("root")); 

效果如下:

图片

接下来,我们再增加一个渲染Portal的组件PortalRenderer,代码如下:

  1. function PortalRenderer() { 
  2.   const [show, setShow] = useState(false); 
  3.  
  4.   return ( 
  5.     <React.Fragment> 
  6.       <button type="button" onClick={() => setShow(true)}> 
  7.         Render portal 
  8.       </button> 
  9.  
  10.       {show && 
  11.         ReactDOM.createPortal( 
  12.           <div>who is handsome?</div>, 
  13.           document.body 
  14.         )} 
  15.     </React.Fragment> 
  16.   ); 

 点击button后会将show状态置为true。

会使用ReactDOM.createPortal在document.body上挂载一个div,内容为who is handsome?。

我们将两个组件一起放在App中:

  1. function App() { 
  2.   return ( 
  3.     <div> 
  4.       <PortalRenderer /> 
  5.       <ToastButton /> 
  6.     </div> 
  7.   ); 

 点击PortalRenderer效果如下:

图片

现在问题来了:

  • 如果先点击PortalRenderer的button,再点击ToastButton会怎么样?

理所当然的答案是:

  • 先显示「who is handsome?」
  • 再显示「Hey, Ka Song~」

然而,在React v17效果如下:

图片

先点击PortalRenderer的button后,再点击ToastButton,不会看见toast的内容。

但是,只要不点击PortalRenderer的button就不会有问题:

图片

这只是一个可复现该bug的极简Demo。

事实上,在一个大型项目中,如果从v16升级到v17,

在使用了如上所示的「在document挂载原生click事件」方式实现toast的同时,

再使用Portal在document.body挂载DOM都会触发该bug。

一旦先渲染了Portal,你的toast就不能用了。意不意外?惊不惊喜?

接下来,让我们一步步揭开这个bug的庐山真面目。

div去哪了?

首先,我们要明确,点击Show Toast没反应,是因为没渲染toast,还是因为渲染了toast又立刻删除了。

审查元素后发现,每当点击Show Toast,ToastButton渲染的div都会闪一下。

这代表该div下发生了DOM变化。

而我们并没有看到DOM的插入,那么这就表示:

这里先发生了DOM插入,紧接着发生了DOM移除

而这个DOM就是toast对应DOM:

<div className="toast">Hey, Ka Song!</div>

我们知道,该DOM显示与否受ToastButton组件的show状态影响,

于是,接下来的线索有三条:

  1. 为什么一次点击,ToastButton组件的show状态先变为true,后变为false?
  2. 为什么只有在挂载了Portal的情况下bug能复现?
  3. 为什么该bug只在v17复现?

该从哪条线索下手呢?

v17有哪些变化?

相比第一、二条,第三条线索能更好控制影响范围。

看看v17的更新log,一条特性变化引起了卡尔摩斯的注意:

在v17之前,整个应用的事件会冒泡到同一个根节点(html DOM节点)。

而在v17,每个应用的事件都会冒泡到该应用自己的根节点(ReactDOM.render挂载的节点,在Demo中是div#root)。

这个改动是为了让一个应用下可以存在多个不同模式的子应用(兼容legacy mode与concurrent mode同时存在于一个应用)。

会不会是这个原因呢?

于是,卡尔摩斯将目光锁定在源码中注册事件的方法:addTrappedEventListener

在应用初始化时(调用ReactDOM.render首屏渲染时),React会遍历所有「原生事件名」,依次在根节点调用该方法注册事件回调。

在应用运行过程中,所有原生事件都会由根节点(Demo中的div#root)代理。

以一个React组件的onClick事件举例,当点击发生后,会依次执行:

  1. 「原生点击事件」向上冒泡
  2. 「原生点击事件」冒泡到根节点,触发addTrappedEventListener注册的事件处理函数
  3. 「合成事件」会在React组件树中从底向上冒泡
  4. 当「合成事件」冒泡到触发点击的组件时,调用onClick方法

这就是React合成事件的原理。

那么,为什么只有在挂载了Portal的情况下bug能复现?

难道Portal与合成事件有关?

果然,当我们点击PortalRenderer的button后,又进入了addTrappedEventListener的断点。

与初始化时(执行ReactDOM.render时)事件挂载的目标节点(div#root)不同,

由于Portal挂载在document.body上,见如下节选代码:

  1. // 节选自PortalRenderer 
  2. {show && 
  3.   ReactDOM.createPortal( 
  4.     <div>who is handsome?</div>, 
  5.     document.body 
  6. )} 

 所以会在document.body再执行一遍所有原生事件的代理逻辑。

可以看到此时事件会在body上注册:

这就意味着,原生事件冒泡到根节点(div#root)后,继续向上冒泡,在document.body又会触发一遍事件处理函数。

以一个React组件的onClick事件举例,当点击发生后,会依次执行:

  1. 「原生点击事件」向上冒泡
  2. 「原生事件」冒泡到根节点(div#root),触发addTrappedEventListener注册的事件处理函数
  3. 「合成事件」会在React组件树中从底向上冒泡
  4. 当「合成事件」冒泡到触发点击的组件时,调用onClick方法
  5. 「原生点击事件」继续向上冒泡到document.body
  6. 重复触发步骤3

难道bug的原因是onClick被重复执行两次?

如果是这么明显的bug大家开发过程中肯定很容易复现。

我们可以在onClick中打印日志,可以看到:一次点击只会打印一条日志。

图片

那么问题出在哪呢?

useEffect的执行时机

让我们回到第一条线索:

  • 为什么一次点击,ToastButton组件的show状态先变为true,后变为false?

我们可以从useEffect回调中找找线索。

  1. // 节选自ToastButton 
  2.  useEffect(() => { 
  3.   if (!show) return
  4.  
  5.   function clickHandler(e) { 
  6.     setShow(false); 
  7.   } 
  8.  
  9.   document.addEventListener("click", clickHandler); 
  10.   return () => { 
  11.     document.removeEventListener("click", clickHandler); 
  12.   }; 
  13. }, [show]); 

可以看到,state变为false是由于clickHandler调用。

而clickHandler调用是由于document被点击。

所以show状态连续变化的原因很可能是:

  1. 点击ToastButton,「原生点击事件」冒泡到应用挂载的根节点
  2. 进入「合成事件」的冒泡逻辑,冒泡到ToastButton时触发onClick
  3. onClick中setShow(true),state变为true,渲染toast DOM
  4. useEffect回调执行,为document绑定click事件
  5. 「原生点击事件」继续冒泡,当冒泡到document时,触发其绑定的click事件
  6. 调用clickHandler将state变为false,移除toast DOM

正当我为这精妙的推理沾沾自喜时,突然意识到一个问题:

要满足如上逻辑,步骤4和步骤5之间必须是同步执行。

因为一旦步骤4是异步执行,则当步骤5「原生点击事件」冒泡到document时,步骤4document的click事件还未绑定。

步骤4在useEffect回调函数中,而useEffect的回调是在执行完DOM操作后异步执行的。

  • 如果useEffect回调在DOM变化后同步执行,会阻塞DOM重排、重绘,所以被设计为异步执行。如果一定要在DOM变化后同步执行副作用,可以使用useLayoutEffect

所以,「正常情况下」,步骤4和步骤5是在不同的两个浏览器task执行。

然而,总有意外。

useEffect的边界case

在React中,一个常见的操作链路是:

  • 用户触发事件 -> 改变state -> 依赖该state的useEffect回调执行

去掉中间环节,就是这样:

  • 用户触发事件 -> ... -> useEffect回调执行

而我们刚才说,useEffect回调是异步执行的。

那么设想以下场景:

用户快速点击鼠标触发onClick事件,如何保证每次点击产生的useEffect回调按顺序执行呢?

为了解决这个问题,React将不同原生事件分类。

其中click、keydown等这种不连续触发的事件被称为「离散事件」(与之对应的就是scroll这种能连续触发的事件)。

  • 源码中所有离散事件的定义见这里

为了保证如下链路中的useEffect回调都能按顺序执行

  • 离散事件 -> ... -> useEffect回调执行

每当处理离散事件前,都会执行flushPassiveEffects方法。

该方法会将还未执行的useEffect回调执行。

这样就能保证下一次useEffect回调执行前上一次的useEffect回调已经执行。

所以,当不点击PortalRenderer的button挂载Portal时,点击ToastButton的完整流程如下:

  1. 点击ToastButton,「原生点击事件」冒泡到应用挂载的根节点
  2. 进入「合成事件」的冒泡逻辑,冒泡到ToastButton时触发onClick
  3. onClick中setShow(true),state变为true,渲染toast DOM
  4. useEffect回调「异步执行」,为document绑定click事件
  5. 「原生点击事件」继续冒泡到document,此时document还未绑定click事件

UI表现为:点击ToastButton,展示toast。

当点击PortalRenderer的button挂载Portal后,再点击ToastButton的完整流程如下:

  1. 点击PortalRenderer的button,在document.body挂载Portal对应DOM
  2. 在document.body执行绑定事件代理逻辑
  3. 点击ToastButton,「原生点击事件」冒泡到应用挂载的根节点
  4. 进入「合成事件」的冒泡逻辑,冒泡到ToastButton时触发onClick
  5. onClick中setShow(true),state变为true,渲染toast DOM
  6. useEffect回调「异步执行」,为document绑定click事件
  7. 「原生点击事件」继续冒泡到document.body,由于body绑定了事件代理逻辑,所以会处理离散事件
  8. 处理的第一步是将还未执行的步骤6同步执行,此时document绑定click事件
  9. 「原生点击事件」继续冒泡到document,触发步骤6绑定的click事件
  10. 调用clickHandler将state变为false,移除toast DOM

UI表现为:点击ToastButton,无反应(实际是先展示toast,再在同一个浏览器task移除toast)

bug解决

可以看到,这是React源码运行流程的几个feature综合起来造成的bug。

如何修复呢?在现有v17架构下无法很好修复。

在v18,伴随Concurrent Mode的「启发式更新算法」,会修复该bug。

bug修复见Flush discrete passive effects before paint #21150

修复的方式很简单:如果一个useEffect回调是由离散事件造成的,则该useEffect回调不会异步执行,而是会在本轮DOM更新完成后同步执行。

至于为什么v16及之前版本不会复现这个bug?

因为之前的版本所有「原生事件」都注册在html DOM上。

就不存在「原生事件」在冒泡过程中触发多个事件代理的情况。

[[405756]]

当bug来临,没有一片feature是无辜的。

现在,终于有点能体会为啥React团队开发Concurrent Mode相关功能花了2年多时间。

真是,牵一发动全身啊~

参考资料

[1]material-ui:

https://github.com/mui-org/material-ui/issues/23215

[2]在线Demo地址:

https://codesandbox.io/s/react-playground-forked-v42kn

[3]离散事件:

https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350

 

责任编辑:姜华 来源: 魔术师卡颂
相关推荐

2021-08-27 12:59:59

React前端命令

2022-06-28 07:41:38

useMountReactahooks

2022-06-16 08:30:03

React 17null

2024-04-09 07:40:42

TOASTPostgreSQL数据

2012-11-22 10:39:37

漏洞PDF文件

2022-01-13 23:14:12

Windows 11Windows微软

2021-05-21 09:34:40

React React 17前端

2022-03-24 12:28:03

React 17React 18React

2022-03-02 14:00:46

Nest.jsExpress端口

2022-10-10 08:28:57

接口内网服务AOP

2024-10-17 10:25:34

2009-11-27 11:26:02

VS2003.NET不

2024-07-08 11:30:35

2023-07-11 08:55:26

系统白名单AO

2019-10-12 09:50:46

Redis内存数据库

2022-07-05 11:48:47

MySQL死锁表锁

2018-01-28 20:39:39

戴尔

2017-02-21 13:11:43

SDN网络体系SDN架构

2009-11-03 08:56:02

linux死机操作系统

2024-04-22 08:17:23

MySQL误删数据
点赞
收藏

51CTO技术栈公众号