前端實(shí)現(xiàn)圖片懶加載的三種方案及對比詳解
一、為什么需要圖片懶加載?
1.1 性能問題
在現(xiàn)代 Web 應(yīng)用中,圖片往往占據(jù)了頁面總資源的 60-70%。如果一次性加載所有圖片,會導(dǎo)致:
- 首屏加載時(shí)間過長:用戶需要等待所有圖片下載完成
- 帶寬浪費(fèi):用戶可能永遠(yuǎn)不會滾動到頁面底部
- 內(nèi)存占用過高:大量圖片同時(shí)存在于內(nèi)存中
- 用戶體驗(yàn)差:頁面卡頓、白屏?xí)r間長
1.2 典型應(yīng)用場景
- 長列表頁面(商品列表、新聞列表)
- 圖片墻/瀑布流
- 社交媒體信息流
- 文章詳情頁
- 相冊/畫廊
二、圖片懶加載的演進(jìn)歷程
2.1 演進(jìn)時(shí)間線
2010 年前 → 滾動監(jiān)聽 + getBoundingClientRect 2016 年 → IntersectionObserver API 發(fā)布 2019 年 → 瀏覽器原生 loading="lazy" 支持 2020 年至今 → 混合方案 + 漸進(jìn)增強(qiáng)
三、方案一:傳統(tǒng)滾動監(jiān)聽(已過時(shí))
3.1 實(shí)現(xiàn)原理
監(jiān)聽 scroll 事件,計(jì)算圖片是否進(jìn)入視口,如果進(jìn)入則加載圖片。
3.2 基本實(shí)現(xiàn)
class ScrollLazyLoad {
constructor(options = {}) {
this.images = [];
this.threshold = options.threshold || 0; // 提前加載的距離
this.handleScroll = this.debounce(this._checkImages.bind(this), 200);
}
init() {
// 收集所有需要懶加載的圖片
this.images = Array.from(document.querySelectorAll('img[data-src]'));
// 監(jiān)聽滾動事件
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleScroll);
// 初始檢查
this._checkImages();
}
_checkImages() {
this.images = this.images.filter((img) => {
if (this._isInViewport(img)) {
this._loadImage(img);
return false; // 已加載,從列表中移除
}
return true; // 未加載,保留在列表中
});
// 所有圖片都加載完成,移除監(jiān)聽器
if (this.images.length === 0) {
this.destroy();
}
}
_isInViewport(element) {
const rect = element.getBoundingClientRect();
const windowHeight = window.innerHeight || document.documentElement.clientHeight;
return (
rect.top <= windowHeight + this.threshold &&
rect.bottom >= -this.threshold
);
}
_loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// 創(chuàng)建臨時(shí) Image 對象預(yù)加載
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.add('loaded');
img.removeAttribute('data-src');
};
tempImg.onerror = () => {
img.classList.add('error');
};
tempImg.src = src;
}
debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
destroy() {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleScroll);
}
}
// 使用示例
const lazyLoad = new ScrollLazyLoad({ threshold: 200 });
lazyLoad.init();
3.3 HTML 結(jié)構(gòu)
<!-- 使用 data-src 存儲真實(shí)圖片地址 --> <img src="placeholder.jpg" data-src="real-image.jpg" alt="描述" class="lazy-image" /> <!-- 或者使用透明占位圖 --> <img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" data-src="real-image.jpg" alt="描述" />
3.4 優(yōu)勢
? 兼容性好:支持所有瀏覽器(包括 IE6+)
? 實(shí)現(xiàn)簡單:邏輯清晰,易于理解
? 可控性強(qiáng):可以精確控制加載時(shí)機(jī)
3.5 劣勢
? 性能差:頻繁觸發(fā) scroll 事件,即使使用防抖也會影響性能
? 計(jì)算開銷大:每次都要調(diào)用 getBoundingClientRect()
? 阻塞主線程:scroll 事件在主線程執(zhí)行,可能導(dǎo)致卡頓
? 代碼復(fù)雜:需要手動管理監(jiān)聽器、防抖、清理等
3.6 性能問題分析
// 問題 1:頻繁觸發(fā)
window.addEventListener('scroll', () => {
console.log('scroll 事件觸發(fā)'); // 滾動時(shí)每秒觸發(fā) 60+ 次
});
// 問題 2:getBoundingClientRect 觸發(fā)重排
const rect = element.getBoundingClientRect(); // 強(qiáng)制瀏覽器重新計(jì)算布局
// 問題 3:主線程阻塞
// scroll 事件在主線程執(zhí)行,如果處理邏輯復(fù)雜,會導(dǎo)致頁面卡頓
3.7 適用場景
- 需要兼容老舊瀏覽器(IE9-)
- 需要精確控制加載時(shí)機(jī)
- 項(xiàng)目已有成熟的滾動監(jiān)聽方案
3.8 結(jié)論
不推薦使用:除非有特殊的兼容性要求,否則應(yīng)該使用更現(xiàn)代的方案。
四、方案二:IntersectionObserver API(推薦)
4.1 實(shí)現(xiàn)原理
使用瀏覽器原生的 IntersectionObserver API,監(jiān)聽元素與視口的交叉狀態(tài),當(dāng)元素進(jìn)入視口時(shí)自動觸發(fā)回調(diào)。
4.2 核心優(yōu)勢
? 異步執(zhí)行:不阻塞主線程,性能優(yōu)秀
? 自動優(yōu)化:瀏覽器內(nèi)部優(yōu)化,無需手動防抖
? 精確控制:支持 rootMargin、threshold 等配置
? 代碼簡潔:無需手動計(jì)算位置
4.3 基本實(shí)現(xiàn)
class IntersectionLazyLoad {
constructor(options = {}) {
this.rootMargin = options.rootMargin || '50px'; // 提前加載距離
this.threshold = options.threshold || 0; // 交叉比例
this.onLoad = options.onLoad; // 加載完成回調(diào)
this.observer = null;
}
init() {
// 創(chuàng)建 IntersectionObserver
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{
rootMargin: this.rootMargin,
threshold: this.threshold,
}
);
// 觀察所有需要懶加載的圖片
const images = document.querySelectorAll('img[data-src]');
images.forEach((img) => this.observer.observe(img));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
// 元素進(jìn)入視口
if (entry.isIntersecting) {
const img = entry.target;
this._loadImage(img);
// 加載后停止觀察
this.observer.unobserve(img);
}
});
}
_loadImage(img) {
const src = img.dataset.src;
if (!src) return;
// 創(chuàng)建臨時(shí) Image 對象預(yù)加載
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
img.classList.add('loaded');
img.removeAttribute('data-src');
this.onLoad?.(img, true);
};
tempImg.onerror = () => {
img.classList.add('error');
this.onLoad?.(img, false);
};
tempImg.src = src;
}
// 動態(tài)添加圖片時(shí)調(diào)用
observe(img) {
if (this.observer && img.dataset.src) {
this.observer.observe(img);
}
}
// 停止觀察某個圖片
unobserve(img) {
if (this.observer) {
this.observer.unobserve(img);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
// 使用示例
const lazyLoad = new IntersectionLazyLoad({
rootMargin: '100px', // 提前 100px 開始加載
threshold: 0.01, // 元素 1% 可見時(shí)觸發(fā)
onLoad: (img, success) => {
console.log(`圖片加載${success ? '成功' : '失敗'}:`, img.src);
},
});
lazyLoad.init();
// 動態(tài)添加圖片
const newImg = document.createElement('img');
newImg.dataset.src = 'new-image.jpg';
document.body.appendChild(newImg);
lazyLoad.observe(newImg); // 觀察新圖片
4.4 高級用法
4.4.1 漸進(jìn)式加載(先加載縮略圖)
class ProgressiveLazyLoad {
constructor(options = {}) {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const images = document.querySelectorAll('img[data-src]');
images.forEach((img) => {
// 先加載縮略圖
if (img.dataset.thumbnail) {
img.src = img.dataset.thumbnail;
}
this.observer.observe(img);
});
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
this._loadHighResImage(img);
this.observer.unobserve(img);
}
});
}
_loadHighResImage(img) {
const src = img.dataset.src;
if (!src) return;
const tempImg = new Image();
tempImg.onload = () => {
// 淡入效果
img.style.opacity = 0;
img.src = src;
img.style.transition = 'opacity 0.3s';
setTimeout(() => {
img.style.opacity = 1;
}, 10);
img.classList.add('loaded');
};
tempImg.src = src;
}
}
4.4.2 響應(yīng)式圖片懶加載
class ResponsiveLazyLoad {
constructor() {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const images = document.querySelectorAll('img[data-srcset]');
images.forEach((img) => this.observer.observe(img));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
this._loadResponsiveImage(img);
this.observer.unobserve(img);
}
});
}
_loadResponsiveImage(img) {
// 根據(jù)屏幕寬度選擇合適的圖片
const srcset = img.dataset.srcset;
const sizes = img.dataset.sizes;
if (srcset) {
img.srcset = srcset;
}
if (sizes) {
img.sizes = sizes;
}
// 設(shè)置默認(rèn) src(兜底)
if (img.dataset.src) {
img.src = img.dataset.src;
}
}
}
HTML 結(jié)構(gòu):
<img
data-srcset="
small.jpg 480w,
medium.jpg 800w,
large.jpg 1200w
"
data-sizes="
(max-width: 600px) 480px,
(max-width: 1000px) 800px,
1200px
"
data-src="medium.jpg"
alt="響應(yīng)式圖片"
/>
4.4.3 背景圖片懶加載
class BackgroundLazyLoad {
constructor() {
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
}
init() {
const elements = document.querySelectorAll('[data-bg]');
elements.forEach((el) => this.observer.observe(el));
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const el = entry.target;
const bg = el.dataset.bg;
if (bg) {
// 預(yù)加載背景圖
const img = new Image();
img.onload = () => {
el.style.backgroundImage = `url(${bg})`;
el.classList.add('loaded');
};
img.src = bg;
}
this.observer.unobserve(el);
}
});
}
}
HTML 結(jié)構(gòu):
<div class="hero-section" data-bg="hero-background.jpg" style="background-color: #f0f0f0;" > <!-- 內(nèi)容 --> </div>
4.5 配置參數(shù)詳解
const observer = new IntersectionObserver(callback, {
// root: 指定根元素(默認(rèn)為視口)
root: document.querySelector('#scrollArea'),
// rootMargin: 根元素的外邊距(提前/延遲加載)
rootMargin: '50px 0px', // 上下提前 50px,左右不變
// threshold: 交叉比例閾值
threshold: [0, 0.25, 0.5, 0.75, 1], // 多個閾值
});
rootMargin 示例:
// 提前 100px 開始加載 rootMargin: '100px' // 上下提前 100px,左右提前 50px rootMargin: '100px 50px' // 上提前 100px,右提前 50px,下提前 80px,左提前 30px rootMargin: '100px 50px 80px 30px' // 延遲加載(元素完全進(jìn)入視口后才加載) rootMargin: '-50px'
threshold 示例:
// 元素剛進(jìn)入視口就觸發(fā) threshold: 0 // 元素 50% 可見時(shí)觸發(fā) threshold: 0.5 // 元素完全可見時(shí)觸發(fā) threshold: 1 // 多個閾值(0%, 25%, 50%, 75%, 100% 時(shí)都會觸發(fā)) threshold: [0, 0.25, 0.5, 0.75, 1]
4.6 性能對比
| 指標(biāo) | 滾動監(jiān)聽 | IntersectionObserver |
|---|---|---|
| CPU 占用 | 高(主線程) | 低(異步) |
| 內(nèi)存占用 | 中 | 低 |
| 觸發(fā)頻率 | 高(60+ 次/秒) | 低(按需觸發(fā)) |
| 代碼復(fù)雜度 | 高 | 低 |
| 瀏覽器優(yōu)化 | 無 | 有 |
| 性能評分 | 60 分 | 95 分 |
4.7 瀏覽器兼容性
| 瀏覽器 | 版本 |
|---|---|
| Chrome | 51+ |
| Firefox | 55+ |
| Safari | 12.1+ |
| Edge | 15+ |
| IE | ? 不支持 |
Polyfill 方案:
// 檢測瀏覽器是否支持
if (!('IntersectionObserver' in window)) {
// 動態(tài)加載 polyfill
import('intersection-observer').then(() => {
// 初始化懶加載
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
});
} else {
// 直接使用
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
}
4.8 適用場景
- ? 現(xiàn)代瀏覽器項(xiàng)目(Chrome 51+, Safari 12.1+)
- ? 需要高性能的懶加載
- ? 長列表、瀑布流、信息流
- ? 需要精確控制加載時(shí)機(jī)
4.9 結(jié)論
強(qiáng)烈推薦:IntersectionObserver 是目前最佳的圖片懶加載方案,性能優(yōu)秀、代碼簡潔、易于維護(hù)。
五、方案三:瀏覽器原生 loading="lazy"(最簡單)
5.1 實(shí)現(xiàn)原理
HTML5 新增的 loading 屬性,瀏覽器原生支持圖片懶加載,無需任何 JavaScript 代碼。
5.2 基本用法
<!-- 懶加載 --> <img src="image.jpg" loading="lazy" alt="描述" /> <!-- 立即加載(默認(rèn)) --> <img src="image.jpg" loading="eager" alt="描述" /> <!-- 自動(瀏覽器決定) --> <img src="image.jpg" loading="auto" alt="描述" />
5.3 iframe 也支持
<iframe src="video.html" loading="lazy"></iframe>
5.4 優(yōu)勢
? 零代碼:無需任何 JavaScript
? 性能最優(yōu):瀏覽器底層優(yōu)化
? 自動優(yōu)化:瀏覽器根據(jù)網(wǎng)絡(luò)狀況自動調(diào)整
? 維護(hù)成本低:無需管理監(jiān)聽器、Observer 等
5.5 劣勢
? 兼容性差:Safari 15.4+ 才支持
? 無法自定義:無法控制提前加載距離
? 無回調(diào):無法監(jiān)聽加載完成事件
5.6 瀏覽器兼容性
| 瀏覽器 | 版本 |
|---|---|
| Chrome | 77+ |
| Firefox | 75+ |
| Safari | 15.4+ |
| Edge | 79+ |
| IE | ? 不支持 |
5.7 漸進(jìn)增強(qiáng)方案
<!-- 方案 1:loading + data-src(兼容老瀏覽器) -->
<img
src="placeholder.jpg"
data-src="real-image.jpg"
loading="lazy"
alt="描述"
class="lazy-image"
/>
<script>
// 檢測瀏覽器是否支持 loading="lazy"
if ('loading' in HTMLImageElement.prototype) {
// 支持:直接設(shè)置 src
document.querySelectorAll('img[data-src]').forEach((img) => {
img.src = img.dataset.src;
});
} else {
// 不支持:使用 IntersectionObserver 降級
const lazyLoad = new IntersectionLazyLoad();
lazyLoad.init();
}
</script>
<!-- 方案 2:使用 <picture> 標(biāo)簽 -->
<picture>
<source
srcset="image-large.jpg"
media="(min-width: 1200px)"
loading="lazy"
/>
<source
srcset="image-medium.jpg"
media="(min-width: 768px)"
loading="lazy"
/>
<img
src="image-small.jpg"
loading="lazy"
alt="響應(yīng)式圖片"
/>
</picture>
5.7 適用場景
- ? 只需要兼容現(xiàn)代瀏覽器(Chrome 77+, Safari 15.4+)
- ? 追求零代碼、零維護(hù)
- ? 不需要自定義加載邏輯
- ? 簡單的圖片懶加載需求
5.8 結(jié)論
推薦使用:如果不需要兼容老瀏覽器,loading="lazy" 是最簡單、最優(yōu)雅的方案。
六、方案對比總結(jié)
| 維度 | 滾動監(jiān)聽 | IntersectionObserver | loading="lazy" |
|---|---|---|---|
| 性能 | ?? | ????? | ????? |
| 兼容性 | ????? | ???? | ??? |
| 代碼復(fù)雜度 | 高 | 中 | 零代碼 |
| 可定制性 | ????? | ???? | ? |
| 維護(hù)成本 | 高 | 中 | 零維護(hù) |
| 推薦指數(shù) | ? | ????? | ???? |
七、業(yè)界最佳實(shí)踐
7.1 混合方案(推薦)
結(jié)合 loading="lazy" 和 IntersectionObserver,實(shí)現(xiàn)漸進(jìn)增強(qiáng):
class HybridLazyLoad {
constructor() {
this.supportsNativeLazy = 'loading' in HTMLImageElement.prototype;
this.observer = null;
}
init() {
const images = document.querySelectorAll('img[data-src]');
if (this.supportsNativeLazy) {
// 支持原生懶加載:直接設(shè)置 src 和 loading 屬性
images.forEach((img) => {
img.src = img.dataset.src;
img.loading = 'lazy';
img.removeAttribute('data-src');
});
} else {
// 不支持:使用 IntersectionObserver 降級
this.observer = new IntersectionObserver(
(entries) => this._handleIntersection(entries),
{ rootMargin: '50px' }
);
images.forEach((img) => this.observer.observe(img));
}
}
_handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
this.observer.unobserve(img);
}
});
}
}
// 使用
const lazyLoad = new HybridLazyLoad();
lazyLoad.init();
7.2 響應(yīng)式圖片懶加載
<!-- 使用 srcset 和 sizes -->
<img
srcset="
small.jpg 480w,
medium.jpg 800w,
large.jpg 1200w,
xlarge.jpg 1600w
"
sizes="
(max-width: 600px) 480px,
(max-width: 1000px) 800px,
(max-width: 1400px) 1200px,
1600px
"
src="medium.jpg"
loading="lazy"
alt="響應(yīng)式圖片"
/>
<!-- 使用 <picture> 標(biāo)簽 -->
<picture>
<source
media="(min-width: 1200px)"
srcset="desktop.jpg"
loading="lazy"
/>
<source
media="(min-width: 768px)"
srcset="tablet.jpg"
loading="lazy"
/>
<img
src="mobile.jpg"
loading="lazy"
alt="響應(yīng)式圖片"
/>
</picture>
7.3 WebP 格式支持
<picture>
<!-- WebP 格式(現(xiàn)代瀏覽器) -->
<source
srcset="image.webp"
type="image/webp"
loading="lazy"
/>
<!-- JPEG 格式(降級) -->
<img
src="image.jpg"
loading="lazy"
alt="描述"
/>
</picture>
7.4 避免布局抖動(CLS)
問題: 圖片加載前后高度變化,導(dǎo)致頁面跳動
解決方案 1:設(shè)置固定尺寸
<img src="image.jpg" loading="lazy" width="800" height="600" alt="描述" />
解決方案 2:使用 aspect-ratio
<img src="image.jpg" loading="lazy" style="aspect-ratio: 16/9; width: 100%;" alt="描述" />
解決方案 3:使用 padding-top 技巧
<div class="image-wrapper">
<img
src="image.jpg"
loading="lazy"
alt="描述"
/>
</div>
<style>
.image-wrapper {
position: relative;
width: 100%;
padding-top: 56.25%; /* 16:9 比例 = 9/16 * 100% */
}
.image-wrapper img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
7.5 預(yù)加載關(guān)鍵圖片
<!-- 首屏關(guān)鍵圖片:使用 preload --> <link rel="preload" as="image" href="hero.jpg" rel="external nofollow" /> <!-- 首屏圖片:不使用懶加載 --> <img src="hero.jpg" loading="eager" alt="首屏大圖" /> <!-- 非首屏圖片:使用懶加載 --> <img src="image.jpg" loading="lazy" alt="描述" />
7.6 圖片加載優(yōu)先級
<!-- 高優(yōu)先級(首屏關(guān)鍵圖片) --> <img src="hero.jpg" loading="eager" fetchpriority="high" alt="首屏" /> <!-- 低優(yōu)先級(非關(guān)鍵圖片) --> <img src="icon.jpg" loading="lazy" fetchpriority="low" alt="圖標(biāo)" /> <!-- 自動優(yōu)先級(默認(rèn)) --> <img src="image.jpg" loading="lazy" fetchpriority="auto" alt="描述" />
以上就是前端實(shí)現(xiàn)圖片懶加載的三種方案及對比詳解的詳細(xì)內(nèi)容,更多關(guān)于前端實(shí)現(xiàn)圖片懶加載的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于Javascript實(shí)現(xiàn)彈出頁面效果
彈出層效果是一個很實(shí)用的功能,很多網(wǎng)站都采用了這種方式實(shí)現(xiàn)登錄和注冊,下面小編通過本文給大家分享具體實(shí)現(xiàn)代碼,對js彈出頁面效果相關(guān)知識感興趣的朋友一起學(xué)習(xí)吧2016-01-01
javascript實(shí)現(xiàn)數(shù)字驗(yàn)證碼的簡單實(shí)例
本篇文章主要是對javascript實(shí)現(xiàn)數(shù)字驗(yàn)證碼的簡單實(shí)例進(jìn)行了介紹,需要的朋友可以過來參考下,希望對大家有所幫助2014-02-02
JavaScript日期處理類庫moment()獲取時(shí)間
moment.js是一個廣泛使用的JavaScript日期處理庫,便于開發(fā)者進(jìn)行日期的解析、驗(yàn)證、操作和格式化,通過引用并設(shè)置區(qū)域,可以輕松實(shí)現(xiàn)本地化日期和時(shí)間的處理,文中通過代碼介紹的非常詳細(xì),需要的朋友可以參考下2024-09-09
JS實(shí)現(xiàn)獲取剪貼板內(nèi)容的方法
這篇文章主要介紹了JS實(shí)現(xiàn)獲取剪貼板內(nèi)容的方法,涉及javascript基于clipboardData操作剪貼板的相關(guān)技巧,需要的朋友可以參考下2016-06-06
Layui 數(shù)據(jù)表格批量刪除和多條件搜索的實(shí)例
今天小編就為大家分享一篇Layui 數(shù)據(jù)表格批量刪除和多條件搜索的實(shí)例,具有很好的參考價(jià)值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-09-09
nuxtjs通過ecosystem.config.js配置pm2的方法
ecosystem.config.js 是一個特殊的配置文件,它允許您定義應(yīng)用的各種屬性,如腳本路徑、環(huán)境變量、日志設(shè)置等,這篇文章主要介紹了nuxtjs通過ecosystem.config.js配置pm2的方法,需要的朋友可以參考下2024-03-03

