搞前端时间久了,你会发现很多项目到最后根本不是败在业务逻辑上,而是栽在“资源加载”这一点上。尤其是图片多、脚本杂、登录态要校验、加载顺序还敏感的页面,随便一个加载策略没想清楚,首屏白屏、图片闪跳、滚动卡顿全来了。我整个“iloader”就是在这样的背景下从需求里冒出来的——它不是一个框架,也不是一个非要你用不可的重型 SDK,而是一个把“加载”这件事做到极致顺手的小工具库。
一开始我只是想解决图片懒加载的问题,后来做着做着,发现脚本加载、并发控制、缓存去重、失败重试这些活儿其实都能抽象到同一个模型里。于是干脆把它收拾成一个独立的 loader,起名 iloader。这个名字看起来很随意,i 可以是 image,也可以理解成 intelligent,反正核心就一个思路:把资源加载变成一种可控制、可观察、可复用的基础能力。
如果你也在写或者准备写一个偏重前端资源的项目,或者你只是想找一个比原生new Image()更踏实的图片加载方案,这篇文章值得你花十分钟看完。我会把我在设计 iloader 时踩过的坑、想通的关键点和最终的落地代码,全部摊开来讲。
1. 内容整体设计与思路拆解
1.1 为什么不做成“一个懒加载插件”
市面上懒加载插件太多了,像 Lozad、vanilla-lazyload 这类库都做得不错,但它们的边界很清晰:只管图片和 iframe,一旦你需要“加载一段脚本再执行某个回调”“并行加载多张图片且控制最大并发数”“同一个资源多个地方同时需要、只请求一次”,这些库就够不着了。
iloader 最初的定位,不是“又一个懒加载器”,而是“前端资源加载的统一入口”。无论你加载的是图片、脚本、JSON 接口,还是需要 preload 的静态资源,都走同一套 API。它的内部只有几个核心模型:资源队列、加载器集合、缓存池、并发调度器。
这样做的好处很实际:项目里资源加载的策略是统一沉淀的,而不是每个页面自己写一套。后面接手的同事不用纠结“这个页面的图片为什么没有占位”“那个脚本为什么重复执行了”,因为所有行为都在 iloader 这里收敛了。
1.2 需求拆解与功能取舍背后的逻辑
我列一下自己在第一版里的需求清单,每一条都不是拍脑袋定的,都是真实业务里被逼出来的:
- 图片懒加载:页面滚动到可视区再加载,避免一次性发上百个请求。
- 脚本顺序加载:比如先加载基础库,再加载业务脚本,且不能重复执行。
- 失败重试:网络抖动时,资源加载失败不能直接放弃,要有重试次数和退避策略。
- 并发限制:批量预加载时,同一时刻最多只发 N 个请求,不能把带宽打满。
- 缓存去重:两个组件同时依赖同一个脚本,不能重复插
<script>标签。 - 状态可订阅:加载完成、失败、进度这些状态要能被外部拿到,才好做 UI 反馈。
这条清单里,前四条是刚需,后两条是体验和工程化层面的优化。如果你做个简单页面,原生方法就够了;但项目一复杂,你一定会需要后面两条——因为重复请求不光浪费流量,还会导致脚本被重复执行,状态污染的问题特别隐蔽。
1.3 方案选型:为什么我不用现成库而选择自研
先坦白,我在动手之前确实调研过 loadjs、script.js、preloadjs 这些库。loadjs 在脚本管理上做得确实好,preloadjs 在资源进度控制上也有一套。但问题在于,两个库的 API 风格不同,混在一个项目里,团队学习成本是双份的;而且它们对“图片懒加载”和“接口数据预加载”的支持要么没有,要么很弱。
自研也不是什么了不起的事,因为需求本身并不复杂,核心是几个 Promise 包的调度即可。自己写,最大的好处是可控:每个细节都知道怎么改,出了问题知道去哪调。对于一个会被全项目依赖的基础模块,这种“可控感”比“省事”更重要。iloader 就这样被我定位成一个轻量、零依赖、浏览器和 Node 环境都能跑的工具。
2. 核心细节解析与实操要点
2.1 IntersectionObserver 的懒加载原理
懒加载最常用的方案就是 IntersectionObserver,它比监听 scroll 事件然后算 offsetTop 性能好得多,因为浏览器的观察回调是异步触发的,不阻塞主线程。
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }, { rootMargin: '100px 0px', threshold: 0.01 }); document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));这里有两个容易被忽略的点。第一,rootMargin一定要设,它相当于给可视区加了一个“预加载缓冲区”,用户还没滚动到图片位置,图片就开始加载了,体验上会顺滑很多。我一般设 100 到 200 像素,再大就有点浪费流量了。第二,threshold不要用 1,因为图片只有一小部分进入可视区时,就应该触发加载,用 1 的话要等整个图片完全出现才加载,滚动快一点就会看到明显的占位闪烁。
iloader 内部对 IntersectionObserver 的封装,主要加了一层兼容处理:如果浏览器不支持,就直接回退到加载全部资源。移动端 WebView 里有些老内核没有这个 API,这一层回退在实战里很重要。
2.2 并发控制与优先级调度的原理
并发控制的本质,是一个简单的异步队列。我维护一个任务数组和一个正在执行的数量计数,每次从任务数组头部取出未开始的任务去执行,直到达到最大并发数;每个任务结束,再从队列里补一个进来。
class TaskQueue { constructor(limit) { this.limit = limit; this.queue = []; this.running = 0; } add(task) { this.queue.push(task); this.run(); } run() { while (this.running < this.limit && this.queue.length) { const task = this.queue.shift(); this.running += 1; Promise.resolve(task()) .finally(() => { this.running -= 1; this.run(); }); } } }这段代码最核心的巧妙之处在.finally()里——不管任务是成功还是失败,running计数都要减一,否则一个失败任务会把整个队列卡死。这个坑我踩过,早期版本只在.then里减,结果某个图片 404 之后,后面的所有请求全部不走了。
优先级调度比单纯并发队列复杂一层。我采用的办法是给每个任务加一个priority字段,入队时按优先级插入到对应位置。实现上不需要真的排序,因为队列通常很短,按需插入的性能完全够用。比如首屏关键图片的优先级设为高,非首屏的图片设为低,高优先级资源会被先抢占并发配额。
2.3 缓存与去重的工程实现
缓存去重要看场景。图片资源我默认不做强缓存,因为浏览器的 HTTP 缓存本身就够用;重点要去重的是脚本资源和同一 URL 的加载 Promise。
核心做法是:用 URL 作为 key,把“正在加载中的 Promise”存到一个 Map 里。第二次请求同一个 URL 时,不重新创建加载任务,直接返回之前那个 Promise。
const pendingCache = new Map(); const LOADING = 'loading'; function loadScriptWithCache(src, options) { if (pendingCache.has(src)) { return pendingCache.get(src); } const promise = createLoadScriptTask(src, options); pendingCache.set(src, promise); promise.finally(() => pendingCache.delete(src)); return promise; }这里有个细节:finally里删除 Map 的 key,而不是保证缓存永久有效。这样设计的好处是,只有加载完成之后,下次再请求才会重新走完整加载流程;而在加载过程中有多个请求进来,它们会共享同一个加载 Promise,天然做到了防重复。
2.4 错误处理、超时与重试策略的经验值
加载资源最讨厌的就是“挂起”。图片迟迟不触发onload,也不触发onerror,用户端的表现就是一张空白图。解决这个问题必须引入超时机制。我在 iloader 里给每个任务默认设了 15 秒超时,使用 Promise.race 实现。
function withTimeout(promise, timeout = 15000) { return Promise.race([ promise, new Promise((_, reject) => { setTimeout(() => reject(new Error('load timeout')), timeout); }) ]); }超时之后怎么办?自动重试。重试次数我默认给 2 次,重试间隔用简单的指数退避:第一次失败后等 1 秒,第二次失败后等 2 秒,之后直接放弃。对于图片这种资源,重试 2 次已经足够了,再多反而会因为积压任务导致队列拥堵。脚本加载建议不要重试超过 1 次,因为脚本加载失败往往意味着页面逻辑已经处于半残状态,更适合直接走错误上报。
3. 实操过程与核心环节实现
3.1 基本用法:一个最小可用的例子
iloader 的 API 设计参考了现代前端习惯,全部用 Promise。安装方式很简单,npm install iloader或者直接引一个 ESM 文件,看你自己项目环境。
import { loader } from 'iloader'; // 加载一张图片 loader.image('https://cdn.example.com/hero.jpg') .then(() => console.log('图片加载完成')) .catch((err) => console.error('图片加载失败', err));单张图片加载是最基础的用法,底层逻辑就是创建一个Image对象,赋值src,监听onload和onerror,然后返回 Promise。这里有一个容易出错的小细节:一定要在设置src之前把onload和onerror挂上,否则遇到缓存中已存在的图片,load事件可能在代码挂载事件之前就触发了,你会得到一个“永远 pending”的 Promise。
3.2 批量预加载和动态脚本加载
批量加载是大多数场景的重点,比如文章详情页,用户可以马上读到正文,但下一篇文章的图片可以先在后台加载好,等用户点过去的时候直接秒开。
loader.add([ 'https://cdn.example.com/next-article-1.jpg', 'https://cdn.example.com/next-article-2.jpg', 'https://cdn.example.com/next-article-3.jpg', ], { concurrency: 3 }).then((results) => { console.log('全部加载完成', results); });动态脚本加载是另一个高频需求。比如项目里要做 AB 实验,需要动态加载某个 SDK,传统做法是手动创建一个 script 标签,然后等onload。用 iloader 写起来更标准:
loader.script('https://cdn.example.com/sdk.min.js') .then(() => { // 确保 SDK 已经挂到 window 上了 window.SDK.init(); }) .catch((err) => { // 上报错误,降级处理 });脚本加载的细节比图片多得多。比如有些脚本会在 load 之后立刻执行,如果同时出现两个 script 请求,虽然浏览器会并行下载,但执行顺序不保证。要想保证顺序,需要把第二个脚本的加载放进第一个脚本的.then里,或者用 iloader 的inOrder模式,内部会自动串行化。
3.3 API 参数详解:这些配置是踩坑换来的
iloader 的参数不多,但每个都有讲究。我列一下核心的配置项:
| 参数 | 默认值 | 说明 |
|---|---|---|
concurrency | 4 | 最大并发数,视网络环境调整 |
timeout | 15000 | 单资源超时时间,毫秒 |
retries | 2 | 失败重试次数 |
retryInterval | 1000 | 重试基础间隔,毫秒 |
priority | 'normal' | high / normal / low |
cache | true | 是否启用加载中 Promise 复用 |
concurrency为什么默认是 4?因为 HTTP/1.1 时代浏览器对同一域名的并发连接数限制就是 6 左右,留一点余量,4 是一个比较稳的值。如果你们的服务已经上了 HTTP/2,可以大胆开到 8 或 10,因为 HTTP/2 的多路复用不再受连接数限制,并发请求数高一点没关系。
priority参数是配合队列调度用的。我举一个实际案例:你有一个综合页面,上方是轮播图,下方是推荐列表的滚动流。轮播图属于首屏,必须最先加载;滚动流图片数量多但不紧急。如果把两者丢进同一个队列,默认按入队顺序执行,滚动流的第一张图可能抢在轮播图前面,用户就会看到轮播图空白很久。解决办法是把轮播图按priority: 'high'提交,让它们优先挤进并发插槽。
3.4 高级玩法:预加载、预渲染和并发策略
除了图片和脚本,iloader 还能做预加载冷门操作:preload。它利用的是浏览器的<link rel="preload">机制,提前把资源下载到内存,但不解析不执行,真正用到的时候直接从本地缓存取。
loader.preload('https://cdn.example.com/font.woff2');字体文件这种资源特别适合 preload,因为字体往往在页面加载一半时才被 CSS 发现,这时候才去下载,就会导致 FOIT(无样式文本闪烁)。提前 preload 就能有效规避。
另外,iloader 有一个比较“高级”的用法是我在实际项目中摸索出来的:合并多个操作,实现“并行 + 顺序”的组合。
// 先并行加载两个基础库,再串行加载业务脚本 const baseLibs = await loader.add([ 'https://cdn.example.com/lib-a.js', 'https://cdn.example.com/lib-b.js' ], { concurrency: 2 }); await loader.script('https://cdn.example.com/business-a.js'); await loader.script('https://cdn.example.com/business-b.js');这种模式用代码的书写顺序就直观表达了依赖关系,比在配置对象里声明依赖要容易维护得多。你可以把它理解成:并发优先级交给并发控制器,代码依赖顺序显式写在调用链上。两件事拆开,心智负担小很多。
4. 常见问题与排查技巧实录
4.1 图片加载失败却不报错的真凶
我调试中遇到的第一个诡异问题是:有些图片在用户手机上加载不出来,但浏览器控制台里没有任何报错。后来排查发现,这些图全部是 HTTP 协议,而页面被切到了 HTTPS 协议,浏览器默认就拦截了混合内容的请求,且多数浏览器不会在控制台里抛出一个显眼的错误。
解决办法是在加载逻辑里检查一下协议,发现 http: 的资源就自动替换成 https: ,或者干脆在项目入口强制把所有静态资源协议改成与页面一致。这种问题不常见,但遇到一次会非常折磨人,因为根本不知道去哪查。
4.2 重复请求总清不掉的缓存陷阱
还有一个让我印象很深的问题:服务端返回的图片响应里带了Cache-Control: no-store,导致用户滑到相同图片时,iloader 虽然从自己的缓存池拿 Promise 返回,但图片重新发起请求,走不了浏览器 HTTP 缓存。结果同一张图片被反复下载。
这个问题暴露出一个设计盲区:应用层缓存和 HTTP 缓存是两层,互不替代。后来我在 iloader 里增加了一个memoryCache配置,开启后图片加载成功后会在内存里存一份<img>对象或者 base64 数据,下次直接跳过网络请求。代价是内存占用会上升,所以默认关闭,只在一些特殊场景下开:比如用户频繁滑动切换的产品详情图。
4.3 一个简单实用的错误上报链路
加载失败不能只靠控制台看。iloader 的每个任务失败后都会带一个结构化的 error 对象,包含阶段(stage)、URL、尝试次数和耗时。你可以在全局捕获这些信息做上报。
loader.config({ onError: (error) => { monitor.report({ type: 'resource_load_error', url: error.url, stage: error.stage, retries: error.attempts, cost: error.cost }); } });这段代码我实际部署之后发现,报错最多的不是 404,而是 timeout。进一步分析才发现,是某几个地区用户的网络对特定 CDN 节点握手延迟很高,15 秒超时不够用。于是我把超时策略改成了“前一次 15 秒,重试时按 1.5 倍递增”,问题上报数量立刻降了一大截。这类数据如果没有上报统计,单靠经验很难定位。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 图片 lazy 加载后仍一片空白 | 没有设置 rootMargin,图片出现在可视区边缘时才触发,视觉上有延迟 | 适当加大 rootMargin,如200px 0px |
| script 被重复执行 | 没有做 Promise 去重,两个组件各自发起了脚本加载 | 打开 cache,按 URL 复用加载 Promise |
| 并发限制不生效 | 并发数配置得太大,或队列里高优先级资源过多 | 检查 concurrency 值,调低到 4-6 |
| 资源一直处于 Pending | 没有超时机制,请求挂起但未触发 load/error | 开启 timeout,设置合理超时时间 |
| 手机端图片闪白 | 占位图没有固定宽高比例,图片加载后撑开布局 | 给 img 设置宽高比属性或 aspect-ratio CSS |
4.5 独家技防:面对加载大象流,如何稳住主线程
加载量非常大的时候,比如一个信息流页面一次要处理几百张图片和几十段脚本,iloader 的并发队列本身是稳的,但真正卡住主线程的往往不是网络,而是图片解码和脚本执行。有一次我用性能面板分析,发现一个页面的主线程被“图片解码”任务塞满了 500ms,甚至影响了滚动。
这个锅不能甩给 loader,但 loader 可以通过调度策略帮忙缓解。我的做法是在批量加载图片时,把decoding设置为 async,让浏览器延迟图片解码,不在主线程上抢时间。
const img = new Image(); img.decoding = 'async'; img.src = url;另外,我还给大尺寸图片设置了一个加载优先级阈值:像素面积大于一定大小的图片,禁止进入preload队列,只允许懒加载。因为大图预加载对带宽消耗极大,但用户不一定看得到,白白浪费资源。
5. 适配不同场景时的配置思路
5.1 移动端弱网环境怎么调优
弱网下最重要的不是加载得快,而是加载得稳。我建议把 concurrency 调低到 2 或 3,避免多个大图同时下载把窄带宽打满;超时时间可以适当调短,比如 10 秒,快速失败,尽早重试;重试次数可以适当调高,比如 3 到 4 次,因为弱网下瞬时波动比较多,重试成功的机会反而大。
另外可以在配置里开启低带宽模式,让图片的懒加载 rootMargin 调小到 0 或者负数。意思是,只有图片真正出现在可视区的时候才加载,绝不提前预加载。这在流量敏感的移动端产品里非常重要,能省下不少流量成本。
5.2 桌面端富媒体页面怎么调优
桌面端带宽通常宽裕,策略可以反过来。图片多但每个都比较小,可以把并发数调到 8 到 10,充分利用 HTTP/2 的多路复用;对首屏之外的图片可以做 200px 的预加载缓冲,让浏览体验更顺滑;脚本资源尽量用 preload 提前下载,因为桌面端网络条件好,preload 几乎不会造成瓶颈。
富媒体页还有一个特点:动画和视频多。iloader 虽然不直接处理视频,但你可以用loader.add去预加载视频的 poster 图,提前把封面和动画依赖的静态资源拉取到位。
5.3 服务端环境下的静态资源预加载
iloader 不只是浏览器里能用,在 Node 环境里也可以用来做构建期预取或爬虫资源探测。因为它的底层网络请求封装是分离的,浏览器环境下用 Image 和 script 标签,Node 环境下自动切换成 Node 的 HTTP 请求模块。
我做过一个工具:在构建脚本里用 iloader 去批量检查 CDN 上的静态资源是否全部可达,如果某个资源返回 404 或超时,就把对应的 URL 和耗时写入一个 JSON 文件,供前端在发布前处理。这个工具跑一次,比我后来写任何页面逻辑都更省心,因为它把整个 CDN 的可用性检查自动化了。
最后再分享一个小技巧
如果你打算在自己的项目里也做一个类似的 loader,或者正在用 iloader,我想说一个最值得参考的设计原则:把“加载”作为一等对象,而不是散落各处的函数。一旦你有了 loader 这个概念,你就拥有了控制加载流程、观察加载状态、治理加载问题的统一入口。所有的资源在你的系统里不再是一个孤立的 URL 字符串,而是一个可以被排队、取消、重复使用、监控状态的任务单元。
我自己在之后做微前端项目时,也复用了 iloader 的队列调度思路,把它扩展到了应用加载的层面——每个子应用也是一个“资源”,只不过这个资源包含 JS、CSS、生命周期回调,本质上和加载一张图片没有区别。从这个角度来看,iloader 带给我的,不仅仅是那几百行代码,更是一套关于加载问题的思考方式。