
去年下半年我接手了一个图片特别多的移动端项目首屏体重直接被十几张高清大图干到了 3 兆以上线上 LCP 一度飘红到 3.8 秒。一开始想偷懒直接引入社区里那个经典的懒加载库结果文档越看越头大API 绕来绕去还捆绑了一套主题系统。最后实在忍不了抽了一个周末自己写了一个图片加载器取名就叫 iloader。核心就做三件事懒加载、按需尺寸、多级缓存。本来只是内部自用的小工具没想到后面两个项目也在用干脆把思路和代码沉淀成本文希望能帮到同样被图片性能折磨的朋友。1.1 为什么要单独写一个图片加载器先说说痛点。普通 img 标签在移动端有多坑做前端的应该都有体感。第一是带宽和流量浪费。用户可能只是扫一眼首屏结果浏览器把所有图片全都请求了尤其是列表页下面的图根本没人看。按现在一张压缩图片 100KB 算一个滚动页面七八张图首屏就白白浪费掉 500KB 以上。第二是布局抖动。图片没设置宽高比的时候图片加载完成前后页面高度会跳用户正在读文章内容突然往下掉一截阅读位置直接丢了。这个体验问题叫 CLS Cumulative Layout Shift 是 Core Web Vitals 里的重要指标掉了这个分SEO 排名都会受影响。第三是主线程阻塞。现在高清图动辄 4000px 宽解码一张大图要占不少 CPU 时间。如果一次性解码一堆图滚动页面的时候你会明显感觉到卡顿尤其是中低端安卓机。现有的成熟方案里lazysizes 功能和兼容性都好但是默认配置偏重想精细控制尺寸和缓存顺手的工具不多后来又试过 vue-lazyload绑定框架太死换个项目基本没法复用。我的诉求其实很简单一个原生 JS 实现、零依赖、支持自定义尺寸规则和缓存策略的加载器。于是就有了 iloader。1.2 技术选型与整体架构技术选型上我给自己定了三条红线不依赖任何框架哪个项目都能直接用不引入运行时重依赖整个核心文件压缩后不超过 6KB优先使用浏览器原生能力再考虑降级方案。所以最终方案如下观察进入视口优先用 IntersectionObserver浏览器不支持时降级为 scroll getBoundingClientRect 节流监听图片解码延时用 requestIdleCallback 在浏览器空闲时解码防止阻塞主线程缓存存储内存 Map 做一级缓存localStorage 做二级缓存HTTP 缓存继续兜底响应式尺寸根据设备像素比和元素尺寸动态计算请求的图片宽度。整个架构我分成四层调度层负责 IntersectionObserver 的管理、任务队列和并发控制缓存层统一封装内存缓存和本地存储提供 get/set/delete 接口解码渲染层负责创建 Image 对象、控制解码时机、回填 DOM兜底层处理加载失败、超时重试、格式降级。这样分层的好处是各层之间依赖边界清晰后面想扩展预加载队列或 Service Worker 配合只需要在调度层加接口不用动底层实现。2. 核心功能拆解与实现思路2.1 懒加载触发机制懒加载的原理不复杂就是先不设置 img 的真实地址把地址放在>if (IntersectionObserver in window) { this.observer new IntersectionObserver(callback, options); } else { this.useScrollFallback true; // 节流监听 scroll/resize用 getBoundingClientRect 判断位置 }降级方案的性能略差但老设备上没有别的更好选择节流间隔我控制在 100ms 左右实测对滚动帧率影响不大。2.2 多尺寸自适应与紧凑占位图图片加载最浪费的情况就是把一张 4000px 宽的原图直接拉到 375px 宽的屏幕上显示。带宽浪费十倍不止。常见方案是原生 srcset sizes但语法复杂很多后端接口也没法配合生成那么多断点。iloader 的做法是在加载前先根据 img 元素的当前渲染宽度和 devicePixelRatio 计算实际需要的图片宽度然后拼进图片 CDN 的裁剪参数里。比如一张图片地址是https://cdn.example.com/images/123.jpgCDN 支持按宽度裁剪那么加载器会把地址改写成https://cdn.example.com/images/123.jpg?w750。这个思路的前提是你的图片服务支持这种裁剪参数或者后端至少要做一次格式化和压缩处理。如果后端完全不支持iloader 也提供了接口让后端按约定的参数格式处理。关于占位图我用的是 LQIP 方案也就是低质量图片占位。做法是在服务端生成一张极小的模糊图宽 32px通过 base64 直接内联到 HTML 里。原始图片加载成功后再把模糊图的 src 替换掉。这样有两个好处先渲染出轮廓避免了完全空白区域的出现同时布局空间提前被占住CLS 指标自然就保住了。2.3 三级缓存策略缓存的思路我是从浏览器 HTTP 缓存机制里借的。iloader 实现了三级缓存第一级内存 Map。同一个页面生命周期内已经加载过的图片不再重复请求。第二级localStorage。进退页面后再次进入时直接从本地读取。第三级交给浏览器 HTTP 缓存让 CDN 自己判断是否命中缓存。这三个层级分别对应不同使用场景。内存缓存最快但页面关了就没localStorage 能跨会话但容量有限我控制上限一般在 5MB 左右超过就按 LRU 策略清理只保留最近使用的图片数据HTTP 缓则是最终兜底即使前两个都没有命中至少请求能走 304 或强缓存。实际上线后我统计过三次访问以上的用户img 请求几乎全部命中缓存CDN 的图片带宽消耗降了 55%。二级缓存存储的是图片数据 URLbase64还是缓存的远程 URL这个选择我纠结过。存 base64 的好处是离线也能展示但 localStorage 存 5MB 的 base64 数据再大的图片就存不下了。最终我的策略是小于 300KB 的图存 base64大于这个体积的只存一个标记告诉加载器“这张图曾经加载过”下次直接走 CDN 的 HTTP 缓存。2.4 加载进度、错误处理与降级图片加载的兜底策略一开始我没当回事直到线上出现了一次事故 CDN 回源出问题某个时段图片全部返回 500用户看到了一堆裂开的图标。从那以后我认真做了三件事每个图片请求最多重试 2 次间隔时间按指数退避1 秒、2 秒重试最终失败后替换为一张内置的错误占位图并且给 img 元素加上错误样式如果重试成功就把成功结果写回缓存避免下次再经历一遍。加载进度这块主要是针对首屏大图场景。页面首屏会有一个整体加载进度事件由调度层统计所有首屏图片的完成状态全部加载完后触发iloader:done自定义事件业务方可以根据这个事件隐藏 loading 动画。3. 关键代码实现与参数调优3.1 配置文件与初始化iloader 提供了比较丰富的配置项用下面这段代码就能完成初始化const loader new ILoader({ // 根容器默认是视口 root: null, // 提前加载距离 rootMargin: 200px 0px, // 是否启用懒加载 lazy: true, // 是否启用内存缓存 memoryCache: true, // 是否启用 localStorage 缓存 storageCache: true, // 存储缓存的上限字节 storageMax: 5 * 1024 * 1024, // 并发加载的最大图片数 concurrent: 6, // 图片宽度计算的回调可以自己控制 CDN 参数 buildImageUrl: (src, width, dpr) { const sep src.includes(?) ? : ?; return ${src}${sep}w${Math.floor(width * dpr)}; }, // 加载失败的回调 onError: (el, src, error) { el.src DEFAULT_ERROR_PLACEHOLDER; } });init 方法负责扫描页面中所有带>loader.init(document.querySelectorAll(img[data-iloader]));这里有个细节值得提一下为什么用>class Scheduler { constructor(limit) { this.limit limit; this.queue []; this.running 0; } add(task) { this.queue.push(task); this.next(); } next() { while (this.running this.limit this.queue.length) { const task this.queue.shift(); this.running; task().finally(() { this.running--; this.next(); }); } } }这个调度器只有 20 行代码但解决了一个大问题所有并发图片请求都被纳入了可控范围。实际使用中我会把 concurrent 设置为 6跟浏览器默认连接数保持一致。设置了 HTTP/2 的站点并发可以稍微调大一点比如 8~10因为 HTTP/2 的并发请求不受同域连接数限制但这要视浏览器兼容情况而定。还有一个优先级问题首屏图片应该比滚动区域以下的图片优先加载。我在队列里加了一个简单的高优通道首屏图片直接插入到队列头部unshift而不是尾部。如果后面要做更精细的优先级控制可以改成最大堆的方式但目前的场景用 unshift 就够了。3.3 缓存淘汰与内存保护localStorage 的存储空间就那么多不加控制地往里塞早晚会撑爆。浏览器在 localStorage 写入超过配额时会直接抛异常而且没法捕获恢复所以必须在写入前主动做容量控制。我的思路是先把图片数据按字节数统计然后维护一个 LRU 列表存储时记录最后访问时间。当总字节数超过 storageMax 时从最久未访问的数据开始清理直到总字节数降到阈值的 80%。LRU 淘汰策略的原理不复杂就是假设“最近访问过的数据以后还会访问”所以优先删除最久没有访问过的条目。内存缓存同样要控制体积我用一个 Map 存放同时记录每个条目的大小。如果页面是一次长列表滚动内存缓存可能积攒 50 张以上的图片数据每张 200KB就接近 10MB 了。这个量在移动端已经很高了所以我给内存缓存设了一个 8MB 上限超出时同样按 LRU 清理。关于浏览器本地存储还有一个坑localStorage 的同步读写本身也会阻塞主线程。如果一个循环里写了大量数据页面会卡顿。我在 set 操作里加了一个批量写入的缓冲机制把多次 set 合并成一次 flush在 requestIdleCallback 里执行实测对滚动的卡顿缓解非常明显。4. 踩坑记录与问题排查实录4.1 IntersectionObserver 不触发的场景线上跑了一段时间后有用户反馈某些页面图片加载不出来。排查发现这些页面都有共同特征图片容器是 display:none 的轮播图或者如果是弹窗里的内容。IntersectionObserver 对 display:none 元素不触发回调因为不可见的元素不会被观察。很多开发者只看文档不知道这个细节。解决办法是对于弹窗、轮播这类的异步渲染节点首次变为可见时手动触发一次 observer.observe(el)或者用rootMargin无法判断时直接调用加载方法加载图片。另一个容易踩的坑是图片本身没有设置 width 和 height。IntersectionObserver 判断的是元素可见性但如果图片宽高都是 0即使进入了视口也不会触发加载。这也是为什么我在初始化时会先读一下 getBoundingClientRect确保元素有实际尺寸后再进行观察。4.2 缓存版本没更新用户永远看到旧图上线后遇到另一个问题后端把某张图片内容更新了CDN 地址没变但是用户端还是显示旧图。原因很简单localStorage 的缓存判断优先于 HTTP 请求根本没有向服务器发起新的验证请求。这个问题本质上是缓存失效策略没做好。后来我在缓存 key 的生成规则里加入了资源版本标记const CACHE_VERSION 2024-11-01; function buildCacheKey(src) { return ${CACHE_VERSION}:${src}; }版本号用构建时间注入后端图片内容有全量更新或 CDN 刷新时只需改一下版本号即可让所有本地缓存失效这些缓存就会重新回源获取。对于单张图片的单独更新更优雅的处理是后端给新地址或替换 CDN 地址参数让 key 自然变化。4.3 大图解码阻塞主线程移动端图片加载完尤其是 2000px 以上宽度的高清图解码期间会阻塞主线程 60ms 以上。在滚动页面时直接表现为“页面忽然卡一下”。后来我查资料发现 WICG 提供了img.decode()这个方法它可以在后台异步解码不阻塞主线程。改造之后逻辑变成先创建 Image 对象加载数据 → decode() 解码 → 解码完成后把图片地址赋给实际 img 元素。const img new Image(); img.src realUrl; img.decode().then(() { el.src realUrl; }).catch(() { el.src realUrl; // decode 失败也直接处理 });这个改动对低端安卓机的滚动流畅度提升特别明显。需要注意的是 decode() 在一些旧内核里不支持catch 里兜底直接赋值避免功能失效。4.4 Chrome 的并发连接限制与回退HTTP/1.1 下浏览器对同一域名的并发连接数限制是 6HTTP/2 则允许很高的并发。但是如果后端服务器或 CDN 只支持 HTTP/1.1把 concurrent 调高反而会使请求排队更严重整体加载时间可能变慢。我在处理这个问题时提供了一个运行时的探测逻辑const isHttp2 performance.getEntriesByType(resource) .some(entry entry.nextHopProtocol h2); loader.setConcurrent(isHttp2 ? 10 : 6);通过 Resource Timing API 判断当前站点的协议再动态调整并发数。如果拿不到数据就默认回退到 6保证任何环境都不至于把连接打挂。4.5 常见问题速查表问题可能原因解决方案图片完全不加载IntersectionObserver 未触发检查元素是否 display:none 或无宽高必要时手动触发加载加载后图片模糊DPR 计算异常确认 buildImageUrl 中 width * dpr 是否正常viewport meta 是否正确第二次访问还是慢缓存 key 没有正确命中检查 localstorage 是否被清理key 规则简单化移动端滚动卡顿解码阻塞主线程使用 decode() 异步解码并控制并发量图片加载失败跨域/CSP 拦截检查图片请求是否带 crossorigin 属性CSP 中 img-src 是否放行5. 实测效果与可以继续扩展的方向iloader 在三个真实项目里跑了快半年线上数据对比效果还是很有说服力的。以那个移动端内容站为例首屏图片流量从 2.8MB 降到了 900KB 左右首屏加载时间缩少了 1.2 秒LCP 从 3.8 秒降到了 2.1 秒相同图片资源下的 CLS 基本归零因为所有图片都提前占好了位置。二次访问直接命中的比例大约是 67%也就是近七成的用户不再重新下载图片。实际数据里最让我意外的是 storage 缓存的命中率比内存缓存高不少。原因很好解释大多数用户不会连续点击页面关掉页面再回来看才是常见行为。内存缓存只对单页内的重复展示有作用而 storage 缓存在会话之间都生效。所以如果你的项目以 H5 活动页为主用户反复进出的场景多storage 缓存的收益会比普通内容站大得多。项目还有几个可以继续扩展的方向。一个是配合 Service Worker 做离线预取在空闲时间里把下一屏的图片缓存到 CacheStorage实现真正的离线可浏览。另一个是把 buildImageUrl 改成通过响应式 Web 平台 API如 Client Hints自动协商尺寸减少 JS 层的换算逻辑。还有一块是我已经着手在做但还没上线的就是在构建阶段增加一个 Vite 插件把图片的尺寸信息直接注入到 data 属性免去加载器运行时额外计算。这样首屏不用等元素布局完成后才知道需要多大的图加载时机能再提前一点。最后再说一个我在实际使用中体会到的重要细节图片加载器这类跟性能挂钩的工具一定要先在弱网条件下做测试。开发者工具里的网络节流功能很好用但还是建议找一台真实的低端安卓机器用 3G 网络跑一遍主要页面。很多缓存策略、并发数、解码时的边界问题在高配开发机上根本看不出来到了用户手里才会放大。