图片懒加载(Lazy Loading)几乎是"性能优化"必考题:看起来简单,但一追问边界条件、兼容性、工程化落地,立刻能区分出"会写 demo"和"能上生产"的差别。本文按面试高频问法 + 项目实战,把 3 种实现方式一次讲清楚。
1. 什么是图片懒加载
图片懒加载的核心思想:先不加载视口外的图片,等它快进入视口时再加载。
典型做法是:
- 初始只给图片一个占位(空白/骨架/低质量缩略图)
- 真实图片地址先放在
data-src - 当图片即将进入可视区域时,把
data-src赋给src,触发下载与渲染
技术图解
flowchart TB
A[页面初始化] --> B[img 只渲染占位\n真实地址在 data-src]
B --> C{图片接近视口?}
C -- 否 --> D[继续滚动/观察]
C -- 是 --> E[把 data-src -> src]
E --> F[监听 load/error\n替换占位/兜底图]
2. 为什么需要懒加载
从"面试官视角",你需要能说出懒加载解决的是什么问题,以及代价是什么。
收益
- 更快的首屏:减少首屏请求数、带宽占用和解码开销
- 更小的内存压力:减少同时存在的图片解码位图(尤其长列表)
- 更好的交互体验:滚动更顺滑,主线程更少被图片相关任务抢占
代价 / 风险点(面试加分)
- 滚动监听方案可能引入频繁计算,需要节流/合并
- 需要处理失败重试、占位、布局抖动(CLS)
- SSR/预渲染场景下要考虑"首屏图片必须立即加载"
3. 实现方案一:scroll + getBoundingClientRect
这是最"原生、可控、兼容好"的方案,面试里常用来考你对 DOM 测量与性能的理解。
3.1 核心思路
- 监听
scroll/resize(必要时监听orientationchange) - 对每张未加载图片做一次可视区域判断:
img.getBoundingClientRect() - 当
rect.top < viewportHeight + preload时触发加载
技术图解
sequenceDiagram
participant U as User Scroll
participant W as Window
participant L as LazyLoader
participant I as Images
U->>W: scroll
W->>L: 触发回调(节流/合并)
L->>I: 遍历未加载图片
I-->>L: getBoundingClientRect()
L->>I: data-src -> src
3.2 代码示例(带注释)
HTML:
html
<!-- 占位图建议是很小的 base64 或本地小图,避免额外请求 -->
<img
class="lazy"
src="placeholder.png"
data-src="https://example.com/big-1.jpg"
width="320"
height="180"
alt="demo"
/>
JS(推荐用 requestAnimationFrame 合并滚动期间的多次触发):
js
const preloadPx = 200; // 提前 200px 预加载,避免滚动到可视区才开始下载
function inViewport(el) {
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
// rect.top < vh + preload:元素顶部进入"预加载区"
// rect.bottom > 0:元素没有完全滚出上方(可选,避免反复触发)
return rect.top < vh + preloadPx && rect.bottom > -preloadPx;
}
function loadImg(img) {
const realSrc = img.getAttribute("data-src");
if (!realSrc) return;
// 防止重复加载
img.setAttribute("data-loaded", "1");
img.src = realSrc;
img.addEventListener(
"error",
() => {
// 失败兜底:替换为错误占位图,避免一直空白
img.src = "error.png";
},
{ once: true }
);
}
function createScrollLazyLoader() {
let ticking = false;
const handler = () => {
if (ticking) return;
ticking = true;
// 合并到下一帧,避免滚动事件高频触发造成的卡顿
requestAnimationFrame(() => {
const imgs = Array.from(document.querySelectorAll("img.lazy"))
// 只处理未加载的
.filter((img) => img.getAttribute("data-loaded") !== "1");
imgs.forEach((img) => {
if (inViewport(img)) loadImg(img);
});
// 全部加载完就解绑,避免无意义监听(面试加分点)
const remain = document.querySelector(
'img.lazy:not([data-loaded="1"])'
);
if (!remain) {
window.removeEventListener("scroll", handler);
window.removeEventListener("resize", handler);
}
ticking = false;
});
};
// 初始化先跑一次,避免首屏图片不加载
handler();
window.addEventListener("scroll", handler, { passive: true });
window.addEventListener("resize", handler);
}
createScrollLazyLoader();
3.3 面试追问点
- 为什么
scroll要passive: true?减少主线程阻塞,提升滚动流畅度 - 为什么不直接每次滚动都
getBoundingClientRect?测量+遍历成本高,需要节流/合帧 - 列表很长怎么办?配合虚拟列表(只渲染视口附近 DOM)是更强的组合
4. 实现方案二:IntersectionObserver(推荐)
这是我在真实项目里最常用的方式:API 语义清晰、性能好、实现也简洁。
4.1 核心思路
浏览器在合适的时机告诉你:某个元素是否与根容器(默认视口)产生交叉。
你只要在回调里:
- 判断
entry.isIntersecting - 加载图片
- 取消观察
observer.unobserve(img)(避免重复回调)
技术图解
flowchart LR
A[观察 img] --> B{isIntersecting?}
B -- 否 --> A
B -- 是 --> C[data-src -> src]
C --> D[unobserve\n释放资源]
4.2 代码示例(带注释)
js
function createIOLazyLoader() {
const imgs = Array.from(document.querySelectorAll("img.lazy"));
// rootMargin 用于"提前加载",等价于把视口向外扩一圈
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
const realSrc = img.getAttribute("data-src");
if (!realSrc) {
observer.unobserve(img);
return;
}
// 标记已处理,避免某些场景下重复赋值
img.setAttribute("data-loaded", "1");
img.src = realSrc;
img.addEventListener(
"error",
() => {
img.src = "error.png";
},
{ once: true }
);
observer.unobserve(img);
});
},
{
root: null, // 视口
rootMargin: "200px 0px", // 上下提前 200px
threshold: 0.01, // 进入一点点就触发
}
);
imgs.forEach((img) => observer.observe(img));
}
// 如果你需要兼容非常老的浏览器,可以在这里做降级(见下文最佳实践)
createIOLazyLoader();
4.3 面试追问点
rootMargin和threshold怎么选?前者控制提前量,后者控制触发时机- 为什么要
unobserve?减少回调次数与内存占用,避免重复加载 - 监听容器滚动怎么办?把
root设置为滚动容器即可(如某个列表容器)
5. 实现方案三:loading="lazy"
这是 HTML 原生属性:一句话就能懒加载,但它的控制力、兼容性和可预测性不如前两种。
5.1 使用方式
html
<!-- 注意最好设置 width/height 或用 CSS 固定占位,避免 CLS -->
<img
src="https://example.com/big-2.jpg"
loading="lazy"
width="320"
height="180"
alt="native lazy"
/>
5.2 面试追问点
- 为什么仍然需要
width/height?避免布局抖动(CLS)是性能指标关键项 - 是否所有图片都适合
loading="lazy"?首屏关键图不要懒加载,否则影响 LCP - 是否能控制提前量?原生行为由浏览器决定,可控性较弱
6. 三种方案对比
| 方案 | 兼容性 | 性能 | 控制力 | 工程复杂度 | 典型适用 |
|---|---|---|---|---|---|
| scroll + getBoundingClientRect | 最好 | 一般(需优化) | 最强 | 中 | 需强兼容、需精细控制 |
| IntersectionObserver | 较好(可降级) | 好 | 强 | 低 | 长列表/内容流(推荐) |
| loading="lazy" | 现代浏览器好 | 好 | 弱 | 最低 | 简单页面、对行为不敏感 |
面试里如果让你"选一种",建议回答:
- 默认用 IntersectionObserver
- 对极老环境做 scroll 降级
- 对极简单场景可以直接
loading="lazy",但首屏关键图要排除
7. 实际项目最佳实践
把"能跑"变成"好用、可维护、可扩展",通常要补齐这些点。
7.1 首屏关键图:不要懒加载
规则很简单:影响 LCP 的图片(banner、主图、首屏卡片第一张)应优先加载。
- IntersectionObserver:直接不加
.lazy类或不观察 - 原生:不要加
loading="lazy"
7.2 占位与 CLS:固定尺寸/骨架屏
- 给
img显式width/height,或用容器按比例占位 - 加载过程中可以显示骨架(灰块)/模糊缩略图(LQIP)
7.3 失败兜底与重试(面试高频)
建议:错误兜底图 + 可选一次重试(避免弱网短抖动导致永久失败)。
js
function loadWithRetry(img, realSrc, retry = 1) {
return new Promise((resolve) => {
let tried = 0;
const attempt = () => {
tried += 1;
img.src = realSrc;
const cleanup = () => {
img.removeEventListener("load", onLoad);
img.removeEventListener("error", onError);
};
const onLoad = () => {
cleanup();
resolve(true);
};
const onError = () => {
cleanup();
if (tried <= retry) {
// 简单重试:可以加上指数退避、换 CDN 域名等策略
attempt();
} else {
img.src = "error.png";
resolve(false);
}
};
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", onError, { once: true });
};
attempt();
});
}
7.4 结合响应式图片:srcset / sizes
懒加载解决"何时加载",srcset 解决"加载哪张更合适"。
html
<img
class="lazy"
src="placeholder.png"
data-src="https://example.com/img-800.jpg"
data-srcset="https://example.com/img-400.jpg 400w, https://example.com/img-800.jpg 800w"
sizes="(max-width: 600px) 400px, 800px"
width="320"
height="180"
alt="responsive"
/>
js
function applySrcset(img) {
const src = img.getAttribute("data-src");
const srcset = img.getAttribute("data-srcset");
if (src) img.src = src;
if (srcset) img.srcset = srcset;
}
7.5 统一封装:可插拔 + 可降级
项目里建议封装成一个小工具:优先用 IntersectionObserver,不支持就降级到 scroll。
js
function createLazyImageLoader() {
const supportsIO = typeof IntersectionObserver !== "undefined";
if (supportsIO) {
createIOLazyLoader();
} else {
createScrollLazyLoader();
}
}
createLazyImageLoader();
8. 总结
- 图片懒加载的本质:延后非关键资源的下载与解码,换取更快首屏与更低资源占用
- 三种方案里:IntersectionObserver 在语义、性能、工程落地上最均衡,适合作为默认方案
- 面试加分关键:能讲清楚 首屏排除、提前量(rootMargin)、解绑/释放、CLS、失败兜底、长列表与虚拟列表组合 这些"生产级细节"
如果你准备面试,建议最后用一句话收尾:
默认 IO 懒加载 + rootMargin 提前加载,首屏关键图不懒加载,失败兜底与尺寸占位保证体验与指标。