后端 SQL 已聚合正确,行被炸开的元凶却在前端 flatMap
需求排期压着,后台报表突然出 bug:同一个实体(门店、订单、客户……)在表格里分裂成了两行甚至更多行。 第一反应——"SQL 没聚合好,加个 GROUP BY 或补个 JOIN"。写完 diff,打开页面,行还在,甚至更多了。
这是一次让人绕了一大圈的反直觉踩坑。
现象
后台报表面板,同一个实体 ID(如某门店编号)出现在两行,分属不同的分组标签。数据来自同一个后端接口,后端查询逻辑最近没动过,但前端在一个月前做过「双视角切换」需求(可以在"按门店维度"和"按跟进人维度"之间 radio-group 切换)。
根因
后端 SQL 其实早就正确。主查询早已做了 JOIN 绑定表、GROUP BY 实体 ID,返回的每个实体只有 1 行。
问题出在"双视角"功能加入时,后端额外跑了 2-3 个 by_dimension breakdown query(按部门、按跟进人分切片),前端用 flatMap 把主行和切片叉乘:
// 炸开点:1 实体 × N 部门切片 = N 行
const rows = mainRows.flatMap(r =>
r.by_dept.map(d => ({ ...r, deptName: d.name, deptCount: d.count }))
);再配上 :span-method 做视觉合并,表象上看像是"合并单元格",本质上数据已经是 N 行了。
「UI 有多行」不等于「后端返回多行」。加 GROUP BY、加 JOIN、加 GROUP_CONCAT 改完后症状依然存在——因为后端一直只返回 1 行,是前端 flatMap 把它展开成了 N 行。改错方向不仅无效,还可能破坏已有正确的聚合逻辑。
排查口诀(30 分钟定根因)
Step 1 — 绕开前端直接跑后端 SQL
把接口对应的 controller action 里的 SQL 单独拿出来,在数据库控制台执行,看原始返回行数。
- 每个实体只有 1 行 → 后端正确,前端在炸 → 跳到 Step 3
- 每个实体有 N 行 → 后端真的有问题(罕见)→ Step 2
Step 2 — 检查是否有额外的 breakdown query
在 action 函数体内搜多个独立 ->query() / ->select() 调用,如果发现 by_dept / by_user / by_channel 之类的切片查询,那些就是前端炸开的数据源。
Step 3 — 前端 grep 炸开点
grep -rn "flatMap\|reduce.*push\|span-method" src/views/<page>/三大典型炸开模式:
flatMap行倍化:主行 × 切片行 = N 行- computed transform 在数据层把 1 行扩展成 N 行
:span-method视觉合并——表象是合并,数据已经是 N 行
Step 4 — 看 git log / ISSUE 历史
git log --all --oneline -- <page-file> | head -20如果发现近期有「加 X 视角」「双维度切换」「按 Y 拆分」主题的提交,那一轮加的代码就是现在的元凶。
正解
优先级从高到低:
- 删 前端
flatMap+span-method+radio-group+by_dimensionfetcher(数据源已对,把展示层还原即可) - 删 后端
by_dimensionbreakdown query(它们只给前端炸开消费,前端不炸了它们就是死代码,顺带减少 1-3 个 SQL roundtrip,接口延迟明显下降) - ~~加后端字段/JOIN~~ — 后端已正确,加只引入冗余
- ~~改后端 GROUP BY~~ — 已聚合到 1 行,改了反而破坏
如果用户要求「保留原有视角,新增一个不炸行的面板」,正确姿势是:后端新加独立 action(复用现有主 SQL,不复用 breakdown query)+ 前端新加独立组件(无 flatMap,无 span-method),两套代码完全隔离,不交叉。
验证
修复后确认:
- 前端:目标
.vue/.tsx文件内flatMap和span-method命中 0 次 - 后端:action 内 breakdown query 已删除或仅在保留视角函数内
- 数据:打开页面,1 个实体只出现 1 行
- 性能:接口响应时间下降(少了 2-3 个 SQL roundtrip)
一句话外卖
看到 UI 行炸开,先跑裸 SQL 验后端是否真的有问题;后端 1 行 / 实体,则 100% 是前端 flatMap 在炸——不要往后端加聚合,要往前端删展开。