手写运行时资源加载器:调度、依赖与并发控制实战
2026/9/14 19:18:05 网站建设 项目流程

iloader 这个名字听起来有点普通,但它是我这两年维护最久的一个自研工具。它不是一个造轮子的玩具,而是一个真正跑在生产环境里的运行时资源加载器——负责管理页面中所有需要按需加载的图片、脚本、样式和接口数据。最初需求很简单:页面上的素材太多,全部提前加载会卡顿,全部懒加载又会在用户交互时出现白屏和闪烁。我需要一个东西,能像流水线的调度员一样,控制资源“什么时候开始加载、先加载谁、同时允许几个请求在跑、加载失败怎么办”。于是就有了 iloader。如果你也遇到过这类资源加载顺序、并发、依赖和缓存的问题,这篇文章应该能给你一份可以直接抄的作业。

1. 为什么自己动手写 iloader:现有加载方案到底哪里不够用

1.1 加载器这个名词背后,通常包含哪些需求

很多人听到“加载器”三个字,第一反应是 webpack 里那种编译期的 loader。但 iloader 走的完全是另一条路:它工作在运行时,处理的是浏览器里动态加载资源这件事。任务也不像“把 CSS 转成 JS”那么底层,而是要管住这类问题——用户点开一个功能模块时,模块里的脚本、样式、几张大图、两个接口的数据,能不能按正确的顺序、正确的时机、正确的数量出现在页面上。

如果只是加载一张图片,那一段new Image().src = url就够了。但真实业务里资源之间是有依赖关系的:某个组件脚本依赖某个基础库先执行完,某块区域要等数据接口返回后才能展示,进入页面首屏时只能有少量网络请求,否则会把业务接口的带宽抢光。这些需求叠加在一起,就不再是“能不能加载”的简单问题,而是“怎么加载才能又快又稳”的工程问题。iloader 要解决的正是后者,它本质是一个带调度能力的资源加载框架,而不是单一加载函数。

1.2 现成加载库与原生写法的三大痛点

在写 iloader 之前,我并不是没用过现成的库,也不是没试过“业务里直接写 Promise + 计数器”的野路子。但实际跑下来,有三个痛点很难绕开。

第一个痛点是通用库和业务耦合太深。网上能找到的加载库,大多绑定在特定框架或者特定构建工具上,换个项目就要重新适配。而业务里的加载逻辑又常常散落在各个组件的useEffect或者onMounted里,想全局调整并发数、做统一缓存,根本找不到一个入口。

第二个痛点是依赖关系表达太弱。原生写法里要表达“先 A 后 B”,最常见的是把await loadA()写在loadB()前面。一旦资源数量超过五六个,这种嵌套就变成了 callback hell 的翻版。更麻烦的是如果 A 和 B 之间其实是并行关系,但业务代码里手滑写了串行,性能损耗非常隐蔽,不压测根本发现不了。

第三个痛点是失败重试和进度反馈基本靠手搓。资源加载失败很常见:服务器抖动、网络切换、CDN 节点临时抽风。原生写一个onerror只能处理单个资源,要做整体进度条、要做失败自动重试、要做部分失败后的继续执行,代码量会迅速膨胀。而且每个人写出来的方案都不一样,今天这个模块重试 2 次,明天那个模块重试 3 次,线上问题根本没法统一排查。

所以我决定写一个轻量的、框架无关的加载器,把这些通用能力收敛到一个模块里,让业务侧只需要关心“我要加载哪些资源”,而不需要关心“加载过程怎么调度”。

2. iloader 整体设计与架构思路

2.1 核心能力清单:从需求反推设计

动手之前我先列了一个需求清单,不是拍脑袋想出来的,而是把过去半年在项目里手写的所有加载逻辑全部翻出来,逐个归类。最后收敛成下面六项核心能力。

能力项需求背景设计目标
任务定义与注册业务需要声明“加载什么”支持 url、type、优先级、依赖等描述
调度策略大量资源同时请求会拖垮性能支持并发数限制、队列、优先级
依赖关系脚本有执行顺序要求,数据有先后要求支持before/after声明
进度反馈页面需要进度条或占位提示提供统一的 global 与 per-task 回调
错误重试弱网环境下加载失败率高可配置重试次数与退避策略
缓存与去重同一资源避免重复加载内存级缓存 + 可选持久化缓存

