
搞前端时间久了你会发现很多项目到最后根本不是败在业务逻辑上而是栽在“资源加载”这一点上。尤其是图片多、脚本杂、登录态要校验、加载顺序还敏感的页面随便一个加载策略没想清楚首屏白屏、图片闪跳、滚动卡顿全来了。我整个“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 的参数不多但每个都有讲究。我列一下核心的配置项参数默认值说明concurrency4最大并发数视网络环境调整timeout15000单资源超时时间毫秒retries2失败重试次数retryInterval1000重试基础间隔毫秒prioritynormalhigh / normal / lowcachetrue是否启用加载中 Promise 复用concurrency为什么默认是 4因为 HTTP/1.1 时代浏览器对同一域名的并发连接数限制就是 6 左右留一点余量4 是一个比较稳的值。如果你们的服务已经上了 HTTP/2可以大胆开到 8 或 10因为 HTTP/2 的多路复用不再受连接数限制并发请求数高一点没关系。priority参数是配合队列调度用的。我举一个实际案例你有一个综合页面上方是轮播图下方是推荐列表的滚动流。轮播图属于首屏必须最先加载滚动流图片数量多但不紧急。如果把两者丢进同一个队列默认按入队顺序执行滚动流的第一张图可能抢在轮播图前面用户就会看到轮播图空白很久。解决办法是把轮播图按priority: high提交让它们优先挤进并发插槽。3.4 高级玩法预加载、预渲染和并发策略除了图片和脚本iloader 还能做预加载冷门操作preload。它利用的是浏览器的link relpreload机制提前把资源下载到内存但不解析不执行真正用到的时候直接从本地缓存取。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 0pxscript 被重复执行没有做 Promise 去重两个组件各自发起了脚本加载打开 cache按 URL 复用加载 Promise并发限制不生效并发数配置得太大或队列里高优先级资源过多检查 concurrency 值调低到 4-6资源一直处于 Pending没有超时机制请求挂起但未触发 load/error开启 timeout设置合理超时时间手机端图片闪白占位图没有固定宽高比例图片加载后撑开布局给 img 设置宽高比属性或 aspect-ratio CSS4.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 带给我的不仅仅是那几百行代码更是一套关于加载问题的思考方式。