React useEffect耗时计算阻塞渲染,select切换时loading不生效
问题成因
- 核心本质:代码里的
sleep是靠同步死循环写的长任务,只要跑起来就把JS主线程占得死死的,循环结束前啥别的事都干不了。浏览器渲染和JS执行共用一条主线程,主线程被堵的时候,所有待更新的DOM内容都堆着画不上去,用户自然看不到加载提示。 - 按钮交互可正常显示加载提示的原因:点按钮触发
click事件的时候,回调里的setIsLoading(true)和setA(a+1)会被React合并成一次调度。回调跑完之后,在进入useEffect跑sleep长任务之前,主线程是有空隙做完整渲染的,先把loading的文本画到屏幕上,之后才会被2秒的死循环卡住。用户自然能看到持续2秒的加载提示。 - select交互无法正常显示加载提示的原因:切select选项触发
change事件的时候,浏览器本身得先把原生控件的交互闭环走完:下拉面板收起来、把选中项标高亮、把控件的值更新成新选项。这整套原生流程,和咱们写的onChange回调、React计算DOM的过程、useEffect里的2秒sleep长任务,会被浏览器串成一整段连续的主线程任务,中间根本留不出渲染loading的空隙。等2秒死循环跑完,主线程终于空了,浏览器要把攒了两次更新的DOM一次性画上去:第一次是要显示loading,第二次是sleep跑完要把loading关掉。这俩更新挨得太近,刚画出来就被覆盖,用户根本察觉不到loading的存在。
修复方案
- 最通用的改法就是给长任务的执行留个渲染的空隙,先把loading渲染完,再跑阻塞的长任务。可以在useEffect里用宏任务(比如
setTimeout)把同步的长任务包一层,把长任务从当前的渲染周期里拆出去,对应修改后的代码:
useEffect(() => { // 延后到下一个宏任务再跑长任务,给浏览器留够画loading的时间 const timer = setTimeout(() => { sleep(2000); console.log("finish"); setIsLoading(false); }, 0); // 组件卸载的时候清掉定时器,避免内存泄漏 return () => clearTimeout(timer); }, [a]);
这么改完之后,不管是按钮还是select触发更新,都会先把loading的UI画上去,再进入长任务的阻塞阶段,加载提示就能稳定显示了。
- 如果不想动长任务的执行逻辑,也可以在select的onChange回调里,把触发长任务的状态更新(也就是
setA(a+1))延后到浏览器画完loading的下一帧,用requestAnimationFrame把两个逻辑的执行周期拆开:
onChange={() => { setIsLoading(true); // 等浏览器把loading渲染完,再触发依赖a更新的长任务 requestAnimationFrame(() => { setA(a => a + 1); }) }}
- 如果长任务本身计算量很大,更推荐把同步阻塞的计算丢到Web Worker里跑,完全不占主线程,从根上解决UI被卡住的问题。
小提醒:原代码里loading的拼写错成
loding了,可以顺手改对。
内容的提问来源于stack exchange,提问作者Kaiyi Zhang
相关产品推荐
相关产品推荐

