前端性能优化:解决列表页1+N请求扇出与转圈问题
2026/9/19 18:20:47 网站建设 项目流程

管理后台的列表页转圈这件事,几乎每个前端都遇到过。页面骨架已经渲染出来了,表头也在,就是数据迟迟不回来,Loading 图标转得人心慌。打开 Network 面板一看,请求数量多到吓人——一个列表接口返回了 20 条记录,紧接着又发出了 20 个请求去补全每条记录的关联信息,这就是典型的1+N 请求扇出。这篇文章就从我最近处理的一个真实案例出发,把这个问题从定位、分析到解决的完整链路拆开讲清楚,涉及Promise.all并发控制、Map 缓存去重、懒加载策略选择等具体手段。不管你是刚接触前端性能优化,还是已经做过一些接口层面的调优,都能从中找到可以直接复用的思路和代码。

1. 从 Network 面板里揪出那个"1+N"的真凶

1.1 列表页转圈的表象与实质

很多人看到列表页转圈,第一反应是"接口太慢了"或者"后端有问题"。这个判断有时候对,但更多时候只对了一半。我遇到的那个管理后台,列表主接口响应时间其实只有 200 毫秒左右,数据也正常返回了,但页面就是不出来。真正的问题在于:主接口返回的每条记录里,只带了关联对象的 ID,比如creatorIddepartmentIdcategoryId,前端拿到这些 ID 之后,需要再去请求对应的详情接口,把名称、头像等信息补全,才能渲染出完整的表格。

于是就有了这样的请求序列:1 个列表接口 + N 个详情接口。N 等于当前页的记录数,如果分页是每页 20 条,那就是 1+20=21 个请求;如果每页 50 条,那就是 51 个请求。这些请求如果还是串行发出的,那页面转圈的时间就是所有请求响应时间的总和。即便浏览器有并发限制,能同时发 6 个左右,整体耗时依然会被拉得很长。

提示:判断是不是 1+N 扇出,最直接的方法就是在 Network 面板里按请求名称排序,看看是不是有大量相似路径的请求,只是参数不同。

1.2 为什么这种模式在管理后台里特别常见

管理后台的业务模型天然就是关系型的。一张订单表关联用户表、商品表、门店表;一条审批记录关联发起人、审批人、审批流程节点。后端在设计 RESTful 接口时,往往倾向于"一个资源一个接口",列表接口只负责返回主资源,关联资源让前端按需去取。这种设计在接口层面是清晰的,但在页面渲染层面就会造成扇出。

更麻烦的是,有些关联数据并不是简单的 ID 到名称的映射。比如审批记录里的审批人,除了名字还要显示头像和职位;订单里的商品,除了名称还要显示规格和缩略图。这些信息如果都塞进列表接口,后端会觉得"太重了",但如果让前端一个个去取,性能问题就转嫁到了浏览器端。

我在排查时还发现一个细节:有些前端代码是在v-for或者map循环里直接发请求的,循环多少次就发多少次,完全没有做去重。如果 20 条记录里有 15 条是同一个创建人,那这个创建人的详情接口就会被重复请求 15 次。这就是纯粹的浪费。

1.3 用 Performance 面板确认阻塞点

光看 Network 面板还不够,得用 Performance 面板录一段,看看主线程到底在干什么。我当时的操作是:打开 Performance 面板,点击录制,刷新页面,等列表加载完成后停止录制。然后在火焰图里找XHR或者Fetch相关的调用栈,看看这些请求是在哪个阶段发出的,是同步阻塞了渲染,还是异步等待。

结果很清晰:列表主接口返回后,JavaScript 主线程进入了一个循环,循环里逐个调用详情接口,并且用await等待每个请求完成。这意味着整个循环是串行的,前一个请求不回来,后一个就不发出去。主线程虽然没有被完全阻塞,但页面的渲染被推迟到了所有请求完成之后。这就是转圈的直接原因。

