React 时间分片:为什么 React 选择 5ms 作为默认的时间片长度?这个数值背后有哪些硬件与感官的考量?

各位同学,大家好!

今天咱们不讲那些花里胡哨的 Hooks,也不扯什么 TypeScript 类型体操。咱们来聊聊 React 内部最核心、最神秘,也是最能体现“工程艺术”的一个机制——时间分片

我知道你们很多人听到“时间分片”这四个字,脑子里可能只有一行代码:requestIdleCallback。别急,今天咱们要把这层窗户纸捅破。咱们要探讨的是,为什么 React 毫不犹豫地选择了 5ms 作为它的默认时间片长度?为什么不是 1ms?为什么不是 10ms?

这不仅仅是一个数字,这是一个在 CPU 的暴力与人类的感官之间,寻找出的那个“黄金平衡点”。

准备好了吗?咱们把键盘敲起来,把咖啡喝满,咱们开始今天的深度剖析。


第一章:单线程的“独裁者”与浏览器的“地狱”

首先,咱们得明白一个残酷的事实:JavaScript 是单线程的。

这是什么意思?想象一下,你是一个厨师(主线程),你面前只有一张案板。如果你要做满汉全席(执行复杂的 JS 逻辑),你每做一道菜(执行一行代码)都得亲力亲为。如果你做红烧肉花了 10 分钟,那后面那 100 道菜只能凉。

浏览器也是一样。它的主线程不仅负责运行 JS,还负责渲染 UI、处理鼠标点击、执行网络请求。

如果 JS 执行时间过长,会发生什么?

浏览器会“死机”。具体表现为:你的网页卡在某个 Loading 状态,动弹不得,甚至出现“页面未响应”的警告弹窗。这时候,用户如果点一下右上角的关闭按钮,浏览器可能都要犹豫一下,因为它正在忙着处理你的代码,没空响应你的关闭请求。

React 15 之前,就是这样一个“暴脾气”的厨师。它接到了一个任务(比如渲染一个包含 10,000 个列表项的组件),它想:“老子一口气全做完!”于是它把主线程锁死,一口气渲染完了。结果就是:页面卡死 500ms,用户在那干瞪眼,甚至以为电脑崩了。

所以,React 16 开始引入 Fiber 架构,核心目的只有一个:把大任务拆成小任务


第二章:16ms 的诅咒与 120fps 的幻觉

那么,拆到多小合适呢?这就要涉及到一个生物学和物理学结合的参数了——60fps

现在的显示器,刷新率大多是 60Hz。这意味着每秒刷新 60 次。每一帧的刷新时间就是 16.67ms

这是人眼的“甜蜜点”。如果你能保持 60fps,你的画面就是流畅的。如果你低于这个数,比如 30fps,你就会感觉到明显的卡顿,像是在看幻灯片。

但是,React 的渲染任务只是浏览器渲染管线中的一环。浏览器在渲染一帧之前,还得干很多事儿:

  1. 执行 JS:这是 React 在干的事儿。
  2. 计算样式:浏览器要算一下这个 div 到底该多大、颜色是什么。
  3. 布局:浏览器要确定元素在页面上的确切位置。
  4. 绘制:把颜色画到屏幕上。
  5. 合成:把图层合成到一起。

你看,16ms 是给整条流水线用的。React 哪怕只占用了 5ms,剩下的 11ms 也要被浏览器内部逻辑瓜分掉。

所以,React 的目标很明确:在 16ms 的时间窗口内,尽可能多地完成渲染工作,同时把剩余的时间留给浏览器自己喘口气。

这就是时间分片的核心逻辑。如果 React 占用了 16ms,那浏览器就没有时间做布局和绘制,画面就会卡顿。如果 React 占用 4ms,那浏览器就有 12ms 的时间去处理 UI,画面依然流畅,甚至能达到 120fps(因为浏览器可以在一帧里渲染两次 UI,虽然显示器只能显示 60 帧,但这样能保证极高的响应性)。

那么,为什么 React 选了 5ms 呢?


第三章:为什么是 5ms?(核心答案)

这 5ms 的选择,不是拍脑袋拍出来的,它是经过无数次惨痛教训和精密计算得出的。

1. 感官的“安全边际”

首先,从感官层面来看,5ms 是一个“绝对安全”的数值

如果 React 每次只切 1ms,那意味着每秒要执行 1200 次调度。这会导致什么?CPU 上下文切换的开销会超过渲染带来的收益。这就好比你要切一块豆腐,你每切 1 微米就停一下,结果刀还没切完,你累得半死,豆腐还没切好。

