
管理后台的列表页转圈这件事几乎每个前端都遇到过。页面骨架已经渲染出来了表头也在就是数据迟迟不回来Loading 图标转得人心慌。打开 Network 面板一看请求数量多到吓人——一个列表接口返回了 20 条记录紧接着又发出了 20 个请求去补全每条记录的关联信息这就是典型的1N 请求扇出。这篇文章就从我最近处理的一个真实案例出发把这个问题从定位、分析到解决的完整链路拆开讲清楚涉及Promise.all并发控制、Map 缓存去重、懒加载策略选择等具体手段。不管你是刚接触前端性能优化还是已经做过一些接口层面的调优都能从中找到可以直接复用的思路和代码。1. 从 Network 面板里揪出那个1N的真凶1.1 列表页转圈的表象与实质很多人看到列表页转圈第一反应是接口太慢了或者后端有问题。这个判断有时候对但更多时候只对了一半。我遇到的那个管理后台列表主接口响应时间其实只有 200 毫秒左右数据也正常返回了但页面就是不出来。真正的问题在于主接口返回的每条记录里只带了关联对象的 ID比如creatorId、departmentId、categoryId前端拿到这些 ID 之后需要再去请求对应的详情接口把名称、头像等信息补全才能渲染出完整的表格。于是就有了这样的请求序列1 个列表接口 N 个详情接口。N 等于当前页的记录数如果分页是每页 20 条那就是 12021 个请求如果每页 50 条那就是 51 个请求。这些请求如果还是串行发出的那页面转圈的时间就是所有请求响应时间的总和。即便浏览器有并发限制能同时发 6 个左右整体耗时依然会被拉得很长。提示判断是不是 1N 扇出最直接的方法就是在 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:123和dept: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 个请求性能提升是数量级的。推动这件事需要一些沟通技巧。我一般会从三个角度说服后端第一减少请求数量能降低服务端的连接开销和日志量第二批量接口更容易做缓存和限流第三前端渲染速度提升对用户体验有直接影响。如果后端一时排不出资源也可以先在前端做一层 BFFBackend for Frontend用 Node.js 中间层来聚合请求。3.2 列表接口直接内联关联字段如果关联字段不多最省事的办法是让列表接口直接返回关联对象的名称等基础信息。比如订单列表接口直接返回creatorName、departmentName前端就不需要再去请求详情了。这种做法的代价是列表接口的响应体变大查询逻辑变复杂。但如果关联字段只是名称这种简单字段后端用一次 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 的 N1 问题在服务端同样存在需要用 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:123、dept:123、category:123。如果同一个类型还有不同的字段需求比如有的地方只要名字有的地方要完整信息那 key 里还要带上字段标识比如user:123:basic和user: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.mark和performance.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. 写在最后的一点个人体会处理这个 1N 扇出问题的过程中我最大的感受是前端性能优化很多时候不是技术问题而是沟通问题和习惯问题。技术方案本身并不复杂Promise.all、Map 缓存、批量接口都是成熟的手段。难的是养成看到循环发请求就警觉的习惯以及推动后端一起优化接口设计的沟通能力。我现在 review 代码时只要看到在循环里发请求不管是不是await都会多问一句这里能不能合并成一个请求能不能加缓存这个习惯帮我提前拦住了很多潜在的性能问题。另外优化之前一定要先测量不要凭感觉猜。Network 面板和 Performance 面板是最可靠的两个工具数据会告诉你瓶颈在哪里。还有一点优化要分阶段做每做一步就测一次数据。这样既能验证效果也能在和后端沟通时拿出有说服力的证据。我见过不少人一上来就要求后端改接口结果后端问能提升多少答不上来事情就推不动了。先做前端能做的把数据拿出来再谈更大的改造成功率会高很多。