资讯动态

极简懒加载库caveman:图片按需加载的原理与实战

发布时间:2026/10/14 18:36:51 来源:尧图企业网站定制
如果你维护过一个图片特别多、滚动列表长得离谱的站点一定体验过那种抓狂感页面刚打开几十张高清图就排着队涌向服务器流量哗哗地跑首屏却迟迟出不来用户滚两下就关掉了。我处理这个问题的方式很朴素——给图片装一个“穴居人”机制只有真正需要进食的时候才出门打猎。这个机制对应到前端就是懒加载。我给自己写的这个极简懒加载库起名就叫caveman名字直译是穴居人正好诠释它的性格不急着加载任何图只在图片即将进入视口时动手。这篇文章会把 caveman 从立项、原理、实现到线上踩坑的完整过程分享出来适合被页面性能困扰、又不想被笨重框架绑架的前端开发者参考也适合想彻底弄清懒加载底层逻辑的朋友。1. 它解决什么问题图片加载的“原始”方案1.1 懒加载到底在“懒”什么浏览器默认行为是只要 HTML 里出现一个img标签并且它有src属性浏览器就会立刻开始下载这张图片不管它是不是在屏幕外面。这个“立刻”的代价在长列表页面里非常夸张。你想想一个电商分类页用户可能只浏览前两屏但页面底部还有 60 张商品图。如果全部加载用户滑到一半流量已经浪费了几十张图的额度如果是移动端用户可能直接放弃页面白白消耗带宽和电池。懒加载的逻辑其实很简单把真正的图片地址从src挪到>const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { // 元素进入视口 console.log(element visible:, entry.target); observer.unobserve(entry.target); // 只看一次之后就移除 } }); }, { root: null, // 相对视口 rootMargin: 200px, // 提前 200px 加载 threshold: 0.1 // 交叉 10% 时触发 }); const lazyImages document.querySelectorAll(.js-lazy); lazyImages.forEach((img) observer.observe(img));这里有几个关键点rootMargin很实用——它可以让图片在“即将进入”视口之前就提前加载。200px 意味着元素距离视口边界还有 200px 时回调就已经触发。这样图片有充足的下载时间用户滚到时已经显示完成。threshold默认是 0表示元素只要露出 1px 就算可见。设成 0.1 则要求至少露出 10%。懒加载一般用 0 或者 0.01 就够了因为我们关心的是“开始加载”而不是“完全展示”。unobserve很重要。加载完就解除观察避免重复触发性能也更干净。IntersectionObserver 是异步回调不会阻塞主线程所以性能上比监听滚动事件要稳得多。2.2 滚动监听加 getBoundingClientRect老一辈的做法在 IntersectionObserver 普及之前懒加载的标准做法是监听scroll、resize、orientationchange然后在事件回调里遍历所有待加载图片用getBoundingClientRect()取元素相对视口的位置判断是否小于某个阈值。核心逻辑大概长这样function checkImages() { images.forEach((img) { const rect img.getBoundingClientRect(); const viewportHeight window.innerHeight || document.documentElement.clientHeight; if (rect.top viewportHeight 200 rect.bottom -200) { img.src img.dataset.src; removeFromList(img); } }); } window.addEventListener(scroll, throttle(checkImages, 100)); window.addEventListener(resize, throttle(checkImages, 100)); checkImages(); // 初始检查这个方案有两个天生短板scroll事件触发频率非常高虽然可以用throttle或requestAnimationFrame优化但只要页面还在滚回调就在不断执行。图片数量一旦多起来每次遍历所有图片做布局计算会强制触发浏览器回流reflow滚动卡顿难以避免。需要开发者自己维护“元素是否可见”的状态还要考虑resize、图片加载后移除监听等一系列细节。代码量不大但边界情况很多。2.3 两条路线怎么选兼容性、性能和实现成本对比对比维度IntersectionObserverscroll getBoundingClientRect性能异步回调主线程开销极低高频事件 强制布局容易卡顿实现成本低几十行代码搞定中需要计算位置、节流、初始化兼容性现代浏览器都支持旧 IE 不支持几乎所有浏览器都支持动态元素直接 observe 新元素需要手动加入数组并重新遍历触发提前量通过rootMargin控制通过自定义阈值控制结论很明确优先使用IntersectionObserver同时做能力检测如果不支持再降级到滚动监听方案。caveman 内部也是这么设计的——用一小段if (IntersectionObserver in window)判断然后走两条分支之一。这样既保证性能又保留兼容底线。3. 实现一个迷你 caveman核心代码与 API 设计3.1 先约定好 HTML 结构使用任何懒加载库HTML 结构约定都是第一步。caveman 的约定很简单img classjs-lazy srcdata:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw >class Caveman { constructor(options {}) { this.options Object.assign({ selector: .js-lazy, root: null, rootMargin: 200px 0px, threshold: 0.01, loadedClass: is-loaded, onLoad: null }, options); this.images []; this.observer null; this.scrollHandler null; this.init(); } init() { this.images Array.from(document.querySelectorAll(this.options.selector)); if (this.images.length 0) return; if (IntersectionObserver in window) { this.observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { this.loadImage(entry.target); this.observer.unobserve(entry.target); } }); }, { root: this.options.root, rootMargin: this.options.rootMargin, threshold: this.options.threshold }); this.images.forEach((img) this.observer.observe(img)); } else { // 降级方案滚动监听 this.scrollHandler this.checkImages.bind(this); window.addEventListener(scroll, this.scrollHandler, { passive: true }); window.addEventListener(resize, this.scrollHandler, { passive: true }); this.checkImages(); } } checkImages() { const viewportHeight window.innerHeight || document.documentElement.clientHeight; const margin parseInt(this.options.rootMargin, 10) || 200; this.images this.images.filter((img) { const rect img.getBoundingClientRect(); if (rect.top viewportHeight margin rect.bottom -margin) { this.loadImage(img); return false; // 从数组中移除 } return true; }); if (this.images.length 0 this.scrollHandler) { window.removeEventListener(scroll, this.scrollHandler); window.removeEventListener(resize, this.scrollHandler); } } loadImage(img) { if (img.dataset.src) { img.src img.dataset.src; } if (img.dataset.srcset) { img.srcset img.dataset.srcset; } const complete () { img.classList.add(this.options.loadedClass); if (typeof this.options.onLoad function) { this.options.onLoad(img); } img.removeEventListener(load, complete); }; if (img.complete) { complete(); } else { img.addEventListener(load, complete); } } }这段代码比很多我见到过的工业级懒加载库都简单但核心能力都在。逐块拆解一下构造函数里的Object.assign提供了默认配置与用户配置的合并。这个模式很常见但不要用嵌套对象直接合并否则会覆盖内部属性。loadImage中同时处理>const mutationObserver new MutationObserver((mutations) { mutations.forEach((mutation) { mutation.addedNodes.forEach((node) { if (node.nodeType ! 1) return; const lazyImages node.querySelectorAll ? node.querySelectorAll(selector) : []; lazyImages.forEach((img) this.observer.observe(img)); if (node.matches node.matches(selector)) { this.observer.observe(node); } }); }); }); mutationObserver.observe(document.body, { childList: true, subtree: true });这里有个小坑MutationObserver回调返回的新增节点可能是一个容器比如一个div它本身不是图片但里面包含图片。所以既要检查node.matches也要在node.querySelectorAll的结果里继续找。两个分支都要处理。4. 实测中的性能收益数据与对比4.1 我搭建的测试场景为了验证 caveman 的实际效果我搭了一个模拟电商列表页面。测试条件如下页面共 80 张图片每张约 100KBJPG 格式尺寸为 800x600。模拟 4G 网络环境下载速度约 1.6Mbps往返延迟 150ms。使用桌面端 Chrome 浏览器的 Performance 面板记录数据。对比同一页面未启用懒加载 vs 启用 caveman 懒加载。记录指标包括首屏网络传输量、总传输量、页面加载耗时、LCP、CLS。4.2 懒加载前后的关键指标我连续跑了 5 轮测试取中间值结果如下指标未启用懒加载启用 caveman变化首屏网络传输量8.0MB1.4MB下降 82.5%总传输量滚动到底8.0MB8.0MB无变化总传输量只滚到第30张8.0MB3.1MB下降 61.3%页面加载完成耗时4.8s1.9s缩短 60.4%LCP3.2s1.8s缩短 43.8%CLS0.180.05明显改善需要解释一下如果用户最终滚动到底部所有图片都会被加载总传输量当然不会减少。懒加载省的从来不是“最终流量”而是首屏加载速度和“用户尚未看到的内容所浪费的流量”。这也是为什么我在第 1 节强调长列表页面的收益最夸张——大多数用户根本不会滚到底。4.3 数据背后的原因拆解为什么启用懒加载后页面加载耗时能从 4.8s 降到 1.9s关键在于浏览器不再同时发起几十个图片请求而是只加载首屏可见的十几张图。网络队列不至于被全部占满HTML、CSS、JS 这些关键资源能更早到达。请求数从 80 降到 15 左右网络拥塞现象大幅缓解。HTTP/1.1 时代的连接数限制是 6 个就算是 HTTP/2同一时间请求数太多也会产生队头阻塞和带宽竞争。把请求分散到“用户即将看到的时候”浏览器就有余力处理更重要的资源。CLS 从 0.18 降到 0.05 则是因为我给每张图都设置了width和height并且通过 CSS 的aspect-ratio保持了宽高比。占位图不会再让图片区域塌陷。4.4 对 LCP 和 CLS 的联动影响很多人担心懒加载会损害 LCP但从我的测试结果来看如果“首屏最大的那张图不懒加载”LCP 反而会优化。原因很简单无懒加载时首屏图和其他几十张屏幕外的图争抢带宽到达时间被拖慢有懒加载时首屏图独占带宽加载更快。CLS 同理。懒加载本身不直接解决 CLS但它给了我们一个机会在图片真正加载前让占位区域保持稳定的尺寸。如果不设置尺寸懒加载反而会放大 CLS——占位图加载前区域塌陷真实图加载后高度撑开用户正在看的位置就会跳一下。所以给图片设置宽高比是实现懒加载的必要配套而不是可选项。5. 避坑记录我在线上环境踩过的几个坑5.1 占位图导致图片区域塌陷第一个坑发生在上线后没多久。运营反馈说某个商品详情页的图片区域“一卡一卡的”滚动体验很差。我打开页面一看图片从底部加载出来的时候整个布局在跳。排查链路是这样的先确认是不是懒加载触发太晚。我在 Chrome DevTools 里把网络切换成 Fast 3G滚动页面发现图片确实是快进入视口时才触发但加载完成后图片区域顶部的元素被向下挤开偏移量很大。检查 DOM发现图片没有width和height属性CSS 里也没有aspect-ratio。占位图是 1x1 的 GIF真实图是 800x600两者固有尺寸差距极大。图片加载完成瞬间高度从 1px 跳到 600px这就是 CLS 的直接来源。修复方案给图片补上width800 height600同时加一条 CSS.js-lazy { aspect-ratio: 800 / 600; width: 100%; height: auto; }现代浏览器会根据宽高属性预计算图片的占位比例。至此加载前后区域高度完全一致CLS 归零。5.2 页面滚动太快图片加载前一闪而过第二个问题用户快速滑动长列表时有些图片会出现“先占位、后加载”的闪烁甚至用户已经滑过去了图片还在慢慢加载。这里是完整排查过程我一开始怀疑是threshold设得太高把 0.01 改成 0无效。然后怀疑是 IntersectionObserver 的默认行为问题。实际上不是。真正原因是rootMargin太小。fast scroll 场景下图片进入 200px 范围才开始下载但用户滑动速度可能每帧移动 100px 以上200px 的缓冲量根本不够。图片还没下载完用户已经看到了占位图。解决方式是增大rootMargin我最终调到400px 0px。代价是更早的请求数略增但换来的是用户感知不到加载过程。还有一个细节如果页面里有些图片特别大可以在 HTML 上单独加>img classjs-lazy srcdata:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw >img src/images/hero.jpg fetchpriorityhigh alt主视觉而页面底部那些非关键图片继续放心交给 caveman。这里有个判断标准如果一张图占了视口面积的一半以上或者它在你心里是“用户打开页面最先看到的那张图”它就不该懒加载。懒加载是给“后面”的图片用的不是给“门口”的客人用的。6.4 后续还能怎么扩展caveman 目前的版本只做了最基础的事但它有能力继续扩展。我接下来想做的几个方向支持>

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