如果 React 每次切 10ms,那意味着每秒只执行 60 次调度。虽然听起来还行,但一旦你的电脑稍微卡顿一下(比如后台程序占用了点内存),或者内存分配稍微慢一点,10ms 就会膨胀成 15ms、16ms。一旦超过 16ms,帧率就会掉到 60fps 以下,用户就会感觉到卡顿。

5ms 是一个“甜蜜点”

  • 它保证了即使在复杂的计算环境下,React 也能在 16ms 内完成一轮渲染。
  • 它给浏览器留出了 11ms 的缓冲区。这 11ms 足够浏览器完成样式计算和布局,甚至还能稍微做点后台垃圾回收(GC)。

2. 硬件层面的“缓存友好”

从硬件层面,特别是 CPU 缓存的角度来看,5ms 也是一个折中的选择。

现代 CPU 有 L1、L2、L3 缓存。CPU 执行指令时,如果数据在缓存里,那是飞快的;如果不在,得去内存里找,那就慢得像蜗牛。

React 的渲染逻辑非常复杂,涉及大量的状态读取、对象创建、虚拟 DOM 比对。这些操作高度依赖于 CPU 缓存。

如果你把时间片切得太短(比如 1ms),React 每次只做一点点工作,然后交出控制权。这就意味着 CPU 频繁地在“任务 A”和“任务 B”之间切换。这种频繁的切换会导致 CPU 缓存频繁失效。CPU 刚把数据加载到 L1 缓存准备干活,结果调度器说“停!”,CPU 只能把数据清空,去加载下一个任务的数据。这极大地浪费了算力。

5ms 的长度,足够 CPU 完成一次完整的、密集的缓存预热和计算周期。它让 CPU 能够在一个时间片内,最大化地利用缓存命中率,而不是在频繁的切换中浪费能量。

3. 内存分配的“隐形杀手”

除了 CPU,还有一个巨大的敌人:内存分配

React 在渲染过程中,需要创建大量的临时对象(比如 Fiber 节点、临时变量)。在 V8 引擎中,频繁的内存分配会触发垃圾回收(GC)

GC(垃圾回收)通常是在主线程上运行的。如果你的 React 渲染任务占用了 10ms,那浏览器就没有 6ms 的时间去跑 GC。GC 一旦在主线程运行,整个页面就会瞬间冻结。这种“卡顿”比掉帧更可怕,因为它是不规律的,毫无征兆的。

通过将时间片限制在 5ms 左右,React 确保了它自己的任务和浏览器的 GC 任务在时间上是错开的。React 干活时,浏览器可以在后台悄悄地回收垃圾;等 React 交出控制权,浏览器正好完成 GC,准备好下一帧的内存。这叫“时间片错峰”,懂吗?这就是高级程序员的生活智慧。


第四章:代码实战——把时间切片具象化

光说不练假把式。咱们来写点代码,看看如果不做时间分片,和不做时间分片到底有什么区别。

场景模拟:渲染 10000 个数字

假设我们要在页面上渲染 10000 个数字,每个数字的计算都很简单,但加起来是个大工程。

方案 A:同步渲染(React 15 的噩梦)

// 这是一个非常糟糕的例子
function renderSync() {
  const list = [];
  const start = performance.now();

  // 假设我们有一个耗时很长的循环
  // 在主线程上,这会阻塞 UI
  for (let i = 0; i < 10000; i++) {
    list.push(<div key={i}>Item {i}</div>);
  }

  console.log(`同步渲染耗时: ${performance.now() - start}ms`);
  return list;
}

// 渲染函数
function App() {
  const [items, setItems] = useState([]);

  useEffect(() => {
    // 立即执行,界面会卡死
    setItems(renderSync());
  }, []);

  return <div>{items}</div>;
}

后果: 页面会瞬间白屏,然后卡死几秒钟。在这几秒钟里,你点击任何按钮都没反应,甚至浏览器标签页上的小圆点都会变成“未响应”。


方案 B:手动时间分片(React 18 之前的方式)

现在,咱们自己动手,实现一个简单的调度器。

// 模拟一个高耗时的任务
function heavyComputation(index) {
  // 这里可以放一些复杂的数学运算
  // 比如 Math.sqrt(Math.random() * 10000000)
  // 或者创建大对象
  return index * 2; 
}

