用Idle预加载提升前端性能的实战经验分享
先看效果,再看代码
上个月我们项目有个需求:首页要加载 10+ 个模块,但首屏只展示 3 个,剩下的滚动才出现。产品经理说“能不能滑到之前就提前加载好,别让用户等”。我第一反应是 Intersection Observer,但后来发现它在低端机上卡得不行——因为一进页面就绑一堆监听,主线程直接被占满。
折腾了半天,最后用了 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 做离线预缓存),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流,或者你遇到什么奇怪的坑也可以说说,一起避雷。

暂无评论