一文手把手教你如何使用JavaScript预加载图片告别加载卡顿
引言
“老板,首页轮播图又卡成PPT了!”
“用户说点开商品详情,主图愣是白了3秒才出来!”
如果你在前端圈子里混得够久,这类吐槽大概率耳朵都听出茧了。图片体积大、网络抖、浏览器懒,三重debuff叠满,页面再精致的动效也顶不住一张图加载慢半拍的尴尬。今天这篇,咱们就掰开揉碎聊聊“图片预加载”这门手艺——它不是银弹,但用好了,能让你的网页从“幻灯片”秒变“丝滑大片”。
为什么你的网页图片总在“慢半拍”?
先别急着甩锅给后台兄弟。浏览器在解析到<img>标签时,才会真正发起网络请求;如果这张图在可视区域外,它还会再拖一会儿(懒加载的锅)。用户一滑到关键位置,浏览器才火急火燎地去拉数据,网络再一抖,白屏、占位图、转菊花,名场面齐活。
更惨的是,现代页面动辄几十张图:轮播、头像、商品缩略图、背景装饰……它们像春运抢火车票一样挤在“最后那一刻”才进站,不怪页面卡顿,怪谁?
揭开图片加载背后的性能真相
浏览器渲染流水线大致分五步:解析HTML → 构建DOM → 计算样式 → 布局 → 绘制。
图片资源属于“渲染阻塞”之外的“延迟加载”队列,但注意,只要图片url一出现,浏览器就会立即发起网络请求,除非你用loading="lazy"显式告诉它“先别动”。如果页面里同域并发超过6个TCP连接(HTTP/1.1老黄历),剩下的请求就得排队。排队+网络RTT+图片体积,慢得有理有据。
预加载的核心思路就是:把“请求”提前,把“排队”错开,把“渲染”和“数据到达”之间的空窗期抹平。
图片预加载——可不只是提前下载那么简单
有人以为预加载就是“new Image().src = url”一句话,too young。真正的预加载要考虑:
- 优先级分级:首屏最关键,后台闲时再去拉次屏。
- 内存与带宽博弈:移动端的4G/5G切换比前任变脸还快,一口气拉50张2MB大图,用户流量直接报警。
- 错误容灾:404、超时、CDN节点挂掉,都得兜底,别让队列卡死。
- 缓存复用:同一张图在A组件预加载,B组件立刻就能从缓存里拿,别再重复请求。
预加载 vs 懒加载——别再把孪生兄弟认错
懒加载:先占位,等用户快看到再拉真图,省流量、省内存,但首次进入可视区那一刻仍可能“闪白”。
预加载:先把图拉到本地缓存,真正插入DOM时秒出,爽点在于“提前”,痛点在于“可能白忙活”。
二者不是非此即彼,高优首屏预加载+次屏懒加载才是日常操作。就像吃自助餐,先拿爱吃的,再慢慢逛。
手把手实现JavaScript图片预加载
下面代码全部可跑通,注释管够,复制粘贴即可去老板面前秀肌肉。
基础版:Image对象逐张加载
/**
* 单张预加载
* @param {string} src - 图片地址
* @returns {Promise<HTMLImageElement>} - 加载成功的img元素
*/
function preloadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.decoding = 'async'; // 提示浏览器可以异步解码
img.src = src;
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`image load fail: ${src}`));
});
}
// 用法
preloadImage('https://example.com/hero@2x.jpg')
.then(img => console.log('英雄图搞定', img.naturalWidth))
.catch(err => console.error('英雄图挂了', err));
进阶版:批量预加载 + 进度反馈
/**
* 批量预加载,带进度回调
* @param {string[]} list - 图片url数组
* @param {object} [options] - 配置
* @param {number} [options.concurrency=6] - 并发数,HTTP/1.1下建议≤6
* @param {function} [options.onProgress] - 单张完成回调 (loaded, total)=>{}
* @returns {Promise<{ok: string[], fail: string[]}>}
*/
function preloadImages(list, { concurrency = 6, onProgress } = {}) {
return new Promise(resolve => {
const total = list.length;
let loaded = 0, failed = 0;
const ok = [], fail = [];
let idx = 0;
function next() {
if (idx >= total) {
// 全部完成
if (loaded + failed === total) resolve({ ok, fail });
return;
}
const cur = idx++;
const src = list[cur];
preloadImage(src)
.then(() => {
ok.push(src);
loaded++;
onProgress?.(loaded + failed, total);
})
.catch(() => {
fail.push(src);
failed++;
onProgress?.(loaded + failed, total);
})
.finally(() => next()); // 无论成功失败都递归补位
}
// 启动并发池
for (let i = 0; i < Math.min(concurrency, total); i++) next();
});
}
// 用法
preloadImages(
[
'https://cdn.a.com/pic1.jpg',
'https://cdn.a.com/pic2.jpg',
'https://cdn.a.com/pic3.jpg'
],
{
onProgress: (cur, total) => {
const percent = ((cur / total) * 100).toFixed(2);
console.log(`进度:${percent}%`);
document.querySelector('.progress-bar').style.width = percent + '%';
}
}
).then(({ ok, fail }) => {
console.log(' success:', ok.length, ' fail:', fail.length);
});
Promise封装:让预加载更优雅
上面已经用Promise了,但还可以再封装成类,方便复用:
class ImagePreloader {
constructor(options = {}) {
this.cache = new Map(); // <url, Promise<HTMLImageElement>>
this.concurrency = options.concurrency || 6;
}
/**
* 加载单张,带缓存
*/
load(src) {
if (this.cache.has(src)) return this.cache.get(src);
const job = preloadImage(src);
this.cache.set(src, job);
return job;
}
/**
* 批量加载
*/
loadGroup(list, onProgress) {
return preloadImages(list, {
concurrency: this.concurrency,
onProgress
});
}
/**
* 清空缓存
*/
clear() {
this.cache.clear();
}
}
// 全局单例
export const preloader = new ImagePreloader({ concurrency: 8 });
结合现代ES6+语法的写法优化
用async/await+for...of控制并发,可读性更高:
async function asyncPool(poolLimit, list, iteratorFn) {
const ret = [];
const executing = [];
for (const item of list) {
const p = Promise.resolve().then(() => iteratorFn(item));
ret.push(p);
if (poolLimit <= list.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= poolLimit) await Promise.race(executing);
}
}
return Promise.all(ret);
}
// 调用
const urls = ['1.jpg', '2.jpg', '3.jpg'];
await asyncPool(6, urls, url => preloadImage(url));
预加载的隐藏成本你注意了吗?
- 内存占用:图片解码后占用的内存是文件体积的几十倍(RGBA 4字节/像素)。一张4000×3000的图解码即48MB,移动端分分钟被杀后台。
- 带宽消耗:用户可能只看了首页10%就关页面,你预拉的后50张图全部浪费,流量土豪请随意。
- 电池:蜂窝网络下频繁唤醒射频,电量肉眼可见地掉。
经验法则:预加载总量 ≤ 首屏加一屏半,且单图体积≤200KB(WebP/AVIF压缩后)。再大就用分段加载或渐进式JPEG。
什么时候不该用预加载?
- 弱网用户占比>30%的海外业务,先保证可用性,再谈爽点。
- 图片尺寸极大(全景图、海报长图)且用户仅低概率查看。
- 浏览器已支持原生
<img loading="eager">且HTTP/2多路复用良好,重复造轮子收益趋近于0。
真实项目中的典型应用场景
首页轮播图提前就位
// React Hook示例
function usePreloadSlider(list) {
const [ready, setReady] = useState(false);
useEffect(() => {
preloader.loadGroup(list.map(item => item.pic)).then(() => setReady(true));
}, [list]);
return ready;
}
function Slider({ data }) {
const ready = usePreloadSlider(data);
if (!ready) return <SkeletonSlider />;
return (
<Swiper>
{data.map(item => (
<img key={item.id} src={item.pic} alt={item.title} />
))}
</Swiper>
);
}
游戏资源包预载策略
H5小游戏:脚本、音频、精灵图三件套,先拉“首关资源”,后台再拉“后续关卡”。用XHR+Blob存进IndexedDB,二次打开秒进游戏,用户直呼“本地客户端”。
电商商品详情页的无缝切换体验
商品详情5张主图+20张SKU图,用户切SKU时若图片未加载完毕,会闪现旧图。解决:鼠标hover SKU按钮即触发预加载,300ms延迟后正式切换,基本做到“无缝”。
配合Webpack或Vite做构建时预加载
Webpack的require.context+import(/* webpackPrefetch: true */),Vite的import.meta.globEager,让浏览器在空闲时提前拉下一页资源。SPA切页如丝般顺滑,SEO也不掉链子。
踩坑实录:那些年我们被预加载“背刺”的瞬间
- 图片404导致整个队列卡死
解决:单张失败不影响整体,Promise.finally递归补位,上面批量代码已处理。 - 重复加载同一张图
解决:用Map/WeakMap做全局缓存,key用完整url。 - 跨域图片加载失败
解决:CDN加Access-Control-Allow-Origin: *,或前端img.crossOrigin="anonymous",否则canvas绘制会报污染。 - 加载完成但图片损坏
解决:监听img.onerror还不够,需用createImageBitmap或decode()API,解码失败即视为损坏。
// 检测损坏
async function isImageBroken(src) {
try {
const img = await preloadImage(src);
await img.decode(); // 如果解码失败会抛错
return false;
} catch {
return true;
}
}
前端老鸟私藏技巧大放送
用WeakMap缓存已加载图片避免重复请求
const cache = new WeakMap(); // 键是Image对象,值无所谓
function loadOnce(imgEl) {
if (cache.has(imgEl)) return Promise.resolve(imgEl);
return new Promise((resolve, reject) => {
imgEl.onload = () => {
cache.set(imgEl, true);
resolve(imgEl);
};
imgEl.onerror = reject;
if (imgEl.complete) resolve(imgEl); // 已缓存过
});
}
结合Intersection Observer实现“智能预加载”
const io = new IntersectionObserver(entries => {
entries.forEach(en => {
if (en.isIntersecting) {
const img = en.target;
const src = img.dataset.prefetch;
if (src) {
preloader.load(src); // 进入视口前200px开始拉
io.unobserve(img);
}
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-prefetch]').forEach(img => io.observe(img));
预加载 + CDN + 图片格式优化三连招
- 图片裁切:用
?imageView2/2/w/750这类参数,避免前端自己压。 - 格式:WebP省30%,AVIF省50%,但不支持老Safari,用
<picture>兜底。 - CDN:把首屏图推送到边缘节点,TTL设短,更新时主动预热。
为低网速用户设计降级方案:骨架屏 or 占位图?
骨架屏(Skeleton)适合结构固定,占位图(BlurUp)适合视觉冲击强。实测3G网络下,LCP(最大内容绘制)骨架屏比空白+转菊花快600ms,用户体感明显。
彩蛋:预加载还能和Service Worker玩出什么花样?
SW拦截图片请求,先查CacheStorage,没有再回源,同时后台拉取最新版本,下次访问即更新。用户第一次秒开,第二次看到新图,双赢。
// sw.js
self.addEventListener('fetch', e => {
if (e.request.destination === 'image') {
e.respondWith(
caches.open('img-v1').then(async cache => {
const cached = await cache.match(e.request);
if (cached) {
// 后台更新
fetch(e.request).then(res => cache.put(e.request, res.clone()));
return cached;
}
const res = await fetch(e.request);
cache.put(e.request, res.clone());
return res;
})
);
}
});
试试用Web Workers分担主线程压力
图片解码放主线程会掉帧,尤其在低端机。借助OffscreenCanvas+Web Worker,可把解码任务甩给子线程,主线程继续90fps滚动。
// worker.js
self.onmessage = async e => {
const { url, id } = e.data;
const res = await fetch(url);
const blob = await res.blob();
const bmp = await createImageBitmap(blob);
self.postMessage({ id, bmp }, [bmp]);
};
未来可期:原生HTML属性 loading=“eager” 能替代JS吗?
loading="eager"只是告诉浏览器“这张图很重要,请尽快”,但不会提前发起请求,和预加载不是一回事。
真正值得期待的是<link rel="preload" as="image" imagesrcset="...">,结合HTTP/3多路复用,浏览器可以在解析HTML前就拉图,届时我们或许可以少写一半预加载代码。但眼下,兼容性、缓存策略、业务定制仍需JS兜底。
至此,从“为什么”到“怎么做”,从“坑”到“彩蛋”,图片预加载的十八般武艺悉数奉上。拿去撸代码吧,下次再遇到“图片慢半拍”,你就能拍着胸脯说:“放心,我提前都拉好了!”
以上就是一文手把手教你如何使用JavaScript预加载图片告别加载卡顿的详细内容,更多关于JavaScript预加载图片的资料请关注脚本之家其它相关文章!


最新评论