深入掌握requestIdleCallback实现前端任务调度与性能优化
requestIdleCallback用着用着,页面卡死了?
今天上线前压测,发现一个很诡异的问题:页面在低端安卓机上滑动几下后,突然就“冻住”了——touchmove不触发、按钮点不动、定时器也停了。不是白屏,不是报错,就是整个事件循环像被按了暂停键。查了半天 DevTools 的 Performance 面板,发现主线程里堆了一堆 requestIdleCallback 的回调没执行完,而且每个回调执行时间都超 50ms,直接把空闲时间吃干抹净……最后发现,是我自己写的 rIC 封装把浏览器给“喂撑了”。
先说结论:别无脑塞任务进去,得带节流 + 超时兜底
核心就两句话:requestIdleCallback 不是免费午餐,它只承诺“空闲时调用”,不承诺“一定会调用”或“调用多少次”;更关键的是,你传进去的 callback 如果跑太久,它会直接中断,但不会帮你重试,也不会清掉队列里的其他 pending 任务——而我之前就是把一堆 DOM 操作、数据计算全塞进一个 rIC 里,还忘了判断 deadline.timeRemaining(),结果一卡就是连锁反应。
折腾了半天才发现:rIC 不是 setTimeout 的平替
一开始我还挺得意,把原来用 setTimeout(() => {}, 0) 做的非关键 UI 更新(比如懒加载图片占位符替换、日志上报聚合、某些状态同步)全换成 rIC,心想:“这下真·空闲执行,性能起飞!” 结果测试机一滚动,performance.now() 打点一看,有些 callback 居然延后了 800ms 才执行,中间还夹着三四次重复注册……后来翻 MDN 才注意到一句话:“If a callback has not been invoked by the time a new one is scheduled, the first callback may be discarded.” ——对,它真的会丢任务!而且不是按顺序丢,是看调度时机,完全不可控。
我试过几种写法:
- 直接裸用:
requestIdleCallback(cb)→ 卡死主力,因为没控制执行时长,callback 一超时就中断,但下一次 rIC 又来一遍,形成“空闲→塞任务→超时→再塞”,恶性循环 - 加了
timeRemaining()判断但没 break → 逻辑写成while (deadline.timeRemaining() > 0) { doWork(); },结果 work 里有个for (let i = 0; i < 10000; i++),根本跑不完,timeRemaining()一直 > 0,死循环卡主线程 - 改用
if (deadline.timeRemaining() > 5) { doWork(); }→ 好一点,但没解决“任务积压”问题,快速滚动时注册了 20 次 rIC,结果只执行了最后 3 个,前面 17 个全丢了,状态不同步
核心代码就这几行:带节流、带超时、带防重入
最后定稿的封装长这样(已上线一周,没再卡过):
const IDLE_TASK_QUEUE = [];
let IS_PROCESSING = false;
let PENDING_IDLE_ID = null;
function scheduleIdleTask(task, options = {}) {
const { timeout = 2000, throttleMs = 100 } = options;
// 节流:100ms 内重复调用只保留最后一次
if (PENDING_IDLE_ID) {
cancelIdleCallback(PENDING_IDLE_ID);
}
const now = performance.now();
const taskWrapper = (deadline) => {
const startTime = performance.now();
while (
deadline.timeRemaining() > 2 &&
IDLE_TASK_QUEUE.length > 0 &&
performance.now() - startTime < 15 // 单次最多跑 15ms,留点余量
) {
const nextTask = IDLE_TASK_QUEUE.shift();
try {
nextTask();
} catch (e) {
console.error('Idle task error:', e);
}
}
// 如果还有任务,且没超时,继续调度
if (IDLE_TASK_QUEUE.length > 0) {
PENDING_IDLE_ID = requestIdleCallback(taskWrapper, { timeout });
return;
}
// 清理状态
IS_PROCESSING = false;
PENDING_IDLE_ID = null;
};
// 入队
IDLE_TASK_QUEUE.push(task);
// 首次触发
if (!IS_PROCESSING) {
IS_PROCESSING = true;
PENDING_IDLE_ID = requestIdleCallback(taskWrapper, { timeout });
}
}
// 使用示例:
scheduleIdleTask(() => {
// 替换图片 src
document.querySelectorAll('.lazy-img[data-src]').forEach(el => {
el.src = el.dataset.src;
el.removeAttribute('data-src');
});
}, { timeout: 3000, throttleMs: 50 });
scheduleIdleTask(() => {
// 上报聚合日志(轻量)
if (window.__pendingLogs?.length) {
fetch('https://jztheme.com/api/log', {
method: 'POST',
body: JSON.stringify(window.__pendingLogs),
headers: { 'Content-Type': 'application/json' }
}).finally(() => window.__pendingLogs = []);
}
});
这里我踩了个坑:timeout 不是“最多等多久”,而是“最长容忍延迟”
MDN 写得很隐晦:timeout 是 “the maximum time in milliseconds that the user agent should wait before running the callback”。注意是“should wait before running”,不是“guarantee run at”。也就是说,如果浏览器一直有空闲,它可能立刻执行;但如果一直 busy,到 timeout 时间点,它会强制在下一个微任务或下一帧执行(不管有没有空闲)。我一开始以为设了 3000 就能保底 3s 内执行,结果在重度动画期间,它真等到第 3 秒才塞进 event loop,导致上报严重延迟。所以现在我所有带 timeout 的任务,都会在 scheduleIdleTask 外层再包一层 setTimeout 做兜底,比如:
function scheduleWithFallback(task, options = {}) {
const { timeout = 3000 } = options;
const fallbackTimer = setTimeout(task, timeout);
scheduleIdleTask(() => {
clearTimeout(fallbackTimer);
task();
}, { timeout });
}
还有一个小尾巴没完美解决
目前这个封装在 Chrome 115+ 和 Safari 16.4+ 上表现很好,但在部分旧版安卓 WebView(比如 UC 内核 12.12)里,requestIdleCallback 根本不触发。我们没做 polyfill(毕竟只是优化项,不是功能必需),而是降级回 setTimeout(() => {}, 1),加了个 UA 检测。这点我也没太纠结——毕竟 rIC 本来就是渐进增强,丢了也不该影响功能。不过如果你项目必须支持老内核,可以试试 facebook 的 polyfill,但我实测过,它用 postMessage + MessageChannel 模拟,在某些低配机上反而比原生 rIC 更耗电……所以我的策略是:能用原生就用原生,不能用就安静地退化,不硬刚。
踩坑提醒:这三点一定注意
- 永远检查
deadline.timeRemaining(),且留至少 1~2ms 余量——别写成> 0,浏览器实际精度有限,有时返回 0.1 但你一用就超 - 别在 rIC 里做 DOM 查询高频操作(如
getBoundingClientRect、offsetHeight),它们会强制同步 layout,直接废掉空闲窗口 - 避免递归调用 rIC——比如 callback 里又调自己,容易绕过节流逻辑,造成任务爆炸
以上是我踩坑后的总结,希望对你有帮助。这个方案不是最优的(比如没做优先级队列),但足够简单、稳定、可维护。如果你有更好的任务调度思路,或者遇到过更刁钻的 rIC 行为,欢迎评论区交流~

暂无评论