// 调度器函数
function renderWithTimeSlicing(totalItems, onChunkComplete, onComplete) {
  let index = 0;
  const CHUNK_SIZE = 100; // 每次处理 100 个,大约耗时 1-2ms
  const FRAME_TIME = 5;   // 每次切 5ms

  function loop() {
    const startTime = performance.now();

    // 在 5ms 的时间内,尽可能多地处理数据
    while (index < totalItems) {
      // 执行计算
      heavyComputation(index);

      index++;

      // 如果超过了时间片,就暂停
      if (performance.now() - startTime > FRAME_TIME) {
        // 把进度传回去,让 UI 更新一下
        onChunkComplete(index);

        // 使用 setTimeout 或 requestAnimationFrame 安排下一帧
        // requestAnimationFrame 是最准确的,因为它绑定在浏览器的刷新率上
        requestAnimationFrame(loop);
        return; 
      }
    }

    // 如果全部完成了
    onComplete();
  }

  loop();
}

效果对比:

当你点击按钮触发 renderWithTimeSlicing 时,你会看到:

  1. 页面不会卡死。虽然浏览器在疯狂计算,但 UI 线程被释放出来了。
  2. 界面会慢慢滚动。你会看到数字一个个蹦出来,非常丝滑。
  3. CPU 占用率。你会发现 CPU 占用率是波动的,它不是一条直线,而是一个锯齿波。这就是时间分片在起作用。

第五章:深入源码——React 的“时间片管家”

React 的源码里,其实并没有直接写一个 setTimeout(..., 5)。它用的是更底层的 API。

在 React 18 之前,主要依赖 requestIdleCallback。这个 API 允许你在浏览器空闲的时候执行任务。但是,requestIdleCallback 有个致命的缺点:它不可靠。如果你的电脑负载很高,浏览器可能永远不会进入空闲状态,那你的任务就会无限期推迟。

所以,React 团队自己实现了一个调度系统,叫做 Scheduler

在 Scheduler 的源码里,你会看到类似的逻辑:

// 伪代码还原 React Scheduler 的核心逻辑
function scheduleCallback(priorityLevel) {
  const currentTime = getCurrentTime();

  // 计算任务的截止时间
  const expirationTime = currentTime + expirationTimes[priorityLevel];

  // 这里的 deadline 就是我们关心的 5ms 的体现
  // 实际上,React 会根据优先级动态调整这个时间
  // 普通任务通常是 50ms,但高优先级任务(比如输入响应)会被压缩到 5ms

  // 如果是高优先级任务,我们强制在当前帧结束前完成
  // 如果是低优先级任务(比如过渡状态),我们可以利用空闲时间

  if (isInputPending()) {
     // 如果用户正在输入,我们必须立即响应,不能等空闲时间
     // 这就是为什么打字时 React 渲染那么快
     return scheduleSyncCallback();
  }

  return scheduleDeferredCallback(deadline => {
     // 这里就是 Fiber 的调度逻辑
     // 如果 deadline.timeRemaining() > 5ms,就继续干活
     // 如果 deadline.timeRemaining() < 5ms,就挂起
     while (workInProgress && deadline.timeRemaining() > 0) {
       performUnitOfWork(workInProgress);
     }
  });
}

这里有个细节:5ms 并不是硬编码的死数

React 会根据 deadline.timeRemaining() 动态调整。

  • 如果系统负载很轻,timeRemaining() 可能还有 10ms,React 会趁机多干点活,比如一次处理 10ms 的量,这样能加快整体渲染速度。
  • 如果系统负载很重,timeRemaining() 只有 1ms,React 就会赶紧停下来,把控制权交出去。

所以,5ms 是一个“动态阈值”,而不是一个固定的时间常数。 它代表了“在当前帧剩余时间里,我们还能再分配给 React 的最大安全额度”。


第六章:感官心理学——为什么是 5ms 而不是 4ms 或 6ms?

咱们再聊聊人。人是感性的动物。

如果你在 5ms 内完成了渲染,用户的眼睛会感觉到一种“顺滑”。这种顺滑来自于高帧率带来的视觉残留。

但是,如果你把时间片缩短到 1ms,虽然帧率变成了 120fps,但用户真的能感觉到区别吗?
不能。

人眼的视觉暂留效应大约是 16ms。在 60fps 和 120fps 之间切换,对于普通人的肉眼来说,区别微乎其微。你甚至需要盯着秒表看才能分辨出来。

多出来的这 1ms 做了什么?
它做了更重要的东西:响应性

