大家好,我是卡颂,人称卡尔摩斯。
今天,我们来追查一个棘手的React bug
,知名组件库material-ui就受其影响。
这个bug
的产生涉及多方因素,包括:
useEffect
执行时机(很可能与你想的不一样)合成事件
原理v17
源码中对合成事件
的改动Portal
原理这篇文章很长很长,有非常多源码细节。
你可以用如下Demo
和我一起debug
源码,更有破案的感觉
在线Demo地址
相信整篇文章过完,你能对如上知识点有更深的理解。
接下来,让我们复现案发现场吧。
假设,我们有个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
会怎么样?
理所当然的答案是:
然而,在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
复现?该从哪条线索下手呢?
相比第一、二条,第三条线索能更好控制影响范围。
看看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
难道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
绑定了事件代理逻辑,所以会处理离散事件
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年多时间。
真是,牵一发动全身啊~
[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