news 2026/10/20 13:48:44

图片懒加载实战:从IntersectionObserver到组件封装与性能优化避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
图片懒加载实战:从IntersectionObserver到组件封装与性能优化避坑指南

接手过好几个页面性能优化的任务,最让人头秃的就是图片拖后腿:翻到某个电商活动页,首屏还没加载完,Network面板里同时窜出几十个图片请求,带宽直接被打满,页面等图片下载完才能顺畅滚动。另一个内容站更夸张,每张配图两三兆,首屏加载快十秒,用户等不及就走人了。这两个场景的通用解法,图片懒加载就是最“稳准狠”的那一个。

图片懒加载,简单说就是页面加载时先不去管视口以外的图片,等用户滚动到图片附近了,再把真正的图片地址赋给img标签并发起请求。首屏几十个图片请求能被压缩到个位数,网页打开速度肉眼可见地变快,滚动过程中也不会一卡一卡的。

这篇文章我会从图片拖慢页面的根本原因讲起,给出三种实现方案,带大家手写一个可以直接复用的懒加载组件,再把我踩过的各种坑和排查经验全部抖出来,前端小白也能照着做。

1. 为什么你的网页越改越卡:图片资源与加载瓶颈

1.1 首屏慢的根源不在网速,在请求数

很多人第一反应是网速问题,其实大部分项目卡顿的根源是“超出预期的请求数量”。浏览器加载一张图片,要走一个完整链路:排队等待、建立连接、下载字节、解码像素、上传纹理到GPU、最后绘制到屏幕。其中下载和decode非常消耗时间与资源,尤其在高清大图数量多的时候,CPU的解码任务会排成长队,滚动时主线程忙不过来,自然就卡。

现代浏览器对同一个域名的并发连接数是有限制的,通常六个左右。页面里如果同时有几十张图片要加载,后面的请求全部在队列里排队,加载时间呈线性拉长。再看性能指标,首屏图片对LCP(最大内容绘制)的影响非常大,一张hero图如果迟迟加载不完,LCP会被拖到四五秒甚至更久,用户在弱网环境下体验更差。

我接手过的一个移动端活动页,首屏包含了轮播图、商品卡片、活动背景图,一共四十多张素材,其中几张是单张超过1MB的高清图。优化前打开页面,光图片相关请求就有二十多个,页面要等三四秒图片才逐步显示出来。这种页面不是网速不行,是请求数量把入口堵死了。

1.2 懒加载的本质:把加载分摊到滚动上

图片懒加载的核心思想是“视口优先”。浏览器有一个视口(viewport)概念,也就是用户当前能看到的那块矩形区域。懒加载的做法是:只加载视口内以及即将进入视口的图片,视口外的图片先用占位形式放着,不产生真实请求,等到用户滚动、图片快要进入视野时,再动态把真实地址替换上去。

这个判断“图片是否在视口附近”有两种技术路线:一种是传统做法,监听scroll事件然后用getBoundingClientRect()算坐标;另一种是现代浏览器提供的IntersectionObserverAPI,由浏览器底层帮我们观察元素与视口的交叉状态。两种方案我会在下一章展开,但无论用哪一种,本质上都是在做同一件事——把图片的加载成本从“页面打开瞬间”挪到“用户滚动途中”,让首屏的请求压力瞬间降下来。

可以把它理解为会场入口发材料:如果一次性把所有材料都堆在门口,门口堵成一锅粥;现在改成等人走近了再递给他,门口始终通畅,每个人也都能拿到材料。懒加载换来的不是“总加载量减少”,而是“加载时机错开”,用户感知上的快,就是从这种错开里来的。

1.3 懒加载能省下什么:三个收益账本

从实际收益来看,图片懒加载主要改善三个维度:

收益维度优化前优化后
首屏图片请求数几十个并发发送只有视口内的几个
首屏传输体积可能高达十几MB通常能降到几MB以内
CPU解码压力主线程被多张图同时拖累,滚动掉帧解码任务滚动时少量分批执行,明显更顺滑

这里要强调一点,懒加载没有减少网站整体的图片流量,它是把“一次性倾泻”改成“按需分时供应”。所以你在看网络面板时,不要指望总传输字节数会大幅减少,真正变化的是首屏阶段的数据量和时间线分布。字段上,页面打开时初始请求大幅缩短,LCP会明显下降;到了用户滚动阶段,图片图片请求开始陆续出现,这是符合预期的行为。

2. 三种实现方案:从一行属性到完整组件

2.1 方案A:原生loading属性的取舍