想象一下,你在玩一个 FPS 游戏。如果你每秒只更新 60 次画面,你的鼠标移动可能会有点顿挫。如果你每秒更新 120 次,鼠标移动就是跟手的。

在 React 中,5ms 的时间片保证了 UI 的响应性。当你点击一个按钮,React 不会等 500ms 的大任务全部做完才给你反馈,它会在 5ms 内就给你一个反馈(比如按钮变灰)。这种微小的、高频的反馈,让用户感觉整个应用是“活”的。

6ms 呢?
6ms 会让 React 占用更多的时间。虽然看起来差别不大,但在极端情况下(比如你正在渲染一个包含 100,000 个节点的巨型列表),6ms 可能就会导致帧率从 60fps 掉到 50fps。一旦掉帧,用户的体验就会断崖式下跌。

5ms 是那个“临界点”。 它是保证在大多数硬件配置下,都能稳稳站在 60fps 线上的最后防线。


第七章:硬件进化与 5ms 的未来

你们可能会问:“现在 CPU 都多快了,内存都多大,5ms 是不是太保守了?”

这是一个非常深刻的问题。

虽然 CPU 跑得很快,但JavaScript 的瓶颈往往不在 CPU 算力,而在内存分配和垃圾回收

V8 引擎在处理大数组、复杂对象时,内存分配是非常昂贵的。而且,现代浏览器的渲染管线越来越复杂(涉及 GPU 加速、WebGL、复杂的 CSS 混合模式)。这些操作都发生在主线程上。

所以,无论 CPU 多快,浏览器的主线程永远是那个独裁者。它必须在一个帧周期内完成所有的工作。

随着硬件的发展,5ms 这个标准其实是在不断逼近极限

在 iPhone 15 Pro 上,屏幕刷新率达到了 120Hz。这意味着每一帧只有 8.33ms
如果 React 还是用 5ms,那留给浏览器的就只有 3.33ms。
这时候,如果浏览器要做一个复杂的布局计算,React 就必须让出更多的时间。

这就是为什么 React 18 引入了 并发渲染自动批处理

  • 自动批处理:它把很多小的状态更新(比如 setState(count1); setState(count2);)合并在一起,只进行一次渲染。这相当于把“5ms”的效率又提高了一倍。
  • 并发:它允许 React 在后台预渲染下一帧,如果用户没有交互,就直接展示;如果用户交互了,就中断后台渲染,优先响应用户。

所以,5ms 是基础,但 React 通过更智能的调度算法,正在把这个“5ms”的价值最大化。


第八章:总结与“专家”建议

好了,咱们今天聊了这么多。

React 选择 5ms 作为时间片的默认长度,不是因为它是一个随便拍脑袋的数字,而是它是 CPU 缓存、内存分配、浏览器渲染管线和人眼视觉暂留 共同博弈后的产物。

它是一个安全边际,也是一个性能护城河

给各位在座的开发者们的建议:

  1. 不要盲目优化:除非你确实遇到了严重的卡顿,否则不要试图去修改 React 的源码来调整时间片。React 团队已经在源码里把 5ms 做成了动态算法,比你手写的 setTimeout 要聪明得多。
  2. 理解“批处理”:React 18 的 startTransition 和自动批处理,本质上就是帮你省时间。尽量使用这些新特性,而不是自己写时间分片的代码。
  3. 长列表优化:如果你的列表有几千条数据,不要试图在主线程里一次性渲染。使用 react-windowreact-virtualized。它们的原理其实就是把 5ms 的切片逻辑做得更细致,只渲染可视区域的数据。

最后,我想说,时间分片是 React 最伟大的发明之一。它让 JavaScript 这种单线程语言,在浏览器这个复杂的生态环境中,找到了生存和发展的空间。

它告诉我们:有时候,慢一点(切分任务),是为了更快地完成(保持流畅)。 这不仅适用于编程,也适用于生活,不是吗?

好了,今天的讲座就到这里。下课!

(附赠一段有趣的代码,庆祝我们学完 5ms 的秘密)

// React 的 5ms 之舞
function reactMagic() {
  const timeSlice = 5;
  const frameBudget = 16; 
  const remaining = frameBudget - timeSlice;

  console.log(`浏览器给了 React ${timeSlice}ms,React 拿着这 5ms,给浏览器留下了 ${remaining}ms 的尊严。`);
  console.log(`React: "I'll be back." (在 5ms 内完成渲染)`);
  console.log(`浏览器: "Thanks, bro. I'll handle the layout and paint." (在剩余 11ms 内处理 UI)`);
}

reactMagic();

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注