预加载技术在前端性能优化中的实战应用
又翻车了,预加载把页面卡成幻灯片
今天上线前最后测一把性能,好家伙,Lighthouse直接给我干到40多分,首屏加载时间快3秒。明明昨天还是80+的,我寻思也没加啥大资源啊。打开Network面板一看,一堆图片在页面初始化的时候全挤着一起请求,瀑布流长得离谱。仔细一查,是我之前加的预加载逻辑出了问题——本来是为了提升体验,结果反手把自己给埋了。
项目是个内容页居多的图文站,用户滑动过程中会动态插入新的图组。为了不让用户滑到一半看到空白,我早早就上了 new Image().src 那一套预加载方案,监听滚动位置,在可视区域前500px就开始加载下一组图片。本以为挺稳妥,结果这次是因为某个运营活动页塞了12组高清大图,每张都700KB以上,一上来就并发12个请求,浏览器直接限流,连JS执行都卡了半秒。
排查过程:从“感觉没问题”到“这不对劲”
一开始我以为是CDN缓存没生效,各种清缓存、换节点、看响应头,折腾了快一小时发现根本不是网络问题。后来打开控制台的Performance面板录了一段,才发现主线程被一堆 Image.decode() 和解码任务占满,帧率直接掉到十几。这才意识到:我只考虑了“提前加载”,压根没控并发,也没做优先级调度。
试过几种方案:
- 第一种是简单粗暴地加延时,用
setTimeout分批触发预加载,比如每200ms加载一张。效果是好点了,但用户体验变得断断续续,滑得快的时候还是能看到空白。 - 第二种是监听
requestIdleCallback,想在空闲时段加载。理想很丰满,现实很骨感,这个回调太不靠谱了,尤其在低端机上几乎不触发,预加载直接失效。 - 第三种是引入
IntersectionObserver做懒加载+预加载结合,结果发现和现有逻辑冲突,改起来成本太高,而且对“预加载距离”的控制不够灵活。
折腾了半天,最后回头去看 Chrome 的并发限制文档——桌面端一般最多6个TCP连接同域,并且浏览器内部还有解码队列。这意味着就算你发了请求,资源回来也得排队解码,照样卡界面。所以关键不是“什么时候开始加载”,而是“怎么平滑地加载”。
最终方案:节流 + 优先级 + 解码优化
最后定下来的策略是:按距离分优先级,用队列控制并发,关键图片强制前置,非关键的延迟加载。核心思路就是别一股脑全冲上去。
我写了个简单的预加载管理器,代码也不长,但解决了大问题:
class ImagePreloader {
constructor(options = {}) {
this.concurrency = options.concurrency || 3; // 最大并发数
this.loaded = new Set();
this.queue = [];
this.active = 0;
this.decoderSupport = 'decode' in new Image(); // 检测是否支持 decode()
}
add(src, priority = 0) {
if (this.loaded.has(src)) return Promise.resolve();
const img = new Image();
const item = { src, img, priority, resolve: null, reject: null };
const promise = new Promise((resolve, reject) => {
item.resolve = resolve;
item.reject = reject;
});
// 绑定事件
img.onload = () => {
if (this.decoderSupport) {
img.decode().then(() => {
this.loaded.add(src);
item.resolve();
this.next();
}).catch(err => {
this.loaded.add(src);
item.resolve(); // 解码失败也当成功,至少能显示
this.next();
});
} else {
this.loaded.add(src);
item.resolve();
this.next();
}
};
img.onerror = () => {
item.reject(new Error(Failed to load image ${src}));
this.next();
};
// 插入队列并按优先级排序
this.queue.push(item);
this.queue.sort((a, b) => b.priority - a.priority);
this.start();
return promise;
}
start() {
while (this.active < this.concurrency && this.queue.length > 0) {
const item = this.queue.shift();
this.active++;
item.img.src = item.src;
}
}
next() {
this.active--;
this.start();
}
clear() {
this.queue = [];
this.loaded.clear();
}
}
然后在滚动监听里调用:
const preloader = new ImagePreloader({ concurrency: 3 });
let ticking = false;
const preloadDistance = 800; // 提前800px开始预加载
function onScroll() {
if (!ticking) {
requestAnimationFrame(() => {
const scrollTop = window.pageYOffset;
const viewportHeight = window.innerHeight;
document.querySelectorAll('.image-group').forEach(group => {
const rect = group.getBoundingClientRect();
const top = rect.top + scrollTop;
// 进入预加载区域,且未加载过
if (top < scrollTop + viewportHeight + preloadDistance) {
const imgs = group.querySelectorAll('img[data-src]');
imgs.forEach(img => {
const src = img.dataset.src;
if (src && !preloader.loaded.has(src)) {
// 越靠近视口,优先级越高
const distance = top - scrollTop;
const priority = Math.max(0, 1000 - distance); // 距离越小,priority越大
preloader.add(src, priority);
}
});
}
});
ticking = false;
});
ticking = true;
}
}
window.addEventListener('scroll', onScroll, { passive: true });
这里有几个关键点:
- concurrency 控制并发:避免一次性发起太多请求压垮网络栈。
- decode() 异步解码:虽然失败了我也放行,不然图片可能渲染异常。
- 优先级排序:离视口越近的越早加载,用户体验更顺滑。
- Set 缓存已加载:防止重复加载同一张图。
上线后重新跑 Lighthouse,分数回到85+,首屏时间降到1.2秒左右,最关键的是滚动流畅多了,没有那种“卡一下出一张图”的割裂感。
还有一些细节没完美解决
说实话,现在还有个小问题:如果用户快速滚动到底部,某些中间的图可能会因为优先级被挤掉而延迟加载。不过实测下来影响不大,毕竟用户已经滑过去了,回看的概率低。而且这些图最终还是会加载,只是时机晚点。
另一个妥协是没上 loading="lazy",因为跟我们这套预加载逻辑有点冲突,还得加额外判断,暂时先不动了。后期可能会考虑用原生懒加载做兜底,预加载只针对“即将进入视野”的图。
另外,对于关键首屏图,我还是保留了直接写 <img src> 的方式,预加载只管非首屏内容。这点要分清楚,别把首屏也搞成异步,那就本末倒置了。
踩坑提醒:这几个点一定要注意
1. 不要盲目预加载所有资源。尤其是活动页这种临时内容,很容易因为图片太多拖累整体性能。建议加个数量阈值,超过6组就降级为懒加载。
2. Image.decode() 不是万能的。它能避免主线程阻塞,但有些老版本安卓浏览器不支持,甚至会抛错。记得包 try-catch 或者做兼容判断。
3. requestAnimationFrame + 节流是标配。滚动事件太频繁,不做节流的话,每帧都在算位置,本身就会卡。
4. 别忘了被动监听器。{ passive: true } 能保证滚动不被JS阻塞,配合RAF效果更好。
5. 测试一定要用弱网+低端机模拟。我在本地开发环境完全看不出问题,一到真机测试就原形毕露。Chrome DevTools 的 Throttling 设置得经常用。
结语
以上是我踩坑后的总结。预加载听着简单,真要做到既快又稳,还得考虑并发、优先级、兼容性、解码这些细节。这次的问题本质不是技术选型错,而是缺乏对“资源调度”的系统性思考。
这个方案不是最优的,但够用、可控、好维护。如果你有更好的实现方式,比如用 Service Worker 缓存预判,或者基于路由的预加载策略,欢迎评论区交流。我也在持续摸索更优雅的做法。

暂无评论