大家好,我是卡颂,人称卡尔摩斯。
今天,我们来追查一个棘手的React bug
,知名组件库material-ui就受其影响。
这个bug
的产生涉及多方因素,包括:
-
useEffect
执行时机(很可能与你想的不一样) -
合成事件
原理 -
v17
源码中对合成事件
的改动 -
Portal
原理
这篇文章很长很长,有非常多源码细节。
你可以用如下Demo
和我一起debug
源码,更有破案的感觉
相信整篇文章过完,你能对如上知识点有更深的理解。
接下来,让我们复现案发现场吧。
只在v17下复现的bug
假设,我们有个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
的庐山真面目。
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
状态影响,
于是,接下来的线索有三条:
-
为什么一次点击,
ToastButton
组件的show
状态先变为true
,后变为false
? -
为什么只有在挂载了
Portal
的情况下bug
能复现? -
为什么该
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
事件举例,当点击发生后,会依次执行:
-
原生点击事件向上冒泡
-
原生点击事件冒泡到根节点,触发
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
中打印日志,可以看到:一次点击只会打印一条日志。
那么问题出在哪呢?
useEffect的执行时机
让我们回到第一条线索:
为什么一次点击,
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
执行。
然而,总有意外。
useEffect的边界case
在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
)
bug解决
可以看到,这是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年多时间。
真是,牵一发动全身啊~