注意:串行await在循环里是性能杀手。如果确实需要多个请求,至少要用Promise.all并发出去,而不是一个一个等。

2. 串行请求、并发请求与缓存去重的取舍逻辑

2.1 串行改并发:Promise.all 的正确打开方式

把串行改成并发,是最直接的优化手段。原来的代码大概长这样:

// 反面示例:串行请求,耗时累加 async function enrichRecords(records) { const result = []; for (const record of records) { const detail = await fetchDetail(record.creatorId); result.push({ ...record, creator: detail }); } return result; }

这段代码的问题在于,for...of循环里的await会让每次迭代都等待上一次请求完成。如果每个请求平均 100 毫秒,20 条记录就是 2 秒。改成Promise.all之后:

// 正面示例:并发请求,耗时取最大值 async function enrichRecords(records) { const promises = records.map(async (record) => { const detail = await fetchDetail(record.creatorId); return { ...record, creator: detail }; }); return Promise.all(promises); }

这样所有请求几乎同时发出,总耗时取决于最慢的那个请求,而不是所有请求之和。20 个请求如果并发出去,浏览器会按域名并发限制排队,但整体耗时通常能从 2 秒降到 300 到 500 毫秒。

不过这里有个坑:如果 N 特别大,比如每页 100 条,那Promise.all会瞬间发出 100 个请求,浏览器并发限制是 6 个左右,剩下的会排队。排队本身不是问题,但如果后端接口没有做限流或者缓存,瞬间 100 个请求可能会把服务打挂。所以并发数量需要控制。

2.2 用 Map 做请求去重:同一个 ID 只请求一次

并发解决了串行等待的问题,但没有解决重复请求的问题。如果 20 条记录里有 15 条是同一个创建人,那Promise.all会同时发出 15 个一模一样的请求。这时候就需要用Map 缓存来做去重。

思路很简单:在发起请求之前,先检查这个 ID 是否已经在缓存里。如果在,直接返回缓存中的 Promise;如果不在,发起请求并把 Promise 存进 Map。这样同一个 ID 的多个请求会共享同一个 Promise,实际只发一次网络请求。

// 用 Map 缓存请求 Promise,实现去重 const detailCache = new Map(); function fetchDetailWithCache(id) { if (detailCache.has(id)) { return detailCache.get(id); } const promise = fetchDetail(id).catch((err) => { // 请求失败时从缓存中移除,避免缓存错误的 Promise detailCache.delete(id); throw err; }); detailCache.set(id, promise); return promise; }

这里有几个细节值得注意。第一,缓存的是 Promise 而不是结果,这样在并发场景下也能去重,因为 Promise 一旦创建就可以被多次await。第二,请求失败时要记得从 Map 里删掉,否则后续重试会一直拿到失败的 Promise。第三,缓存的粒度要控制好,如果数据变化频繁,需要设置过期时间或者手动清理。

提示:Map 缓存的 key 不一定是单个 ID,也可以是type:id的组合,比如user:123dept:123要区分开。

2.3 并发数量控制:别让浏览器和后端都喘不过气

Promise.all虽然好,但不能无脑用。我一般会加一个并发池,控制同时发出的请求数量。实现方式有很多种,最简单的就是分批:

// 分批并发,每批最多 6 个请求 async function enrichRecordsInBatches(records, batchSize = 6) { const results = []; for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map(async (record) => { const detail = await fetchDetailWithCache(record.creatorId); return { ...record, creator: detail }; }) ); results.push(...batchResults); } return results; }

这样每批最多 6 个请求,和浏览器的并发限制基本吻合,不会造成大量请求排队。批与批之间是串行的,但每批内部是并发的,整体耗时是批数 × 单批耗时,比完全串行快很多,又比无限制并发更可控。

选择 6 这个数字是有依据的。主流浏览器对同一域名的 HTTP/1.1 并发连接数限制通常是 6 个,HTTP/2 虽然支持多路复用,但实际并发数也受限于服务端配置。所以 6 是一个比较稳妥的默认值。如果后端明确支持更高的并发,可以适当调大,但一般不建议超过 10。

