浏览器时间切片与协作式调度

从长任务、帧预算、requestAnimationFrame 与任务分片角度理解浏览器主线程调度。

#tech / dev / frontend #resource / javascript #type / synthesis #status / growing #platform / browser

[!info] related notes

浏览器时间切片与协作式调度

一句话定义

时间切片的核心不是“让任务变快”,而是把长任务拆成多段执行,主动把主线程让出来,让浏览器有机会响应输入、更新动画和完成渲染。

1. 为什么会需要时间切片

浏览器主线程要同时做几件事:

  • 执行 JavaScript
  • 处理用户输入
  • 计算样式和布局
  • 绘制页面

如果一段同步 JavaScript 长时间不结束,浏览器就拿不回主线程控制权,结果就是:

  • 点击后没反应
  • 动画掉帧
  • 输入卡顿
  • INP、TBT 变差

这就是“长任务阻塞主线程”。

2. 先抓住一个核心直觉

60fps 表示一帧大约只有 16.67ms

但这 16.67ms 不是都给 JavaScript 的。浏览器还要留时间给:

  • 样式计算
  • 布局
  • 绘制
  • 合成

所以当你看到“每次最多执行 15ms”这类题时,本质是在考:

  • 你知不知道主线程不能一直被同步代码霸占
  • 你会不会主动在任务之间 yield

3. 常见调度工具怎么选

工具适合什么优点边界
requestAnimationFrame动画、视觉相关更新回调发生在下一次绘制前,和刷新节奏对齐不适合纯后台重计算;隐藏标签页会降频或暂停
setTimeout(fn, 0)最通用的让出执行权兼容性最好,易理解定时器有最小延迟,不够精细
MessageChannel轻量任务分片setTimeout 更适合高频切片调度仍然在主线程,不能解决 CPU 太重的问题
requestIdleCallback低优先级、可延后任务适合空闲时慢慢做空闲不足时可能长期得不到执行
Web Worker纯计算型重任务真正移出主线程不能直接操作 DOM,需要消息通信

4. 为什么高帧动画优先用 requestAnimationFrame

这类题最稳的回答不是“它更快”,而是:

  1. 它和浏览器绘制节奏对齐
  2. 回调发生在下一次 paint 之前
  3. 浏览器可以在后台标签页自动降频,避免无意义消耗
  4. 比起 setTimeout,更不容易出现漂移和无效重绘

所以做高帧动画、平滑位移、逐帧视觉更新时,requestAnimationFrame 是默认首选。

如果是纯计算分片,不涉及每一帧都要改 UI,则 MessageChannel / setTimeout 更常见。

5. 时间切片题到底在考什么

题目通常会描述成:

  • 有大量同步函数
  • 每次最多执行 15ms
  • 然后让浏览器去做动画或渲染
  • 某一个函数报错就 reject

它考的是四件事:

  1. 顺序执行而不是并发乱跑
  2. performance.now() 统计当前切片耗时
  3. 到预算上限后主动调度下一轮
  4. Promise 暴露完成和错误

6. 一个可用的实现

下面这版默认用 MessageChannel 调度,必要时也可以切到 setTimeoutrequestAnimationFrame

function timeSlice(functionArray, option = {}) {
  const {
    budget = 15,
    args = [],
    yieldStrategy = 'messageChannel',
    onProgress,
  } = option;

  if (!Array.isArray(functionArray)) {
    return Promise.reject(new TypeError('functionArray must be an array'));
  }

  if (!functionArray.every((fn) => typeof fn === 'function')) {
    return Promise.reject(new TypeError('every item in functionArray must be a function'));
  }

  const tasks = functionArray.slice();
  const results = new Array(tasks.length);
  let index = 0;
  let channel = null;

  return new Promise((resolve, reject) => {
    const cleanup = () => {
      if (channel) {
        channel.port1.onmessage = null;
        channel = null;
      }
    };

    const scheduleNext = () => {
      if (yieldStrategy === 'raf' && typeof requestAnimationFrame === 'function') {
        requestAnimationFrame(runSlice);
        return;
      }

      if (yieldStrategy === 'timeout' || typeof MessageChannel === 'undefined') {
        setTimeout(runSlice, 0);
        return;
      }

      if (!channel) {
        channel = new MessageChannel();
        channel.port1.onmessage = runSlice;
      }

      channel.port2.postMessage(undefined);
    };

    const runSlice = () => {
      const sliceStart = performance.now();

      try {
        while (index < tasks.length && performance.now() - sliceStart < budget) {
          const task = tasks[index];
          results[index] = task(...args);
          onProgress?.({
            index,
            total: tasks.length,
            result: results[index],
          });
          index += 1;
        }

        if (index >= tasks.length) {
          cleanup();
          resolve(results);
          return;
        }

        scheduleNext();
      } catch (error) {
        cleanup();
        reject(error);
      }
    };

    scheduleNext();
  });
}

7. 这版实现的关键点

7.1 为什么用 performance.now()

因为它更适合高精度统计执行耗时,比 Date.now() 更稳。

7.2 为什么不是每执行一个函数都 setTimeout

因为那样切得太碎,调度开销会很高。

合理做法是:

  • 当前切片内尽量多执行几个任务
  • 一旦接近预算上限,再主动让出主线程

7.3 为什么返回 Promise

因为调用方需要知道:

  • 全部任务什么时候完成
  • 中间是否有错误
  • 每个任务的结果如何汇总

7.4 为什么一出错就 reject

因为题目要求“一旦某个函数报错就返回错误 Promise”,这意味着失败策略是 fail-fast,而不是吞错继续跑。

8. 面试里还可以顺手补的升级点

8.1 支持取消

可以返回一个带 cancel() 的控制器,或者把 AbortSignal 传进来。

8.2 支持异步任务

如果任务本身返回 Promise,可以用串行 await 包一层;但那时“时间预算”主要只约束同步部分,网络等待本身不占主线程。

8.3 计算太重时用 Worker

如果单个任务本身就要跑几十毫秒,光切片不够,因为每一小块仍然会卡。

这时更稳的做法是:

  • 把纯计算移到 Web Worker
  • 主线程只做结果合并和界面更新

9. 这类题最稳的回答模板

这题本质是在做协作式调度。浏览器主线程既要跑 JS,也要处理输入和渲染,所以不能把大量同步任务一次性跑完。我会把任务数组按时间预算分片执行,用 performance.now() 统计当前切片耗时,超出预算后通过 MessageChannelsetTimeoutrequestAnimationFrame 把控制权让出去。整个过程用 Promise 包起来,全部执行完就 resolve,只要某个任务抛错就立即 reject。

10. 最短记忆方式

  • requestAnimationFrame:适合动画
  • MessageChannel / setTimeout:适合主线程分片
  • Web Worker:适合真正重计算
  • 时间切片解决的是“别一直霸占主线程”,不是“把重活 magically 做没”
创建于 2026/3/29 更新于 2026/5/27