首屏压下来之后,另一个“看不见的杀手”会慢慢浮上来:列表页往下滚,图片多得可以把网速拖爆。Caveman这个名字听起来确实很原始,但它在图片懒加载这个领域里,恰恰代表了一种“不搞花活、回到最朴素的加载逻辑”的技术思路。这篇文章就以我手写的一套Caveman风格懒加载项目为例,完整讲讲图片懒加载的底层原理、代码实现、工程化接入方式,以及几个我在真实业务中踩过的坑。内容偏向实战,代码可以直接抄走改造,适合正在做页面性能优化、或在列表渲染场景里被图片请求数量搞到头大的前端开发者。
1. Caveman项目要解决的真实痛点:页面图片加载的失控时刻
先说一个我在接线上活动页时遇到的场景:页面结构其实很简单,一个banner轮播、两排商品卡片、下面是一整屏的信息流列表。但每次点开,页面都感觉卡顿,更关键的是,开发者工具里Network面板的请求瀑布图几乎被图片请求刷屏,首屏的关键图片反而被排到了最后面。
那时候团队还没有统一的图片懒加载方案,活动里每一张图片都是用<img>标签直接写死src。浏览器一解析到图片标签,就会立刻发出请求。你往下滚动时,那些本来用户还没看到、甚至可能永远不会看到的图片,早就在后台把带宽占满了。
1.1 为什么图片会成为页面性能的头号杀手
一张高清商品图的大小,动辄几百KB,如果是轮播图、长图,甚至能到几MB。页面里一旦有几十张这样的图,后果是什么?就是带宽被大量无感知的资源蚕食,真正需要展示的关键图片排在队列后面等着加载。用户看到的是一大片空白、模糊的占位区域,或者干脆白屏几秒。
网页的加载顺序其实是有一个“优先级窗口”的。浏览器会尽量按照资源的阻塞程度来安排下载顺序,但图片本身数量一多,尤其在一个很深的滚动页面里,浏览器没法判断“用户接下来会看哪里”,它只能照着解析顺序一路请求下去。这正是懒加载存在的基本逻辑:用户看到哪儿,图片才加载到哪儿。它不是在优化图片文件本身的大小,而是优化图片被请求的时间点,把一段填得满满的加载曲线,拉平成一条跟着滚动走的平滑曲线。
1.2 Caveman的定位:一个不加框架依赖的轻量懒加载方案
我给手写这套项目起名叫Caveman,思路很简单:像原始人处理工具一样,不追求复杂的架构,只求“最简单的方式解决掉眼前这个生存问题”。放进前端语境里就是——不依赖任何框架、不引入重量级库、不搞复杂的配置体系,用原生JavaScript就把图片懒加载这个事做干净。
它和那些功能大而全的第三方懒加载库的区别在于:那种库往往带了各种边界情况的封装、兼容层,体积从几十KB起步,很多能力你根本用不上。而Caveman这套实现,压缩之后不过几KB,核心逻辑就三件事:
- 把图片的真实地址从
src挪到><img>const rect = img.getBoundingClientRect(); const viewHeight = window.innerHeight || document.documentElement.clientHeight; if (rect.top < viewHeight + threshold && rect.bottom > 0) { // 图片进入了预加载范围 loadImage(img); }这段代码就是懒加载里的“一票判断”。
rect.top小于“视口高度加上提前量”,说明图片顶部已经进入了预加载范围;rect.bottom > 0说明图片的底部还没有完全滚出屏幕上方。两个条件同时满足,才认为这张图值得加载。第三种是现代浏览器推荐的
IntersectionObserver。它相当于把上面这种“自己计算位置”的过程交给浏览器底层去做,通过后台线程观察元素与根元素的交叉情况,性能更好、代码更少。Caveman在实现时也做了兼容支持,这一部分的进阶设计我会在第五节专门展开。用
getBoundingClientRect的好处是浏览器支持范围广、逻辑完全可控;代价是每次滚动、每次窗口尺寸变化,你都要手动遍历所有图片去执行这个判断。所以这里马上涉及到第三层机制:事件绑定的性能问题。2.3 事件绑定、节流与销毁
你不可能让
scroll事件每触发一次,就同步执行几百个图片的判断——滚轮一滚,浏览器光处理事件就卡住了。所以我给Caveman内置了一个throttle(节流)函数,把滚动事件的回调频率限制在每100ms最多执行一次。这个间隔足够保证滚动时判断不滞后,又不会给主线程带来压力。具体实现也很有代表性,下面是一段简化版的节流函数:
function throttle(fn, interval) { let last = 0; return function(...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; }然后把它绑到
scroll和resize事件上:this.check = this.check.bind(this); this.scrollHandler = throttle(this.check, 100); window.addEventListener('scroll', this.scrollHandler, { passive: true }); window.addEventListener('resize', this.scrollHandler, { passive: true });passive: true这个参数值得单独拿出来说。它告诉浏览器:“我这个事件处理函数内部不会调用preventDefault(不会阻止滚动)”,所以浏览器可以放心地不等待事件回调结束就直接执行默认滚动行为。在移动端页面里,如果不加这个参数,滚动监听有时会造成肉眼可见的卡顿和掉帧。事件绑定之后还要养成一个习惯:提供
destroy()方法。因为在单页应用里,页面切换后如果组件被卸载了、事件监听还挂在window上,就会发生经典的内存泄漏。Caveman的destroy()方法在做的事就是解绑事件、清空定时器、移除所有元素上被打的标记。3. 从零实现一个Mini-Caveman:完整代码与配置说明
理论部分聊清楚了,下面直接上代码。这套Mini-Caveman我写了大约八十行核心逻辑,没有依赖任何第三方包,整个类可以直接粘贴进项目使用。
3.1 核心类实现
先搭建一个基础骨架,包括初始化、事件绑定、核心判断、图片加载和销毁五个部分:
class Caveman { constructor(options = {}) { this.options = { selector: 'img[data-src]', threshold: options.threshold || 200, loadingClass: options.loadingClass || 'caveman-loading', loadedClass: options.loadedClass || 'caveman-loaded', container: options.container || window, onLoad: options.onLoad || null, onError: options.onError || null }; this.images = []; this.init(); } init() { this.collectImages(); this.bindEvents(); this.check(); // 初始化时先跑一次,保证首屏图片能立刻加载 } collectImages() { this.images = Array.from(document.querySelectorAll(this.options.selector)); } bindEvents() { this.scrollHandler = this.throttle(() => this.check(), 100); this.resizeHandler = this.throttle(() => this.check(), 100); if (this.options.container === window) { window.addEventListener('scroll', this.scrollHandler, { passive: true }); window.addEventListener('resize', this.resizeHandler, { passive: true }); } else { this.options.container.addEventListener('scroll', this.scrollHandler, { passive: true }); } } throttle(fn, interval) { let last = 0; return function(...args) { const now = Date.now(); if (now - last >= interval) { last = now; fn.apply(this, args); } }; } check() { const viewHeight = window.innerHeight || document.documentElement.clientHeight; const viewWidth = window.innerWidth || document.documentElement.clientWidth; this.images = this.images.filter((img) => { if (!img || !img.parentNode) return false; const rect = img.getBoundingClientRect(); const isInView = rect.top < viewHeight + this.options.threshold && rect.bottom > 0 && rect.left < viewWidth + this.options.threshold && rect.right > 0; if (isInView) { this.loadImage(img); return false; // 加载完就从待检测列表里移除 } return true; // 还没加载,继续留在列表里 }); } loadImage(img) { const src = img.getAttribute('data-src'); if (!src) { img.classList.add(this.options.loadedClass); return; } img.classList.add(this.options.loadingClass); const completeLoad = () => { img.classList.remove(this.options.loadingClass); img.classList.add(this.options.loadedClass); img.removeAttribute('data-src'); if (this.options.onLoad) this.options.onLoad(img); }; const handleError = () => { img.classList.remove(this.options.loadingClass); img.classList.add('caveman-error'); if (this.options.onError) this.options.onError(img); }; if (img.complete && img.naturalWidth > 0) { // 浏览器缓存里已经存在,直接走完成逻辑 completeLoad(); return; } img.onload = completeLoad; img.onerror = handleError; img.src = src; } destroy() { if (this.options.container === window) { window.removeEventListener('scroll', this.scrollHandler); window.removeEventListener('resize', this.resizeHandler); } else { this.options.container.removeEventListener('scroll', this.scrollHandler); } this.images = []; } refresh() { this.collectImages(); this.check(); } }这段代码里有一点特别容易踩:判断逻辑里,不仅判断了图片进入视口,还要判断“图片底部没有滚出屏幕上边缘”。如果你只写了
rect.top < viewHeight这一个条件,那么在快速往下滚动时,已经滚出屏幕顶部、但还没有加载过的图片也会满足条件,然后在用户看不到的地方被白白加载——这本身不致命,但当用户在列表顶部、快速往下滑又往上回来看刚才的内容时,图片可能还没加载完,体验很分裂。3.2 配置项说明
我把配置项整理成一张表,方便按需调整:
配置项 默认值 作用 selectorimg[data-src]选择哪些图片参与懒加载 threshold200提前加载的距离阈值,单位px loadingClasscaveman-loading图片加载中时附加的类名,方便写loading样式 loadedClasscaveman-loaded图片加载完成后附加的类名 containerwindow滚动容器默认为window,滚动容器是某个div时改成对应元素 onLoadnull单张图片加载完成后的回调 onErrornull单张图片加载失败后的回调 实际项目里最常用到的两个配置:
threshold和container。同一套代码,如果嵌入的是内部信息流页面,滚动容器往往不是window,而是一个overflow: auto的div。这种场景下如果不指定container,滚动那个div时永远搜集中不了图片,懒加载会直接“失效”。3.3 实际调用与效果验证
使用非常简单:
const caveman = new Caveman({ threshold: 250, onLoad: (img) => { // 可以在这里做埋点统计:图片实际加载的位置事件 } });调用之后,你可以在DevTools里的Performance面板里看Network请求时间线。一个直观的验证方法是:打开页面后别动,看前两秒发出的图片请求数量,然后开始往下滚动,观察后续请求是否是“随着滚动逐渐追加”的,而不是一次性全部发完。如果是后者,说明懒加载没有生效,大概率是事件监听没绑上,或者图片地址还写在
src而不是>// lazy.js import Caveman from './caveman'; let caveman = null; const lazy = { mounted(el, binding) { // 把图片的真实地址挪到><template> <img v-lazy="product.posterUrl" alt="商品封面"> </template> <script> import lazy from '@/directives/lazy'; export default { directives: { lazy } }; </script>这里有一个在Vue3里容易踩的细节:自定义指令的
mounted时期,图片元素一定已经插入到真实DOM里了吗?答案是肯定的。但如果你把v-lazy用在一个还没有挂载完成的组件内部,比如异步组件还没有渲染完,最好再检查一下el.parentNode是否存在。如果父节点为空,getBoundingClientRect拿到的是一个全零的位置,会把图片误判进可视区域,导致懒加载变成“立刻全部加载”。4.2 React自定义Hook
React里没有指令这种语法,更常规的做法是写一个自定义Hook,返回一个
ref,把需要懒加载的图片节点交给我们来管理。我用一个简单的版本演示逻辑:
import { useEffect, useRef } from 'react'; import Caveman from './caveman'; function useCaveman(threshold = 200) { const containerRef = useRef(null); useEffect(() => { const caveman = new Caveman({ threshold, container: containerRef.current || window }); return () => caveman.destroy(); }, [threshold]); return containerRef; } export default useCaveman;组件的用法:
function ProductList() { const listRef = useCaveman(150); return ( <div ref={listRef}> {products.map((p) => ( <img>useEffect(() => { cavemanRef.current?.refresh(); }, [products]);这个细节经常被忽略,结果是:页面加载时懒加载正常,一重新拉取数据、追加一页内容之后,新图片全部不加载,直接破图。
4.3 封装时需要注意的三个细节
第一个细节是销毁时机。在Vue里如果每个用到
v-lazy的组件unmounted时都去销毁全局唯一的Caveman实例,其他正在运行的页面里的懒加载会全部失效。正确做法是把Caveman实例挂到应用级,保证它在路由切换之后还活着,只有应用真正退出时才销毁。第二个细节是图片缓存问题。当一张图片已经加载完成、下一次数据刷新又复用它时,浏览器缓存会直接命中。此时你如果不做
img.complete判断,会再次把图片扔进加载流程,虽然不会发真实请求,但loading类名会闪一下、图片区域会闪烁。所以Caveman的loadImage里必须加img.complete && img.naturalWidth > 0这个提前分支,命中缓存时直接跳过加载态。第三个细节是列表项复用的场景。移动端长列表经常会用虚拟滚动或组件复用,一个DOM节点可能先展示A商品、滚动后改造成B商品。这种场景下,
>function createObserver(caveman) { const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; caveman.loadImage(img); observer.unobserve(img); // 加载完成后停止观察这张图 } }); }, { rootMargin: '0px 0px 200px 0px', // 相当于threshold,提前200px进入观察窗口 threshold: 0.01 } ); document.querySelectorAll('img[data-src]').forEach((img) => { observer.observe(img); }); return observer; }看到区别没有?
rootMargin直接取代了手写的threshold,observer.unobserve取代了从数组里filter移除的逻辑。代码量少了一半,性能也更好,因为观察任务交给浏览器内核来处理,不再占用你的scroll事件循环。5.2 渐进增强与降级方案
但你不能假设所有用户浏览器都支持
IntersectionObserver。尤其是某些老旧系统内置的WebView、以及一些被严格锁定版本号的安卓机器,可能压根没有这个API。所以Caveman在第二版里做了渐进增强:优先检测浏览器是否支持
IntersectionObserver,支持就走观察者模式,不支持就自动回退到原来的getBoundingClientRect加scroll监听的老路线。if ('IntersectionObserver' in window) { this.observer = createObserver(this); } else { this.bindLegacyScrollEvents(); this.check(); }这部分的判断逻辑一定要在初始化时做一次,不要运行时反复判断,否则会有无谓的开销。同时,回退方案里的
destroy()还需要多处理一步:如果当前用的是Observer模式,需要调用observer.disconnect()来断开所有观察连接。5.3 与原生loading="lazy"的取舍
到这里可能有人会问:那干脆直接用浏览器原生的
<img loading="lazy">不就行了?还要自己写库干什么?这个问题我实际对比过。原生
loading="lazy"是免费午餐,它不需要JavaScript,但是有一个天生的缺陷:自定义能力差。你不能修改提前加载的距离,不能控制图片加载失败的兜底行为,不能拿到“加载完成”的回调时机来做埋点。在业务需要统计图片曝光和加载成功率时,只靠原生方案是做不到的。更关键的是,原生
loading="lazy"在部分浏览器里会默认寄生在图片解码优先级上,有时候明明图片已经进入屏幕了,它还会等一段时间才解码,表现上就是“滚动到位了,图还是糊的”。如果你需要精细控制,势必要关闭原生loading="lazy",用自己的懒加载逻辑顶上。我在Caveman里通常会在初始化时做一次“去重复标记”:把页面上所有带有loading="lazy"的图片属性移除,避免两种懒加载机制同时作用,互相打架。6. 踩坑记录:从开发到上线的完整排错链路
写一个懒加载库的难点从来不是核心代码本身,而是它被放到真实业务里之后,会遇到的各种超出预期的状态。下面这几个坑,我全部在真实项目里踩过,每一个都花过不少时间定位。
6.1 页面底部图片提前加载的问题
上线第一周就有人反馈:还没滚动到页面底部,最下面的图片却已经在Network里发出请求了。排查完之后发现原因很小但很典型:页面整体高度不够高,视口高度加上预加载阈值直接覆盖了整个页面,所有图片都在初始化后被判定为“即将进入视口”,于是全部加载了。
这其实不是bug,是懒加载方案的固有行为。解决办法有两个方向:一是把
threshold调小,二是限定懒加载只作用于那些真正需要懒加载的区域,比如只给首屏以下的内容区图片加>.caveman-loading { position: relative; background: #f5f5f5; } .caveman-loading img { /* 如果是背景图场景 */ }更好的做法是在HTML里直接给图片设定
width和height,或者用CSS的aspect-ratio属性提前锁定宽高比。这样无论图片加载与否,页面占位始终一致,布局就不会跳了。这个问题不解决,懒加载反而会比不懒加载体验更差,因为它把原本“一次性加载完”的布局变化,切碎了分布在每一次滚动里。6.4 弱网环境下加载失败的处理
弱网环境里,图片加载失败是常态。如果没有任何兜底,破图标就会糊在页面上,用户点不进去也看不到内容。Caveman在
loadImage里预留了onError回调,我在业务里做了一层失败重试和占位替换的逻辑:const caveman = new Caveman({ onError: (img) => { // 第一次失败先尝试换CDN域名重试 const fallbackUrl = img.getAttribute('data-fallback'); if (fallbackUrl && img.src !== fallbackUrl) { img.src = fallbackUrl; } else { // 二次失败则显示统一占位图 img.src = DEFAULT_BLANK_IMG; } } });这里要注意一个容易忽略的问题:
img.onerror在给一张已经加载失败过的图片重新赋值同一个src时,不会触发第二次加载。所以重试时必须换成全新的URL,比如给原图地址后面拼接一个时间戳参数,强制浏览器当成新请求处理。错误处理里还应记录日志。每次图片加载失败时,把图片所在位置、URL信息上报到监控平台,这对于判断CDN可用性和图片处理参数是否正确非常有用。
7. 上线后的数据表现与个人体会
这套Caveman方案上线后,我盯着数据面板看了一整周。页面初始图片请求数量从之前的一口气发出全部请求,锐减到只剩首屏那几张;整页所有图片加载完成的耗时不降反增,但用户实际感知到的“首屏可交互时间”明显提前了。这就是懒加载的核心价值:它不是让你把图片加载得更快,而是让你优先加载用户当前关心的部分,把后面的压力分摊到用户浏览的过程中去。
有一个数据特别说明问题:启用懒加载前,信息流页面的LCP(最大内容绘制)大概在2.8秒左右;启用之后,去掉首屏图的懒加载、只对下方的信息流内容做延迟加载,LCP降到了1.9秒以内。原因很简单,浏览器不再把队列排到一堆屏幕外的图片上,首屏主图所需的带宽和请求优先级都被释放出来了。
从维护角度讲,这套方案最大的收益反而不是性能指标,而是代码的确定性。它不像某些重型库那样有版本升级风险,也没有框架依赖,本质上就是一套原生JavaScript工具。今天做的是一个活动页,明天搬到一个后台系统,基本不用改核心代码,只调整配置,套个壳就能用。
如果让我给正在犹豫要不要动手做图片懒加载的人一个建议:不要贪多求全。懒加载解决的是请求时机问题,它解决不了图片本身太大、太冗余的问题。懒加载和图片压缩、响应式切图这些手段并不冲突,而是上下游关系:压缩让单张图片更快,懒加载让无感知图片不拖后腿。把这两层都做好,页面的图片体验才算是真正立住了。