期望的正常效果
正常效果:一个字符一个字符增加
异常效果
异常结果:第一个字符“【”被吞了、结尾出现undefined
异常结果: 打印了两个相同的字符
正常的文案应该是:.90%的人都不知道的国宝冷知识
变成了:90%的都都不知道的国宝冷知识
前置知识
React 17 之前 setState 有时“异步”,有时同步
| 场景 | React 17 及之前 | 说明 |
|---|---|---|
| React 事件处理函数 | 异步(批处理) | 等所有事件处理完再渲染 |
| setTimeout/setInterval | 同步 | 每次 setState 立即渲染 |
| Promise.then | 同步 | 每次 setState 立即渲染 |
| 原生 DOM 事件 | 同步 | 每次 setState 立即渲染 |
为什么会这样?
React 17 之前,批处理依赖 React 的事件系统:
- React 合成事件:React 控制整个事件流程,可以延迟渲染
- setTimeout/原生事件:React 无法控制,每次
setState都立即触发渲染
React 18 中,setState始终是“异步”的
在 React 18 中,setState 从调用到渲染完成的过程可以分为两个阶段:
1. setState 本身:始终是"异步"的(非阻塞)
console.log(count); // 0
setCount(1);
console.log(count); // 还是 0(不会立即更新)
setState 调用后立即返回,不会阻塞后续代码执行。
2. 渲染时机:取决于是否被批处理
| 场景 | 行为 | 说明 |
|---|---|---|
| React 事件处理函数 | 批处理 | 等所有事件处理函数执行完再渲染 |
| setTimeout/setInterval/Promise | 批处理(React 18) | 等当前回调执行完再渲染 |
| 原生 DOM 事件 | 批处理(React 18) | 等事件处理完再渲染 |
flushSync 包裹 | 同步渲染 | 立即渲染,阻塞后续代码 |
注意⚠️:
setState不是 Promise/setTimeout 那种异步- 是 队列 + 同步执行 的模式
- 所谓"异步"只是指:调用后不会立即渲染,而是等合适的时机统一处理
- 所有操作都在 主线程 完成
原因分析:第一个字符被吞了,结尾出现undefined
错误代码:
let currentIndex = 0;
timerRef.current = setInterval(() => {
if (currentIndex < limitedText.length && limitedText[currentIndex]) {
setDisplayedText((prev) => prev + limitedText[currentIndex]);
currentIndex++;
} else {
clearInterval(timerRef.current); // 停止定时器
}
}, 50);
导致错误的原因: react18 setState的都是“异步”的,导致的这个问题。
这段代码在react17以及17之前是不会出问题的
因为
setDisplayedText((prev) => prev + limitedText[currentIndex]);
currentIndex++;
执行setInterval的时候,是先执行了setDisplayedText,然后页面渲染,然后在currentIndex++的
所以每次取的字符都是准确的,渲染也是准确的。
在react18会出现问题
因为react18是异步的,渲染的时候,会在seInterval回调执行完在处理渲染。
距离:假设整段limitedText内容是【国宝有话说】,初始值displayedText是空字符。
- 第一次循环
-
- 执行到setDisplayedText((prev) => prev + limitedText[currentIndex]);(异步,丢入队列)
- 继续执行currentIndex++
- 执行完seInterval回调了,从队列里取setDisplayedText,处理数据,渲染。
- 这个时候 由于闭包问题,limitedText[currentIndex]里的currentIndex已经是1了,所以取到了字符国而不是字符【
- 第二次循环
-
- 执行到setDisplayedText((prev) => prev + limitedText[currentIndex]);(异步,丢入队列)
- 继续执行currentIndex++
- 执行完seInterval回调了,从队列里取setDisplayedText,处理数据,渲染。
- 这个时候 由于闭包问题,limitedText[currentIndex]里的currentIndex已经是2了,所以取到了字符宝而不是字符国
- …… 一直如此循环,直到最后一个循环
- 最后一个循环
-
- 行到setDisplayedText((prev) => prev + limitedText[currentIndex]);(异步,丢入队列)
- 继续执行currentIndex++
- 执行完seInterval回调了,从队列里取setDisplayedText,处理数据,渲染。
- 这个时候 由于闭包问题,limitedText[currentIndex]里的currentIndex已经是7了,所以取到了undefined而不是字符】
- 导致了最后的输出结果是:国宝有话说】undefined
解决方案1:先把char存一个快照,这样能保证setDisplayedText拿到的是正确的字符
const char = limitedText[currentIndex];
setDisplayedText((prev) => prev + char);
currentIndex++;
解决方案2: 用flushSync强制同步(不推荐 性能差)
flushSync(() => setDisplayedText((prev) => prev + limitedText[currentIndex]));
原因分析:某个字符取到了下一个字符的内容展示
偶发,把循环的时间调到1ms更容易触发
首先首字符被吞和结尾字符出现undefiend上面已经分析过了,那为什么字符人消失了,被替换成了字符都呢?
正常情况下,主线程不卡,会让一个回调跑完在进下一个回调:
- 时间到
- 进入定时器回调
- 执行setState登记(入队)
- index++
- 回调结束
- React立即更新状态
- 渲染
- 等下一个时间到,在进来一次
这种情况下:不会重复,不会乱
但是,主线程永远不卡是不存在的,浏览器随便干点啥都会微卡几毫秒,然后setInterval就会堆任务,那这个时候发生了什么?
- 0ms: setInterval到点 -> 放任务1进队列 -> 但主线程忙-> 不跑
- 50ms: 又到点 -> 放任务2进队列 -> 但主线程还在忙-> 不跑
- 100ms: 又到点 -> 放任务3进队列 -> 但主线程还在忙-> 不跑
假设到了120ms,主线程终于不忙了,然后再把堆积的任务连续执行了,然后就触发了react18的批处理……
拿上面的错误例子进行分析。
正确文案:.90%的人都不知道的国宝冷知识
错误文案: 90%的都都不知道的国宝冷知识
- 第一次循环,主线程不卡,执行了回调,回调结束,处理setDisplayedText((prev) => prev + limitedText[currentIndex]),这个时候currentIndex因为闭包原因,已经是1了,所以displayedText = "9"
- 第二次循环,同理所以displayedText = "90"
- 第三次循环,同理所以displayedText = "90%"
- 第四次循环,同理所以displayedText = "90%的"
- 第五次循环,主线程卡了一下,于是这次的回调就堆积到任务队列了,我们命名为 回调5
- 第六次循环, 回调6 进任务队列 主线程不卡,于是开始把堆积的任务( 回调5, , 回调6 )连续执行了,也触发了批处理,于是 displayedText = "90%的都都"
- 后续就主线程不卡又正常输出了……
看打印我们也可以看到,都都这两个字符是一起出来的,就是因为react的批处理机制。
解决方案
用上面的存快照、强制同步的解决方案,可以解决这里的问题吗?
答案:上面的解决方案可以保证文案的正常渲染,但保证不了打字机的效果,当线程卡主的时候,由于批处理机制,还是会出现多个字符一起出现的问题。
所以更优方案应该是这样的:(伪代码)
useEffect(()=>{
let index = 0
let timer
const text = "国宝有话说"
function run(){
if(index>text?.length)return
const char = text[index]
setText(prev => prev+char)
index++;
// 下一个定时器是当前这次执行完才诞生了,所以不会有任务堆积的情况
timer = setTimeout(run,50)
}
run()
return ()=>clearTimeout(timer)
},[])