最偷懒的写法是给每个img加一个loading="lazy"属性,纯HTML一行搞定。现代浏览器比如Chrome、Edge、Firefox以及新版本Safari都会原生支持,浏览器自己判断滚动位置,自动延迟加载视口外的图片。

<img src="photo-1.jpg" alt="示例图片" loading="lazy" />

这个方案的优势非常明显:零依赖、零JS代码,浏览器内部实现,性能有保障。但它也有两个不能忽略的短板。第一,触发时机完全由浏览器决定,开发者没法设置“提前多少像素开始加载”,不同浏览器策略还不一样,在某些浏览器里图片可能会“临到头了才开始拉”,表现为快速滚动时图片短暂空白;第二,低版本浏览器完全不认识这个属性,虽然它们会直接忽略该属性并加载图片,不会造成JS报错,但懒加载效果也没了。

基于这些特性,我在实际项目里的定位是:原形Demo用一把挺好,生产环境如果对加载时序有要求,光靠它不够。

2.2 方案B:IntersectionObserver的平衡点

这是我目前在主要项目里的首选方案。IntersectionObserver让JavaScript“观察”一个元素与视口(或某个容器)的交叉状态,当图片出现或接近视口时触发回调,具备天然的异步性能优势,因为观察逻辑跑在浏览器底层,不需要在主线程上重复做坐标计算。

基础代码如下:

const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '200px 0px', threshold: 0 }); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));

这段代码的意图很清楚:把真实图片地址放在>let ticking = false; function checkImages() { ticking = false; const images = document.querySelectorAll('img[data-src]'); images.forEach((img) => { const rect = img.getBoundingClientRect(); if (rect.top < window.innerHeight && rect.bottom > 0) { img.src = img.dataset.src; img.removeAttribute('data-src'); } }); } window.addEventListener('scroll', () => { if (!ticking) { window.requestAnimationFrame(checkImages); ticking = true; } }, { passive: true });

这里有几个细节值得留意。滚动事件触发频率极高,直接在里面做遍历和坐标计算会卡到怀疑人生,所以要用requestAnimationFrame做节流;passive: true告诉浏览器不需要取消默认行为,滚动手感更好;判断条件是rect.top < window.innerHeight && rect.bottom > 0,这样图片刚进入视口或者刚好从底部路过时都会触发。

这个方案的缺点也很明显:页面里的待加载图片数量一旦多起来,每次滚动都要逐个遍历,性能比IntersectionObserver差一大截。而且滚动事件触发时机存在间隙,快速滚动时可能漏掉某些图片。因此它只是一个兜底方案,适合维护历史遗留项目时使用。

2.4 方案选型建议与不适用场景

方案实现成本性能可控性兼容性推荐场景
原生loading属性最低高低中高原型、个人站、对时序无要求
IntersectionObserver中高高中高生产环境主力方案
滚动监听高低中最高老项目兜底

另外再说一句,懒加载不是所有场景都适合。首屏hero图、产品主图、登录页logo这类“用户第一眼必须看到”的图片,不应该走懒加载,否则反而拖累LCP。页面总共只有三张图且都在首屏,那也没必要做懒加载,纯属增加复杂度。做优化前先判断页面结构,图片多、页面长、首屏外素材占比高,懒加载才有明显收益。

3. 手把手实现一个可复用的懒加载组件

3.1 HTML结构与占位设计

既然是小白友好,我们直接从可以复制的完整代码开始。先说HTML结构约定。所有需要懒加载的图片,真实地址放在><img class="lazy-img" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 400'%3E%3C/svg%3E" >.lazy-img { width: 100%; height: auto; aspect-ratio: 3 / 2; opacity: 0; transition: opacity 0.3s ease-out; background: #f0f0f0; } .lazy-img.is-loaded { opacity: 1; }

需要说明一下aspect-ratio属性。之前我们已经在HTML里写了width="600" height="400",这里又写aspect-ratio: 3 / 2,会不会重复?其实两者配合是合理的:HTML属性告诉浏览器初始宽高比,CSS里的aspect-ratio保证在不同布局宽度下容器始终按比例占位,height: auto让图片自适应而不会拉伸变形。老项目常用padding-top百分比方案,但CSS原生aspect-ratio出来后,写法更干净,兼容性也足够。

3.3 LazyLoader核心代码与关键细节

下面是我整理过的一个完整懒加载组件,支持原生IntersectionObserver,并对不支持的老浏览器做了自动降级处理。代码里有详细注释,可以直接复制到项目里用。

