React Transition未按预期工作:为何0秒setTimeout可生效?
问题场景
已知React中变更会先更新Virtual DOM,经Diff后同步到Real DOM,useEffect钩子会在Real DOM渲染完成后触发。但遇到以下矛盾情况:
无过渡效果的代码
import React from 'react'; import { useEffect, useRef } from 'react'; export function App(props) { const ref = useRef(); useEffect(() => { const ref2 = ref.current; const div = document.createElement('div') div.innerHTML = "ASDF" div.style.position = "absolute"; div.style.top = 0; div.style.transition = "all 5s" ref2.appendChild(div); // 理论上应产生过渡,但实际直接显示top:100px div.style.top = "100px" }, []) console.log("render") return ( <div ref={ref} className='App'> <h1>Hello React.</h1> <h2>Start editing to see some magic happen!</h2> </div> ); } console.log('Hello console')
运行后,div直接停留在top:100px的静态状态,无过渡动画。
添加0秒setTimeout后的有效代码
import React from 'react'; import { useEffect, useRef } from 'react'; export function App(props) { const ref = useRef(); useEffect(() => { const ref2 = ref.current; const div = document.createElement('div') div.innerHTML = "ASDF" div.style.position = "absolute"; div.style.top = 0; div.style.transition = "all 5s" ref2.appendChild(div); // 添加0秒setTimeout后,过渡生效 setTimeout(()=>{ div.style.top = "100px" }, 0) }, []) console.log("render") return ( <div ref={ref} className='App'> <h1>Hello React.</h1> <h2>Start editing to see some magic happen!</h2> </div> ); } console.log('Hello console')
此时div从顶部平滑过渡到top:100px,符合预期。
核心原因:浏览器事件循环与渲染机制
这完全和浏览器的事件循环以及渲染队列合并机制相关:
样式变更的批量优化:浏览器为了避免频繁重排/重绘影响性能,会把同一宏任务中的所有DOM样式变更收集起来,一次性执行渲染操作。在第一段代码中,从创建div、设置
top:0到立刻修改为top:100px,所有操作都在同一个useEffect的宏任务里,浏览器会直接合并这两次top值的变更,最终只应用top:100px——元素从来没有以top:0的状态被渲染到页面上,自然不会触发过渡。setTimeout的宏任务调度:
setTimeout(callback, 0)会把回调函数放到下一个宏任务队列中。当当前useEffect的宏任务执行完毕后,浏览器会先处理渲染队列:把刚才创建的div以top:0的状态渲染到页面。等渲染完成后,再执行下一个宏任务里的回调,此时修改top:100px,浏览器能检测到样式的前后变化,从而触发过渡动画。React useEffect的时机补充:
useEffect确实在Real DOM更新完成后执行,但此时浏览器只是完成了DOM树的更新,还没执行实际的渲染流程(重排/重绘)。所以直接修改样式会被合并,而setTimeout让样式修改延迟到渲染完成后的下一个宏任务,就能触发过渡。
内容的提问来源于stack exchange,提问作者Arpit Patel