3. 从接口设计层面减少扇出的三种思路

3.1 推动后端提供批量查询接口

前端做再多优化,都不如从源头减少请求数量。最彻底的办法是推动后端提供一个批量查询接口,前端把需要查询的 ID 列表一次性传过去,后端返回一个 ID 到详情的映射。

比如原来的接口是GET /api/user/{id},可以新增一个POST /api/user/batch,请求体是{ ids: [1, 2, 3] },返回{ "1": {...}, "2": {...}, "3": {...} }。这样 20 个请求就变成了 1 个请求,性能提升是数量级的。

推动这件事需要一些沟通技巧。我一般会从三个角度说服后端:第一,减少请求数量能降低服务端的连接开销和日志量;第二,批量接口更容易做缓存和限流;第三,前端渲染速度提升对用户体验有直接影响。如果后端一时排不出资源,也可以先在前端做一层 BFF(Backend for Frontend),用 Node.js 中间层来聚合请求。

3.2 列表接口直接内联关联字段

如果关联字段不多,最省事的办法是让列表接口直接返回关联对象的名称等基础信息。比如订单列表接口直接返回creatorNamedepartmentName,前端就不需要再去请求详情了。

这种做法的代价是列表接口的响应体变大,查询逻辑变复杂。但如果关联字段只是名称这种简单字段,后端用一次 JOIN 就能搞定,成本并不高。我在实际项目里经常建议后端这样做,尤其是那些"列表页只需要显示名称,详情页才需要完整信息"的场景。

判断标准很简单:如果某个关联字段在列表页的显示率超过 80%,就应该内联到列表接口里。如果只有少数记录需要显示,或者需要完整详情,那才考虑按需加载。

3.3 用 GraphQL 或类似方案按需取数

如果项目本身就在用 GraphQL,那这个问题天然就好解决。GraphQL 允许前端在一个请求里声明需要哪些字段,包括关联对象的字段,后端一次性返回。这样既不会像 REST 那样扇出,也不会像内联那样返回多余数据。

query { orders(page: 1, size: 20) { id orderNo creator { id name avatar } department { id name } } }

一个查询搞定所有数据,前端不需要再发任何补充请求。当然,GraphQL 的引入成本不低,如果项目没有在用,不建议为了这一个问题去改造。但如果已经在用,那就应该充分利用它的按需取数能力。

注意:GraphQL 的 N+1 问题在服务端同样存在,需要用 DataLoader 之类的工具做批处理,否则只是把扇出从客户端转移到了服务端。

4. 懒加载与可视区域渲染的实战边界

4.1 什么时候该用懒加载,什么时候不该用

懒加载是个好东西,但不是所有场景都适合。列表页的关联数据加载,如果用户一屏只能看到 10 条记录,那剩下 10 条的关联数据其实可以等滚动到可视区域再加载。这就是懒加载的思路。

但这里有个前提:用户确实会滚动。如果列表页的数据量很小,用户一眼就能看完,那懒加载反而增加了交互的复杂度,滚动时数据才慢慢出现,体验并不好。我一般会这样判断:如果列表默认展示超过 30 条,且用户有较大概率会滚动浏览,才考虑懒加载。否则,一次性加载完更简单直接。

另外,懒加载的实现方式也有讲究。用IntersectionObserver监听元素进入视口是最推荐的做法,性能好,代码也简洁:

// 用 IntersectionObserver 实现关联数据的懒加载 const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const row = entry.target; const creatorId = row.dataset.creatorId; fetchDetailWithCache(creatorId).then((detail) => { row.querySelector('.creator-name').textContent = detail.name; }); observer.unobserve(row); } }); }, { rootMargin: '100px' }); document.querySelectorAll('.order-row').forEach((row) => { observer.observe(row); });