/** * 可复用的图片懒加载模块 * 用法: * const loader = new LazyLoader(); * document.querySelectorAll('img[data-src]').forEach(img => loader.observe(img)); */ class LazyLoader { constructor(options = {}) { this.options = Object.assign({ root: null, // 观察视口,默认是浏览器视口 rootMargin: '200px 0px', // 提前200px开始加载 threshold: 0, // 图片出现1像素就触发 successClass: 'is-loaded', errorClass: 'is-error', errorPlaceholder: '', // 加载失败时替换的兜底图 useNativeIfPossible: true // 支持原生loading时优先使用原生方案 }, options); this.observer = null; this.pendingImages = []; this.supportNative = false; if (this.options.useNativeIfPossible && 'loading' in HTMLImageElement.prototype) { this.supportNative = true; } else { this._setupObserver(); } } observe(img) { if (!img) return; if (this.supportNative) { // 用原生loading,还是额外加上decoding优化 if (!img.loading) img.loading = 'lazy'; img.decoding = 'async'; return; } if (!this.observer) this._setupObserver(); if (img.dataset.lazyBound) return; img.dataset.lazyBound = 'true'; this.observer.observe(img); this.pendingImages.push(img); } _setupObserver() { if (!('IntersectionObserver' in window)) { // 老浏览器兜底:直接全部加载,避免图片永远不显示 console.warn('当前浏览器不支持IntersectionObserver,所有图片将直接加载'); document.querySelectorAll('img[data-src]').forEach(img => this._loadImage(img)); return; } this.observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (!entry.isIntersecting) return; const img = entry.target; this.observer.unobserve(img); this._loadImage(img); }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); } _loadImage(img) { const realSrc = img.dataset.src; const realSrcset = img.dataset.srcset; if (!realSrc && !realSrcset) return; const successHandler = () => { img.classList.add(this.options.successClass); }; const errorHandler = () => { img.classList.add(this.options.errorClass); if (this.options.errorPlaceholder) { img.src = this.options.errorPlaceholder; } }; if (realSrcset) { img.srcset = realSrcset; img.sizes = img.dataset.sizes || '100vw'; } if (realSrc) { img.src = realSrc; } // 图片可能已经在缓存里,load事件不会再触发 if (img.complete && img.naturalWidth > 0) { successHandler(); } else { img.addEventListener('load', successHandler, { once: true }); img.addEventListener('error', errorHandler, { once: true }); } } // 动态新增的图片需要重新观察 observeNewImages(container) { const images = container.querySelectorAll('img[data-src]'); images.forEach((img) => this.observe(img)); } }

这段代码里有几个细节是踩过坑才加进去的,单独说一下。

unobserve很关键。图片一旦加载完成就要立即解除观察,否则每次滚动都回调一次,浪费性能。我的做法是等isIntersecting为true时先解除观察再加载,不管加载成功还是失败,都不会被重复观察。

img.complete && img.naturalWidth > 0这个判断是很多人会漏的。如果图片已经在浏览器缓存里,并且已经在之前加载过,此时再给img设置相同的src,load事件可能根本不会触发。这时候就需要主动检查一下complete和naturalWidth,命中缓存就直接给图片加上已加载样式,不然图片会一直保持着淡入前的透明状态。

errorPlaceholder的作用是加载失败时的兜底。懒加载图片src被替换后,如果图片请求失败,浏览器默认会显示一个破碎的图片图标,非常难看。给一个备用占位图地址,加载失败时把src换成兜底图,再标记一个is-error类方便排查,体验会好很多。

3.4 参数调优与动态内容扩展

先聊rootMargin。这个参数我调研过很多案例,最终在项目中控制在200px到400px之间。太小了,用户手指滚到一半图片才开始加载,会看到明显的白块或loading区域;太大了,相当于提前一整个屏幕加载,懒加载的请求削减效果就被稀释了。常规页面用200px起步,大图或者页面滚动速度快的场景(比如信息流)建议加大到300px到500px,让图片有充足的加载时间。

再聊threshold。它的含义是“元素可见比例达到多少才触发”,0表示只要有1像素进入视口就触发,0.5表示一半进入视口才触发。对图片懒加载来说,我们想要尽量快的触发,所以一般设0或0.01。设大threshold的场景通常是视频、统计打点这类,比如视频播放到50%才埋点上报,图片场景用不上。

动态内容方面,大部分实际项目都不是静态页面。无限滚动列表、Tab切换、弹窗里的图片都是后插入DOM的,这些新节点不会自动被懒加载组件观察到。解决方式有两种,一是对外暴露observeNewImages(container)方法,插入新节点后手动调用;二是用一个MutationObserver监听容器变化,自动observe新图片。前者可控性更好,推荐为主。

如果你还要处理背景图懒加载,img的loading和data-src方案都帮不上忙,最直接的做法是监听背景容器的视口交叉状态,进入视口后再设置backgroundImage:

const bgObserver = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const el = entry.target; el.style.backgroundImage = `url(${el.dataset.bg})`; bgObserver.unobserve(el); } }); }, { rootMargin: '200px 0px' }); document.querySelectorAll('[data-bg]').forEach((el) => bgObserver.observe(el));

