- Published on
- 约 2069 字
一次由Antd Modal关闭后遮罩无法消失问题来看React state的更新机制
- Authors
- Name
- 小辉辉
前言
先前博客里就有过一次和antd modal有关的排查经历,具体见文章对话框组件抖动问题的排查,分析造成的原因和解决方案。
这次又遇到了modal的问题,不过相比于上次的情况严重多了,现象是modal对话框在关闭之后页面不能点击了,给用户的感觉就是误以为为卡死了,那我们仔细看看页面元素之后就会发现页面并没有卡死,而是对话框有个外部容器类名为ant-modal-wrap的div显示在页面上,而这个样式处于页面的最上层,导致其它元素点击了没有反应给人造成了卡死的错觉。
原因排查
首先说明下用户是怎么操作触发这个问题,用户有个页面上面有个点击按钮,在按钮点击事件上面绑定了如下代码:
async function click(){
showLoading();
await doSth();
closeLoading();
}
其中showLoading就是将页面上一个由Modal对话框转换成了加载的效果给显示出来,通过设置Loading组件内部维护的一个open状态来赋值到Modal的open属性中实现,而closeLoading刚刚相反,它是将open状态给设置为false,达到隐藏Loading组件的结果。
按理说这是一个很常见的功能,项目里很多地方也在用都没有出现过问题,但是在这里怎么就行不通了呢?
唯独这里有个区别是显示和隐藏都是在一个方法下调用的,但是又好像有点不对?为什么这么说呢?大家都知道React在点击事件里面会对state的修改做批处理的,也就是说连续调用open状态的修改,从open为true到false最后只有false才会生效,这样一来其实是看不到loading的效果的,但事实上loading还是在界面上显示了,也就是说这个批处理其实是没有发挥作用的。
为什么呢?仔细一看代码,中间的doSth()这个方法的调用似乎是这个问题的关键。我们尝试了把这个方法注释掉就发现loading的显示彻底没有了,那么这个方法有啥特别的呢?我们注意到这个方法返回了promise对象,相比于正常的方法调用我们在调用前加了await修饰符来等待promise执行结束后再调用后续的closeLoading方法。
那在这里我们大胆猜测react内部对state的更改流程因为中间加了一个异步执行等待导致最终state批处理无法按照预期进行,而变为了open state: true到异步方法调用再到open state:false这样。
通过打印日志上述的猜测得到了验证,state的确是有两次更改。
但是问题又来了,常规的操作也是两次更改为什么就没有问题呢?为了解决这个问题只能调试antd modal内部的实现了。
我们看到antd modal的外部有这么一段逻辑:
if (!animiatedVisible) {
return null;
}
return <XXX />
其中XXX组件就是用于Modal组件内部的实现,我们试过正常情况对话框关闭的时候页面上是没有多余元素的,但是我们卡死的情况就是因为有多出的div元素,也就是说异常情况下animatedVisible出错了,在关闭的时候没有被设置为false,那animatedVisible的值又是取之哪里?什么时候变成false的呢?我们将相关代码列在下方:
const [animiatedVisible, setAnimatedVisible] = useState(props.visible);
useEffect(()=>{
if (props.visible) {
setAnimatedVisible(true);
}
}, [props.visible])
return <XXX onClose={()=>{
setAnimatedVisible(false);
}} />
通过上面一段代码,我们对modal的实现基本有个了解了,流程如下:
modal内部通过animatedVisible这个state来决定对话框的显示隐藏,默认值来自于外部传入的visible值。
内部加了外部visible的监听,识别到为true时,会将状态animatedVisible同步设置为true,以此来达到显示对话框的效果。
同时给内部对话框组件XXX传入了一个onClose参数,具体实现为在关闭时,同时将animatedVisible状态设置为false,以此来达到关闭对话框的效果。
说到这里好像没有啥问题,初步一看我们外部传入的visible state变化好像也没有什么毛病?那么问题究竟出现在哪里了呢?难道最后的animatedVisible真的没有跟随外部的visible状态变化同步设置为false吗?
我们同样通过打印来进行验证,果然正常情况下animatedVisible在最后被设置为了false,而我们卡死的异常情况在最后却还是为true。
让我们再回头看上述的流程,设置animatedVisible为false的地方只有onClose这一处,如此说来onClose在异常情况下没有被调用,寻着这个调用的地方我们发现了onClose是在内部的rc-motion组件内部触发的,结合animatedVisible这个命名,我们大致猜测调用链可能是这样的:
为了实现对话框的动画效果,antd modal内部维护了一个animatdVisible状态,rc-motion组件内部会在关闭对话框动画结束后调用外部的onClose方法,这个时候将animatedVisible状态设置为false,如此一来可以完美实现动画效果。
通过一大波调试,最后终于把流程给捋清楚了,具体如下:
首次设置visible true dialog wrap内部维护animatdVisible为false,通过个useeffect更新animatdVisible 很短时间内设置visible false 此时dialog wrap内部状态animatdVisible还未更新 内部的dialog此时不会渲染,依赖于portal上的visible属性,该属性值为visible||animatdVisible
当该轮渲染结束后,轮到dialog wrap自身状态更新,更新结束后portal组件内部开始渲染内部的dialog,此时内部的dialog对应的wrap div元素会一直渲染,dialog内部维护一个animatdVisible状态,该状态同样依赖于外部visible参数,通过effect监听当外部visible为true时,会更新animatdVisible为true dialog内部div依赖于animatdVisible状态来决定display样式,当值为false时,值为none,否则为null,当前外部visible一个为false,所以display样式为null
当再次设置外部visible为true时,此时wrap状态animatdVisible仍为true,portal渲染内部dialog,此时内部dialog传入的visible为true,会触发effect事件,开始设置dialog状态animatdVisible为true 此时在很短一段时间内设置外部visible为false,由于dialog wrap自身状态animatdVisible没有变化仍为true(依赖于close事件才能变为false),导致portal保持渲染状态,当该轮渲染结束后,dialog内部状态animatdVisible刷新为true,内部的wrap元素display属性变为null,导致出现页面不能点击的现象。
解决方案
原以为这个问题和之前modal那个问题一样,最新的antd版本可能已经修复掉了结果一看最新的实现还是这样的。但是奇怪的是官方文档上的演示demo我在上面写了一段测试代码,发现情况完全不一样,是好的!
找了下区别,唯一不同的是react的版本,官方文档上用的是最新的19版本,而我们项目里用的是18的版本。现在让我们升级React版本短时间内肯定不现实,那么只能采取一个比较取巧的办法了,那就是最上层loading实现组件那一块根据open state的状态来确认是否渲染modal组件,这样的话就不会受到modal组件内部额外渲染div元素的影响了,但是这样带来的问题是对话框的动画效果就没有了。
写到最后,后期刚刚好可以趁着这个机会看看react19版本和18版本更新机制到底该了哪些而导致了上述不同的现象。