rootMargin设置为100px是为了提前加载,用户还没滚到那一行时就开始请求,滚到时数据已经就绪,体验更顺滑。

4.2 骨架屏与占位符的配合使用

懒加载的副作用是数据出现有延迟,如果处理不好,用户会看到空白或者闪烁。这时候骨架屏就派上用场了。在关联数据还没加载出来时,先显示一个灰色的占位块,数据到了再替换成真实内容。这样视觉上更稳定,用户也知道这里会有内容。

骨架屏的实现很简单,就是一个带背景色的div,加上一点动画效果:

.skeleton { display: inline-block; width: 60px; height: 16px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

这个动画效果是背景渐变从左到右移动,看起来像光扫过一样,比单纯的灰色块更有"加载中"的感觉。注意动画时间不要设得太短,1.5 秒左右比较自然,太快了会显得焦躁。

4.3 预加载下一页数据的时机把握

如果列表有分页,用户翻到第二页时又要等一轮请求,体验会断掉。可以在用户浏览第一页时,提前预加载第二页的数据。但预加载的时机要把握好,太早了浪费带宽,太晚了没效果。

我的做法是监听滚动事件,当用户滚动到列表底部附近时,触发下一页数据的预加载。预加载的数据先存在内存里,用户真正点击下一页时直接从内存取,瞬间渲染。

// 滚动到接近底部时预加载下一页 let preloadedPage = null; window.addEventListener('scroll', () => { const scrollBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight; if (scrollBottom < 200 && !preloadedPage) { preloadedPage = currentPage + 1; fetchList(preloadedPage).then((data) => { // 存入缓存,等用户翻页时使用 pageCache.set(preloadedPage, data); }); } });

这里用pageCache来存预加载的数据,用户翻页时先查缓存,命中就直接渲染,没命中再发请求。预加载的请求优先级可以设低一点,避免和当前页的请求抢带宽。

5. 缓存策略的层次与失效处理

5.1 内存缓存、SessionStorage 与 IndexedDB 的选择

缓存不是只有一层。根据数据的生命周期和大小,可以选择不同的存储介质。

存储方式生命周期容量限制适用场景
内存 Map页面刷新即失效受内存限制当前页面会话内的请求去重
SessionStorage标签页关闭失效5-10MB跨页面跳转的临时数据
LocalStorage手动清除才失效5-10MB不常变化的字典数据
IndexedDB手动清除才失效较大大量结构化数据缓存

对于列表页的关联数据,我一般用内存 Map 做请求去重,用 SessionStorage 做跨页面的临时缓存。比如用户从列表页点进详情页,再返回列表页,如果数据在 SessionStorage 里,就不用重新请求了。

IndexedDB 适合缓存大量数据,比如几千条用户信息。但它的 API 比较复杂,如果数据量不大,没必要上 IndexedDB。

5.2 缓存失效的三种触发方式

缓存最大的问题是数据可能过期。用户改了名字,但列表页还显示旧名字,这就尴尬了。所以缓存必须有失效机制。

第一种是时间失效,设置一个 TTL,比如 5 分钟,超过时间就重新请求。实现方式是在缓存值里存一个时间戳,读取时检查是否过期。

function getWithTTL(cache, key, ttl = 5 * 60 * 1000) { const item = cache.get(key); if (!item) return null; if (Date.now() - item.timestamp > ttl) { cache.delete(key); return null; } return item.value; }

第二种是事件失效,当用户执行了修改操作,比如改了名字,就主动清除相关缓存。这需要维护一个 ID 到缓存 key 的映射,改动时精准清除。

第三种是版本失效,在缓存 key 里带上数据版本号,版本变了旧缓存自然失效。这种方式适合整体数据结构变化时使用。

提示:缓存失效策略没有银弹,通常需要组合使用。我的经验是:时间失效兜底,事件失效精准清除,版本失效应对大改版。

5.3 缓存穿透与并发请求的防护

缓存穿透是指请求的 ID 在缓存和数据库里都不存在,导致每次请求都打到数据库。对于关联数据查询,这种情况不常见,但也要防一手。可以在缓存里存一个空值标记,表示"这个 ID 确实没有数据",避免重复查询。

并发请求的防护前面已经讲过,用 Map 缓存 Promise 就能解决。但要注意,如果请求失败了,要从缓存里删掉,否则后续请求会一直拿到失败的 Promise。这个细节很容易被忽略,我在实际项目里就踩过这个坑:一个接口偶发超时,结果这个 ID 的缓存一直是一个 rejected 的 Promise,后续所有请求都直接失败,直到页面刷新。

// 失败时清除缓存,避免缓存错误的 Promise const promise = fetchDetail(id).catch((err) => { detailCache.delete(id); throw err; });

这段代码虽然简单,但能避免很多诡异的问题。

6. 一次完整的优化过程复盘

6.1 优化前的基线数据

回到我最初遇到的那个管理后台。优化前的情况是:列表页每页 20 条记录,主接口返回后,前端串行请求 20 个创建人详情接口。Network 面板显示总请求数 21 个,页面完全渲染耗时约 4.2 秒。Performance 面板显示主线程在请求期间有大量空闲等待,说明瓶颈在网络请求的串行等待上。

用户反馈是"列表页要转好久才出来",尤其是在网络状况一般的情况下,转圈时间更长。这个问题在测试环境不明显,因为测试环境网络延迟低,但在生产环境就暴露出来了。

6.2 分阶段优化的效果对比

我分三步做了优化,每步都测了数据:

优化阶段具体措施请求数渲染耗时
优化前串行请求详情214.2s
第一步改为 Promise.all 并发211.1s
第二步加 Map 缓存去重80.9s
第三步推动后端批量接口20.4s

第一步把串行改并发,耗时从 4.2 秒降到 1.1 秒,提升最明显。第二步加缓存去重,因为 20 条记录里有重复的创建人,请求数从 21 降到 8,耗时进一步降到 0.9 秒。第三步推动后端做了批量接口,前端只需要发一个批量请求,加上主接口一共 2 个请求,耗时降到 0.4 秒。

这个过程中,前两步是前端可以独立完成的,第三步需要后端配合。实际推进时,我先把前两步做了,让效果先出来,然后再拿着数据去和后端沟通批量接口的必要性,这样更有说服力。

6.3 优化后仍需注意的边界情况

优化完成后,并不是就高枕无忧了。有几个边界情况需要处理:

第一,批量接口的 ID 数量限制。后端可能对批量查询的 ID 数量有上限,比如最多 100 个。如果列表页每页 200 条,就需要分批调用批量接口。这个限制要提前和后端确认清楚。

第二,缓存和实时性的平衡。创建人改了名字,列表页的缓存可能还是旧名字。我们的处理是:在用户管理页面修改名字后,主动清除列表页的相关缓存。这需要跨页面的缓存通信,可以用storage事件或者简单的发布订阅模式来实现。

第三,错误处理。批量接口如果部分 ID 查询失败,返回的数据可能不完整。前端要做好兜底,缺失的字段显示为"未知"或者空字符串,而不是让整个页面报错。

第四,加载状态的精细化管理。优化后加载很快,但仍有加载过程。Loading 状态要区分"主数据加载中"和"关联数据加载中",避免用户看到闪烁。我的做法是主数据到了就先渲染表格骨架,关联数据用占位符,到了再替换。

7. 几个容易踩的坑和我的处理习惯

7.1 在循环里 await 的隐蔽写法

最容易被忽略的串行请求,往往藏在看起来很正常的代码里。比如:

// 这种写法看起来没问题,实际上是串行的 const results = []; records.forEach(async (record) => { const detail = await fetchDetail(record.creatorId); results.push(detail); });

forEach里的async回调不会被等待,results在循环结束后可能还是空的。而且这些请求虽然是并发发出的,但results的填充顺序不可控。正确的做法是用map返回 Promise 数组,再用Promise.all等待。

还有一种更隐蔽的:在reduce里用await累加。这种写法逻辑上就是串行的,因为每次累加都依赖上一次的结果。如果确实需要串行,那没问题;如果不需要,就应该改成并发。

7.2 缓存 key 设计不当导致的串数据

缓存 key 设计不好,会出现 A 的数据被 B 用了的情况。我见过一个 bug:缓存 key 只用了 ID,没有区分类型,结果用户 ID 为 123 的详情被部门 ID 为 123 的请求命中了,显示出来的创建人名字是部门名称。这种问题排查起来很费劲,因为数据看起来"有值",只是值不对。

我的习惯是缓存 key 一定要带类型前缀,比如user:123dept:123category:123。如果同一个类型还有不同的字段需求,比如有的地方只要名字,有的地方要完整信息,那 key 里还要带上字段标识,比如user:123:basicuser:123:full

7.3 接口失败后的重试与降级

网络请求失败是常态,尤其是在移动端或者网络不稳定的环境下。关联数据加载失败,不能让整个列表页挂掉。我的处理策略是:

  • 单个关联数据失败,该字段显示为"--",不影响其他行。
  • 批量接口失败,降级为逐个请求,虽然慢但能保证数据完整。
  • 连续失败超过阈值,停止自动重试,显示手动刷新按钮。

重试也要有策略,不能立即重试,否则可能加剧服务端压力。我一般用指数退避,第一次失败等 1 秒,第二次等 2 秒,第三次等 4 秒,最多重试 3 次。

// 指数退避重试 async function fetchWithRetry(fn, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (err) { if (i === maxRetries - 1) throw err; await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000)); } } }

这段代码在实际项目里很实用,尤其是对接第三方接口或者网络不稳定的场景。

7.4 监控与告警的补充

优化做完之后,怎么知道有没有退化?我在项目里加了一个简单的监控:记录列表页从发起到渲染完成的时间,超过阈值就上报。这样如果某天后端接口变慢或者前端代码改动引入了新的扇出,能及时发现。

监控的实现可以用PerformanceObserver监听资源加载,也可以用performance.markperformance.measure手动打点。我一般用后者,因为更灵活,可以精确测量从点击菜单到列表渲染完成的时间。

// 手动打点测量列表页渲染耗时 performance.mark('list-start'); // ... 加载逻辑 ... performance.mark('list-end'); performance.measure('list-render', 'list-start', 'list-end'); const measure = performance.getEntriesByName('list-render')[0]; if (measure.duration > 2000) { // 上报慢加载 reportSlowLoad(measure.duration); }

这个监控不复杂,但能帮你守住优化成果,避免问题悄悄回归。

8. 写在最后的一点个人体会

处理这个 1+N 扇出问题的过程中,我最大的感受是:前端性能优化很多时候不是技术问题,而是沟通问题和习惯问题。技术方案本身并不复杂,Promise.all、Map 缓存、批量接口,都是成熟的手段。难的是养成"看到循环发请求就警觉"的习惯,以及推动后端一起优化接口设计的沟通能力。

我现在 review 代码时,只要看到在循环里发请求,不管是不是await,都会多问一句:这里能不能合并成一个请求?能不能加缓存?这个习惯帮我提前拦住了很多潜在的性能问题。另外,优化之前一定要先测量,不要凭感觉猜。Network 面板和 Performance 面板是最可靠的两个工具,数据会告诉你瓶颈在哪里。

还有一点,优化要分阶段做,每做一步就测一次数据。这样既能验证效果,也能在和后端沟通时拿出有说服力的证据。我见过不少人一上来就要求后端改接口,结果后端问"能提升多少",答不上来,事情就推不动了。先做前端能做的,把数据拿出来,再谈更大的改造,成功率会高很多。

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

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

立即咨询