你们好,我是卡颂,人称卡尔摩斯。html
今天,咱们来追查一个棘手的React bug
,知名组件库material-ui就受其影响。react
这个bug
的产生涉及多方因素,包括:git
useEffect
执行时机(极可能与你想的不同)github
合成事件
原理算法
v17
源码中对合成事件
的改动浏览器
Portal
原理markdown
这篇文章很长很长,有很是多源码细节。架构
你能够用以下Demo
和我一块儿debug
源码,更有破案的感受app
在线Demo地址dom
相信整篇文章过完,你能对如上知识点有更深的理解。
接下来,让咱们复现案发现场吧。
假设,咱们有个ToastButton
组件,代码以下:
function ToastButton() {
const [show, setShow] = useState(false);
useEffect(() => {
if (!show) return;
function clickHandler(e) {
setShow(false);
}
document.addEventListener("click", clickHandler);
return () => {
document.removeEventListener("click", clickHandler);
};
}, [show]);
return (
<div> <button type="button" onClick={() => setShow(true)}>Show Toast</button> {show && <div className="toast">Hey, Ka Song~</div>} </div>
);
}
复制代码
点击button
后,show
状态变为true
,展现toast
。
同时在useEffect
回调中,在document
上注册点击事件。
触发点击事件会让show
状态置为false
,达到点击页面任意区域关闭toast的效果。
入口函数以下:
function App() {
return (
<ToastButton />
);
}
ReactDOM.render(<App />, document.getElementById("root"));
复制代码
效果以下:
接下来,咱们再增长一个渲染Portal
的组件PortalRenderer
,代码以下:
function PortalRenderer() {
const [show, setShow] = useState(false);
return (
<React.Fragment> <button type="button" onClick={() => setShow(true)}> Render portal </button> {show && ReactDOM.createPortal( <div>who is handsome?</div>, document.body )} </React.Fragment>
);
}
复制代码
点击button
后会将show
状态置为true
。
会使用ReactDOM.createPortal
在document.body
上挂载一个div
,内容为who is handsome?
。
咱们将两个组件一块儿放在App
中:
function App() {
return (
<div> <PortalRenderer /> <ToastButton /> </div>
);
}
复制代码
点击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
的庐山真面目。
首先,咱们要明确,点击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
状态影响,
因而,接下来的线索有三条:
为何一次点击,ToastButton
组件的show
状态先变为true
,后变为false
?
为何只有在挂载了Portal
的状况下bug
能复现?
为何该bug
只在v17
复现?
该从哪条线索下手呢?
相比第1、二条,第三条线索能更好控制影响范围。
看看v17
的更新log
,一条特性变化引发了卡尔摩斯的注意:
在v17
以前,整个应用的事件会冒泡到同一个根节点(html DOM
节点)。
而在v17
,每一个应用的事件都会冒泡到该应用本身的根节点(ReactDOM.render
挂载的节点,在Demo
中是div#root
)。
这个改动是为了让一个应用下能够存在多个不一样模式的子应用(兼容legacy mode
与concurrent mode
同时存在于一个应用)。
会不会是这个缘由呢?
因而,卡尔摩斯将目光锁定在源码中注册事件的方法:addTrappedEventListener
在应用初始化时(调用ReactDOM.render
首屏渲染时),React
会遍历全部原生事件名,依次在根节点调用该方法注册事件回调。
在应用运行过程当中,全部原生事件都会由根节点(Demo
中的div#root
)代理。
以一个React
组件的onClick
事件举例,当点击发生后,会依次执行:
原生点击事件向上冒泡
原生点击事件冒泡到根节点,触发addTrappedEventListener
注册的事件处理函数
合成事件会在React
组件树中从底向上冒泡
当合成事件冒泡到触发点击的组件时,调用onClick
方法
这就是React
合成事件的原理。
那么,为何只有在挂载了Portal
的状况下bug
能复现?
难道Portal
与合成事件有关?
果真,当咱们点击PortalRenderer
的button
后,又进入了addTrappedEventListener
的断点。
与初始化时(执行ReactDOM.render
时)事件挂载的目标节点(div#root
)不一样,
因为Portal
挂载在document.body
上,见以下节选代码:
// 节选自PortalRenderer
{show &&
ReactDOM.createPortal(
<div>who is handsome?</div>,
document.body
)}
复制代码
因此会在document.body
再执行一遍全部原生事件
的代理逻辑。
能够看到此时事件会在body
上注册:
这就意味着,原生事件冒泡到根节点(div#root
)后,继续向上冒泡,在document.body
又会触发一遍事件处理函数。
以一个React
组件的onClick
事件举例,当点击发生后,会依次执行:
原生点击事件向上冒泡
原生事件冒泡到根节点(div#root
),触发addTrappedEventListener
注册的事件处理函数
合成事件会在React
组件树中从底向上冒泡
当合成事件冒泡到触发点击的组件时,调用onClick
方法
原生点击事件继续向上冒泡到document.body
重复触发步骤3
难道bug
的缘由是onClick
被重复执行两次?
若是是这么明显的bug
你们开发过程当中确定很容易复现。
咱们能够在onClick
中打印日志,能够看到:一次点击只会打印一条日志。
那么问题出在哪呢?
让咱们回到第一条线索:
为何一次点击,
ToastButton
组件的show
状态先变为true
,后变为false
?
咱们能够从useEffect
回调中找找线索。
// 节选自ToastButton
useEffect(() => {
if (!show) return;
function clickHandler(e) {
setShow(false);
}
document.addEventListener("click", clickHandler);
return () => {
document.removeEventListener("click", clickHandler);
};
}, [show]);
复制代码
能够看到,state
变为false
是因为clickHandler
调用。
而clickHandler
调用是因为document
被点击。
因此show
状态连续变化的缘由极可能是:
点击ToastButton
,原生点击事件冒泡到应用挂载的根节点
进入合成事件的冒泡逻辑,冒泡到ToastButton
时触发onClick
onClick
中setShow(true)
,state
变为true
,渲染toast DOM
useEffect
回调执行,为document
绑定click
事件
原生点击事件继续冒泡,当冒泡到document
时,触发其绑定的click
事件
调用clickHandler
将state
变为false
,移除toast DOM
正当我为这精妙的推理沾沾自喜时,忽然意识到一个问题:
要知足如上逻辑,步骤4和步骤5之间必须是同步执行。
由于一旦步骤4是异步执行,则当步骤5原生点击事件冒泡到document
时,步骤4document
的click
事件还未绑定。
步骤4在useEffect
回调函数中,而useEffect
的回调是在执行完DOM
操做后异步执行的。
若是
useEffect
回调在DOM
变化后同步执行,会阻塞DOM
重排、重绘,因此被设计为异步执行。若是必定要在DOM
变化后同步执行反作用,可使用useLayoutEffect
因此,正常状况下,步骤4和步骤5是在不一样的两个浏览器task
执行。
然而,总有意外。
在React
中,一个常见的操做链路是:
用户触发事件 -> 改变
state
-> 依赖该state
的useEffect
回调执行
去掉中间环节,就是这样:
用户触发事件 -> ... ->
useEffect
回调执行
而咱们刚才说,useEffect
回调是异步执行的。
那么设想如下场景:
用户快速点击鼠标触发onClick
事件,如何保证每次点击产生的useEffect
回调按顺序执行呢?
为了解决这个问题,React
将不一样原生事件
分类。
其中click
、keydown
等这种不连续触发的事件被称为离散事件(与之对应的就是scroll
这种能连续触发的事件)。
源码中全部离散事件的定义见这里
为了保证以下链路中的useEffect
回调都能按顺序执行
离散事件 -> ... ->
useEffect
回调执行
每当处理离散事件
前,都会执行flushPassiveEffects
方法。
该方法会将还未执行的useEffect
回调执行。
这样就能保证下一次useEffect
回调执行前上一次的useEffect
回调已经执行。
因此,当不点击PortalRenderer
的button
挂载Portal
时,点击ToastButton
的完整流程以下:
点击ToastButton
,原生点击事件冒泡到应用挂载的根节点
进入合成事件的冒泡逻辑,冒泡到ToastButton
时触发onClick
onClick
中setShow(true)
,state
变为true
,渲染toast DOM
useEffect
回调异步执行,为document
绑定click
事件
原生点击事件继续冒泡到document
,此时document
还未绑定click
事件
UI
表现为:点击ToastButton
,展现toast
。
当点击PortalRenderer
的button
挂载Portal
后,再点击ToastButton
的完整流程以下:
点击PortalRenderer
的button
,在document.body
挂载Portal
对应DOM
在document.body
执行绑定事件代理逻辑
点击ToastButton
,原生点击事件冒泡到应用挂载的根节点
进入合成事件的冒泡逻辑,冒泡到ToastButton
时触发onClick
onClick
中setShow(true)
,state
变为true
,渲染toast DOM
useEffect
回调异步执行,为document
绑定click
事件
原生点击事件继续冒泡到document.body
,因为body
绑定了事件代理逻辑,因此会处理离散事件
处理的第一步是将还未执行的步骤6同步执行,此时document
绑定click
事件
原生点击事件继续冒泡到document
,触发步骤6绑定的click
事件
调用clickHandler
将state
变为false
,移除toast DOM
UI
表现为:点击ToastButton
,无反应(实际是先展现toast
,再在同一个浏览器task
移除toast
)
能够看到,这是React
源码运行流程的几个feature
综合起来形成的bug
。
如何修复呢?在现有v17
架构下没法很好修复。
在v18
,伴随Concurrent Mode
的启发式更新算法,会修复该bug
。
bug
修复见Flush discrete passive effects before paint #21150
修复的方式很简单:若是一个useEffect
回调是由离散事件
形成的,则该useEffect
回调不会异步执行,而是会在本轮DOM
更新完成后同步执行。
至于为何v16
及以前版本不会复现这个bug
?
由于以前的版本全部原生事件都注册在html DOM
上。
就不存在原生事件在冒泡过程当中触发多个事件代理的状况。
当bug
来临,没有一片feature
是无辜的。
如今,终于有点能体会为啥React
团队开发Concurrent Mode
相关功能花了2年多时间。
真是,牵一发动全身啊~