管理后台列表页为什么一直转圈?从一次 1+N 请求扇出聊聊前端性能优化

这篇文章的灵感来自我自己负责的一个社团官网项目。在开发管理端的过程中,我注意到一个反复出现、又容易被忽略的性能问题:页面一打开就长时间转圈圈。排查后确定是典型的 1+N 扇出。管理端基本是"列表 + 详情"的套路(项目管理、课程管理、文件管理......),所以这种问题出现在管理端的概率其实非常高。遂记录下来,分享给大家,希望对做管理后台的同行有帮助。

问题描述

在管理端 项目展示管理(ProjectDisplay.vue) 页面(记录这个情境以便下面代码的理解),每次进入页面、翻页、搜索时,表格都会长时间处于转圈圈等待(tableLoading) 状态,要等很久才出数据。

进一步,Network表现

刷新一次,发出 1 个 GET /admin/project/page + 10 个 GET /admin/project/detail?id=...,一共 11 个请求。大转圈要等这 11 个全回来才停,最慢的那个决定了页面速度,这就是典型的 1+N 扇出

前置知识

1.什么是 1+N 扇出?

1 次主请求拉到 N 条列表,再对每条各发 1 次子请求去补信息,总请求数 = 1 + N。本项目就是:1 次 page 拿 10 个项目,再循环 10 次 detail 去拿每个项目的成员。

2.串行等 vs 并行等 vs 少发

js 复制代码
// 串行:10 个各 300ms,总共约 3000ms,一个一个等
for (const item of list) {
  await getProjectDetail(item.id);
}

// 并行:10 个一起发,等最慢的那个,约 300~600ms,一起来
await Promise.all(list.map(item => getProjectDetail(item.id)));

// 少发(懒加载+缓存):只发 1 个列表,谁要看才发谁的,也就是按需请求

Promise.all 只是从串行变成并行,请求数还是 N,没有治扇出。

3.什么是内存缓存(Map)?

JS 里 new Map() 就像一个小本子,也就是把拿到的一条key:value数据Map 缓存存储在当前页面运行环境的内存中生命周期与当前页面一致,刷新页面后会重新创建

由于 Map 生命周期跟随当前页面,刷新后自动销毁,因此不会像持久化缓存一样长期保存旧数据。

问题代码

问题一:

​ 列表一进来就预查全部详情,大转圈被 Promise.all 卡住。

​ 我们可以看到,从开始加载tableLoading.value = true到停止转圈tableLoading.value = false之间有多次请求,这也就是拿数据慢在前端的原因。

js 复制代码
const fetchProjectList = async () => {
    // 开大转圈
    tableLoading.value = true; 
  try {
    // 第 1 个请求:只拿项目基础信息,后端返回 ProjectPageVO,没有成员
    const res = await getProjectPage({
      pageNum: currentPage.value,
      pageSize: pageSize.value,
      name: searchKeyword.value || undefined,
    });
    if (res.code === 200) {
      projectList.value = res.data.records || [];
      total.value = res.data.total || 0;
      projectList.value.forEach((item) => {
        item.selectedGeneration = null;
        item.memberInfo = [];
      });

      // 问题点:N 个请求,每个项目再查一次 detail 去补成员
      // 后端 detail 要拼 3 张表,很重,10 条就是 10 次
      // await 等全部回来才往下走,finally 才关转圈,所以一直转
      await Promise.all(
        projectList.value.map(async (item) => {
          try {
            const detailRes = await getProjectDetail(item.id);
            if (detailRes.code === 200) {
              item.memberInfo = detailRes.data?.generations || [];
            }
          } catch (error) {
            //以下省略
          }
        })
      );
    } else {
      //以下省略
    }
  } catch (error) {
    //以下省略
  } finally {
    // 所有 detail 回来才停圈
    tableLoading.value = false; 
  }
};

问题不在 Promise.all 写法,而在于列表加载阶段主动触发了 N 次详情请求。

