打字机动效为何出现吞字、顺序错乱的场景?

0 阅读6分钟

期望的正常效果

正常效果:一个字符一个字符增加

image.png

异常效果

image.png 异常结果:第一个字符“【”被吞了、结尾出现undefined


image.png 异常结果: 打印了两个相同的字符 正常的文案应该是:.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上面已经分析过了,那为什么字符人消失了,被替换成了字符都呢?

正常情况下,主线程不卡,会让一个回调跑完在进下一个回调:

  1. 时间到
  2. 进入定时器回调
  3. 执行setState登记(入队)
  4. index++
  5. 回调结束
  6. React立即更新状态
  7. 渲染
  8. 等下一个时间到,在进来一次

这种情况下:不会重复,不会乱

但是,主线程永远不卡是不存在的,浏览器随便干点啥都会微卡几毫秒,然后setInterval就会堆任务,那这个时候发生了什么?

  1. 0ms: setInterval到点 -> 放任务1进队列 -> 但主线程忙-> 不跑
  2. 50ms: 又到点 -> 放任务2进队列 -> 但主线程还在忙-> 不跑
  3. 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)
},[])