4. 避坑指南:我踩过的懒加载的坑,你没必要再踩一遍

4.1 首屏误懒加载与布局跳动

懒加载最大的坑,不是图片不动,而是首屏图片被“误伤”。

很多人实现data-src替换后,把所有图片统一交给observer,结果首屏hero图也进入观察队列。浏览器执行JavaScript、创建observer、判断交叉状态都需要时间,首屏图片本来应该立即加载,现在非要等多出一段JS执行时间,LCP反而变差了。这个问题的解法很简单:首屏图片不要用懒加载,直接写真实src;懒加载只作用于首屏之外的图片。判断方法也直接:页面加载时,图片位置如果已经在viewport内,就没必要走懒加载。

另一个高频问题就是布局跳动。如果img没有写宽高,也没有aspect-ratio占位,懒加载替换src的瞬间,图片会从0高度变成真实高度,页面高度突然变化,用户往下翻的时候滚动位置会跳,体验十分糟糕。这个问题的修复我在代码里已经演示过了,写宽高属性、加aspect-ratio样式、或者用padding-top百分比占位,三选一,但不要什么都不做。

4.2 SEO与爬虫视角的懒加载兼容

懒加载的JS实现会带来一个隐患:搜索引擎爬虫抓取页面时,不会像普通用户一样滚动页面,如果真实图片地址只存在data-src里,DOM里的src是占位图,爬虫拿到的图片信息是空的,SEO收录会打折扣。

处理这个问题有两种思路。第一种,对需要被搜索收录的关键内容图,尽量用原生loading="lazy"属性,因为当前主流搜索引擎对原生loading属性是有支持能力的,图片真实地址仍然在src里,DOM结构不会被劫持;第二种,如果必须用JS方案做精细控制,那就在图片旁边提供一个noscript兜底:

<img class="lazy-img" src="占位图地址" >const container = document.querySelector('.product-scroll'); new IntersectionObserver(callback, { root: container, rootMargin: '100px 0px' });

4.4 常见问题排查速查表

症状可能原因解决思路
图片一直是占位图,滚动后也不加载没有对所有img调用observe,或JS报错检查控制台报错,确认选择器正确
首屏hero图加载明显变慢hero图被误加入懒加载队列首屏图直接写真实src,不参与懒加载
图片显示出来前页面高度突然跳变img没有宽高占位写width/height属性,或用aspect-ratio
快速滚动时大片图片白屏rootMargin太小、图片没来得及预加载把rootMargin加大到300px以上,对单张图较大时更明显
图片加载失败显示破图图标没有监听error事件加error监听,替换兜底图并标记失败状态
无限滚动新图片不触发懒加载新节点没重新observe新增内容后调用observeNewImages,或用MutationObserver
图片在内部滚动容器里不加载root未设置成滚动容器IO的root指向overflow容器的DOM元素
兼容老浏览器时懒加载失效使用的API不被支持加polyfill或走滚动监听方案
搜索收录不到图片真实src被藏在data-src里关键图用原生loading,或加noscript兜底

5. 怎么验证懒加载真的生效了

5.1 打开DevTools看三个地方

写完代码,最重要的就是验证到底有没有生效。最简单的方式是打开浏览器开发者工具的Network面板,刷新页面,先按图片类型筛选请求,观察页面一打开是否只有视口内的几张图片发起了请求。然后往下滚动,能看到新的图片请求“临时”被加进来,请求时间是滚动之后,而不是页面加载瞬间,这就说明懒加载逻辑在正常工作。

第二个地方是瀑布图。打开Performance面板,录制一次页面加载和滚动过程,在瀑布图里找图片请求的起点时间。优化前的图片请求通常集中在页面加载早期,像一堵墙一样扎堆;优化后能明显看到请求被分散到滚动发生的时间点附近,这是最直观的证据。

