用Idle预加载提升前端性能的实战经验分享

码农景红 优化 阅读 1,298
赞 15 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

上个月我们项目有个需求:首页要加载 10+ 个模块,但首屏只展示 3 个,剩下的滚动才出现。产品经理说“能不能滑到之前就提前加载好,别让用户等”。我第一反应是 Intersection Observer,但后来发现它在低端机上卡得不行——因为一进页面就绑一堆监听,主线程直接被占满。

用Idle预加载提升前端性能的实战经验分享

折腾了半天,最后用了 requestIdleCallback 做预加载,亲测有效。核心思路很简单:等浏览器空闲时,偷偷把后续要用的资源塞进缓存或 DOM。下面这段代码是我现在项目里跑着的,直接复制就能用:

function preloadModules(modules) {
  const queue = [...modules];
  
  function processNext() {
    if (queue.length === 0) return;
    
    const next = queue.shift();
    // 模拟异步加载,比如 fetch 或动态 import
    loadModule(next).then(() => {
      console.log(预加载完成: ${next});
    });
    
    // 如果还有任务,继续安排下一次空闲执行
    if (queue.length > 0) {
      requestIdleCallback(processNext, { timeout: 2000 });
    }
  }
  
  requestIdleCallback(processNext, { timeout: 2000 });
}

// 调用示例
preloadModules(['moduleA', 'moduleB', 'moduleC']);

这里注意我踩过好几次坑:一定要加 timeout!否则在持续高负载页面(比如有动画、视频)里,requestIdleCallback 可能永远不执行,导致模块一直不加载。设个 2 秒超时,保证兜底。

这个场景最好用

Idle 预加载不是万能药,但以下几种情况特别香:

  • 非关键路径的懒加载内容:比如商品详情页下方的“猜你喜欢”,用户大概率会滑下去看,但又不能阻塞首屏。
  • 大体积静态资源预缓存:像 WebAssembly 模块、大型 JSON 数据,提前在空闲时 fetch 到 CacheStorage 里。
  • 动态路由的 chunk 预取:用 React/Vue 的动态 import 时,可以在空闲期提前加载后续路由的 JS bundle。

举个实际例子,我们有个数据看板页面,点开后要加载 5 个图表。首屏只显示 2 个,剩下 3 个在 Tab 里。以前用户切 Tab 时要等 1-2 秒,现在用 Idle 预加载,切换基本无感:

// 假设这是 React 组件里的逻辑
useEffect(() => {
  const nonCriticalCharts = ['chart3', 'chart4', 'chart5'];
  
  const idleId = requestIdleCallback(() => {
    nonCriticalCharts.forEach(name => {
      // 提前触发 import,Webpack 会自动缓存
      import(./charts/${name}.js);
    });
  }, { timeout: 1500 });
  
  return () => cancelIdleCallback(idleId);
}, []);

注意这里用了 cancelIdleCallback 清理,避免组件卸载后还执行。虽然概率低,但加上更稳妥。

踩坑提醒:这三点一定注意

1. 别在 Idle 回调里干重活:浏览器给你的空闲时间可能只有几毫秒,如果一次处理太多(比如循环 1000 次),会直接卡住。建议每次只处理一个任务,像上面代码那样用队列 + 递归调用。

2. Safari 兼容性问题:iOS Safari 直到 16.4 才支持 requestIdleCallback,老版本直接 undefined。我的方案是简单 polyfill:

if (!window.requestIdleCallback) {
  window.requestIdleCallback = function(cb, options) {
    const start = Date.now();
    return setTimeout(() => {
      cb({
        didTimeout: false,
        timeRemaining: () => Math.max(0, 50 - (Date.now() - start))
      });
    }, 1);
  };
  
  window.cancelIdleCallback = function(id) {
    clearTimeout(id);
  };
}

这个 polyfill 不完美(比如没真用空闲时间),但至少保证代码不崩,且兜底逻辑能走通。对老设备来说,相当于立即执行,总比不加载强。

3. 别和 Intersection Observer 混用逻辑:我见过有人先用 IO 监听元素进入视口,再在回调里用 Idle 加载。结果 IO 触发时页面已经卡了,Idle 根本没机会执行。正确做法是:IO 只负责标记“需要加载”,Idle 负责实际加载。两者解耦:

const modulesToPreload = new Set();

// IO 负责收集
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // 标记为“已进入视口”,后续不用预加载
      modulesToPreload.delete(entry.target.id);
    } else {
      // 进入视口前,加入预加载队列
      modulesToPreload.add(entry.target.id);
    }
  });
});

// Idle 负责消费
function idlePreload() {
  if (modulesToPreload.size === 0) return;
  
  const moduleId = modulesToPreload.values().next().value;
  modulesToPreload.delete(moduleId);
  
  loadModule(moduleId);
  
  if (modulesToPreload.size > 0) {
    requestIdleCallback(idlePreload, { timeout: 1000 });
  }
}

requestIdleCallback(idlePreload, { timeout: 1000 });

高级技巧:结合优先级调度

真实项目里,模块有轻重缓急。比如“用户头像”比“广告 banner”重要得多。这时候可以给预加载任务分优先级:

class IdlePreloader {
  constructor() {
    this.highPriority = [];
    this.lowPriority = [];
    this.isRunning = false;
  }
  
  add(module, priority = 'low') {
    if (priority === 'high') {
      this.highPriority.push(module);
    } else {
      this.lowPriority.push(module);
    }
    this.schedule();
  }
  
  schedule() {
    if (this.isRunning) return;
    this.isRunning = true;
    
    const run = () => {
      // 优先处理高优先级
      if (this.highPriority.length > 0) {
        this.load(this.highPriority.shift());
      } else if (this.lowPriority.length > 0) {
        this.load(this.lowPriority.shift());
      }
      
      if (this.highPriority.length > 0 || this.lowPriority.length > 0) {
        requestIdleCallback(run, { timeout: 1500 });
      } else {
        this.isRunning = false;
      }
    };
    
    requestIdleCallback(run, { timeout: 1500 });
  }
  
  load(module) {
    // 实际加载逻辑
    console.log('加载模块:', module);
  }
}

// 使用
const preloader = new IdlePreloader();
preloader.add('user-avatar', 'high'); // 高优先级
preloader.add('ad-banner', 'low');    // 低优先级

这套逻辑在我们后台系统里跑了几个月,加载体验提升明显,尤其对中低端 Android 机。不过要注意,优先级策略得根据业务调整,别过度设计。

最后说两句

Idle 预加载不是银弹,但它在“不阻塞主线程”和“提前准备资源”之间找到了一个不错的平衡点。我现在的项目里,凡是非首屏的关键资源,基本都走这个流程。改完后 Lighthouse 的“最大内容绘制”(LCP)指标稳定在 1.2s 以内,老板看了直呼内行。

以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如结合 Service Worker 做离线预缓存),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流,或者你遇到什么奇怪的坑也可以说说,一起避雷。

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

暂无评论