这个清单直接影响后续的代码结构。比如因为要支持缓存和去重,我必须在执行层之前单独抽象一层任务注册表,而不是每次加载都直接从零发起网络请求。又因为要支持依赖关系,调度层不能简单地使用“先进先出”,而是要在每个任务执行前先确认它的前置条件是否满足。

2.2 分层结构与模块划分

iloader 的源码结构我保持了四个清晰的层级,这也是我给团队内部做分享时最常画的一张图。

最底层是执行层,负责真正发请求。它封装了不同资源类型的加载方式:脚本用动态script标签,样式用动态link,图片用Image对象,接口用fetch。这一层不关心业务逻辑,只负责“把一个 url 变成浏览器里可用的资源”。

往上一层是调度层,这是 iloader 的核心。它维护一个任务队列,根据每个任务的优先级、依赖关系和全局并发数,决定下一个该执行谁。调度层会有两个队列:一个等待队列、一个运行队列。运行队列永远不超过并发上限,等待队列里的任务在条件满足时被“唤醒”。

再往上是注册层,它维护一张任务表,记录所有加载过的资源的key -> status映射。发起加载时先查询这张表,如果资源已经在加载中,直接复用同一个 Promise;如果已经加载完成,直接返回成功;只有从未加载过才真正创建任务。这一步天然实现了去重。

最上层是对外 API,提供loader.add()loader.start()loader.on()这些方法。这一层保持极薄,方便以后扩展成不同前端框架的插件形式,但核心逻辑完全不依赖框架。

分层的核心目的只有一个:让每一层都可以单独测试。实际开发中我也确实这么做了——执行层用本地静态资源测试,调度层用模拟延迟的假任务测试,注册层用单元测试覆盖各种并发场景。分层如果没做好,这类测试根本写不下去。

2.3 关键设计取舍:并发数、优先级与依赖关系

这三个词听上去很常规,但真正做的时候每一个都有坑。

先讲并发数。是不是并发越大越好?不是。浏览器对同一域名有连接数限制,超过后请求会排队,并不会更快。而且如果页面首屏阶段就跑十几个并发,很容易把服务器连接池打满。我初期设计时默认并发数设成了 3,理由很简单:这个数值既能保证资源加载速度,又不会明显挤压业务接口请求。当然配置项是开放的,项目接入时可以根据页面实际情况调大或调小。

再讲优先级。早期版本我只有普通队列和高优队列两个档位,后来发现不够。比如用户点击一个弹窗,里面的图片需要立即加载,但此时队列里还有一堆下一页的预加载任务。如果只分两档,这些预加载任务可能在弹窗任务之前执行,弹窗就会出现白屏。最后我把优先级做成了数字,数值越小越靠前,并且在调度时采用“高优先级任务可插队”的策略——但这里加了一个限制,已经在运行中的任务不会被中断,避免网络请求被无谓地取消。

最后讲依赖关系。iloader 里每个任务都支持声明beforeafter,分别表示“我必须在这些任务之前执行”和“我必须等这些任务完成后才能执行”。实现时不是用图遍历去求拓扑排序,而是采用了一个更朴素的方案:任务要真正开始执行前,调度层检查它的依赖任务是否都处于完成状态。如果未完成,任务继续等待,等依赖任务完成的事件触发后再检查一次。这个方案在处理几十个资源时完全够用,而且实现简单、容易排查。

3. 核心实现:从零写出一个可用的加载器

3.1 定义任务模型:加载单元到底长什么样

iloader 的起点是任务模型。我把它定义成一个纯粹的描述对象,不掺杂任何执行逻辑。这样做的好处是,同一个任务可以被统计、被序列化、被放入任何队列,行为都可预期。