问题二:

​ 切一次代数就全量重查,没有记性。

js 复制代码
// 表格中切换代数 --- 从项目详情中查询该代成员信息
const handleGenerationChange = async (row, generation) => {
  row.selectedGeneration = generation;
  try {
    // 问题点:每次切都重新请求全量 detail,其实 generations 全在里面了
    const res = await getProjectDetail(row.id);
    if (res.code === 200) {
      row.memberInfo = res.data?.generations || [];
    }
  } catch (error) {
    console.error(error);
  }
};

解决方案

1.Promise.all 解决串行等待(不够)

for + await 一个一个等,变成 Promise.all 一起等。时间从 N 倍变成 1 倍最慢的,但请求数还是 N,照样排队、照样查库 10 次、大转圈照样等全部。所以只保留思路,不再预查。

2.Map 缓存 + 懒加载,减少请求次数(治本)

两个字:懒 +存

  • 懒:列表只拿列表,fetchProjectList 里删掉 Promise.all,大转圈只等 1 个 page 就停,表格先出来。成员那列先显示"请选择代数查看成员"。
  • 存:用户点了哪一行的代数,handleGenerationChange 才去查那 1 个 detail,回来 detailCache.set(id, generations) 存住。下次切同项目别的代数,直接 detailCache.get(id),0 请求。

修改后代码

改动一:

fetchProjectList 去掉扇出,列表回来直接展示。

js 复制代码
//记得加上内存缓存
const detailCache = new Map();

const fetchProjectList = async () => {
  // 大转圈只管列表这 1 个请求,成员不在这里预查
  tableLoading.value = true;
  try {
    // 1 次主请求:拿 10 条项目基础信息
    const res = await getProjectPage({
      pageNum: currentPage.value,
      pageSize: pageSize.value,
      name: searchKeyword.value || undefined,
    });
    if (res.code === 200) {
      // 直接展示,不再 Promise.all 去查 detail
      // memberLoading:这一行自己的小转圈,不卡整个表
      // memberLoaded:这一行查过没有,没查过显示提示文案
      projectList.value = (res.data.records || []).map((item) => ({
        ...item, // 保留 id/name/imageUrl 等原字段
        selectedGeneration: null, // 当前选中的代数,默认没选
        memberInfo: [], // 成员详情,先空着,等用户点了再填
        memberLoading: false, // 是否正在查这个项目,防重复点击
        memberLoaded: false, // 是否已经查过,查过就不再发请求
      }));
      total.value = res.data.total || 0;
    } else {
      ElMessage.error(res.message || '获取项目列表失败');
    }
  } catch (error) {
    console.error(error);
    ElMessage.error('网络异常,获取项目列表失败');
  } finally {
    // 列表回来就停大圈,不用等 N 个 detail
    tableLoading.value = false;
  }
};

改动二:

handleGenerationChange 改成先查内存,没有才发请求。

这里有一个缓存设计点:

"为什么按id缓存"

因为详情数据的唯一标识是项目 id,因此缓存粒度选择项目 id,而不是代数。一次 detail 返回所有 generations,可以避免同一个项目不同代数重复请求。

js 复制代码
// 表格中切换代数 --- 懒加载:第一次查 detail 并用 Map 记住,同项目下次直接用
const handleGenerationChange = async (row, generation) => {
  // 先记住用户选了哪个代数,用于过滤显示
  row.selectedGeneration = generation;

  // 命中缓存:之前查过这个项目,直接从内存拿,不发请求
  if (detailCache.has(row.id)) {
    row.memberInfo = detailCache.get(row.id);
    return;
  }

  // // 并发守卫:这一行正在查就别重复发,避免连点发出多个相同请求
  if (row.memberLoading) return;

  // 没命中:才发 1 次 detail,查的是全量 generations,前端再按代数过滤
  row.memberLoading = true; // 打开这一行的小转圈
  try {
    const res = await getProjectDetail(row.id);
    if (res.code === 200) {
      const gens = res.data?.generations || [];
      detailCache.set(row.id, gens); // 存进小本子,下次直接用
      row.memberInfo = gens; // 填到这一行,页面显示出来
      row.memberLoaded = true; // 标记查过,模板从提示文案切换到成员列表
    }
  } catch (error) {
    console.error(error);
  } finally {
    row.memberLoading = false; // 关小转圈
  }
};

