深入掌握requestIdleCallback实现前端任务调度与性能优化

Designer°子儒 优化 阅读 2,216
赞 7 收藏
二维码
手机扫码查看
反馈

requestIdleCallback用着用着,页面卡死了?

今天上线前压测,发现一个很诡异的问题:页面在低端安卓机上滑动几下后,突然就“冻住”了——touchmove不触发、按钮点不动、定时器也停了。不是白屏,不是报错,就是整个事件循环像被按了暂停键。查了半天 DevTools 的 Performance 面板,发现主线程里堆了一堆 requestIdleCallback 的回调没执行完,而且每个回调执行时间都超 50ms,直接把空闲时间吃干抹净……最后发现,是我自己写的 rIC 封装把浏览器给“喂撑了”。

深入掌握requestIdleCallback实现前端任务调度与性能优化

先说结论:别无脑塞任务进去,得带节流 + 超时兜底

核心就两句话: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 查询高频操作(如 getBoundingClientRectoffsetHeight,它们会强制同步 layout,直接废掉空闲窗口
  • 避免递归调用 rIC——比如 callback 里又调自己,容易绕过节流逻辑,造成任务爆炸

以上是我踩坑后的总结,希望对你有帮助。这个方案不是最优的(比如没做优先级队列),但足够简单、稳定、可维护。如果你有更好的任务调度思路,或者遇到过更刁钻的 rIC 行为,欢迎评论区交流~

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论