你有没有写过这种代码:页面一打开,你顺手就想做点“顺便的事”——埋点、预加载、计算一堆东西、顺手还想把用户最近浏览记录也整理一下。

然后你发现:用户的顺便 = 主线程的噩梦

requestIdleCallback 的思路很朴素:正事(输入、滚动、动画、渲染)优先,剩下的等浏览器“喘口气”再做。像你在工位被人叫住:“哥你先把线上故障处理了,我这 PPT 不急,等你空了再说。”

先一句话:requestIdleCallback 是啥?

requestIdleCallback(cb) 用来注册一个回调:当浏览器主线程出现空闲(idle)时,尽量调用它,让你在“不会影响用户交互”的时间片里做一些不紧急的工作。

几个关键词你得先记住:

  • 它是“尽量”,不是“保证”
  • 它面向的是主线程的空闲时间(不是 Web Worker 那种真并行)
  • 它更适合“可切片、可中断”的任务

为什么会有“空闲”这种东西?

浏览器主线程平时主要忙这些:

  • 跑 JS
  • 样式计算 / 布局 / 绘制
  • 处理输入事件(点击、滚动、键盘)
  • 合成与动画调度

如果你在关键路径里塞了一个大计算,用户体验就会变成经典三连:

  1. 点了没反应
  2. 滚动像 PPT
  3. “是不是卡死了?”

而在很多帧之间,或者某些任务结束后,主线程会出现一点点空档期。requestIdleCallback 就是让你把“非关键工作”排队到这些空档里。

API 长啥样?

1
2
3
4
5
6
7
8
9
const id = requestIdleCallback(
(deadline) => {
console.log('空闲时间还有:', deadline.timeRemaining(), 'ms');
console.log('是不是超时被逼着执行:', deadline.didTimeout);
},
{ timeout: 2000 }
);

cancelIdleCallback(id);

回调会收到一个 IdleDeadline 对象,常用就俩:

  • deadline.timeRemaining():这次空闲期大概还剩多少毫秒可用
  • deadline.didTimeout:如果你设置了 timeout,并且等太久还没空闲,浏览器会“硬执行”,此时它会是 true

注意:timeRemaining() 是“估计值”,你别拿它当精密仪器用,它更像“差不多还能摸鱼 10 分钟”那种感觉。

它适合干什么?(以及不适合干什么)

适合

  • 埋点/日志上报的“整理工作”(真正发请求还是要注意节流)
  • 预计算/缓存:比如把一些数据结构准备好、预生成索引
  • 预加载:prefetch 一些可能用到的资源(注意网络别被你薅秃了)
  • 大任务切片:把一个很重的计算拆成很多小块分批做

不适合

  • 任何“必须立刻完成”的工作(比如首屏关键渲染、关键请求、用户点击后的反馈)
  • 严格时序任务(它不保证什么时候执行)
  • 需要稳定持续执行的轮询(你会被空闲调度搞得心态爆炸)

最常见也最实用的用法:把大任务切成小块

你可以把 requestIdleCallback 理解成“给你一个小时间片”,那你的任务就要学会“见好就收”。

比如你要处理一万个数据项,直接 for 循环跑完,主线程可能会直接翻白眼。更聪明的做法是:每次空闲处理一部分,时间不够就先停。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function processInIdle(tasks, handler, options) {
const queue = tasks.slice();

function run(deadline) {
while (queue.length > 0 && deadline.timeRemaining() > 5) {
const task = queue.shift();
handler(task);
}

if (queue.length > 0) {
requestIdleCallback(run, options);
}
}

requestIdleCallback(run, options);
}

processInIdle(
Array.from({ length: 10000 }, (_, i) => i),
(n) => {
// 假装这里很重
Math.sqrt(n * 999999);
},
{ timeout: 1000 }
);

这里的关键是这句:

  • deadline.timeRemaining() > 5

意思是“别把时间片吃干抹净,给浏览器留点缓冲”。你也可以根据任务复杂度调整阈值。

timeout:给它一个“最迟期限”

一个很现实的问题:如果页面一直很忙(比如动画多、输入多、任务多),空闲期可能很少,甚至你注册的回调一直不触发。

这时候 timeout 就像你在群里 @ 一句:“有空帮我看下”没人理,然后你过两分钟直接打电话。

1
2
3
4
5
6
7
8
requestIdleCallback(
(deadline) => {
if (deadline.didTimeout) {
// 被 timeout 逼出来了:赶紧做最小工作,别拖更久
}
},
{ timeout: 2000 }
);

经验上:

  • 你希望“最终一定要做”的事情(比如缓存清理),可以给个 timeout
  • 你希望“完全不影响用户体验”的事情(比如某些预计算),可以不设或设更长

取消:别让它变成“过期任务”

有些任务是“此时此刻需要”,过两秒可能就没意义了,比如用户已经离开页面了。

所以在组件卸载、路由切换、tab 不可见时,记得取消:

1
2
3
4
5
6
7
8
9
10
11
12
let idleId = null;

function start() {
idleId = requestIdleCallback(() => {
// do something
});
}

function stop() {
if (idleId !== null) cancelIdleCallback(idleId);
idleId = null;
}

兼容性:别把它当成“人人都有”

requestIdleCallback 并不是所有浏览器都支持,尤其在 Safari / iOS 生态里经常踩坑。

所以更稳的写法是做一个降级:

1
2
3
4
5
6
7
const requestIdle = window.requestIdleCallback
? window.requestIdleCallback.bind(window)
: (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: true }), 1);

const cancelIdle = window.cancelIdleCallback
? window.cancelIdleCallback.bind(window)
: (id) => clearTimeout(id);

这个 fallback 很“糙”,但它至少保证了:

  • 不会因为 API 不存在直接报错
  • 逻辑还能跑,只是“空闲调度”变成了“尽快安排”

如果你特别在意跨端一致性,可以考虑引入 polyfill(但别为了一个小功能把包体拉大到离谱)。

常见坑:你以为空闲,其实浏览器没空

1) 任务永远没机会跑

页面一直忙,回调一直不触发。解决思路:

  • timeout
  • 降低任务强度:切片更小、每次处理更少
  • 重新评估:这活是不是该放 Worker 里?

2) 回调里干了“重活”,还是会卡

requestIdleCallback 不是免死金牌。它只是把你安排在“可能空闲”的时间段,但如果你一次干 30ms,用户还是会感觉卡。

这就是为什么“切片”很重要。

3) 在回调里疯狂读写 DOM

回调里频繁触发布局(比如读 offsetHeight、写 style,再读 layout)会把浏览器搞得更忙。

建议:

  • 尽量做纯计算/数据整理
  • DOM 更新要做也分批,别一口气把布局系统掀翻

结尾:怎么用才算“有用但不作妖”?

如果你只记三条,我建议是:

  • 把它当成“后台排队”,别当成“准时闹钟”
  • 任务要可中断、可切片,永远别在里面憋大招
  • 需要最终保证执行的,加 timeout,并做好降级

等你把这套用顺了,你会发现页面性能优化里有个很现实的境界:不是把主线程榨干,而是让它有呼吸感

Happy Coding!