改动3:

因为改动二的缓存机制,也牵动了这个改动。

如果缓存失效,改完数据要擦小本子,不然看到旧的。

js 复制代码
// 搜索、翻页、切每页条数:条件变了旧缓存没用
const handleSearch = () => {
  currentPage.value = 1;
  detailCache.clear();
  fetchProjectList();
};

// 建议再包一层给分页用,或者在fetchProjectList开头clear
// 不然@current-change直调fetchProjectList会绕过clear

// handleSubmit编辑成功分支,ElMessage.success后面
detailCache.delete(projectForm.id);

// handleDelete单个删除成功分支
detailCache.delete(row.id);

// handleBatchDelete批量删除成功分支
detailCache.clear();

注:

1.模板部分也有小小的改动,本篇文章主要整理逻辑,暂不写出。

2.长时间转圈不一定只是前端问题,在团队开发中也可检查后端逻辑,后端也可能出现此类问题,在此不做赘述。

如果某字段是列表页面必展示的数据,更推荐后端在分页接口中完成数据聚合,避免前端通过多个详情接口拼接列表数据;如果字段属于低频查看信息,则使用懒加载更加合适。

性能对比

本篇文章的重点不是追求并发,而是减少无效请求。

方案 请求数量 等待时间 问题
串行请求 1+10 约3000ms 等待时间最长
Promise.all 1+10 约300~600ms 请求数量没减少
懒加载+缓存 1+用户点击数量 首屏最快 需要管理缓存
后端聚合 1 最快 接口复杂度增加

总结

  1. Promise.all 是"10 份外卖一起点",快一点但还是 10 份;懒加载 + 缓存是"谁吃谁点,吃过不点",才真正把请求数从 1+10 降到 1+谁看查谁
  2. 本次只改了 ProjectDisplay.vue 一个前端文件,大转圈只等 1 个 page,表格秒开;成员点哪行查哪行,同项目只查 1 次。
  3. Map 缓存活在标签页内存里,刷新就没,不用担心爆内存和长期脏数据,但记得在增删改后 delete/clear,不然改完看的还是旧的。
  4. 验证:F12 -> Network 刷新,原来 1 个 page + 10 个 detail,现在只有 1 个 page;点某行代数才多 1 个 detail,同项目再切代数不再发请求就是成功了。
  5. 如果列表页面需要展示的字段没有包含在列表接口中,而是需要逐条调用详情接口补充,那么很容易产生 1+N 问题。
相关推荐
_山海2 小时前
Bun入门指南
前端·javascript·后端
YHL2 小时前
🎯 JavaScript 单例模式(Singleton Pattern)—— 从理论到实战
javascript·设计模式
vx_Biye_Design3 小时前
springboot游泳馆系统93765-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·spring·课程设计
晴天163 小时前
VSCodium 项目介绍
前端·javascript·vscode
世岩清上3 小时前
展厅数字内容同质化严重,怎样打造专属叙事风格?
大数据·前端·javascript·人工智能·html·音视频·展厅改造
持敬chijing3 小时前
Web前后端开发-JavaScript 入门教程
开发语言·前端·javascript
leoZ2313 小时前
2026-09-10-静态扫描连错三次-用运行环境当裁判
前端·javascript·vue.js·人工智能·opencv·机器学习·数据挖掘
Maxkim3 小时前
我把一个"会自己查资料"的 AI 助手塞进了浏览器侧边栏,开源了
前端·javascript