ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

图片太多拖垮首屏?手写轻量级懒加载调度器 iloader

图片太多拖垮首屏?手写轻量级懒加载调度器 iloader 年初接了一个聚合页项目前端同事交接的时候反复强调“图片有点多”我当时没太当回事。等把页面跑起来才发现问题不只是“有点多”——首屏十几个模块每个模块三到五张高清大图商品图、活动图、内容封面混在一起页面还没滚动完就已经发出去上百个图片请求。结果很典型首屏白屏时间被拖到快四秒流量在弱网环境下直接失控运营那边三天两头反馈“页面打不开”“图片裂了”。我当时的第一个念头是找现成的懒加载库但试了一圈之后发现需求稍微复杂一点——按优先级加载、失败自动重试、同时要限制并发数——现成方案就变得很别扭。要么功能太重要么只能处理img标签对背景图、picture、预加载这些场景几乎不管。折腾了两天我决定自己写一个这就是 iloader 的来历。iloader 是一个轻量、零依赖的 JavaScript 资源加载器核心解决三件事懒加载触发、加载调度、缓存与重试。它不仅管img还能管 CSS 背景图、picture源、普通图片 URL甚至脚本和样式表。这篇文章就把这个工具从设计到落地、从踩坑到实测的完整过程整理出来包括核心实现思路和能直接抄作业的代码片段。1. 为什么会有 iloader一个聚合页引发的血案先说背景。那个聚合页的 DOM 结构大概是这样的首屏十几个内容块每个块里有一张主图、两组缩略图加上背景图和运营配置的活动图。如果直接在window.onload之后全部加载移动端 4G 网络下的体验就是灾难。我在 Chrome DevTools 里做了个简单模拟Fast 3G 下整页图片总大小 8.7MB光是 TCP 连接就要排队几十个部分请求直接进入 stalled 状态。原生loadinglazy属性我在早期版本里试过确实省事但有两个问题说服了我放弃。1.1 原生懒加载的局限第一控制粒度太粗。原生懒加载只有“进入视口就开始加载”这一个行为没办法表达“首屏上面的图优先级高于下面的图”“轮播图里的图必须提前预加载”这类业务需求。尤其是电商聚合页轮播图如果等滚动到那个位置才加载用户早就滑走了。第二适用范围太窄。loadinglazy只对img和iframe生效。CSS 背景图要想懒加载还是得靠 JavaScript 自己管picture标签里的srcset源原生行为也比较呆。再加上我这边还要处理一批由前端异步渲染的图片地址能够在数据到达后再决定“立刻加载”还是“延迟加载”这已经超出原生方案的能力范围了。1.2 我对 iloader 的设计目标写这个工具之前我把需求收敛成四条硬性标准轻量压缩后不超过 4KB不依赖任何第三方库方便在任何前端脚手架里直接引入。统一入口无论是img、CSS 背景图还是 URL 字符串都用同一个方法触发加载内部自动分流。可调度支持设置最大并发数支持优先级同一个 URL 的重复请求必须合并成一次。失败可恢复网络一抖就失败的资源必须有退避重试机制而不是直接放弃。当时我写了一条很原始的需求备忘“用户感知到的加载完成时间比实际所有资源加载完成时间更重要。”这句话后来变成了 iloader 的调度原则——页面可以慢慢加载完但用户最先看到的那部分必须最快出现。2. 设计取舍API 简化与加载优先级怎么平衡工具的第一版我写得非常“工程师”——暴露了一堆配置项queue、concurrency、retry、timeout全部要调用方自己传结果同事们用起来很痛苦配置 20 行代码才加载一张图。后来我反思了一下这个工具的调用者不只是我还有不怎么关心加载细节的业务开发。他们需要的是“默认能用特殊场景再调参数”。于是 API 被压成了三个核心方法2.1 三个对外方法load / preload / bind第一个是load传入资源地址和可选的配置对象返回 Promise。这个方法是主力几乎所有场景都在用它。import { iloader } from ./iloader; // 加载一张图片等真正显示在视口里才发请求 iloader.load(https://example.com/assets/cover.jpg, { priority: high, timeout: 10000, }).then((url) { img.src url; }).catch((err) { // 这里可以放占位图逻辑 });第二个是preload用于预加载那些“现在不显示但马上会用到”的资源。比如轮播图的第二张、第三张或者用户 hover 某个卡片之后几乎必然要打开的详情图。// 轮播图里当前索引 2 以内的图片在首屏加载完成后预加载 carouselImages.forEach((url, index) { if (index currentIndex 2) { iloader.preload(url, { priority: low }); } });第三个是bind直接把页面里所有带>img>// 扫描并接管所有>iloader.load(https://example.com/assets/small.png, { priority: (ctx) { // 当前队列里如果已经有不少 high 任务小图就自动降级 return ctx.highTaskCount 4 ? low : normal; }, });这个能力看起来很细但实际业务里特别有用。尤其是瀑布流页面上面的卡片加载慢就会挡住下面的卡片通过这种动态优先级可以让“体积小、快加载完”的资源先占用通道尽量避免大图卡住整个队列。2.3 内存缓存与自定义缓存iloader 内部默认维护一个Mapkey 是资源地址value 是加载状态。同一个 URL 同时被 10 处引用只会真正发一次网络请求。这个去重机制很关键聚合页里运营经常在多个位置配置同一张图片没有去重的话用户就被白白扣了很多流量。默认缓存只保存在内存里页面刷新就没了。针对 Long Term 缓存的需求我预留了一个cacheAdapter接口允许调用方自己接 IndexedDB 或者 localStorageiloader.setCacheAdapter({ get: async (url) { const cached await idb.get(images, url); return cached ? cached.blob : null; }, set: async (url, blob) { await idb.put(images, { url, blob }); }, });接入缓存适配器之后iloader 会优先尝试命中缓存命中就直接 resolve不用走网络。这个设计在移动端 H5 里效果很明显二次进入页面的图片秒开。3. 核心实现拆解懒加载触发到资源落地的完整链路前面讲了设计现在讲实现。iloader 的加载流程可以拆成四段元素可见性判定、任务入队与调度、实际加载执行、结果回调。这一段我会把关键代码和当时的思考过程一起写出来。3.1 基于 IntersectionObserver 的可见性判定懒加载的第一步是判定“元素是否进入了视口”。现代浏览器首选的方案是IntersectionObserver它不会在主线程里频繁执行getBoundingClientRect而是由浏览器原生回调触发事件性能开销小得多。基本的用法是这样const observer new IntersectionObserver((entries) { for (const entry of entries) { if (entry.isIntersecting) { const el entry.target; observer.unobserve(el); // 触发实际加载 iloader.load(el.dataset.src).then((url) { el.src url; }); } } }, { rootMargin: 100px 0px, });rootMargin这个参数值得单独拿出来说。它表示“视口边缘的扩展区域”设置为100px 0px意味着元素进入视口上下边缘 100 像素范围内就开始加载。这样做的目的是提前加载让用户的滚动操作和图片完成渲染之间的间隙被填平。我在项目里实际测过rootMargin设置 50px 到 100px 之间体感最好再大的话一次性触发太多远端图片弱网下反而会拖慢首屏。浏览器不兼容IntersectionObserver的时候iloader 会降级到滚动监听加getBoundingClientRect的实现但这个降级版本我只保留了最基本的视口判断不再叠加优先级逻辑因为老设备的性能经不起太复杂的判定循环。3.2 调度器的设计把并发数管住浏览器对同一域名下的并发连接数是有限制的HTTP/1.1 时代是 6 个左右HTTP/2 虽然可以多路复用但移动端弱网环境下无限制并发仍然是灾难。所以 iloader 内部维护了一个任务队列同一时刻最多执行maxConcurrent个任务。class Scheduler { constructor(maxConcurrent) { this.max maxConcurrent; this.running 0; this.queue []; } add(task) { this.queue.push(task); this.queue.sort((a, b) b.priority - a.priority); this.tick(); } tick() { while (this.running this.max this.queue.length 0) { const task this.queue.shift(); this.running 1; task.execute() .finally(() { this.running - 1; this.tick(); }); } } }这段代码就是调度器的主干。核心逻辑在tick里只要还有空闲位就从队列头部取出优先级最高的任务执行某个任务结束之后立刻递归调用tick让下一个任务补位。这个模型非常简单但它真正可用的关键在于priority的比较——任务对象里的priority必须实时更新否则前面说的“动态优先级”策略就不起作用。另一个细节是不要让网络请求直接堆在队列里。实际开发中很多懒加载库的队列里存的直接是fetch或者Image的加载操作。这样一旦任务出队就立刻发出网络请求并没有一个“可取消”的中间状态。iloader 的做法是在入队时接到一个“取消令牌”如果任务还在排队中就被解绑了那它出队时直接丢弃不产生真实请求。3.3 重试机制指数退避而不是立刻重来移动端环境的网络抖动是常态尤其在地铁、电梯里请求失败会频繁发生。第一版我写的是“失败就立刻重试一次”结果在弱网下反而加剧了拥塞——失败、重试、又失败、又重试最后资源和时间全浪费了。后来换成了指数退避策略第一次失败后等 500ms第二次失败等 1000ms第三次等 2000ms最多重试 5 次。期间如果用户主动断开了这个加载比如元素从视口里滚走了就直接取消重试不再挣扎。async function fetchWithRetry(url, { maxRetry 5, baseDelay 500 } {}) { for (let attempt 0; attempt maxRetry; attempt 1) { try { const response await fetch(url); if (!response.ok) throw new Error(HTTP ${response.status}); return response; } catch (err) { if (attempt maxRetry) throw err; const delay baseDelay * 2 ** attempt; await sleep(delay); } } }baseDelay 500这个初始值不是随便拍的它是从实际观测里得出来的弱网下一次小型图片请求的失败恢复时间通常在两三百毫秒左右500ms 起步既能避开瞬时抖动又不会让用户等太久。3.4 请求去重与中止去重逻辑简单说就是同一时间同一个 URL 只允许一个真实网络请求存在于系统中。iloader 内部用状态机管理每个 URL 的状态pending、loading、loaded、failed。如果load被调用时发现 URL 状态为loading就直接挂一个 Promise 到该 URL 的等待列表里当真实请求结束时等待列表里的所有 Promise 一并 resolve 或 reject。这个机制听上去简单但有一个隐藏的坑如果某个 URL 第一次加载失败了状态变成failed后面再来一个load请求不能直接复用失败的 Promise。必须允许重新发起请求。所以状态机里failed是“终态但不冻结”每次新的调用都会生成全新的 Promise。这一步在代码里可以这样表达const urlStates new Map(); function load(url) { const state urlStates.get(url); if (state state.status loading) { return state.promise; } const promise doActualLoad(url); urlStates.set(url, { status: loading, promise }); // doActualLoad 内部自己处理结果并更新状态 }取消方面load返回的 Promise 还挂了一个cancel方法本质上就是实际请求的AbortControllerconst controller new AbortController(); fetch(url, { signal: controller.signal }); // 外部调用 cancel 时执行 controller.abort()移动端页面切换、组件卸载时大量不再需要的图片请求如果还挂在网上会白白消耗带宽。我在 bind 方法返回的解绑函数里会对所有由该模块发起的 pending 任务执行 cancel保证切换页面时请求能及时中断。4. 实测与翻车记录性能数据和三个隐蔽问题工具写完只是第一步真正有价值的是上线后的数据。我在那个聚合页项目里对 iloader 做了完整接入并用 DevTools 的快照功能和真实的 4G 弱网节流跑了几轮对比。结果如下指标改造前改造后iloader首屏图片请求数首屏模块2311整页图片总请求数完全滚动142142首屏可交互时间Fast 3G4.2s2.6s无效加载用户没看到就滚动过去的图多明显减少重复 URL 请求数180总请求数没有变少这个数据是符合预期的。iloader 做的事情不是不加载而是延迟加载调度加载真正滚动到的地方该加载还是会加载。但首屏的体验提升非常明显2.6 秒对 4.2 秒优化幅度接近 40%。不过上线过程并不是一帆风顺的。下面三个问题每一个都让当时的我抓狂了一段时间。4.1 背景图懒加载差点把样式搞崩第一个版本发布后有同事反馈“背景图区域显示为纯色检查元素发现背景图请求根本没发”。跟踪下来发现是 CSS 背景图懒加载的实现方式有问题。背景图的常规懒加载写法是先让元素不带背景图等元素即将进入视口时再动态加上background-image。问题是很多业务会把这个样式写在元素的class里iloader 扫描的时候元素已经有背景图了于是跳过后续永远不会进入“待加载”队列。我的解决办法是在bind扫描元素时先读取元素当前的背景图地址存到data里再主动清掉内联的background-image样式等元素进入视口时恢复。注意这里必须操作内联样式因为要覆盖掉样式表里的规则内联优先级最高。function normalizeBackground(el) { const style getComputedStyle(el); const bg style.backgroundImage; if (bg bg ! none) { const url extractUrl(bg); el.dataset.bgSrc url; el.style.backgroundImage none; } }4.2 低端机的内存峰值问题图片懒加载有一个反直觉的现象加载完成之后图片占用的内存并不会因为滚动离开视口而释放。尤其是那种百来张高清图的资讯流页面低端 Android 机上页面滑到底之后内存直接飙到 300MB部分机器开始掉帧甚至被系统杀掉。iloader 后来增加了一个releaseOnLeave选项当元素滚出视口超过一定时间默认 5 秒并且没有处于动画或用户交互状态时自动把图片的src替换为一张 1x1 透明图并尝试通过revokeObjectURL或src 释放资源。这个功能默认是关闭的因为它可能引起滚动回看时图片闪烁但对低端机场景开启后内存峰值能降 40% 左右。具体是否开启建议结合业务页面里图片的可回看频率来权衡。4.3 iframe 的视口判定边缘场景项目里有一部分内容是 iframe 嵌入的活动页用bind托管 iframe 的src懒加载时发现一个边缘场景intersectionRatio 在 iframe 的边界重合时偶尔会一直是 0。原因是 iframe 元素本身有默认尺寸 300x150当它刚好在视口边缘时Observe 回调把isIntersecting判定成了 false结果 iframe 一直不加载。这个问题的修复其实很蠢在判定 isIntersecting 之外额外加了一次边界值检查——只要元素的boundingClientRect.top window.innerHeight且rect.bottom 0就认为它已经进入可加载范围。也就是用“矩形相交”逻辑兜底不完全依赖 observer 的判定。function isInViewport(rect) { return rect.top window.innerHeight rect.bottom 0; }这类问题在开发环境几乎不可能出现因为开发时浏览器窗口够大、位置也居中只有在真机小屏幕上用户悬浮窗半遮挡、横竖屏切换瞬间才会暴露出这类边缘问题。而且一旦出现表现又特别像僵尸 bug——刷新几次又好过一会儿又出现非常难排查。5. 从图片到通用资源iloader 的边界延展iloader 一开始是冲着图片去的但写的过程中我意识到图片加载和其他网络资源加载的本质是一样的异步、需缓存、要调度。所以实现时我把“加载动作”抽象成了插件式的小函数图片只是其中一种。后面顺手扩展了几个场景也都验证可行。5.1 脚本和样式表按需加载聚合页里有些模块用到第三方图表库首屏根本用不到但要是直接在head里加载所有用户的流量都要为这个库买单。用 iloader 把脚本包一层思路和图片加载完全一样。function loadScript(src) { return iloader.load(src, { type: script, priority: low }) .then(() { // 动态插 script 标签 }); }动态插入脚本最需要注意的是重复加载。同一份公共库多个模块都可能触发加载iloader 的去重机制在这里天然适用。实际写下来这个场景几乎没有增加额外代码只是多了一个type分支。5.2 数据分片加载还有一个我后来补的用法是接口 JSON 的分片预取。内容型页面的详情数据往往比较大用户点击详情按钮之后才请求体感会慢一拍。改进办法是在当前页面阅读过程中用空闲时间悄悄把下一个详情页的 JSON 预取到缓存里。用户点击的瞬间数据已经在本地了。这个场景的本质是用空闲带宽换交互速度。iloader 的优先级默认为low确保它不会和首屏图片抢带宽。我在一个资讯类小程序页面里试过详情页 70% 的情况下能实现“点击即渲染”几乎不需要 loading 状态。5.3 自定义加载器万物皆可 load如果连 JSON 和脚本都不够用iloader 还留了一个后门——registerLoader。调用方可以注册自定义的加载器函数处理任何类型的资源iloader.registerLoader(json, async (url, config) { const resp await fetch(url, { headers: config.headers }); return resp.json(); });注册之后直接调用iloader.load(url, { provider: json })就能复用整套优先级、去重和重试机制。这种设计的价值在于业务方不需要重新为“预加载一个新类型资源”造一套轮子。写在最后的一点个人体会iloader 从最初只是为了解决一个页面的图片问题最后长成了一个还算通用的小工具这个转变是我没预料到的。回过头看最值钱的经验不是用了什么新技术而是“把调度逻辑从业务里剥离出来”这个思路。业务只需要表达“我要什么资源、多急着要”至于怎么排队、怎么失败重试、怎么缓存都交给底层去处理。如果你也想在自己的项目里实现一个类似的工具我的建议是**先不要追求大而全先把并发控制和去重做扎实。**这两个机制解决的是最痛的问题。有了它们后面加什么特性都不难没有它们功能再多也只是表面热闹。最后分享一个小技巧。接入懒加载以后记得在bind的扫描过程中做一次性能打点performance.mark(iloader-scan-start)和performance.mark(iloader-scan-end)并上报扫描耗时。DOM 数量比较多的时候扫描本身也可能出现几百毫秒的卡顿。这个指标如果偏大就要考虑把大列表改成虚拟滚动而不是继续堆懒加载。工具再好用也抵不过一个展开几万个 DOM 节点的页面。
返回列表