export type ResourceType = 'script' | 'style' | 'image' | 'json' | 'fetch'; export interface LoadTask { id: string; // 唯一标识,也用作缓存 key url: string; // 资源地址 type: ResourceType; // 加载方式 priority: number; // 数值越小越优先,默认 10 before?: string[]; // 必须先于哪些任务执行 after?: string[]; // 必须等哪些任务完成后执行 timeout?: number; // 单次加载超时,默认 15000ms retry?: number; // 失败重试次数,默认 0 payload?: Record<string, any>; // 业务透传数据,加载完成后可获取 }

id很重要,它不只是标识,更是缓存和依赖判断的依据。业务侧在添加任务时如果不传 id,iloader 会基于type + url生成一个默认 id,这样同一个 url 天然就能命中去重逻辑。beforeafter里存的也都是其他任务的 id,而不是任务对象引用,避免对象引用导致的内存泄漏和循环依赖问题。

任务创建好之后会立即进入注册表,但不会马上执行。对外暴露的add方法只负责登记,真正的执行动作由start方法触发。这种“先登记,再启动”的模式有一个好处:业务代码可以先注册一批任务、调整它们的依赖关系,然后一次性启动,而不是边注册边执行,导致中间状态不可控。

3.2 调度核心:手写一个带优先级的并发池

调度层是所有逻辑里最需要细致处理的部分。我写了一个内部类Scheduler,它的职责是接收任务、维护队列、控制并发、派发事件。先看基础结构。

class Scheduler { private waiting: LoadTask[] = []; private running: LoadTask[] = []; private concurrency: number; private taskMap: Map<string, LoadTask>; private statusMap: Map<string, 'waiting' | 'running' | 'success' | 'fail'>; constructor(concurrency: number) { this.concurrency = concurrency; this.taskMap = new Map(); this.statusMap = new Map(); } add(task: LoadTask) { this.waiting.push(task); this.taskMap.set(task.id, task); this.statusMap.set(task.id, 'waiting'); } canRun(task: LoadTask): boolean { const pending = this.waiting.map(t => t.id); const afterMet = (task.after ?? []).every(depId => { const status = this.statusMap.get(depId); // 依赖项已经存在且成功,才认为依赖满足;如果依赖不存在则忽略 return !this.taskMap.has(depId) || status === 'success'; }); const beforeBlocked = (task.before ?? []).some(id => { return this.waiting.some(t => t.id === id); }); return afterMet && !beforeBlocked; } runNext() { if (this.waiting.length === 0) return; while (this.running.length < this.concurrency) { let candidateIndex = -1; let candidatePriority = Infinity; for (let i = 0; i < this.waiting.length; i++) { const task = this.waiting[i]; if (!this.canRun(task)) continue; if (task.priority < candidatePriority) { candidateIndex = i; candidatePriority = task.priority; } } if (candidateIndex === -1) break; const [task] = this.waiting.splice(candidateIndex, 1); this.running.push(task); this.execute(task); } } }

canRun里有两个判断逻辑,解释一下。after是“我必须等谁”,只有当依赖的状态是success时才算满足;如果依赖任务是fail,说明依赖失败了,当前任务也应该直接失败,否则会出现“数据没回来但依赖数据的脚本先跑了”的时序错误。before是“我必须先于谁”,只要那个任务还滞留在等待队列中,当前任务就不能执行;如果它已经在 running 状态,说明当前任务已经错过了顺序窗口,这种情况在正常逻辑里不会发生,但我会在新增依赖时做一次校验并给出警告。

执行一个任务时,running数组会保存正在跑的任务,执行完成后无论成功失败都会从running里移除,再调一次runNext补位。这种“启动时补位”而不是“定时轮询”的方式,可以保证调度延迟极低——一个任务结束,下一个任务几乎立即开始。

3.3 加载器主类:进度、缓存与失败重试

有了任务模型和调度器,主类的工作就顺理成章了。它负责把对外 API 和底层执行器连接起来,同时处理进度统计、缓存命中和失败重试。

export class ILoader { private scheduler: Scheduler; private cache: Map<string, Promise<any>>; private total = 0; private done = 0; private listeners: Record<string, Array<(data: any) => void>> = {}; constructor(options: { concurrency?: number } = {}) { this.scheduler = new Scheduler(options.concurrency ?? 3); this.cache = new Map(); } add(task: LoadTask): this { const id = task.id || `${task.type}:${task.url}`; const normalized = { ...task, id }; this.scheduler.add(normalized); this.total++; return this; } start(): Promise<void> { return new Promise((resolve, reject) => { const results: Record<string, any> = {}; this.scheduler.registerHooks({ onSuccess: (id, data) => { results[id] = data; this.done++; this.emit('progress', { done: this.done, total: this.total }); this.emit('task-success', { id, data }); }, onFail: (id, err) => { this.done++; this.emit('progress', { done: this.done, total: this.total }); this.emit('task-fail', { id, err }); }, onDrain: () => { const failed = this.scheduler.getFailedIds(); if (failed.length > 0) { reject(new Error(`tasks failed: ${failed.join(', ')}`)); } else { resolve(); } }, }); this.scheduler.runNext(); }); } on(event: string, handler: (data: any) => void): this { if (!this.listeners[event]) this.listeners[event] = []; this.listeners[event].push(handler); return this; } private emit(event: string, data: any) { (this.listeners[event] ?? []).forEach(fn => fn(data)); } }

缓存层我用的是Map<string, Promise<any>>,而不是Map<string, any>。这里有个很关键的区别:以 Promise 作为缓存值,可以让两个完全独立的任务在同一个资源加载过程中安全地共享同一次请求。例如组件 A 和组件 B 都依赖某个公共脚本,它们在同一个 tick 里都调用了loader.add(),如果没有缓存,这个脚本会被加载两次。用了 Promise 缓存后,第二个任务拿到的就是第一个任务请求的同一个 Promise,网络层面只会发一次请求。

超时和重试是在执行器里实现的。加载超时会 reject 一个特定错误码,然后执行器根据任务的retry设置决定是否重试。重试之间有退避间隔,避免在服务器抖动时造成二次拥塞。写到这里必须强调一个经验:不要用固定的退避间隔,比如每次重试都等 500ms。我实际踩过的坑是,固定间隔会导致多个失败任务在同一时刻重试,形成一种“节奏性”的请求尖峰。iloader 里用的是指数退避加随机抖动,具体公式是Math.min(2^n * 500, 5000) + Math.random() * 300,n 是重试次数。

4. 项目实际落地:接入 iloader 时的步骤与注意点

4.1 在真实项目里怎么用:最小接入配置

iloader 不需要构建工具,也不需要框架适配,直接new ILoader()就能用。下面是一个视频类页面常见的接入场景:进入页面先加载首屏需要的框架脚本和首图,用户点击某个模块时再去加载模块的组件脚本和数据。

import { ILoader } from './iloader'; const loader = new ILoader({ concurrency: 3 }); // 注册首屏任务 loader .add({ id: 'core-lib', url: '/assets/vendor.js', type: 'script', priority: 1, }) .add({ id: 'hero-image', url: '/images/hero.jpg', type: 'image', priority: 2, }) .add({ id: 'home-data', url: '/api/home', type: 'fetch', priority: 2, after: ['core-lib'], // 数据请求必须在 core-lib 执行后进行,便于携带公共头部逻辑 }); loader.on('progress', ({ done, total }) => { console.log(`加载进度:${done}/${total}`); }); loader.start().catch(err => { console.error('首屏资源加载失败', err); });

用户点击模块时,再次注册新任务即可。由于任务 id 是全局唯一的,之前已经加载过的资源不会重复请求。

function openDetailModal(articleId: string) { loader .add({ id: `article-detail-${articleId}`, url: `/api/detail?id=${articleId}`, type: 'fetch', priority: 0, // 高优先级,比预加载任务先执行 }) .add({ id: 'detail-styles', url: '/assets/detail.css', type: 'style', after: [`article-detail-${articleId}`], }) .start(); }

这里有一个很容易被忽略的点:每次调用start()会返回一个新的 Promise,并且只对“当前时刻已注册但尚未执行”的任务负责。如果前一个start()还没跑完,又调用了第二次start(),两个 Promise 的完成时机是各自独立的,不会互相覆盖。我在接入时遇到过开发同事误以为第二次start()会“取消”第一次任务的情况,确实需要在使用文档里明确声明。

4.2 参数调优经验与真实性能数据

并发数的选取是接入时最需要根据项目类型调整的参数。我在同一个测试环境里跑过一组对比,加载对象是 20 个本地静态资源(12 张图片 + 6 个 JS + 2 个 JSON),浏览器是 Chrome 的模拟 4G 网络,结果如下。

并发数全部完成耗时现象
18.6s资源串行加载,速度最慢
33.9s速度明显提升,接口请求未被过度挤压
53.4s速度略有提升,但已经接近带宽瓶颈
83.2s提升很小,部分请求开始排队,首屏接口延迟升高

这个测试结果说明一件事:并发数从 1 调到 3 的收益最大,从 3 再往上调就到了收益递减区间。如果你的页面里主要加载的是图片,可以适当调大,因为图片请求通常比较轻;如果加载的是数据接口,最好控制在 2 到 4 之间,否则很可能把后端的数据库连接池打满。我个人的默认值是 3,遇到以图片为主的相册类页面会调成 6。

关于优先级,我也有一条经验:所有“用户即将触发但还没触发”的资源,优先级统一设为 10 以上;所有“用户已经触发、正在等待展示”的资源,优先级设为 0 到 5。不要在业务代码里把每个任务都设成 0,那样优先级系统等于废掉了。

5. 常见问题与排查技巧实录

5.1 加载顺序错乱,明明声明了依赖却没生效

这是接入期最高频的问题,现象是任务 B 在after里声明依赖任务 A,但跑起来以后 B 还是先执行了。排查下来九成原因是id不匹配。业务代码里 A 任务注册时用了不同的 id,比如一个用了固定id: 'lib',另一个用了动态id: 'lib-v1',调度器就认为它们没有任何关系。iloader 默认只会对同一type + url的重复资源做去重,但依赖声明是按 id 精确匹配的,不会智能推断。

解决方法是做一个“预注册校验”:在所有任务添加完成后、调用start()之前,检查一遍afterbefore里引用的 id 是否都存在于任务表中。如果不存在,直接抛出明确错误,而不是等运行时默默忽略。

5.2 重复加载问题:缓存命中率低

如果资源明明加载过,但第二次使用时还是发了一次网络请求,要先检查 url 是否完全一致。最常见的坑是 url 末尾带了不同的缓存参数,比如file.js?v=1file.js?v=2,iloader 会把它们视为两个资源。这个行为其实是设计使然,缓存键不区分 query 顺序,但区分不同的 key。如果你希望忽略 query part,可以在生成默认 id 时只取url.split('?')[0]

另外一个容易被忽略的问题是 Promise 缓存只存在于当前页面生命周期。一旦用户刷新浏览器,缓存必然清空,这个无法避免。如果需要跨页面复用,建议配合 HTTP 缓存来做,让浏览器自身的强缓存生效,iloader 的缓存更多是用来防止同一个页面内重复加载。

5.3 并发数太高,服务器“假死”

这个坑我是在一次活动页上线时踩到的。活动页要同时展示几十张商品图,我把并发数调到了 10,结果页面首屏刚加载,后端报了一堆超时,数据库连接数直接打满。事后分析,罪魁祸首并不是那几十张图片,图片走的是 CDN,但业务数据接口和图片接口共用同一个域名,并发数一高,大量图片请求把浏览器同一域名的连接池占满了,数据接口的请求只能排队等待。

教训有两条:第一,不同目的的资源尽量用不同域名,图片可以放 CDN,脚本和接口走主域名,这样浏览器连接池不会被单一资源类型占满;第二,iloader 的并发数限制的是所有资源的总体并发,如果你的业务里既有图片又有接口,不要盲目调大并发数,应该优先保证接口请求的带宽。把接口任务的优先级调低、把并发数控制在 3 到 4,通常比无脑调高更有效。

5.4 进度条卡住不动或者直接跳过

进度条卡住,通常是有任务一直停留在等待队列里,它的依赖永远没有达到success状态。最常见的原因是依赖任务本身失败了,但业务侧没有监听task-fail事件,所以进度条没有前进,也没有报错。

我在 iloader 里做了一个保护逻辑:如果某个任务依赖的任务失败,当前任务会自动标记为失败,而不是无限等待。这样进度条不会永远卡在 80%,但需要业务侧监听task-fail来感知。如果发现进度条跳过了某个任务,排查思路是把所有失败任务打印出来,逐个检查它们的after依赖项是否先行失败。还有一个细节经常被忽略:有些依赖任务如果本身是可降级的(比如埋点脚本),业务侧应该给它加更高的重试次数,或者手动把它从依赖链路中摘出去,否则一个埋点脚本失败会导致整个资源链路失败。

6. 进一步扩展:iloader 还能往哪些方向演化

6.1 从加载器变成资源管理器

目前的 iloader 主要解决了“怎么加载”的问题,但在实际使用一段时间后,我发现真正有价值的方向是进一步做资源生命周期管理。加载只是起点,资源用完之后是否需要释放、是否要从缓存中移除、是否需要支持版本回退,这些才是大项目里更麻烦的点。

比如一个组件被销毁时,它加载的样式和脚本并不会自动从页面上移除。如果业务侧反复进出同一个页面,带有状态的对象会越积越多,最终导致内存泄漏。iloader 目前保留资源引用的方式比较轻量,但我已经计划增加一个release(taskId)方法,允许业务侧显式释放某个资源及其副作用。这个扩展方向的难点在于,脚本和样式一旦注入 DOM,就没有一个标准 API 可以“卸载”它们,需要业务侧配合提供清理函数。这也是我从实践中发现的真实需求。

6.2 支持预加载模式的另一种思路

除了运行时的调度,iloader 也可以承担一部分预加载职责。浏览器的<link rel="preload">能力很强,但它需要页面构建时就知道资源地址,不能很好地处理“运行时才知道的依赖关系”。iloader 的思路是在运行时维护一个“未来高概率会用到”的资源列表,在浏览器空闲时用低优先级加载它们,用户真正触发时直接从缓存取用,体验上几乎无感知。

这种预加载会有一个问题:空闲时加载的任务可能会和用户正在触发的正常加载任务竞争带宽。我的处理方式是把预加载任务的优先级设为最高的数值,比如 100,并且允许调度层对“未开始执行的预加载任务”做取消操作。这样当用户真正需要某个预加载资源时,可以从队列中把它摘出来,立即以正常优先级重新放入,而不是等待空闲队列慢慢轮转。

6.3 更完善的可观测性

最后一个方向是数据上报和可观测性。iloader 在内部已经收集了每个任务的耗时、重试次数、失败原因,但目前只能在浏览器控制台输出。我打算把这些数据统一汇总,通过回调或者自定义事件抛出,方便接入公司的监控系统。这样以后排查线上资源加载问题,就不需要再靠用户反馈“页面很慢”来从头猜了。

我个人在这套机制上踩过的坑,也是新手接入时最容易忽略的:一旦开始做可观测性,一定要给每个任务加payload字段来标记业务场景。比如payload: { scene: 'home-first-screen' },这样上报到监控平台后,可以按场景聚合,一眼看出首屏资源的 P95 耗时和失败率。如果不加场景信息,你只会看到一堆 url,排查问题时要靠想象去猜这个 url 属于哪个业务模块。

7. 最后说几句掏心窝的话

iloader 这个项目从第一个能跑的版本到现在,改了大概有五轮。每一次重构都不是因为功能不够多,而是因为实际接入方提出了更具体的场景。这个东西最让我满意的部分不是代码多优雅,而是它的 API 足够小,业务侧接入成本极低,团队成员基本看一遍文档就能上手。

如果你也要写类似的加载器,我建议不要把目光局限在“请求资源的封装”上。真正拉开差距的是调度策略的细节:依赖判断写不写、优先级是否支持动态调整、缓存能不能跨任务共享 Promise。这些点每一个单拿出来都不难,组合在一起却会产生完全不同的使用体验。你可以试着先用原生方式写一个包含依赖 + 并发限制 + 进度条的版本,跑一遍业务场景,然后再回头看看是不是值得做成一个独立的库。以我自己的体会来说,这个投入是值得的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询