一文手把手教你如何使用JavaScript預(yù)加載圖片告別加載卡頓
引言
“老板,首頁輪播圖又卡成PPT了!”
“用戶說點(diǎn)開商品詳情,主圖愣是白了3秒才出來!”
如果你在前端圈子里混得夠久,這類吐槽大概率耳朵都聽出繭了。圖片體積大、網(wǎng)絡(luò)抖、瀏覽器懶,三重debuff疊滿,頁面再精致的動(dòng)效也頂不住一張圖加載慢半拍的尷尬。今天這篇,咱們就掰開揉碎聊聊“圖片預(yù)加載”這門手藝——它不是銀彈,但用好了,能讓你的網(wǎng)頁從“幻燈片”秒變“絲滑大片”。
為什么你的網(wǎng)頁圖片總在“慢半拍”?
先別急著甩鍋給后臺(tái)兄弟。瀏覽器在解析到<img>標(biāo)簽時(shí),才會(huì)真正發(fā)起網(wǎng)絡(luò)請(qǐng)求;如果這張圖在可視區(qū)域外,它還會(huì)再拖一會(huì)兒(懶加載的鍋)。用戶一滑到關(guān)鍵位置,瀏覽器才火急火燎地去拉數(shù)據(jù),網(wǎng)絡(luò)再一抖,白屏、占位圖、轉(zhuǎn)菊花,名場面齊活。
更慘的是,現(xiàn)代頁面動(dòng)輒幾十張圖:輪播、頭像、商品縮略圖、背景裝飾……它們像春運(yùn)搶火車票一樣擠在“最后那一刻”才進(jìn)站,不怪頁面卡頓,怪誰?
揭開圖片加載背后的性能真相
瀏覽器渲染流水線大致分五步:解析HTML → 構(gòu)建DOM → 計(jì)算樣式 → 布局 → 繪制。
圖片資源屬于“渲染阻塞”之外的“延遲加載”隊(duì)列,但注意,只要圖片url一出現(xiàn),瀏覽器就會(huì)立即發(fā)起網(wǎng)絡(luò)請(qǐng)求,除非你用loading="lazy"顯式告訴它“先別動(dòng)”。如果頁面里同域并發(fā)超過6個(gè)TCP連接(HTTP/1.1老黃歷),剩下的請(qǐng)求就得排隊(duì)。排隊(duì)+網(wǎng)絡(luò)RTT+圖片體積,慢得有理有據(jù)。
預(yù)加載的核心思路就是:把“請(qǐng)求”提前,把“排隊(duì)”錯(cuò)開,把“渲染”和“數(shù)據(jù)到達(dá)”之間的空窗期抹平。
圖片預(yù)加載——可不只是提前下載那么簡單
有人以為預(yù)加載就是“new Image().src = url”一句話,too young。真正的預(yù)加載要考慮:
- 優(yōu)先級(jí)分級(jí):首屏最關(guān)鍵,后臺(tái)閑時(shí)再去拉次屏。
- 內(nèi)存與帶寬博弈:移動(dòng)端的4G/5G切換比前任變臉還快,一口氣拉50張2MB大圖,用戶流量直接報(bào)警。
- 錯(cuò)誤容災(zāi):404、超時(shí)、CDN節(jié)點(diǎn)掛掉,都得兜底,別讓隊(duì)列卡死。
- 緩存復(fù)用:同一張圖在A組件預(yù)加載,B組件立刻就能從緩存里拿,別再重復(fù)請(qǐng)求。
預(yù)加載 vs 懶加載——別再把孿生兄弟認(rèn)錯(cuò)
懶加載:先占位,等用戶快看到再拉真圖,省流量、省內(nèi)存,但首次進(jìn)入可視區(qū)那一刻仍可能“閃白”。
預(yù)加載:先把圖拉到本地緩存,真正插入DOM時(shí)秒出,爽點(diǎn)在于“提前”,痛點(diǎn)在于“可能白忙活”。
二者不是非此即彼,高優(yōu)首屏預(yù)加載+次屏懶加載才是日常操作。就像吃自助餐,先拿愛吃的,再慢慢逛。
手把手實(shí)現(xiàn)JavaScript圖片預(yù)加載
下面代碼全部可跑通,注釋管夠,復(fù)制粘貼即可去老板面前秀肌肉。
基礎(chǔ)版:Image對(duì)象逐張加載
/**
* 單張預(yù)加載
* @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));
進(jìn)階版:批量預(yù)加載 + 進(jìn)度反饋
/**
* 批量預(yù)加載,帶進(jìn)度回調(diào)
* @param {string[]} list - 圖片url數(shù)組
* @param {object} [options] - 配置
* @param {number} [options.concurrency=6] - 并發(fā)數(shù),HTTP/1.1下建議≤6
* @param {function} [options.onProgress] - 單張完成回調(diào) (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()); // 無論成功失敗都遞歸補(bǔ)位
}
// 啟動(dòng)并發(fā)池
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(`進(jìn)度:${percent}%`);
document.querySelector('.progress-bar').style.width = percent + '%';
}
}
).then(({ ok, fail }) => {
console.log(' success:', ok.length, ' fail:', fail.length);
});
Promise封裝:讓預(yù)加載更優(yōu)雅
上面已經(jīng)用Promise了,但還可以再封裝成類,方便復(fù)用:
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 });
結(jié)合現(xiàn)代ES6+語法的寫法優(yōu)化
用async/await+for...of控制并發(fā),可讀性更高:
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);
}
// 調(diào)用
const urls = ['1.jpg', '2.jpg', '3.jpg'];
await asyncPool(6, urls, url => preloadImage(url));
預(yù)加載的隱藏成本你注意了嗎?
- 內(nèi)存占用:圖片解碼后占用的內(nèi)存是文件體積的幾十倍(RGBA 4字節(jié)/像素)。一張4000×3000的圖解碼即48MB,移動(dòng)端分分鐘被殺后臺(tái)。
- 帶寬消耗:用戶可能只看了首頁10%就關(guān)頁面,你預(yù)拉的后50張圖全部浪費(fèi),流量土豪請(qǐng)隨意。
- 電池:蜂窩網(wǎng)絡(luò)下頻繁喚醒射頻,電量肉眼可見地掉。
經(jīng)驗(yàn)法則:預(yù)加載總量 ≤ 首屏加一屏半,且單圖體積≤200KB(WebP/AVIF壓縮后)。再大就用分段加載或漸進(jìn)式JPEG。
什么時(shí)候不該用預(yù)加載?
- 弱網(wǎng)用戶占比>30%的海外業(yè)務(wù),先保證可用性,再談爽點(diǎn)。
- 圖片尺寸極大(全景圖、海報(bào)長圖)且用戶僅低概率查看。
- 瀏覽器已支持原生
<img loading="eager">且HTTP/2多路復(fù)用良好,重復(fù)造輪子收益趨近于0。
真實(shí)項(xiàng)目中的典型應(yīng)用場景
首頁輪播圖提前就位
// 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>
);
}
游戲資源包預(yù)載策略
H5小游戲:腳本、音頻、精靈圖三件套,先拉“首關(guān)資源”,后臺(tái)再拉“后續(xù)關(guān)卡”。用XHR+Blob存進(jìn)IndexedDB,二次打開秒進(jìn)游戲,用戶直呼“本地客戶端”。
電商商品詳情頁的無縫切換體驗(yàn)
商品詳情5張主圖+20張SKU圖,用戶切SKU時(shí)若圖片未加載完畢,會(huì)閃現(xiàn)舊圖。解決:鼠標(biāo)hover SKU按鈕即觸發(fā)預(yù)加載,300ms延遲后正式切換,基本做到“無縫”。
配合Webpack或Vite做構(gòu)建時(shí)預(yù)加載
Webpack的require.context+import(/* webpackPrefetch: true */),Vite的import.meta.globEager,讓瀏覽器在空閑時(shí)提前拉下一頁資源。SPA切頁如絲般順滑,SEO也不掉鏈子。
踩坑實(shí)錄:那些年我們被預(yù)加載“背刺”的瞬間
- 圖片404導(dǎo)致整個(gè)隊(duì)列卡死
解決:單張失敗不影響整體,Promise.finally遞歸補(bǔ)位,上面批量代碼已處理。 - 重復(fù)加載同一張圖
解決:用Map/WeakMap做全局緩存,key用完整url。 - 跨域圖片加載失敗
解決:CDN加Access-Control-Allow-Origin: *,或前端img.crossOrigin="anonymous",否則canvas繪制會(huì)報(bào)污染。 - 加載完成但圖片損壞
解決:監(jiān)聽img.onerror還不夠,需用createImageBitmap或decode()API,解碼失敗即視為損壞。
// 檢測損壞
async function isImageBroken(src) {
try {
const img = await preloadImage(src);
await img.decode(); // 如果解碼失敗會(huì)拋錯(cuò)
return false;
} catch {
return true;
}
}
前端老鳥私藏技巧大放送
用WeakMap緩存已加載圖片避免重復(fù)請(qǐng)求
const cache = new WeakMap(); // 鍵是Image對(duì)象,值無所謂
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); // 已緩存過
});
}
結(jié)合Intersection Observer實(shí)現(xiàn)“智能預(yù)加載”
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); // 進(jìn)入視口前200px開始拉
io.unobserve(img);
}
}
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-prefetch]').forEach(img => io.observe(img));
預(yù)加載 + CDN + 圖片格式優(yōu)化三連招
- 圖片裁切:用
?imageView2/2/w/750這類參數(shù),避免前端自己壓。 - 格式:WebP省30%,AVIF省50%,但不支持老Safari,用
<picture>兜底。 - CDN:把首屏圖推送到邊緣節(jié)點(diǎn),TTL設(shè)短,更新時(shí)主動(dòng)預(yù)熱。
為低網(wǎng)速用戶設(shè)計(jì)降級(jí)方案:骨架屏 or 占位圖?
骨架屏(Skeleton)適合結(jié)構(gòu)固定,占位圖(BlurUp)適合視覺沖擊強(qiáng)。實(shí)測3G網(wǎng)絡(luò)下,LCP(最大內(nèi)容繪制)骨架屏比空白+轉(zhuǎn)菊花快600ms,用戶體感明顯。
彩蛋:預(yù)加載還能和Service Worker玩出什么花樣?
SW攔截圖片請(qǐng)求,先查CacheStorage,沒有再回源,同時(shí)后臺(tái)拉取最新版本,下次訪問即更新。用戶第一次秒開,第二次看到新圖,雙贏。
// 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) {
// 后臺(tái)更新
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分擔(dān)主線程壓力
圖片解碼放主線程會(huì)掉幀,尤其在低端機(jī)。借助OffscreenCanvas+Web Worker,可把解碼任務(wù)甩給子線程,主線程繼續(xù)90fps滾動(dòng)。
// 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"只是告訴瀏覽器“這張圖很重要,請(qǐng)盡快”,但不會(huì)提前發(fā)起請(qǐng)求,和預(yù)加載不是一回事。
真正值得期待的是<link rel="preload" as="image" imagesrcset="...">,結(jié)合HTTP/3多路復(fù)用,瀏覽器可以在解析HTML前就拉圖,屆時(shí)我們或許可以少寫一半預(yù)加載代碼。但眼下,兼容性、緩存策略、業(yè)務(wù)定制仍需JS兜底。
至此,從“為什么”到“怎么做”,從“坑”到“彩蛋”,圖片預(yù)加載的十八般武藝悉數(shù)奉上。拿去擼代碼吧,下次再遇到“圖片慢半拍”,你就能拍著胸脯說:“放心,我提前都拉好了!”
以上就是一文手把手教你如何使用JavaScript預(yù)加載圖片告別加載卡頓的詳細(xì)內(nèi)容,更多關(guān)于JavaScript預(yù)加載圖片的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
ionic2自定義cordova插件開發(fā)以及使用(Android)
這篇文章主要為大家詳細(xì)介紹了ionic2自定義cordova插件開發(fā)以及使用,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-06-06
基于js與flash實(shí)現(xiàn)的網(wǎng)站flv視頻播放插件代碼
這篇文章主要介紹了基于js與flash實(shí)現(xiàn)的網(wǎng)站flv視頻播放插件代碼,該功能在很多網(wǎng)站上都有著廣泛的應(yīng)用,本文以實(shí)例形式對(duì)其進(jìn)行介紹,需要的朋友可以參考下2014-10-10
JavaScript進(jìn)行數(shù)據(jù)去重的多種實(shí)現(xiàn)方式
在數(shù)據(jù)處理的過程中,數(shù)據(jù)去重是一個(gè)常見且重要的任務(wù),在 JavaScript 中,有多種方法可以實(shí)現(xiàn)數(shù)據(jù)去重,每種方法都有其適用場景和優(yōu)缺點(diǎn),下面我們就來詳細(xì)講講吧2025-05-05
小程序多圖列表實(shí)現(xiàn)性能優(yōu)化的方法步驟
這篇文章主要介紹了小程序多圖列表實(shí)現(xiàn)性能優(yōu)化的方法步驟,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-05-05
微信小程序?qū)崿F(xiàn)時(shí)間戳格式轉(zhuǎn)換
這篇文章主要為大家詳細(xì)介紹了微信小程序?qū)崿F(xiàn)時(shí)間戳格式轉(zhuǎn)換,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-07-07