第三个地方是Coverage面板,也就是覆盖率面板。它记录了页面资源实际被浏览器使用的比例。对懒加载图片来说,页面刚加载时的图片资源通常没有被真正使用,覆盖率是0%,滚动后覆盖率才逐步增加。这个指标比较适合用来跟同事或老板解释“懒加载到底省了什么”。

5.2 一个实际案例的优化数据对比

有一次我给某个内容站做图片优化,那个页面总共46张配图,其中相当一部分在首屏之外。优化前打开页面,光图片请求就发了29个,总体积大约18MB,LCP是3.8秒,FCP是1.6秒,滚动时明显掉帧。加懒加载之后,首屏只有6张图片要加载,LCP降到1.7秒,FCP降到1.2秒,滚动过程中的图片请求分摊到用户操作后,主线程解码压力小了很多。

这里要说一句,懒加载是请求时序优化,不是图片体积优化。那18MB的总流量还在,只是不再一次性倾泻到首屏。想要进一步提速,还得配合图片压缩、WebP转换、CDN加速、响应式图片这些手段一起上。懒加载负责“减少首屏阻塞”,图片压缩负责“减少总传输量”,两者搭配才是一个完整的图片优化方案。

我在实际项目中的体会是,懒加载是前端性能优化里投入产出比最高的一课,它的核心思路“按需加载、视口优先”还能延伸到无限滚动列表、可视区动画触发、播放器自动播放这些场景中,学会这一套API,后面很多优化都能触类旁通。最后再分享一个小技巧:本地调试懒加载时,最好在Network面板里把网络节流调到Slow 3G,不然在本地强网环境下你很难复现用户真实的卡顿感,很多问题也就暴露不出来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 13:48:00

MATLAB实现airPLS拉曼光谱基线校正:原理、代码与参数指南

简介&#xff1a;MATLAB环境下的拉曼光谱处理代码示例&#xff0c;主要面向化学、生物、材料科学等需要开展光谱数据分析的科研人员&#xff0c;以及希望掌握airPLS算法的MATLAB学习者&#xff1b;该示例针对拉曼光谱中常见的背景干扰与噪声问题&#xff0c;系统地演示了数据导…

作者头像 李华
网站建设 2026/10/20 13:47:36

点选验证码自动化全解析:浏览器自动化框架与打码平台坐标映射实战

点选式验证码这东西&#xff0c;干过网页自动化的人应该都不陌生。它看起来只是让用户对着图片点几个字&#xff0c;背后却是一整套图形语义识别引擎&#xff0c;如果只靠浏览器自动化框架的“找到元素再点击”那一套&#xff0c;根本过不了。这个项目我前后调了两天&#xff0…

作者头像 李华
网站建设 2026/10/20 13:46:57

AI网关之后:可验证安全体系的落地实践与策略即代码

今年我陪着一家制造业客户做AI应用合规自查。他们的平台已经把所有大模型服务都收编到了统一网关后面&#xff0c;API密钥统一托管&#xff0c;路由统一分发&#xff0c;限流配额也做了&#xff0c;按理说"接入"这件事已经收口了。可真正坐下来看安全的时候&#xff…

作者头像 李华
网站建设 2026/10/20 13:46:51

AI视觉形状识别全流程实战:从图像处理到模型部署

说到AI视觉图像形状识别&#xff0c;可能很多人第一反应是高大上的深度学习模型。但真正做过落地项目的人都知道&#xff0c;形状识别这条链路远不止“训练一个模型”那么简单&#xff0c;从数据怎么来、标注怎么做&#xff0c;到特征怎么选、模型怎么训&#xff0c;再到最后怎…

作者头像 李华
网站建设 2026/10/20 13:46:30

AI网关与大模型API直接调用:差距到底在哪

很多人第一次听到 AI 网关这个概念时&#xff0c;反应几乎都是同一个&#xff1a;“我直接调大模型 API 不就行了&#xff1f;加一层东西不是多此一举吗&#xff1f;”我一开始也是这么想的。直到有一次&#xff0c;我自己维护的系统里出现了三个团队抢同一个模型账号的情况&am…

作者头像 李华
网站建设 2026/10/20 13:46:26

量化交易如何识别趋势与震荡?三招划分行情运行区间

做交易的人大概都经历过这个阶段&#xff1a;盯着行情看半天&#xff0c;方向判断没错&#xff0c;可一走进去就被套。等到止损&#xff0c;行情又顺着原方向去了。复盘的时候发现&#xff0c;问题不在进场点&#xff0c;而在于根本没搞清楚当下到底处于什么行情里。行情运行区…

作者头像 李华