React useLayoutEffect阻塞UI更新的实现机制与现象解析
问题复现
运行下述代码,点击带绿色边框的Click me区域后,打开浏览器开发者工具的Elements标签页观察:可以看到class为msg的span元素已经被添加到DOM结构中,但页面要卡顿1秒后才会显示出红色的提示元素。
const sleep = (duration) => { const start = new Date().getTime(); let end = start; while(end < start + duration) { end = new Date().getTime(); } } const Message = ({boxRef, children}) => { const msgRef = React.useRef(null); React.useLayoutEffect(() => { const element = document.querySelector('.msg'); console.log(element); const rect = boxRef.current.getBoundingClientRect(); sleep(1000); msgRef.current.style.top = `${rect.height + rect.top}px`; }, []); return <span ref={msgRef} className="msg">{children}</span>; }; const Index = () => { const [show, setShow] = React.useState(false); const boxRef = React.useRef(null); return ( <div> <div ref={boxRef} className="box" onClick={() => setShow(prev => !prev)}>Click me</div> {show && <Message boxRef={boxRef}>Foo bar baz</Message>} </div> ); }; ReactDOM.render(<Index />, document.getElementById('root'));
.box { position: absolute; width: 100px; height: 100px; border: 1px solid green; user-select: none; } .msg { user-select: none; position: relative; border: 1px solid red; background-color: red; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.1/umd/react.production.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.1/umd/react-dom.production.min.js"></script> <div id="root"></div>
原理说明
DOM已更新但页面未渲染的原因
这个效果没有什么特殊API,完全是React对useLayoutEffect的执行时机设计,刚好卡中了浏览器原生的渲染管线规则:
- React对
useLayoutEffect的执行时机定义非常明确:所有DOM修改完成后、浏览器执行视觉绘制之前,同步执行对应的回调函数 - 浏览器本身的渲染逻辑是固定的:先执行当前任务队列里的JS代码,等JS全部执行完、主线程空闲后,才会走样式计算、布局、绘制的全流程,把最新的DOM结构渲染到用户可见的页面上
整个流程的执行顺序是:
- 点击按钮触发状态更新,show变为true,React启动重渲染
- React计算完虚拟DOM差异,把真实的span.msg节点插入到DOM树中,这一步结束时DOM结构已经变了,但浏览器还没到绘制环节
- React立刻同步执行Message组件挂载阶段的
useLayoutEffect回调 - 回调里的
sleep是靠while循环写的同步阻塞逻辑,会一直占住主线程1秒,这期间浏览器拿不到主线程控制权,根本没法走后续的绘制流程,所以用户看不到页面上出现新元素 - 等1秒阻塞结束,
useLayoutEffect回调执行完,React把主线程交还给浏览器,浏览器才走完剩下的渲染步骤,把红色提示框绘制到页面上
移除console.log后Elements面板看不到即时节点的原因
这个现象和React没关系,是Chrome DevTools本身的更新机制导致的:
- Elements面板不会在DOM每次发生修改时都同步刷新视图,它的DOM树展示需要做DOM快照同步,这个操作要占用主线程时间片,跟着浏览器的渲染帧调度走
- 保留
console.log(element)时,控制台打印DOM元素是特殊的实时引用逻辑,会强制DevTools立刻同步当前DOM状态生成快照,所以你能第一时间在Elements里看到刚插入的节点 - 删掉这句console之后,从DOM插入到
sleep阻塞主线程的1秒里,没有任何操作触发DevTools做快照同步,主线程又被while循环完全占死,DevTools根本没机会执行刷新面板的逻辑,自然要等1秒阻塞结束、主线程释放后,才能在Elements里看到新增的节点。
补充:如果把阻塞逻辑挪到
useEffect里就不会出现这个现象,因为useEffect是在浏览器完成页面绘制之后才会异步执行,元素会先显示到页面上,再触发1秒的卡顿。
内容的提问来源于stack exchange,提问作者Alexander Pankin
相关产品推荐
相关产品推荐

