前阵子接手一个老SPA后台项目,用户反馈首屏加载很慢,打开页面经常白屏两三秒。打开Network面板一看,一整个首屏居然打出了二十多个接口,用户还没看到业务界面,浏览器线程先被一堆请求状态、JSON解析和组件更新塞满了。最开始我也以为是网速问题,后来逐个排查,发现真正卡顿的原因根本不在带宽,而是请求数量太多、发出的时机和优先级没有统一管起来。
这个现象在SPA项目里太常见了:页面组件初始化时各自拉数据,用户信息一个接口、菜单权限一个接口、配置项一个接口、列表一个接口、下拉框数据一个接口……每个接口单独看都很快,但并发打出去之后,浏览器调度不过来,关键数据被排队,页面就会卡在白屏状态。这篇内容我想把自己落地的一套前端请求调度方案完整分享出来,包括请求调度的核心原理、如何在老SPA项目中渐进式接入、实测怎么验证效果,以及真实业务里踩过的几个坑。
1. 首屏卡顿的四个隐形杀手:为什么接口多会拖垮整个页面
很多同学遇到首屏慢,第一反应是找后端要性能报告、查接口响应耗时,或者直接上CDN、上服务端渲染。这些方向不能说错,但往往解决不了"接口数量本身太多"带来的问题。先把首屏卡顿的真实机制讲清楚,后面调度方案的设计才顺理成章。
1.1 浏览器并发连接数限制:几十个请求只能挤几个窗口
浏览器对同一域名的并发请求数量是有硬限制的。在HTTP/1.1协议下,Chrome同一域名最多同时建立6个TCP连接,超过的请求会进入排队状态。也就是说,如果首屏同时发出20个请求,那么同一时刻最多只有6个请求真正在网络上飞,剩下14个都在浏览器内部排队。
这里有个很容易被忽略的点:排队的不一定是低优先级请求,先发出的请求会先占用连接。如果页面初始化时先请求了一堆耗时较长的非关键接口,那么后面读取用户权限、初始化路由数据的请求就得等它们腾出连接,关键路径被非关键请求活活堵死。
用餐厅来类比的话,厨房出菜口只有六个窗口,服务员一窝蜂下了二十张单,先来的订单做的都是费时的炖菜,快餐反而被压在后面。用户坐在餐桌前等,看到的不是每道菜实际一分钟就能出锅,而是整体被拖到十几分钟。
1.2 响应风暴抢占主线程:JSON解析和视图更新都在抢渲染时间
第二个问题比并发连接数更隐蔽,也更容易被甩锅给"浏览器慢"。当首屏大量接口在几乎同一时间返回时,浏览器主线程需要同时处理二十多份JSON数据;框架层面,Vue或React还要把这些数据转化为组件状态、执行虚拟DOM比对、触发重渲染。这一连串操作全部发生在主线程上,而主线程同时还要负责页面布局、绘制和用户交互响应。
拿Chromium的Performance面板看就很直观,Long Task一个接一个,每段都超过100毫秒,白屏期间的帧率低得可怜。这就是为什么有时候你感觉网络也不慢、后端接口也就几十毫秒,但页面就是卡——处理响应的时间被主线程挤爆了。
这种情况和"接口慢"完全是两码事。后端接口再优化,响应还是要回到浏览器,回到主线程;只要首屏同一瞬间响应数量太多,渲染瓶颈就在那里。这也是我认为请求调度方案比单纯"找后端加索引"更根本的原因。
1.3 接口依赖链条没有编排,白白多等一整轮
SPA首屏接口还有一类问题:大量请求之间存在隐式依赖。最常见的是"先拿用户信息,才能判断角色权限;拿到权限才知道哪几个资源可以展示";"先拿配置,才知道列表页显示哪些列"。
如果这些请求全部由各个组件独立发出去,依赖关系就只能靠等待完成。比如组件A在请求用户信息,组件B也在请求用户信息(甚至同一个接口),组件C等待A和B的结果,组件D根本不需要这些数据却也被阻塞在首屏。整个请求瀑布图看起来一天世界,但其实有大量等待时间是可以被压缩掉的。
更麻烦的是,有些接口不在同一个页面层级里,普通开发者根本看不出来它们之间有前后关系。等到用户反馈"页面出来一半、另一半半天不显示",排查起来成本极高。
1.4 重复请求和无效请求:看不见的资源浪费
老项目里最让我头疼的不是接口慢,而是同一个接口被调了三次。用户信息接口,路由守卫里调用一次,布局组件里调用一次,侧边栏组件里又调用一次。三个请求并发打给后端,后端的响应数据完全一样,但浏览器要等三份网络响应,解析三份同样的JSON,触发三次组件更新。
还有一种无效请求:用户在首屏切换路由,上一个页面组件已经卸载了,但之前发出的请求并没有因为组件卸载而取消。响应回来以后,异步回调还在执行,setState还在触发,有的还会报"Can't perform a React state update on an unmounted component"警告。这些请求本来完全可以不发送,或者发送了也不该产生后续更新。
所以"首屏接口多"这件事,不能简单归结为"加带宽""让后端合并接口"。接口数量多、时序乱、并发无限制、重复无意识,这些问题合在一起,才是SPA首屏卡顿的完整真相。搞清楚了这一点,接下来调度器的设计就有明确的靶子了。
2. 请求调度器的核心设计:队列、并发池与优先级
请求调度器的设计思路,可以理解成给前端请求流建一个"交通调度中心"。所有请求不再直接发往网络,而是先进入一个可控的队列,调度器根据优先级挑出该发的请求,控制同时在途的请求数量,并提供去重、缓存等机制。
2.1 统一请求入口:给axios包一层调度外壳
要做调度,第一件事是统一请求入口。如果项目里axios直接裸用,散落在几十个文件里,调度器根本无从下手。最简单的做法不是把所有调用都改成走新方法,而是封装一个requestWithSchedule函数,在原axios实例之上加一层调度壳。
javascript复制import axios from 'axios';
const http = axios.create({
baseURL: '/api',
timeout: 10000,
});
class RequestScheduler {
constructor({ maxConcurrency = 6 } = {}) {
this.maxConcurrency = maxConcurrency;
this.pendingCount = 0;
this.queue = [];
this.cache = new Map();
this.inflight = new Map();
}
enqueue(task, options = {}) {
const {
priority = 'normal',
cache = false,
cacheTTL = 30000,
dedupe = false,
signal,
cacheKey,
} = options;
return new Promise((resolve, reject) => {
const wrappedTask = {
task,
priority,
cache,
cacheTTL,
dedupe,
signal,
resolve,
reject,
cacheKey,
key: cacheKey || Symbol('request'),
};
// 如果开启了缓存,且缓存未过期,直接返回
if (cache && this.cache.has(wrappedTask.key)) {
const hit = this.cache.get(wrappedTask.key);
if (hit.expireAt > Date.now()) {
resolve(hit.data);
return;
}
this.cache.delete(wrappedTask.key);
}
// 如果开启去重,同一key的请求复用同一个Promise
if (dedupe && this.inflight.has(wrappedTask.key)) {
const existing = this.inflight.get(wrappedTask.key);
existing.push({ resolve, reject });
return;
}
this.queue.push(wrappedTask);
this._drain();
});
}
_drain() {
while (this.pendingCount < this.maxConcurrency && this.queue.length > 0) {
this.queue.sort((a, b) => this._priorityWeight(a.priority) - this._priorityWeight(b.priority));
const item = this.queue.shift();
if (item.signal && item.signal.aborted) {
item.reject(new DOMException('Aborted', 'AbortError'));
continue;
}
this.pendingCount += 1;
const inflightGroup = this.inflight.get(item.key) || [];
inflightGroup.push({ resolve: item.resolve, reject: item.reject });
this.inflight.set(item.key, inflightGroup);
item.task()
.then((data) => {
if (item.cache) {
this.cache.set(item.key, { data, expireAt: Date.now() + item.cacheTTL });
}
const group = this.inflight.get(item.key) || [];
group.forEach(({ resolve }) => resolve(data));
this.inflight.delete(item.key);
})
.catch((error) => {
const group = this.inflight.get(item.key) || [];
group.forEach(({ reject }) => reject(error));
this.inflight.delete(item.key);
})
.finally(() => {
this.pendingCount -= 1;
this._drain();
});
}
}
_priorityWeight(priority) {
const weightMap = { high: 0, normal: 1, low: 2 };
return weightMap[priority] ?? 1;
}
}
const requestScheduler = new RequestScheduler({ maxConcurrency: 6 });
export function requestWithSchedule(config) {
const meta = config.meta || {};
delete config.meta;
return requestScheduler.enqueue(
() => http.request(config),
{
priority: meta.priority,
cache: meta.cache,
cacheTTL: meta.cacheTTL,
dedupe: meta.dedupe,
signal: config.signal,
cacheKey: meta.cacheKey || `${config.method || 'get'}:${config.url}`,
}
);
}
这段代码是调度器的核心骨架。实现上并不复杂,但有几个点需要解释清楚:
maxConcurrency控制在途请求数量,默认设置为6,正好对齐HTTP/1.1的浏览器并发连接上限。priority分为high、normal、low三档,_priorityWeight决定队列里哪个请求先出去。cache是短时缓存,dedupe是请求去重。后两者容易混淆,后面我会专门讲区别。
这里有个关键设计:调度器的enqueue返回的是一个新的Promise,而不是axios的原始Promise。这样请求真正发出的时机被完全掌握在调度器手里,调用方只需等待结果,结构上比较干净。
2.2 优先级队列:把首屏关键接口放在最前面
优先级队列的作用,就是在请求排队时保证关键请求先拿到连接。什么是关键请求?就是用户看到首屏内容之前,必须返回的数据。比如用户信息、权限、初始化配置、首屏列表数据。这些请求适合标记为high。
非关键请求包括:低频使用的下拉框数据、个性化推荐、埋点上报、统计图表数据等。这些即使晚几百毫秒甚至几秒返回,对用户感知没有明显影响,标记为low。普通的业务数据请求为normal。
有了优先级之后,即使首屏整体并发请求超过6个,high优先级的请求也会从队列中被先取出来。这就相当于把餐厅出菜口的排队策略从"先到先做"改成了"加急菜先做"。
实际开发中我建议不要分太细,三档足够了。分太多会让人纠结于业务分类,反而增加理解成本。在高优先级里再想细分,不如在业务层面直接减少请求数。
javascript复制// 初始化用户信息,首屏关键请求
requestWithSchedule({
url: '/user/info',
method: 'get',
meta: { priority: 'high', cache: false }
});
// 非首屏推荐内容,延后加载
requestWithSchedule({
url: '/recommend/list',
method: 'get',
meta: { priority: 'low', cache: true, cacheTTL: 60000 }
});
2.3 动态并发池:不是固定6条连接就一定要用满
固定并发数是1.0版方案,2.0我建议做成动态并发。动态并发的思路是:在页面刚加载、主线程最忙的时段,把并发数暂时压低;等首屏基本渲染完成、主线程空闲下来,再逐步放开。
为什么需要动态?因为在请求响应阶段,主线程除了处理JSON还要做首屏渲染。如果响应在同一瞬间全部涌回来,主线程还是会被挤爆。调度器虽然控制了"在途"请求数量,但不控制后端返回的时机。并发太高,依然会造成响应风暴。
具体实现上,可以给调度器增加一个setMaxConcurrency方法,在路由生命周期里动态调整。比如组件挂载完成之后,调用setMaxConcurrency(4),让前几批请求少一些;等主线程跑了requestIdleCallback或用户已经能交互,再setMaxConcurrency(8)甚至10。
javascript复制export function throttleConcurrency(nextMax) {
requestScheduler.setMaxConcurrency(nextMax);
}
// 首屏组件挂载后先降并发
onMounted(() => {
throttleConcurrency(4);
requestIdleCallback(() => {
throttleConcurrency(8);
});
});
需要提醒的是,动态并发并不是万能药。如果项目本身首屏接口数量非常少,动态并发的收益不明显;但如果像开头说的那种首屏二十多个接口的页面,这项功能帮助很大。
2.4 请求去重:同一时间只打一次接口
请求去重解决的是"同一个接口在同一时间被调用多次"的问题。典型场景就是前面说的用户信息接口:路由守卫调一次、布局组件调一次、侧边栏调一次。
去重实现的原理很简单:调度器维护一个inflight表,记录当前正在发送中的请求。当新请求进来,发现请求的cacheKey已经在inflight表里,则不再发起真正的HTTP请求,而是把本次的resolve/reject挂到已有的请求上。等第一个请求返回,所有等待这个key的调用方一起resolve。
javascript复制requestWithSchedule({ url: '/user/info', meta: { dedupe: true } });
requestWithSchedule({ url: '/user/info', meta: { dedupe: true } });
// 第二次调用不会真正发出HTTP请求,而是等待第一个响应
去重的粒度建议包含method + url + 主要参数。注意GET请求的参数是拼在URL里的,可以直接用完整URL做key;POST请求在设计缓存key时,要把请求体核心字段拼进去,避免不同参数的POST被错误合并。
2.5 短时缓存:减少短期内重复调用的开销
请求去重和短时缓存容易混淆。去重是"正在飞的不重复飞",缓存是"飞过的不再飞"。短时缓存的适用场景是:接口数据在短时间内不会变化,或者即使变化了,UI也不要求立刻看到最新值。比如系统配置、字典项、一些静态下拉数据。
我给缓存设置了TTL(过期时间),默认30秒,这个值需要根据业务谨慎调整。缓存时间太短没有意义,太长则可能造成数据不新鲜。比如用户改了头像回来,页面还显示旧头像,就会被投诉。
javascript复制// 配置项5分钟内不重新请求
requestWithSchedule({
url: '/system/config',
meta: { cache: true, cacheTTL: 5 * 60 * 1000 }
});
调度器中缓存结果存放在内存Map中,页面刷新即失效,所以不用担心跨页面污染。为了安全,可以在路由切换后清掉不需要的缓存,只保留全局级别的配置缓存。
3. 现有SPA项目接入的完整改造流程
调度器设计好了,接下来最关键是落地。这里给出一个从零到一的接入方案,兼容Vue和React,核心思路是:不要一次性替换所有请求,而是先改首屏关键路径。
3.1 资源安装与代码结构规划
把调度器代码单独放一个文件,推荐放在src/utils/requestScheduler.js。这样全局只需维护一份调度逻辑。如果项目已经封装了axios实例,直接在此基础上增加requestWithSchedule函数即可;如果没有封装,建议顺手把axios统一封装这件事一起做了。
接入之前,先把项目里所有首屏发起的请求列出来。打开DevTools的Network面板,清空缓存后刷新页面,记录下所有请求的URL、发起模块、是否关键、是否重复。我用一个表格整理,方便后续一个个打标签:
| 接口URL | 发起模块 | 是否首屏关键 | 是否重复 | 建议标签 |
|---|---|---|---|---|
| /user/info | 路由守卫、布局组件 | 是 | 是 | high + dedupe |
| /system/config | 全局配置 | 是 | 否 | high + cache |
| /list/page | 首页列表 | 是 | 否 | high |
| /recommend/list | 首页次要模块 | 否 | 否 | low |
| /analytics/report | 分析模块 | 否 | 否 | low + cache |
这一步是整个改造过程中最花时间、也最有价值的。整理完清单,你通常会惊讶地发现:同一接口被调用多次、某些请求根本不需要在首屏发出、有些数据明明可以合并成一个聚合接口。
3.2 给接口打标签:优先级、缓存、去重、超时
接入改造时,把原有直接调用axios的地方替换为requestWithSchedule,并加上meta配置。以Vue项目为例:
javascript复制// 改造前
const userInfo = await http.get('/user/info');
const config = await http.get('/system/config');
const list = await http.get('/list/page', { params: { page: 1, size: 10 } });
// 改造后
const [userInfo, config, list] = await Promise.all([
requestWithSchedule({ url: '/user/info', meta: { priority: 'high', dedupe: true } }),
requestWithSchedule({ url: '/system/config', meta: { priority: 'high', cache: true, cacheTTL: 300000 } }),
requestWithSchedule({ url: '/list/page', params: { page: 1, size: 10 }, meta: { priority: 'high' } }),
]);
这里要注意:Promise.all并发发起三个请求时,调度器内部会自动排队,不一定三个请求同时发出。这正是我们要的效果。高优先级请求会被立刻发出,而低优先级请求会被排在后面。
改造过程中要遵循两个原则:
- 高频接口优先配置
dedupe,至少能解决一倍以上的重复请求。 - 低频但响应慢的接口配置
cache,减少用户来回切换页面时的等待。
3.3 与路由和组件生命周期配合
调度器接入之后,与路由和组件生命周期的配合非常重要。一个很容易踩的坑是:组件卸载后,调度器里的请求仍然在队列里排队,甚至已经发出去,响应回来后会触发不必要的回调。
解决方案是给调度器请求传入AbortSignal。在Vue3的onScopeDispose或React的useEffect清理函数中,将对应的AbortController abort掉。调度器在处理任务时,要检查signal的状态,已abort的任务直接拒绝,不真正发起请求。
javascript复制// Vue3中使用
import { onMounted, onBeforeUnmount } from 'vue';
const controller = new AbortController();
onMounted(() => {
requestWithSchedule({
url: '/list/page',
signal: controller.signal,
meta: { priority: 'normal' }
});
});
onBeforeUnmount(() => {
controller.abort();
});
调度器_drain里的abort检查,就是为了处理这种情况:一旦任务从队列中取出,发现signal已经aborted,就不再执行真正的axios请求,直接reject。这样既避免了无用请求,也避免了内存泄漏。
3.4 用Performance API验证优化前后的实际效果
接入改造完成后,不能靠"感觉快了"来做结论。推荐用Performance API统计几个指标:FCP(First Contentful Paint)、LCP(Largest Contentful Paint)、Long Task数量和总阻塞时间。
javascript复制new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const longTasks = entry.getEntriesByType('longtask');
console.log(`Total Long Task count: ${longTasks.length}`);
}
}).observe({ type: 'longtask', buffered: true });
const paintEntries = performance.getEntriesByType('paint');
for (const entry of paintEntries) {
console.log(`${entry.name}: ${entry.startTime}ms`);
}
实际测试我都是清空缓存,用DevTools的Slow 4G模拟网络环境,对比改造前后的瀑布图。优化后最直观的变化是:首屏请求的总时间明显下降,好几个请求从阻塞变成并发执行,关键接口(用户信息、配置、列表)都在首屏首批发出。
另一个指标是首屏白屏时长。在改造前,用户看到内容的时间经常被非关键请求阻塞;改造后,高优先级请求先完成,页面内容提前展示,即便部分低优先级数据还在路上,界面上可以用骨架屏或局部loading兜底。
4. 这套方案在真实业务中踩过的坑与兜底策略
任何方案,落地过程中都会遇到文档里没有写过的问题。这几个月在实际项目里使用调度器,我踩了不少坑,也总结了一些兜底策略,值得单独拿出来说。
4.1 路由切换后队列里的“幽灵请求”
这个坑我印象最深。调度器上线之后,用户反馈页面偶尔出现"Loading转圈不停"。排查后发现,用户在首屏还没加载完时就切换到了另一个路由,之前高优先级的请求还在队列里,等它发出并返回,再触发回调,此时组件已经卸载,页面状态混乱。
解决思路分两层。第一层,所有进入调度器的任务都在创建时绑定AbortSignal;第二层,调度器的_drain在任务弹出队列时,必须检查signal。不仅检查aborted然后直接拒绝,还要在finally中把pendingCount扣掉,否则并发数会被"幽灵请求"长期占用,后续排队任务永远发不出去。
我在代码里增加了一个小统计字段,用于观测有多少任务是被abort掉的:
javascript复制this.stats = { total: 0, aborted: 0 };
// 在 enqueue 中 this.stats.total++
// 在 _drain abort 分支中 this.stats.aborted++
通过这个统计,我能判断是不是某个页面把大量请求发出后又不断取消,从而避免"频繁切路由导致请求开销反而变大"的情况。
4.2 并发数调太高,页面反而更卡
调度器默认并发数调到10甚至12以后,我发现首屏并没有变快,Long Task反而更多了。原因前面提到过:HTTP/2虽然支持同域多路复用,网络层的排队减少了,但响应回包后,主线程消化大量JSON和触发组件更新的成本没有变。并发数越高,同一瞬间返回的响应越多,主线程越容易被击穿。
最终我的做法是:默认并发数保持4到6,不等首屏加载完成不放开;低优先级请求放到请求空闲时再发。也就是说,调度器不应该只追求"尽快发出所有请求",而是要在"请求尽快返回"和"主线程不被打垮"之间找平衡点。
4.3 缓存误伤数据实时性
短时缓存上线后,有同事反馈"数据改了好几分钟页面还是老样子",一查就是缓存TTL设置得太长。尤其是管理后台,用户改了开关状态,期望下次打开页面立刻看到新值,结果被30分钟的缓存挡住了。
所以缓存策略必须按接口数据特性来分:纯静态的字典项可以缓存10分钟;涉及用户操作结果的应立即失效;列表数据最好不缓存,或者只做几秒级缓存。另外,操作类接口成功后,要主动清理相关缓存key。
javascript复制export function invalidateCache(urlPrefix) {
for (const key of requestScheduler.cache.keys()) {
if (String(key).startsWith(urlPrefix)) {
requestScheduler.cache.delete(key);
}
}
}
业务侧比如创建角色成功后,调用invalidateCache('/system/role'),下一次获取角色列表就是最新数据。
4.4 Network面板请求时间没变,是不是没生效?
很多人在改造后对比Network面板,发现接口耗时看起来和之前差不多,就认为调度器没起效。严格来说,调度器优化的是"请求编排"而不是"接口本身耗时"。打开单个接口看加载时间,当然变化不大;但看整体瀑布图和用户可交互时间,差距才明显。
所以验证时要看整体指标,而不是单个请求。强烈建议用Performance面板录一段加载过程,观察Long Task数量和主线程阻塞时间。我实际优化过的一个项目,首屏接口从22个降到14个(其中4个是重复合并掉的,4个延后到路由切换后再请求),FCP从3.2秒降到1.8秒,Long Task总数从12个降到3个。单个接口响应时间并没有变快,但页面整体的"体感速度"完全不一样了。
最后再分享一个建议。如果你也要在现有SPA项目里改造请求调度,先不要急着把整套方案铺开。先做三件事:第一,把所有首屏请求列成清单,找出重复请求和可延后请求;第二,给关键接口标记high优先级,非关键接口标记low优先级;第三,把明显重复的接口加上dedupe选项。这三步做完,通常就能解决一大部分卡顿问题。等团队都接受了调度器的思路,再逐步把动态并发和短时缓存加上去。我自己前后迭代了三四个月,才把这套方案打磨到比较稳的状态。每个项目的业务场景不一样,调度器的参数和策略也要跟着调,这需要一点耐心。
