这篇文章的灵感来自我自己负责的一个社团官网项目。在开发管理端的过程中,我注意到一个反复出现、又容易被忽略的性能问题:页面一打开就长时间转圈圈。排查后确定是典型的
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 | 最快 | 接口复杂度增加 |
总结
Promise.all是"10 份外卖一起点",快一点但还是 10 份;懒加载 + 缓存是"谁吃谁点,吃过不点",才真正把请求数从1+10降到1+谁看查谁。- 本次只改了
ProjectDisplay.vue一个前端文件,大转圈只等 1 个page,表格秒开;成员点哪行查哪行,同项目只查 1 次。 Map缓存活在标签页内存里,刷新就没,不用担心爆内存和长期脏数据,但记得在增删改后delete/clear,不然改完看的还是旧的。- 验证:F12 -> Network 刷新,原来 1 个
page+ 10 个detail,现在只有 1 个page;点某行代数才多 1 个detail,同项目再切代数不再发请求就是成功了。 - 如果列表页面需要展示的字段没有包含在列表接口中,而是需要逐条调用详情接口补充,那么很容易产生 1+N 问题。