大家好,我是卡颂,人称卡尔摩斯。
成都创新互联公司云计算的互联网服务提供商,拥有超过13年的服务器租用、服务器托管、云服务器、雅安服务器托管、网站系统开发经验,已先后获得国家工业和信息化部颁发的互联网数据中心业务许可证。专业提供云主机、雅安服务器托管、域名注册、VPS主机、云服务器、香港云服务器、免备案服务器等。
今天,我们来追查一个棘手的React bug,知名组件库material-ui就受其影响。
这个bug的产生涉及多方因素,包括:
这篇文章很长很长,有非常多源码细节。
你可以用如下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 (
- {show &&
Hey, Ka Song~}- );
- }
点击button后,show状态变为true,展示toast。
同时在useEffect回调中,在document上注册「点击事件」。
触发点击事件会让show状态置为false,达到「点击页面任意区域关闭toast」的效果。
入口函数如下:
- function App() {
- return (
- );
- }
- ReactDOM.render(
, document.getElementById("root"));
效果如下:
接下来,我们再增加一个渲染Portal的组件PortalRenderer,代码如下:
- function PortalRenderer() {
- const [show, setShow] = useState(false);
- return (
- Render portal
- {show &&
- ReactDOM.createPortal(
who is handsome?,- document.body
- )}
- );
- }
点击button后会将show状态置为true。
会使用ReactDOM.createPortal在document.body上挂载一个div,内容为who is handsome?。
我们将两个组件一起放在App中:
- function App() {
- return (
- );
- }
点击PortalRenderer效果如下:
现在问题来了:
理所当然的答案是:
然而,在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:
我们知道,该DOM显示与否受ToastButton组件的show状态影响,
于是,接下来的线索有三条:
该从哪条线索下手呢?
相比第一、二条,第三条线索能更好控制影响范围。
看看v17的更新log,一条特性变化引起了卡尔摩斯的注意:
在v17之前,整个应用的事件会冒泡到同一个根节点(html DOM节点)。
而在v17,每个应用的事件都会冒泡到该应用自己的根节点(ReactDOM.render挂载的节点,在Demo中是div#root)。
这个改动是为了让一个应用下可以存在多个不同模式的子应用(兼容legacy mode与concurrent mode同时存在于一个应用)。
会不会是这个原因呢?
于是,卡尔摩斯将目光锁定在源码中注册事件的方法:addTrappedEventListener
在应用初始化时(调用ReactDOM.render首屏渲染时),React会遍历所有「原生事件名」,依次在根节点调用该方法注册事件回调。
在应用运行过程中,所有原生事件都会由根节点(Demo中的div#root)代理。
以一个React组件的onClick事件举例,当点击发生后,会依次执行:
这就是React合成事件的原理。
那么,为什么只有在挂载了Portal的情况下bug能复现?
难道Portal与合成事件有关?
果然,当我们点击PortalRenderer的button后,又进入了addTrappedEventListener的断点。
与初始化时(执行ReactDOM.render时)事件挂载的目标节点(div#root)不同,
由于Portal挂载在document.body上,见如下节选代码:
- // 节选自PortalRenderer
- {show &&
- ReactDOM.createPortal(
who is handsome?,- document.body
- )}
所以会在document.body再执行一遍所有原生事件的代理逻辑。
可以看到此时事件会在body上注册:
这就意味着,原生事件冒泡到根节点(div#root)后,继续向上冒泡,在document.body又会触发一遍事件处理函数。
以一个React组件的onClick事件举例,当点击发生后,会依次执行:
难道bug的原因是onClick被重复执行两次?
如果是这么明显的bug大家开发过程中肯定很容易复现。
我们可以在onClick中打印日志,可以看到:一次点击只会打印一条日志。
那么问题出在哪呢?
让我们回到第一条线索:
我们可以从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状态连续变化的原因很可能是:
正当我为这精妙的推理沾沾自喜时,突然意识到一个问题:
要满足如上逻辑,步骤4和步骤5之间必须是同步执行。
因为一旦步骤4是异步执行,则当步骤5「原生点击事件」冒泡到document时,步骤4document的click事件还未绑定。
步骤4在useEffect回调函数中,而useEffect的回调是在执行完DOM操作后异步执行的。
所以,「正常情况下」,步骤4和步骤5是在不同的两个浏览器task执行。
然而,总有意外。
在React中,一个常见的操作链路是:
去掉中间环节,就是这样:
而我们刚才说,useEffect回调是异步执行的。
那么设想以下场景:
用户快速点击鼠标触发onClick事件,如何保证每次点击产生的useEffect回调按顺序执行呢?
为了解决这个问题,React将不同原生事件分类。
其中click、keydown等这种不连续触发的事件被称为「离散事件」(与之对应的就是scroll这种能连续触发的事件)。
为了保证如下链路中的useEffect回调都能按顺序执行
每当处理离散事件前,都会执行flushPassiveEffects方法。
该方法会将还未执行的useEffect回调执行。
这样就能保证下一次useEffect回调执行前上一次的useEffect回调已经执行。
所以,当不点击PortalRenderer的button挂载Portal时,点击ToastButton的完整流程如下:
UI表现为:点击ToastButton,展示toast。
当点击PortalRenderer的button挂载Portal后,再点击ToastButton的完整流程如下:
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上。
就不存在「原生事件」在冒泡过程中触发多个事件代理的情况。
[[405756]]
当bug来临,没有一片feature是无辜的。
现在,终于有点能体会为啥React团队开发Concurrent Mode相关功能花了2年多时间。
真是,牵一发动全身啊~
[1]material-ui:
https://github.com/mui-org/material-ui/issues/23215
[2]在线Demo地址:
[3]离散事件:
https://github.com/facebook/react/blob/a8a4742f1c54493df00da648a3f9d26e3db9c8b5/packages/react-dom/src/events/ReactDOMEventListener.js#L294-L350
新闻标题:大佬,怎么办?升级React17,Toast组件不能用了
文章来源:http://www.shufengxianlan.com/qtweb/news43/554743.html
网站建设、网络推广公司-创新互联,是专注品牌与效果的网站制作,网络营销seo公司;服务项目有等
声明:本网站发布的内容(图片、视频和文字)以用户投稿、用户转载内容为主,如果涉及侵权请尽快告知,我们将会在第一时间删除。文章观点不代表本网站立场,如需处理请联系客服。电话:028-86922220;邮箱:631063699@qq.com。内容未经允许不得转载,或转载时需注明来源: 创新互联