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