性能预算重构专项 - 完整审计报告
审计日期: 2026-07-05
审计范围: e:\Desktop\CICD 全项目(Next.js 16.0.10 + React 19.2.1 + Turbopack + Drizzle ORM + MySQL + NextAuth v5)
审计维度: ① Bundle 体积预算(首期重点)② Core Web Vitals ③ 组件渲染性能 ④ Server Action / 数据层
审计方式: 静态代码搜索 + 依赖图分析 + 源码路径追踪(未运行时采样)
交付形式: 完整审计报告(不做实施)
说明: 当前 next build 因 Turbopack 编译错误未成功出包,量化指标为基于依赖图与库体积经验值的合理推断值,需在修复 P0 项后重新构建获取实际产物体积进行第二轮校准。
目录
执行摘要
本次性能预算审计覆盖 4 个维度,共发现 24 个 P0/P1 问题、22 个 P2 问题、14 个 P3 问题,全部为静态发现,未做运行时验证。
P0 严重问题(影响生产稳定性 / 必须先修)
| # |
维度 |
问题 |
文件位置 |
| 1 |
Bundle |
KnowledgeGraph(@xyflow/react)未动态导入,textbook 路由 chunk 膨胀 ~120 KB |
src/modules/textbooks/components/knowledge-graph.tsx:5-14 |
| 2 |
Bundle |
ExamRichEditor / RichTextEditor / rich-text-block 三处 tiptap 未动态导入,编辑器路由 chunk 膨胀 ~180 KB |
src/shared/components/ui/rich-text-editor.tsx:5-8、src/modules/exams/editor/exam-rich-editor.tsx:4-6、src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6 |
| 3 |
Bundle |
next.config.ts 未启用 experimental.optimizePackageImports(lucide-recharts-radix-tiptap 全部未优化) |
next.config.ts:6-11 |
| 4 |
Bundle |
serverExternalPackages 仅含 mysql2,未追加 tencentcloud-sdk-nodejs(2-3 MB 误打包风险) |
next.config.ts:10 |
| 5 |
Bundle |
src/modules/exams/editor/index.ts barrel 已实际造成构建错误(代码中已有显式绕过注释) |
src/modules/exams/editor/index.ts |
| 6 |
CWV |
无 Web Vitals 上报闭环,生产环境零真实用户性能数据 |
src/app/layout.tsx(缺失 useReportWebVitals) |
| 7 |
组件 |
React Compiler(babel-plugin-react-compiler)未安装,memoization 缺失无法自动补全 |
package.json + next.config.ts |
| 8 |
组件 |
SidebarProvider value 内联对象 + toggleSidebar 未 useCallback + resize 监听未防抖,全站 Header/Sidebar 高频重渲染 |
src/modules/layout/components/sidebar-provider.tsx:63-66 |
| 9 |
组件 |
备课编辑器 LessonPlanEditor 通过 useLessonPlanEditor() 订阅全 store,每次按键触发三栏全量重渲染 |
src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62 |
| 10 |
组件 |
题库表 QuestionDataTable 无虚拟化,1000+ 题时首屏渲染秒级卡顿 |
src/modules/questions/components/question-data-table.tsx:45-59 |
| 11 |
数据层 |
copyCoursePlanToClasses 双层循环单条 INSERT,无事务无批量无大小限制 |
src/modules/course-plans/data-access.ts:347-406 |
| 12 |
数据层 |
跨请求数据缓存完全缺失(0 处 unstable_cache),低变更频率数据每次重查 DB |
全项目 |
| 13 |
数据层 |
每次 Server Action 调用必查 DB 解析 DataScope(admin 0 次 / teacher 2 次 / parent 2 次串行) |
src/shared/lib/auth-guard.ts:44-47 |
| 14 |
数据层 |
searchQuestions JSON 字段 CAST 后 LIKE %kw% 全表扫描 |
src/modules/search/data-access.ts:74-75 |
| 15 |
数据层 |
getAllUserIds 无 LIMIT 全表返回,5000+ 用户场景公告 fan-out 内存爆炸 |
src/modules/users/data-access.ts:348-351 |
P1 高优先级问题(影响高频接口性能)
详见各分章节,共 9 项关键 P1(含:recharts barrel import、tencentcloud-sdk serverExternalPackages、exams/editor barrel 拆分、loading.tsx 缺失、force-dynamic 滥用、Lighthouse CI 缺失、layout 串行 await、autoGradeSubmission 循环 UPDATE、exams 索引缺失等)。
关键优化杠杆点(ROI 排序)
- React Compiler 启用:单点改动,自动解决第 3 部分列出的多数 memoization 反模式
- next.config.ts 完善:单点改动,optimizePackageImports + serverExternalPackages 扩展
- tiptap / ReactFlow 动态导入:3 处文件改动,编辑器路由 chunk 减少 ~300 KB
- 备课编辑器 store 订阅拆分:单文件改动,编辑器按键响应速度预计提升 5-10x
- unstable_cache 引入:DataScope + 低变更数据缓存,单次 Server Action DB 查询数减半
第一部分:Bundle 体积预算审计(首期重点)
1.1 大体积依赖清单
| 依赖 |
版本 |
估算体积(min+gzip) |
用途 |
服务端/客户端 |
严重度 |
recharts |
^3.6.0 |
~95-110 KB |
图表库(13 处使用) |
客户端 |
P1 |
@xyflow/react |
^12.11.0 |
~120-140 KB |
ReactFlow 知识图谱 |
客户端 |
P1 |
react-force-graph-2d |
^1.29.1 |
~80-100 KB(含 d3-force) |
引力图谱 |
客户端 |
P2(已 dynamic) |
tencentcloud-sdk-nodejs |
^4.1.254 |
~2-3 MB(巨型 SDK) |
腾讯云短信 |
服务端 |
P0 |
exceljs |
^4.4.0 |
~250-300 KB |
Excel 导入导出 |
服务端 |
P1 |
@tiptap/*(5 包 + tiptap-markdown) |
3.x |
~180-220 KB |
富文本编辑器(3 处) |
客户端 |
P0 |
@dnd-kit/*(core+sortable+utilities) |
6.x/10.x |
~30-40 KB |
拖拽 |
客户端 |
P2 |
lucide-react |
^0.562.0 |
单图标 ~1 KB,全集 ~800 KB |
图标库(100+ 处) |
客户端 |
P1 |
radix-ui/*(16 个包) |
各版本 |
单包 ~3-8 KB |
UI 原语 |
客户端 |
P2 |
openai |
^6.25.0 |
~60-80 KB |
AI SDK |
服务端 |
P2 |
react-markdown + remark-* + rehype-* |
4.x-10.x |
~50-70 KB |
Markdown 渲染 |
客户端 |
P2 |
@tanstack/react-query + react-table |
5.x/8.x |
~35-45 KB |
数据层 |
客户端 |
P3 |
1.2 "use client" 指令分布
| 指标 |
数值 |
"use client" 文件总数 |
553 |
src/**/*.{ts,tsx} 文件总数 |
>700 |
| 客户端组件占比(估算) |
~70-75% |
shared/components/ui/ 下 "use client" 文件 |
40 个 |
典型反例:
| 文件 |
问题 |
严重度 |
src/shared/components/ui/status-badge.tsx:1 |
仅渲染 Badge 无 hook 无事件,过度标记为 client |
P2 |
src/shared/components/ui/label.tsx:1 |
仅 forwardRef + cva,可考虑改 server |
P3 |
1.3 动态导入使用情况
| 模式 |
数量 |
位置 |
next/dynamic |
2 |
force-graph.tsx:18、homework-assignment-exam-error-explorer-lazy.tsx:5 |
React.lazy |
0 |
无 |
服务端动态 import() |
1 |
sms-channel.ts:186(tencentcloud) |
应使用但未使用动态导入的重型组件:
[P0] ReactFlow(@xyflow/react)未动态导入
- 位置:
src/modules/textbooks/components/knowledge-graph.tsx:5-14
- 代码:
import { ReactFlow, Background, ... } from "@xyflow/react" 顶层静态导入
- 影响路由:
teacher/textbooks/[id] 与 student/learning/textbooks/[id]
- 估算影响:每个 textbook 路由 chunk 增加 ~120-140 KB
- 修复参考:
force-graph.tsx:74-80 已有的 dynamic(() => import(...), { ssr: false, loading: ... }) 模式
[P0] tiptap 富文本编辑器未动态导入(3 处)
- 位置 1:
src/shared/components/ui/rich-text-editor.tsx:5-8
- 位置 2:
src/modules/exams/editor/exam-rich-editor.tsx:4-6
- 位置 3:
src/modules/lesson-preparation/components/blocks/rich-text-block.tsx:3-6
- 影响路由:
teacher/exams/[id]/edit-rich、teacher/exams/new、教材阅读页链路、备课编辑页
- 估算影响:每个含编辑器路由增加 ~180-220 KB
1.4 大体积第三方库使用模式
lucide-react(100+ 处使用)
- ✅ 全部使用具名导入
import { IconName } from "lucide-react"
- ❌ 缺少
experimental.optimizePackageImports: ["lucide-react"](Turbopack 内建 tree-shaking 可能不彻底)
- 严重度:P1
recharts(13 处使用)
- ✅ 业务侧具名导入
- ❌ [P1 反例]
src/shared/components/ui/chart.tsx:5 → import * as RechartsPrimitive from "recharts" barrel 导入
- 影响:chart.tsx 被 12 个图表组件引用,barrel 导入让 Turbopack 难以精确剔除未用子模块
- 修复:改为按需具名导入
@xyflow/react(4 处使用,仅 textbooks 模块)
- 模块隔离良好 ✅
- 但未动态导入导致 textbook 路由 chunk 膨胀 ❌
tencentcloud-sdk-nodejs(服务端专用,已正确隔离)
- 位置:
src/modules/notifications/channels/sms-channel.ts:186
- 模式:
await import("tencentcloud-sdk-nodejs") ✅
- [P0] 建议追加
serverExternalPackages: ["tencentcloud-sdk-nodejs"] 防止误打包
exceljs(1 处使用)
- 位置:
src/shared/lib/excel.ts:3
- 模式:静态
import ExcelJS from "exceljs"(值导入)
- 风险:若被 client 组件误引用将导致 250-300 KB 打包
1.5 next.config.ts 配置审计
当前完整内容(11 行):
缺失配置项:
| 缺失配置 |
影响 |
严重度 |
experimental.optimizePackageImports |
lucide-react/recharts/@xyflow/react/@radix-ui/* /@tiptap/* 未启用按需优化 |
P0 |
serverExternalPackages 未含 tencentcloud-sdk-nodejs |
2-3 MB SDK 误打包风险 |
P0 |
serverExternalPackages 未含 exceljs |
250-300 KB 误打包风险 |
P1 |
| 自定义 webpack/turbopack manualChunks |
无 splitChunks 自定义 |
P2 |
experimental.webVitalsAttribution |
无法精确定位 Web Vitals 来源元素 |
P2 |
建议补充配置:
1.6 Barrel File 风险
| 路径 |
导出 |
风险 |
src/shared/components/ui/ |
无 index.ts barrel |
✅ 良好 |
src/modules/exams/editor/index.ts |
ExamRichEditor + 6 个 tiptap 扩展 + 类型 |
P0 高风险 |
src/modules/exams/ai-pipeline/index.ts |
— |
低 |
src/shared/hooks/index.ts |
7 个 hook |
低 |
[P0] exams/editor/index.ts 高风险 barrel
- 位置:
src/modules/exams/editor/index.ts:1-26
- 已造成实际问题:
src/app/(dashboard)/teacher/exams/[id]/edit-rich/page.tsx:9-14 已有显式注释绕过 barrel
- 但同文件第 15 行同步导入
ExamRichForm(其内部第 22-32 行又通过 barrel 导入)→ 绕过失效
- 错误信息:
'Class extends value undefined is not a constructor or null'(Server Component 连带加载 @tiptap/react 客户端扩展)
- 修复方向:拆分为
editor/types.ts(仅类型,可被 server 引用)+ editor/index.ts(仅值导出,禁止 server 引用)
1.7 图片/字体/静态资源
| 资源 |
现状 |
评估 |
next/image 使用 |
4 处(exam-preview、scan-uploader、scan-image-viewer、exam-rich-form) |
✅ 良好 |
原生 <img> |
1 处(scan-image-viewer.tsx:143,已加 eslint-disable 注释) |
⚠️ 见 CLS-1 |
next/font |
1 个(Inter,display: "swap" + variable) |
✅ 良好 |
manifest.ts |
完整 |
✅ 良好 |
public/ |
5 个 SVG |
✅ 良好 |
1.8 Bundle 预算建议基线表
因当前 next build 失败无法获取实际产物体积,以下为基于依赖估算的 K12 教务系统推荐基线(gzip 后)。
| 路由类别 |
First Load JS 阈值 |
当前估算 |
严重度 |
落地页 / 登录 / 注册((auth)/*) |
≤ 180 KB |
~150-180 KB |
P2 |
| 学生端 Dashboard |
≤ 250 KB |
~250-320 KB |
P2 |
| 教师端 Dashboard |
≤ 280 KB |
~280-350 KB |
P2 |
| 列表/详情页(公告/作业/成绩等) |
≤ 300 KB |
~300-400 KB(recharts 拉入) |
P1 |
| 教材阅读页(含 ReactFlow + tiptap + force-graph) |
≤ 600 KB |
~700-900 KB(全量静态导入) |
P0 |
| 试卷富文本编辑页(含 tiptap 全家桶) |
≤ 500 KB |
~600-750 KB |
P0 |
| 备课编辑页(含 tiptap + dnd-kit) |
≤ 550 KB |
~600-800 KB |
P0 |
| 单路由增量 chunk(动态加载组件) |
≤ 200 KB |
force-graph 单独 chunk ~80 KB ✅ |
— |
| 共享框架 chunk(react/react-dom/next) |
≤ 130 KB |
~130 KB(标准) |
— |
| 单文件体积上限(lint 规则) |
≤ 1000 行 |
已有项目规则约束 |
— |
第二部分:Core Web Vitals 审计
2.1 LCP(最大内容绘制)
[P1] LCP-1:根 layout.tsx 串行 await
- 位置:
src/app/layout.tsx:45-50
- 问题:每个 SSR 请求串行调用
getLocale() → getMessages() → auth(),三者无数据依赖
- 影响:TTFB、LCP、FCP(每个页面渲染都被推迟)
- 修复:
const [locale, messages, session] = await Promise.all([getLocale(), getMessages(), auth()])
[P1] LCP-3:teacher/student/parent/management 根路由缺失 loading.tsx
- 问题:通过 Glob 搜索
**/loading.tsx 仅找到 admin/loading.tsx,4 个角色根路由段下无 loading.tsx
- 影响:角色切换/首次进入时白屏闪烁,LCP/CLS 退化
- 建议:为 4 个角色根路由分别创建
loading.tsx,复用 DashboardLoadingSkeleton
[P1] LCP-5:force-dynamic 滥用,无 ISR 缓存收益
- 问题:全项目共 96 处
export const dynamic = "force-dynamic",即使是相对静态的页面(学校列表、部门列表、教材目录)也强制动态
- 影响:每请求都重新 SSR + DB 查询,TTFB/LCP 无静态缓存命中加速
- 建议:对低频变更页面用
revalidate = 3600 替代 force-dynamic
[P2] LCP-2:备课编辑器 textbook → chapters 数据瀑布
- 位置:
src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx:34-46
- 问题:第 34-46 行串行调用
getTextbookById() → getChaptersByTextbookId()
- 参考:
parent/lesson-plans/[planId]/view/page.tsx:49-52 已修复为 Promise.all
正面发现
- ✅
teacher/lesson-plans/page.tsx、teacher/exams/[id]/build/page.tsx、teacher/exams/[id]/analytics/page.tsx、parent/lesson-plans/[planId]/view/page.tsx 均用 Promise.all
- ✅ 4 个角色 dashboard 均用 React
use() + Suspense 流式渲染
- ✅
DashboardSection 封装 SectionErrorBoundary + Suspense + 骨架屏,分区流式渲染
2.2 CLS(累计布局偏移)
[P2] CLS-1:scan-image-viewer.tsx 使用 <img> 且未指定宽高
- 位置:
src/modules/homework/components/scan-image-viewer.tsx:142-148
- 问题:原生
<img>(已加 eslint-disable 注释)仅设 maxHeight: "80vh",无 width/height 也无 aspect-ratio
- 影响:图片加载完成时推动布局
- 修复:用
next/image 替换(同文件第 194-200 行缩略图已正确用 next/image fill sizes="48px"),或为 <img> 加 aspect-ratio 内联样式
正面发现
- ✅ 字体配置正确(
display: "swap" + variable)
- ✅
<iframe> 全项目零使用
- ✅ Sonner Toaster 使用 fixed 定位
- ✅ ThemeProvider 配置
disableTransitionOnChange
2.3 INP(交互到下一次绘制)
[P2] INP-3:AiAssistantWidget 未懒加载
- 位置:
src/app/(dashboard)/layout.tsx:7,29
- 问题:
<AiAssistantWidget /> 在 dashboard layout 同步渲染,依赖 useFloatingBall(拖拽位置计算 + localStorage 持久化 + 屏幕边缘吸附)+ AiChatPanel + Sheet,未通过 next/dynamic 懒加载
- 影响:INP(主线程占用)、FCP(hydration 时间)
- 建议:用
next/dynamic 包裹,ssr: false,初始仅渲染悬浮球,点击后再加载 chat panel
- 位置:
src/modules/layout/components/app-sidebar.tsx:31-60
- 问题:每次渲染对
allNavItems 做 filter + map 重新计算 navItems,未使用 useMemo
- 建议:
useMemo 包裹 navItems 计算,依赖 [allNavItems, permissions]
正面发现
- ✅ 全项目 44 处
useTransition / useDeferredValue 使用,覆盖良好(exam-assembly 防抖、exam-rich-form 双 transition、scan-uploader 等)
- ✅ 备课编辑器使用 zustand store,无大对象 setState
- ✅ react-force-graph-2d 已正确动态加载
2.4 FCP / TTFB
[P1] FCP-1:根 layout 串行 await(同 LCP-1)
见 LCP-1。
[P2] FCP-4:next.config.ts 未启用 optimizePackageImports
见第一部分 1.5。
正面发现
- ✅
proxy.ts 中间件无数据库同步调用,纯 JWT 位图运算
- ✅
instrumentation.ts 启动时一次性执行
- ✅
globals.css 多层 @import 均为本地资源无外部阻塞
2.5 资源加载
[P2] RES-1:metadata 缺 metadataBase / openGraph / robots
- 位置:
src/app/layout.tsx:15-24
- 问题:metadata 仅含
title/description/manifest/appleWebApp,缺 metadataBase、openGraph、twitter、robots;src/app/icon.svg 文件可能不存在(public/ 下仅有 5 个 SVG)
- 影响:FCP(OG image 加载)、SEO、build 时 warn
- 建议:补充
metadataBase: new URL(process.env.NEXTAUTH_URL ?? "http://localhost:3000")
正面发现
- ✅
viewport 导出符合 Next.js 16 规范
- ✅
manifest.ts 配置完整
- ✅
<Script> 标签零使用,无第三方脚本阻塞
- ✅
next/image 使用合规(4 处)
2.6 性能监控缺失
[P0] MON-1:无 Web Vitals 上报闭环
- 问题:Grep
web-vitals|reportWebVitals|useReportWebVitals|onCLS|onINP|onLCP 在 src/ 下零匹配;package.json 无 web-vitals 依赖
- 影响:生产环境无法追踪真实用户 LCP/CLS/INP/TTFB/FCP,所有性能问题只能靠经验推断
- 建议:
- 安装
web-vitals 包
- 在
src/app/layout.tsx 或新建 src/instrumentation-client.ts 实现 useReportWebVitals,上报到 /api/web-vitals 或第三方(Vercel Analytics / PostHog / Sentry)
[P1] MON-2:无 Lighthouse CI 配置
- 问题:项目根目录无
.lighthouserc.json / .lighthouserc.js;package.json 无 @lh3/cli 依赖;.gitea/workflows/ci.yml 无 lighthouse step
- 影响:CI 流水线无 Web Vitals 回归门槛,性能劣化无法自动拦截
- 建议:在
.gitea/workflows/ 新增 lighthouse.yml,配置 Lighthouse CI 对关键路由(/dashboard、/teacher/lesson-plans、/admin/questions)做采样断言
[P2] MON-4:Playwright 无性能断言
- 问题:
playwright.config.ts 已配置 E2E + 视觉回归,但未使用 page.metrics() 或 @playwright/test-performance 做性能断言
- 建议:在
tests/e2e/ 新增 performance.spec.ts,使用 page.evaluate(() => performance.getEntriesByType('navigation')) 断言关键路由 LCP
2.7 路由级性能风险
| 路由 |
LCP 风险 |
INP 风险 |
严重度 |
备课编辑器 teacher/lesson-plans/[planId]/edit |
中(waterfall + ReactFlow + tiptap) |
中(多 useTransition + zustand) |
P2 |
试卷富文本编辑器 teacher/exams/[id]/edit-rich |
中(两 await 无 Promise.all) |
高(tiptap + 多状态) |
P2 |
教材详情/知识图谱 teacher/textbooks/[id] |
低(已 Promise.all) |
高(react-force-graph + @xyflow) |
P2 |
题库管理 admin/questions |
中(200 题一次拉) |
高(200 题列表渲染) |
P2 |
| 4 个角色 dashboard |
低(已流式渲染) |
中(recharts) |
P3 |
2.8 Web Vitals 预算建议表
| 指标 |
Good(达标) |
Needs Improvement |
Poor |
建议预算(P95) |
| LCP |
≤ 2.5s |
2.5s - 4.0s |
> 4.0s |
≤ 2.5s(备课编辑器允许 ≤ 3.0s) |
| INP |
≤ 200ms |
200ms - 500ms |
> 500ms |
≤ 200ms(仪表盘图表允许 ≤ 300ms) |
| CLS |
≤ 0.1 |
0.1 - 0.25 |
> 0.25 |
≤ 0.05 |
| TTFB |
≤ 800ms |
800ms - 1800ms |
> 1800ms |
≤ 800ms(dashboard 路由) |
| FCP |
≤ 1.8s |
1.8s - 3.0s |
> 3.0s |
≤ 1.8s |
按路由细分预算建议
| 路由 |
LCP |
INP |
TTFB |
主要风险 |
/login |
≤ 1.5s |
≤ 100ms |
≤ 500ms |
无 |
/admin/dashboard |
≤ 2.5s |
≤ 300ms |
≤ 800ms |
recharts 多图表 + 6 个并行数据流 |
/teacher/dashboard |
≤ 2.5s |
≤ 300ms |
≤ 800ms |
同上 |
/student/dashboard |
≤ 2.0s |
≤ 200ms |
≤ 800ms |
数据较少 |
/parent/dashboard |
≤ 2.5s |
≤ 250ms |
≤ 800ms |
多子女卡片 + AI 摘要 |
/teacher/lesson-plans/[id]/edit |
≤ 3.0s |
≤ 400ms |
≤ 1000ms |
ReactFlow + tiptap + 数据瀑布 |
/teacher/exams/[id]/edit-rich |
≤ 3.0s |
≤ 400ms |
≤ 1000ms |
tiptap 富文本 |
/teacher/textbooks/[id] |
≤ 2.5s |
≤ 400ms |
≤ 800ms |
react-force-graph-2d(动态加载) |
/admin/questions |
≤ 2.5s |
≤ 400ms |
≤ 800ms |
200 题列表 |
第三部分:组件渲染性能审计
3.1 Memoization 缺失审计
全局使用统计
| 指标 |
数量 |
备注 |
React.memo / memo() |
4 处 / 4 文件 |
严重偏低(项目有 100+ 客户端组件) |
useMemo |
113 处 / 70 文件 |
中等覆盖 |
useCallback |
129 处 / 53 文件 |
中等覆盖 |
style={{ ... }} 内联 |
122 处 / 36 文件 |
大量内联对象每次渲染重建 |
memo / useMemo 比例 = 4 / 113 ≈ 3.5%,远低于企业级项目 30%+ 常见水平。
React.memo 实际使用位置(仅 4 处)
src/modules/ai/components/ai-markdown-renderer.tsx:162 — AiMarkdownRenderer 正确 memo
src/modules/textbooks/components/graph-prerequisite-edge.tsx
src/modules/textbooks/components/graph-kp-node.tsx
src/modules/student/components/student-courses-view.tsx
[P1] 反例 1:recharts 内联对象 props
- 位置:
src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx:39-61
- 代码:
- 影响:
margin / tick / contentStyle / dot 每次渲染均为新对象引用,触发 recharts 内部 SVG 元素重渲染
- 修复:提升为模块级常量
[P1] 反例 2:内联事件回调每行重建
- 位置:
src/modules/messaging/components/message-list.tsx:319-321
- 代码:
<button onClick={(e) => void handleToggleStar(e, m.id, isStarred)} ... />
- 影响:20 条消息 × 多个内联回调,父组件每次 setTab/setKeyword 都重建 20+ 函数
[P2] 反例 3:style 内联 122 处
- 位置:
lesson-preparation/components/detail-panel/qa-editor.tsx(16 处)、paper-editor/paper-context-menu.tsx(11 处)、paper-editor/inline-node.tsx(11 处)、detail-panel/detail-head.tsx(7 处)、detail-panel/detail-panel.tsx(7 处)
- 影响:备课编辑器子组件每次父级渲染都重算样式对象
[P2] 反例 4:昂贵计算未 memo
- 位置:
src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:36
- 代码:
const textbookNode = doc.nodes.find((n) => n.type === "textbook_content");
- 影响:
doc 引用变化(任意节点更新)都重算
- 同样问题:
src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:24
3.2 Context 嵌套审计
全部 Context Provider 清单(共 17 个)
根布局链路(providers.tsx + dashboard layout.tsx):
7 层嵌套,未达到 5+ 警戒线的"严重"标准但有提升空间。
- 位置:
src/modules/layout/components/sidebar-provider.tsx:63-66
- 代码:
- 问题:
value 是每次渲染新建的对象字面量,引用必然不同
toggleSidebar 不是 useCallback,每次重建
useSidebar() 的所有 consumer(SiteHeader、AppSidebar 等)在 SidebarProvider 任意 state 变化时全部重渲染
- 第 51 行
window.addEventListener("resize", checkMobile) 未防抖,拖动窗口时连续触发 setIsMobile
- 影响:全站所有 dashboard 页面 Header/Sidebar 重渲染频率高
- 修复:
value 用 useMemo([expanded, isMobile]),toggleSidebar 用 useCallback,resize 加防抖
3.3 大列表虚拟化审计
关键发现:package.json 中未安装任何虚拟化库
- ❌
@tanstack/react-virtual
- ❌
react-window
- ❌
react-virtuoso
[P0] 重点反例:题库表无虚拟化
- 位置:
src/modules/questions/components/question-data-table.tsx:45-59
- 代码:
- 影响:题库 1000+ 条时,即使分页每页 10 条,
getFilteredRowModel 仍要遍历全部数据;切换筛选条件时一次性 diff 大量 DOM
- 预期数据量:1000-10000+ 题
其他大列表
| 列表 |
文件:行号 |
渲染方式 |
预期数据量 |
严重度 |
| 考试列表 |
src/modules/exams/components/exam-data-table.tsx:78-87 |
全量渲染 |
100-1000+ |
P1 |
| 出勤表 |
src/modules/attendance/components/attendance-sheet.tsx |
一次性渲染全班学生 × 6 状态按钮 |
30-60 学生/班 × 6 = 180-360 控件 |
P1 |
| 审计日志表 |
src/modules/audit/components/audit-log-table.tsx:61-105 |
服务端分页 + 每行 AuditLogDetailDialog |
10-50/页 |
P2 |
| 消息列表 |
src/modules/messaging/components/message-list.tsx:263-352 |
客户端分页 PAGE_SIZE=20,每行 Card 较重 |
20/页 |
P2 |
| 用户列表 |
src/modules/users/components/admin-users-view.tsx:219-265 |
服务端分页 + 每行 DropdownMenu |
10-50/页 |
P2 |
| 备课结构树 |
src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:79-107 |
全量渲染所有节点 |
5-50 节点 |
P3 |
| 教材章节图 |
src/modules/textbooks/components/knowledge-graph.tsx + force-graph.tsx |
@xyflow/react + react-force-graph-2d |
100+ 节点 |
P2 |
3.4 Zustand Store 性能审计
Store 清单(共 2 个)
| Store |
文件 |
slices |
使用情况 |
useUiStore |
src/shared/stores/ui-store.ts |
单一 |
未被任何组件使用(死代码) |
useLessonPlanEditor |
src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts |
5 个 slices(editor / selection / version / history / expanded) |
备课编辑器全量使用 |
[P1] 反例 1:useModal 返回新对象引用(当前为死代码)
- 位置:
src/shared/stores/ui-store.ts:73-78
- 代码:
- 问题:Zustand v5 默认使用
Object.is 比较,selector 返回新对象字面量意味着每次 store 变化都触发组件重渲染
- 当前状态:Grep 显示
useUiStore / useModal 仅在 ui-store.ts 自身出现,项目其他位置未使用,是预备的 modal registry 但未落地。一旦采用会立刻触发性能问题
- 修复:用
useShallow 包裹
[P0] 反例 2:备课编辑器无 selector 订阅全 store
- 位置(4 处直接调用
useLessonPlanEditor() 不带 selector):
| 文件:行号 |
解构 |
影响 |
src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62 |
const editor = useLessonPlanEditor() |
订阅全 store,每次按键/选区/展开/撤销/保存状态变化都重渲染整个三栏布局 |
src/modules/lesson-preparation/components/blocks/text-study-block.tsx:22 |
const { updateNode } = useLessonPlanEditor() |
updateNode 引用稳定,但订阅粒度过粗 |
src/modules/lesson-preparation/components/blocks/exercise-block.tsx:31 |
const { updateNode, planId } = useLessonPlanEditor() |
同上 |
src/modules/lesson-preparation/components/paper-editor/paper-context-menu.tsx:40 |
多重解构 |
同上 |
- 影响:备课编辑器是高频交互场景(键盘输入 + 节点选中 + 撤销重做),订阅全 store 导致每次按键都重渲染 StructureTree + PaperEditor + DetailPanel 三个子树
- 修复:拆为多个细粒度 selector:
useLessonPlanEditor((s) => s.title)、s.isSaving、s.isDirty 等;block 组件改为 const updateNode = useLessonPlanEditor((s) => s.updateNode)
正面发现
- ✅
useLessonPlanEditor 已正确拆分为 5 个 slice,便于按 slice 订阅
- ✅ Slice 文件使用
import type { StateCreator } 类型导入,无运行时循环依赖
- ✅ 备课模块其余 25 处
useLessonPlanEditor((s) => s.xxx) 使用了正确 selector 模式
3.5 Server / Client 边界审计
优秀实践
- ✅ Server Component 路由分发:
src/app/(dashboard)/dashboard/page.tsx
- ✅ Server Component + Suspense + Streams:admin-dashboard.tsx
- ✅ Server Component 嵌套 Provider:dashboard layout 在 server 函数体内
createFullAiClientService(),避免模块级单例
- ✅ 100+ 页面 Server Component
- ✅ Server → Client 通过 ReactNode slot:
<SidebarProvider sidebar={<AppSidebar />}>
- ✅ Server Action +
useFormStatus:5 处
- ✅ Dialog 内部状态管理:lesson-preparation providers 用
useMemo 包裹 service value
[P2] 反例:Server → Client 数据传递过大对象
| 传递点 |
文件 |
数据量 |
评估 |
messages prop → MessageList |
src/app/(dashboard)/messages/page.tsx → message-list.tsx |
全部 messages(含 content、senderName 等) |
⚠️ 可能过大,无字段裁剪 |
messages prop → NextIntlClientProvider |
providers.tsx:46 |
全量 i18n 字典 |
⚠️ 应按 namespace 拆分 |
3.6 React 19 特定优化审计
关键缺失
| 特性 |
是否启用 |
严重度 |
备注 |
React Compiler (babel-plugin-react-compiler) |
❌ 未安装 |
P0 |
package.json 无依赖,next.config.ts 无配置 |
use() Hook 处理 Promise |
❌ 0 处使用 |
P2 |
admin-dashboard 注释声称使用,实际是 Suspense + await |
useOptimistic |
❌ 0 处使用 |
P1 |
消息星标、出勤状态、批改提交等多处应使用 |
useFormStatus |
✅ 5 处 |
— |
attendance-sheet、attendance-rules-form、create-chapter-dialog、textbook-form-dialog、password-change-form 等 |
useActionState |
✅ 3 处 |
— |
password-change-form、leave-request-form、leave-review-dialog |
[P0] React Compiler 缺失
- 问题:
package.json 无 babel-plugin-react-compiler 依赖
next.config.ts 未配置 reactCompiler: true
eslint-plugin-react-compiler 未安装
- 影响:React Compiler 可自动 memoize 组件内部状态和派生值,可一次性解决第 3.1 节列出的所有内联对象/未 memo 问题。React 19 + Next.js 16 是启用 Compiler 的最佳时机
- 修复:安装
babel-plugin-react-compiler + eslint-plugin-react-compiler,在 next.config.ts 加 experimental: { reactCompiler: true } 或使用 Next.js 16 的稳定 API
[P1] useOptimistic 缺失场景
场景 1:消息星标切换
- 位置:
src/modules/messaging/components/message-list.tsx:119-148
- 现状:用
starredOverride state + try/catch 回滚模拟乐观更新
- 修复:
场景 2:出勤状态切换
- 位置:
src/modules/attendance/components/attendance-sheet.tsx:129-140
- 现状:
statuses state 实际是乐观值容器
- 修复:用
useOptimistic 表达
场景 3:成绩录入
- 位置:
src/modules/grades/components/batch-grade-entry.tsx、grade-record-form.tsx
3.7 重渲染热点审计
[P0] 7.1 备课编辑器(lesson-preparation)— 重灾区
7.1.1 LessonPlanEditor 订阅全 store
- 位置:
src/modules/lesson-preparation/components/lesson-plan-editor.tsx:62
- 代码:
const editor = useLessonPlanEditor();
- 影响:该组件渲染顶部工具栏(标题输入框、保存/撤销/重做/发布等 10+ 按钮)+ 三栏布局(StructureTree + PaperEditor + DetailPanel)+ 5 个 Dialog。
editor 任意字段变化(每次按键改 title、每次选区变化、每次撤销重做、每次保存状态切换)都触发整个组件树重渲染
- 额外问题:
editor.canUndo() / editor.canRedo() 在 render 中直接调用(第 189-190 行),不是 selector,每次渲染都执行函数调用,且依赖整个 history slice
7.1.2 PaperEditor 订阅 doc 整体
- 位置:
src/modules/lesson-preparation/components/paper-editor/paper-editor.tsx:25
- 代码:
const doc = useLessonPlanEditor((s) => s.doc);
- 影响:
doc 是不可变对象,任意 node 更新都生成新 doc 引用,导致 PaperEditor 全量重渲染
- 额外问题:第 36 行
doc.nodes.find() 无 memo;第 54-65 行 onPaperContextMenu / onInlineNodeContextMenu / 第 86 行 onContextMenu 内联回调;第 133-141 行 expandedNodes.map 内联函数
7.1.3 StructureTree 同样问题
- 位置:
src/modules/lesson-preparation/components/structure-tree/structure-tree.tsx:12-14
- 影响:订阅
doc + expandedNodeIds + addNode,每次 doc 变化都重渲染所有 TreeNodeRow。第 79-107 行 teachingNodes.map 内联 childItems 数组与 onToggleChildren 回调
7.1.4 tree-node-row.tsx 内联 style 11 处
- 位置:
src/modules/lesson-preparation/components/structure-tree/tree-node-row.tsx
- 影响:11 处
style={{}} 内联,每次父级 doc 变化都重算
[P1] 7.3 表格组件(@tanstack/react-table)
7.3.1 题库表 QuestionDataTable(P0)
- 位置:
src/modules/questions/components/question-data-table.tsx:36-155
- 问题:无虚拟化(见 3.3)+ 第 78-93 行
table.getHeaderGroups().map 渲染表头 + 第 96-111 行 table.getRowModel().rows.map 渲染所有行 + 第 102-108 行 row.getVisibleCells().map 渲染所有单元格
- 额外问题:第 61-64 行
selectedIds useMemo 依赖 [table, getRowId],table 引用每次 useReactTable 调用都变化,导致 memo 失效
7.3.2 考试表 ExamDataTable(P1)
- 位置:
src/modules/exams/components/exam-data-table.tsx:33-132
- 问题:同样问题,无虚拟化
7.3.3 审计日志表(P2)
- 位置:
src/modules/audit/components/audit-log-table.tsx:31-118
- 问题:服务端分页(好),但每行渲染
<AuditLogDetailDialog>(第 101 行),即使未打开也实例化 Dialog 组件
- 建议:改为点击行时
next/dynamic 加载 Dialog
[P2] 7.4 表单组件
7.4.2 备课编辑器标题输入直接改 store
- 位置:
src/modules/lesson-preparation/components/lesson-plan-editor.tsx:329-333
- 代码:
<input value={editor.title} onChange={(e) => editor.setTitle(e.target.value)} />
- 影响:每次按键直接触发 store 更新 → 触发 LessonPlanEditor 全量重渲染(因第 62 行无 selector)
- 修复:使用
useDeferredValue 或本地 state + debounce
7.4.3 AttendanceSheet 多重 useState
- 位置:
src/modules/attendance/components/attendance-sheet.tsx:115-127
- 问题:11 个 useState(classId / date / period / statuses / reasons / searchQuery / focusedStudentIndex / isSubmitting / showSwitchConfirm / pendingClassIndex)。
statuses / reasons 用 Record<string, ...> immutable 更新,每次按键都创建新对象 → 全表重渲染
第四部分:Server Action / 数据层审计
4.1 N+1 查询审计
[P0] 反例:循环单条 INSERT,无事务无批量化
- 位置:
src/modules/course-plans/data-access.ts:347-406 copyCoursePlanToClasses
- 问题:外层
for (classId of targetClassIds) 内 await db.insert(coursePlans),嵌套 for (item of sourceItems) 内再 await db.insert(coursePlanItems)。10 班 × 20 周 = 200 次顺序 INSERT,无事务保护,无大小限制
- 影响:复制 10 个班需 200+ 次往返;中途失败导致部分复制遗留脏数据;调用方传入 100+ classIds 时响应时间分钟级
- 修复:改为
db.transaction(async tx => { await tx.insert(coursePlans).values([...]) ; await tx.insert(coursePlanItems).values([...]) }),并限制 targetClassIds.length <= 50
[P1] 反例:事务内循环 UPDATE
- 位置 1:
src/modules/homework/data-access-write.ts:326-338 autoGradeSubmission 事务内
- 位置 2:
src/modules/homework/data-access-write.ts:360-375 gradeHomeworkAnswers 事务内
- 问题:
for (const ans of gradedAnswers) { await tx.update(homeworkAnswers) },每道题一次 UPDATE
- 影响:30 题作业需 30 次 UPDATE,事务持锁时间线性增长,并发批改时锁竞争加剧
- 修复:改用 MySQL
INSERT ... ON DUPLICATE KEY UPDATE 或构造 CASE WHEN id IN (...) THEN score ... END 单条 UPDATE
[P2] 反例:批量删除 fallback 路径循环
- 位置:
src/modules/files/data-access.ts:174-182 deleteFileAttachments fallback
- 问题:主路径已
inArray 批量,但 fallback 仍循环单条 delete
良好实践
- ✅
src/modules/dashboard/data-access.ts:15-30 getAdminDashboardData 用 Promise.all 并行 6 个 stats
- ✅
src/modules/homework/stats-service.ts:58-82 getTeacherGradeTrends 用 Promise.all 并行
- ✅
src/modules/grades/data-access.ts:96-107 getGradeRecords 用 Promise.all 并行 count + rows
4.2 索引缺失审计
Schema 索引现状
- 总表数: 50+ 张
- 已建索引: 110+ 个(含复合、唯一、外键)
- 整体索引覆盖良好,但存在以下缺口
[P1] 高频查询字段无索引
| 表 |
缺失索引 |
影响 |
exams (schema.ts:517-561) |
creatorId 无索引 |
getExams 按 creatorId 过滤("我的试卷"),全表扫描 |
exams |
status 无索引 |
getExams 按 status 过滤;getHomeworkDashboardStats 反复按 status 过滤 |
examQuestions (schema.ts:565-572) |
questionId 无索引 |
题目被引用检测、删除时反查 exam_questions 全表扫描 |
homeworkAssignmentQuestions (schema.ts:635-653) |
questionId 无索引 |
同上 |
submissionAnswers (schema.ts:589-602) |
questionId 无索引 |
题目级统计反查全表 |
[P2] 复合索引未覆盖单字段查询
| 表 |
现状 |
问题 |
examSubmissions (schema.ts:574-587) |
仅有 (examId, studentId) 复合 |
单独按 studentId 查询(学生全部提交)走全表 |
homeworkAnswers (schema.ts:701-723) |
仅有 (submissionId, questionId) 复合 |
单独按 questionId 反查(错题统计)走全表 |
修复建议
良好实践
- ✅
attendanceRecords 索引覆盖优秀:classDatePeriodIdx 复合索引覆盖按节次点名场景
- ✅
gradeRecords 已建 classSubjectIdx 复合索引
- ✅
messages 已建 receiverReadIdx、receiverStarredIdx 复合索引
- ✅
auditLogs 已建 createdAtIdx 支持时间范围查询
4.3 并行数据获取审计
[P2] 串行 await 可优化为 Promise.all
| 文件:行号 |
反例 |
优化 |
src/modules/users/data-access.ts:27-37 getUserProfile |
先 db.query.users.findFirst,再 db.select roles —— 两次串行 await |
改 Promise.all([findUser, findRoles]) |
src/modules/homework/stats-service.ts:261-302 getStudentDashboardGrades |
9 次串行 await |
部分可并行(如 assignments 与 getAssignmentMaxScoreById 无依赖关系) |
src/modules/dashboard/page.tsx:11-15 根 dashboard 页 |
await auth() → await resolvePermissions(roles) 串行 |
JWT 已含 permissionsBitmap,可直接 decodePermissionsBitmap(session.user.permissionsBitmap) 跳过 resolvePermissions DB 查询 |
src/modules/rbac/lib/data-scope-resolver.ts:71-79 parent scope |
getChildren → getGradeIdsForStudentIds 串行 |
可在 getChildren 一并返回 studentIds → gradeIds 联表查询,消除二次往返 |
良好实践
- ✅
src/modules/dashboard/actions.ts:58-65 getTeacherDashboardAction 6 路并行
- ✅
src/modules/dashboard/actions.ts:108-113 getStudentDashboardAction 4 路并行
- ✅
src/modules/dashboard/data-access.ts:23-30 admin dashboard 6 路并行
- ✅
src/modules/exams/data-access.ts:235-238 resolveSubjectGradeNames 2 路并行
4.4 缓存策略审计
[P0] 跨请求数据缓存完全缺失
| 现状 |
证据 |
全项目 0 处 unstable_cache 使用 |
Grep unstable_cache 返回 0 匹配 |
全项目 0 处 "use cache" 指令 |
Next.js 16 的 "use cache" 未启用 |
全项目 0 处 cache: "no-store" / "force-cache" 显式配置 |
Grep cache:\s*["'] 返回 0 匹配 |
全项目 0 处 next: { revalidate } 配置 |
同上 |
Data-access 层仅使用 cache() from React(请求内去重) |
例如 src/modules/dashboard/data-access.ts:15 cache(async ...) |
影响
每个 Server Action 调用都重新查 DB,包括低频变更的高频读取数据:
getSubjectOptions / getGradeOptions(学期内几乎不变)
getClassesDashboardStats(班级总数)
getSystemSettings(系统配置)
getTextbooks(教材列表)
getRoles(角色列表)
resolvePermissions(每次 JWT refresh 才查 DB,但根 dashboard 仍重复查)
DataScope 每次 Server Action 调用都从 DB 重查(1-3 次查询)
修复建议
良好实践
- ✅
revalidatePath 在 50+ actions 文件中正确使用,路径精确(如 /teacher/exams/all、/student/error-book)
- ✅
src/auth.ts:55-86 JWT callback 在 token refresh 时刷新 roles/permissions
4.5 数据传输体积审计
[P1] 单次查询拉取过大
| 文件:行号 |
问题 |
影响 |
src/modules/search/data-access.ts:64-79 searchQuestions |
select({ content: questions.content, ... }) 把整个 JSON 题干内容(可能含图片 base64 / SlateJS 节点树)拉出,仅用于 truncate(text, 200) 摘要 |
单题 content 可达 10-50 KB;搜索 10 条返回 500 KB+ |
src/modules/homework/stats-service.ts:162-172 getHomeworkAssignmentAnalytics |
db.query.homeworkSubmissions.findMany({ with: { answers: true, student: true } }) 一次拉取所有已批改提交 + 答案 + 学生信息,limit: 5000(line 345) |
50 人班级 × 30 题 = 1500 行 answer + 50 行 submission + 50 行 user,单次返回 MB 级 |
src/modules/users/data-access.ts:348-351 getAllUserIds |
db.select({ id: users.id }).from(users) 无 LIMIT |
大型学校 5000+ 用户时返回 5000 行 |
src/modules/grades/data-access.ts:105 getGradeRecords |
.limit(params.limit ?? 1000) 默认 1000 行 |
默认值偏高,前端通常只展示 20 行 |
[P2] 中等过度传输
src/modules/homework/stats-service.ts:216 wrongAnswers.slice(0, 500) 每题返回最多 500 个错答样本
src/modules/textbooks/data-access.ts:43-91 getTextbooks 无显式 LIMIT
良好实践
- ✅
users.password 字段在所有 select 中均未暴露
- ✅
getExams 用 Drizzle with: { subject, gradeEntity } 关联查询,避免 N+1
- ✅
getGradeRecords 批量 fetch 名称而非逐行 JOIN,模式正确
- ✅
getAdminUsers 显式 pageSize = Math.min(100, ...) 限制
4.6 权限校验性能审计
[P0] 每次 Server Action 调用必查 DB
调用链: actions.ts → requirePermission() → getAuthContext() → resolveDataScope() → DATA_SCOPE_RULES[].resolve(userId) → DB
| 角色 |
DB 查询次数 |
证据 |
| admin |
0(直接返回 {type:"all"}) |
data-scope-resolver.ts:30 |
| grade_head / teaching_head |
1 次(getGradesForStaff) |
data-scope-resolver.ts:36 |
| teacher |
2 次(并行:classes + subjects) |
data-scope-resolver.ts:48 调 getTeacherScopeData 内部 Promise.all |
| student |
1+ 次(getStudentScopeData) |
data-scope-resolver.ts:60 |
| parent |
2 次(串行:getChildren → getGradeIdsForStudentIds) |
data-scope-resolver.ts:72-74 |
关键证据(src/shared/lib/auth-guard.ts:44-47):
注释明确说明"不缓存因可变",导致每次 Server Action 至少 1-2 次 DB 查询仅用于权限。
[P2] getSessionTeacherId 未用 React cache() 包装
- 位置:
src/modules/classes/data-access.ts:17-31 getSessionTeacherId
- 问题:每次
await auth() + DB 查询,无 cache() 包装。同一请求内多次调用(如 dashboard action + 子函数)会重复查询
- 对比:
getUserProfile(users/data-access.ts:26)、getAdminDashboardData(dashboard/data-access.ts:15)、getStudentDashboardGrades(stats-service.ts:257)均用 cache() 包装,请求内去重正确
修复建议
良好实践
- ✅
src/auth.ts:47-83 JWT callback 在 token 创建 + refresh 时统一查询 roles + permissions
- ✅
src/auth.ts:47-50 permissions 用 bitmap 压缩(14 字符 vs 1.1 KB 数组),节省 cookie 体积
4.7 批量操作审计
[P0] 无事务、无批量、无大小限制
见 4.1 copyCoursePlanToClasses。
良好实践
- ✅ 全项目 41+ 处
db.transaction 包裹写入操作
- ✅
src/modules/grades/data-access.ts:179-201 batchCreateGradeRecords 正确用事务 + 批量 insert
- ✅
src/modules/users/data-access.ts:130 用户导入用事务
- ✅
src/modules/elective/data-access-operations.ts 选课操作多处用事务
4.8 慢查询风险审计
[P0] 全表扫描 / 无 LIMIT 全量返回
| 文件:行号 |
问题 |
src/modules/search/data-access.ts:74-75 searchQuestions |
CAST(${questions.content} AS CHAR) LIKE ${kw} —— JSON 字段 CAST 后 LIKE,无 FULLTEXT 索引,必然全表扫描 |
src/modules/lesson-preparation/data-access-knowledge.ts:98, 126 |
like(lessonPlans.content, \%${...}%`)` —— JSON content 列 LIKE |
src/modules/users/data-access.ts:348-351 getAllUserIds |
db.select({ id }).from(users) 无 LIMIT,无 WHERE |
src/modules/announcements/data-access.ts:560-561 |
school 公告 fan-out 调 getAllUserIds(),5000+ 用户全量加载到内存 |
src/modules/files/data-access.ts:128-139 getAllFileAttachments |
全量返回文件表,无分页 |
[P1] LIKE 前缀通配 %xxx%(无法走索引)
| 文件:行号 |
字段 |
src/modules/textbooks/data-access.ts:50-53 |
title / subject / grade / publisher 4 列 %${q}% |
src/modules/exams/data-access.ts:70 |
title / description %${q}% |
src/modules/standards/data-access.ts:147 |
title %${keyword}% |
src/modules/lesson-preparation/data-access.ts:238 |
title %${q}% |
src/modules/audit/data-access.ts:46 |
action %${params.action}% |
src/modules/users/data-access.ts:384 |
ilike(users.name, search) / ilike(users.email, search) |
src/modules/rbac/data-access-assignments.ts:103 |
ilike(users.name, term) / ilike(users.email, term) |
src/modules/messaging/data-access.ts:140 |
like(messages.subject, kw) / like(messages.content, kw) |
✅ 例外:src/modules/files/data-access.ts:204 like(fileAttachments.mimeType, \${mimeType}%`)` 是前缀匹配,可走索引
[P2] 高 LIMIT 默认值
| 文件:行号 |
LIMIT |
src/modules/grades/data-access.ts:105 getGradeRecords |
默认 1000 |
src/modules/homework/stats-service.ts:276 getStudentDashboardGrades |
limit: 200 |
src/modules/homework/stats-service.ts:345 classGradedSubmissions |
limit: 5000 |
src/modules/homework/stats-service.ts:216 |
wrongAnswers.slice(0, 500) |
src/modules/adaptive-practice/data-access-strategy.ts:293 |
.limit(1000) |
修复建议
- 搜索 API 改用 MySQL FULLTEXT 索引:
getAllUserIds 改为游标分页 + fan-out 改为批量插入 message_notifications(每次 500 条)
LIKE %xxx% 改为前缀匹配 LIKE xxx% 或 ES/OpenSearch 外部搜索引擎
- 高 LIMIT 改为基于业务实际值:
classGradedSubmissions 50 人班级够用 1000
第五部分:综合性能预算基线表
5.1 Bundle 体积预算(gzip 后)
| 维度 |
预算 |
强制等级 |
| 落地页 / 登录 First Load JS |
≤ 180 KB |
强制 |
| 学生端 Dashboard First Load JS |
≤ 250 KB |
强制 |
| 教师端 Dashboard First Load JS |
≤ 280 KB |
强制 |
| 列表/详情页 First Load JS |
≤ 300 KB |
强制 |
| 教材阅读页 First Load JS(含 ReactFlow + tiptap + force-graph) |
≤ 600 KB |
强制 |
| 试卷富文本编辑页 First Load JS |
≤ 500 KB |
强制 |
| 备课编辑页 First Load JS |
≤ 550 KB |
强制 |
| 单路由增量 chunk(动态加载组件) |
≤ 200 KB |
推荐 |
| 共享框架 chunk(react/react-dom/next) |
≤ 130 KB |
— |
| 单文件体积上限 |
≤ 1000 行 |
强制(已有 lint 规则) |
5.2 Web Vitals 预算(P95)
| 指标 |
预算 |
强制等级 |
| LCP |
≤ 2.5s(备课编辑器允许 ≤ 3.0s) |
强制 |
| INP |
≤ 200ms(仪表盘图表允许 ≤ 300ms) |
强制 |
| CLS |
≤ 0.05 |
强制 |
| TTFB |
≤ 800ms(dashboard 路由) |
强制 |
| FCP |
≤ 1.8s |
强制 |
5.3 数据层性能预算
| 维度 |
预算 |
强制等级 |
| 单次 Server Action 响应时间(P50) |
≤ 200 ms |
强制 |
| 单次 Server Action 响应时间(P99) |
≤ 800 ms |
强制 |
| 单次 DB 查询执行时间 |
≤ 50 ms |
强制 |
| Server Action 内 DB 查询次数 |
≤ 5 次 |
强制 |
| 单次查询返回行数 |
≤ 200 行 |
强制 |
| 单次查询返回数据体积 |
≤ 100 KB |
强制 |
| N+1 查询 |
严格禁止 |
强制(CI 检查) |
循环内 await db.insert/update/delete |
严格禁止(除非有 fallback 注释) |
强制 |
| 权限校验 DB 查询次数 |
≤ 1 次/请求(用 React cache() 去重) |
强制 |
| DataScope 解析 DB 查询次数 |
≤ 1 次/请求(30s unstable_cache) |
强制 |
| 跨请求缓存命中率(dashboard/统计) |
≥ 80% |
推荐 |
revalidatePath 路径精确度 |
≤ 2 条/写操作 |
强制 |
| 批量操作事务包裹率 |
100% |
强制 |
| 批量操作大小限制 |
≤ 50 行/事务 |
强制 |
LIKE 前缀通配 %xxx% 出现次数 |
0(除非有 FULLTEXT 索引) |
强制 |
| 全表扫描查询(无 LIMIT + 无 WHERE) |
0 |
强制 |
| 默认分页 size |
≤ 50 |
推荐 |
| 单条返回 JSON 字段体积 |
≤ 50 KB |
强制 |
Server → Client 传输 password/passwordHash/token 字段 |
0 |
强制 |
5.4 关键 KPI(建议接入监控)
- Server Action P50/P99 响应时间(按 action 名分维度)
- DB 查询次数 / Server Action 调用
- 慢查询(>50ms)次数与 SQL 摘要
- DataScope 解析耗时
unstable_cache 命中率
- N+1 自动检测(建议接入
drizzle-orm/dev 的 query log + CI 规则)
- Web Vitals 上报(LCP/CLS/INP/TTFB/FCP)
- Bundle 体积变化(每次 PR 与基线对比)
第六部分:P0/P1 修复路线图
6.1 P0 修复路线图(按 ROI 排序)
| # |
项目 |
涉及章节 |
预期收益 |
单点改动 |
风险 |
| 1 |
启用 React Compiler |
§3.6 |
一次性解决 §3.1 大部分内联对象问题 |
✅ |
低( Compiler 有 fallback) |
| 2 |
next.config.ts 完善(optimizePackageImports + serverExternalPackages) |
§1.5 |
全路由 -30~80 KB + 防止 2-3 MB 误打包 |
✅ |
低 |
| 3 |
tiptap / ReactFlow 动态导入(4 处文件) |
§1.3 |
编辑器路由 chunk 减少 ~300 KB |
✅ |
低 |
| 4 |
exams/editor/index.ts barrel 拆分 |
§1.6 |
修复已存在的构建错误 + 防止 server 误拉 tiptap |
✅ |
中(需测试 server 引用路径) |
| 5 |
接入 Web Vitals 上报 |
§2.6 |
生产环境性能可见性 |
✅ |
低 |
| 6 |
SidebarProvider value memoize + resize 防抖 |
§3.2 |
全站 dashboard 重渲染减少 50%+ |
✅ |
低 |
| 7 |
lesson-plan-editor.tsx:62 拆分 selector |
§3.4, §3.7 |
备课编辑器按键响应速度提升 5-10x |
✅ |
中(需测试编辑器交互) |
| 8 |
题库表启用虚拟化 |
§3.3, §3.7 |
1000+ 题时首屏可交互时间从秒级降到 100ms |
❌(需引入 @tanstack/react-virtual) |
中 |
| 9 |
copyCoursePlanToClasses 改为事务 + 批量 INSERT |
§4.1, §4.7 |
复制 10 班从分钟级降到秒级 |
✅ |
低 |
| 10 |
unstable_cache 引入(DataScope + 低变更数据) |
§4.4, §4.6 |
单次 Server Action DB 查询数减半 |
❌(需引入 tag 失效机制) |
中(缓存一致性) |
| 11 |
searchQuestions 改用 FULLTEXT 索引 |
§4.5, §4.8 |
搜索接口从全表扫描降到索引扫描 |
❌(需 DB 迁移) |
中(需测试搜索召回率) |
| 12 |
getAllUserIds 加 LIMIT + 公告 fan-out 改批量 |
§4.5, §4.8 |
防止 5000+ 用户场景内存爆炸 |
✅ |
低 |
6.2 P1 修复路线图
| # |
项目 |
涉及章节 |
预期收益 |
| 1 |
serverExternalPackages 追加 exceljs |
§1.5 |
防止 250-300 KB 误打包 |
| 2 |
chart.tsx 改 barrel 为具名导入 |
§1.4 |
全图表路由 -20~40 KB |
| 3 |
移除 status-badge.tsx 等过度标记的 "use client" |
§1.2 |
提升 SSR 覆盖率 |
| 4 |
4 个角色根路由新增 loading.tsx |
§2.1 |
LCP/CLS 改善 |
| 5 |
96 处 force-dynamic 评估改 revalidate |
§2.1 |
TTFB/LCP 静态缓存命中加速 |
| 6 |
Lighthouse CI 接入 |
§2.6 |
CI 流水线 Web Vitals 回归门槛 |
| 7 |
根 layout.tsx 串行 await 改 Promise.all |
§2.1 |
TTFB/FCP 改善 |
| 8 |
autoGradeSubmission / gradeHomeworkAnswers 改批量 UPDATE |
§4.1 |
30 题作业 UPDATE 次数从 30 降到 1 |
| 9 |
exams 表新增 5 个索引 |
§4.2 |
"我的试卷"等高频查询全表 → 索引扫描 |
| 10 |
recharts props 提升为模块级常量 |
§3.1 |
仪表盘图表重渲染减少 |
| 11 |
useOptimistic 用于消息星标 / 出勤 / 成绩 |
§3.6 |
替代手写 override state,UX 提升 |
| 12 |
考试表启用虚拟化 |
§3.3 |
数据量增长时避免卡顿 |
| 13 |
高频子组件 React.memo(MessageCard、TreeNodeRow、InlineNode、AttendanceRow) |
§3.1 |
阻断父级渲染扩散 |
6.3 P2 跟进项
useModal 修复(如计划采用)
AuditLogDetailDialog 懒加载
use() Hook 替代部分 Suspense 嵌套
- 备课编辑器 title 输入
useDeferredValue
getSessionTeacherId 加 cache() 包装
- parent DataScope 改为单次 JOIN
- 默认 LIMIT 调低
metadata 补充 metadataBase / openGraph / robots
- Playwright 性能断言
AiAssistantWidget 懒加载
- scan-image-viewer
<img> 加 aspect-ratio
- 备课编辑器 textbook → chapters 改
Promise.all
6.4 建议执行顺序
附录 A:关键文件清单
配置文件
e:\Desktop\CICD\next.config.ts
e:\Desktop\CICD\package.json
e:\Desktop\CICD\playwright.config.ts
e:\Desktop\CICD\vitest.config.ts
入口与 Provider
e:\Desktop\CICD\src\app\layout.tsx
e:\Desktop\CICD\src\app\providers.tsx
e:\Desktop\CICD\src\app\(dashboard)\layout.tsx
e:\Desktop\CICD\src\app\manifest.ts
e:\Desktop\CICD\src\proxy.ts
e:\Desktop\CICD\src\instrumentation.ts
e:\Desktop\CICD\src\auth.ts
重型组件(Bundle 风险点)
e:\Desktop\CICD\src\modules\textbooks\components\knowledge-graph.tsx(ReactFlow,未 dynamic)
e:\Desktop\CICD\src\modules\textbooks\components\force-graph.tsx(react-force-graph-2d,已 dynamic ✅)
e:\Desktop\CICD\src\shared\components\ui\rich-text-editor.tsx(tiptap)
e:\Desktop\CICD\src\modules\exams\editor\exam-rich-editor.tsx(tiptap)
e:\Desktop\CICD\src\modules\lesson-preparation\components\blocks\rich-text-block.tsx(tiptap)
Barrel 文件(高风险)
e:\Desktop\CICD\src\modules\exams\editor\index.ts(已造成构建错误)
重渲染热点(组件性能风险点)
e:\Desktop\CICD\src\modules\layout\components\sidebar-provider.tsx
e:\Desktop\CICD\src\modules\lesson-preparation\components\lesson-plan-editor.tsx
e:\Desktop\CICD\src\modules\lesson-preparation\components\paper-editor\paper-editor.tsx
e:\Desktop\CICD\src\modules\lesson-preparation\components\structure-tree\structure-tree.tsx
e:\Desktop\CICD\src\modules\dashboard\components\admin-dashboard\user-growth-chart.tsx
e:\Desktop\CICD\src\modules\questions\components\question-data-table.tsx
e:\Desktop\CICD\src\modules\exams\components\exam-data-table.tsx
数据层风险点
e:\Desktop\CICD\src\modules\course-plans\data-access.ts(copyCoursePlanToClasses P0)
e:\Desktop\CICD\src\modules\homework\data-access-write.ts(循环 UPDATE P1)
e:\Desktop\CICD\src\modules\search\data-access.ts(LIKE 全表扫描 P0)
e:\Desktop\CICD\src\modules\users\data-access.ts(getAllUserIds 无 LIMIT P0)
e:\Desktop\CICD\src\shared\lib\auth-guard.ts(DataScope 每次重查 P0)
e:\Desktop\CICD\src\shared\db\schema.ts(exams 索引缺失 P1)
e:\Desktop\CICD\src\modules\homework\stats-service.ts(5000 行 + with 关联 P1)
缺失文件清单(建议新增)
src/app/(dashboard)/teacher/loading.tsx
src/app/(dashboard)/student/loading.tsx
src/app/(dashboard)/parent/loading.tsx
src/app/(dashboard)/management/loading.tsx
e:\Desktop\CICD\.gitea\workflows\lighthouse.yml(建议新增 Lighthouse CI)
e:\Desktop\CICD\src\app\instrumentation-client.ts(建议实现 useReportWebVitals)
e:\Desktop\CICD\src\app\api\web-vitals\route.ts(建议新增指标上报端点)
附录 B:审计涉及的依赖清单
客户端大体积依赖(按估算体积排序)
| 依赖 |
估算体积(min+gzip) |
用途 |
当前问题 |
@tiptap/*(5 包 + tiptap-markdown) |
~180-220 KB |
富文本编辑器(3 处) |
未动态导入(P0) |
@xyflow/react |
~120-140 KB |
ReactFlow 知识图谱 |
未动态导入(P0) |
recharts |
~95-110 KB |
图表库(13 处) |
chart.tsx barrel import(P1) |
react-force-graph-2d |
~80-100 KB |
引力图谱 |
已动态导入 ✅ |
react-markdown + remark-* + rehype-* |
~50-70 KB |
Markdown 渲染 |
— |
openai |
~60-80 KB |
AI SDK |
服务端 |
@tanstack/react-query + react-table |
~35-45 KB |
数据层 |
— |
@dnd-kit/*(core+sortable+utilities) |
~30-40 KB |
拖拽 |
— |
radix-ui/*(16 个包) |
单包 ~3-8 KB |
UI 原语 |
— |
lucide-react |
单图标 ~1 KB,全集 ~800 KB |
图标库(100+ 处) |
缺 optimizePackageImports(P1) |
服务端大体积依赖(防误打包)
| 依赖 |
估算体积 |
用途 |
当前状态 |
tencentcloud-sdk-nodejs |
~2-3 MB |
腾讯云短信 |
已动态 import ✅,但未加 serverExternalPackages(P0) |
exceljs |
~250-300 KB |
Excel 导入导出 |
静态 import,未加 serverExternalPackages(P1) |
mysql2 |
— |
MySQL 驱动 |
已加 serverExternalPackages ✅ |
缺失依赖(建议新增)
| 依赖 |
用途 |
必要性 |
babel-plugin-react-compiler |
React Compiler 自动 memoization |
P0 |
eslint-plugin-react-compiler |
React Compiler ESLint 规则 |
P0 |
web-vitals |
Web Vitals 上报 |
P0 |
@tanstack/react-virtual |
题库/考试表虚拟化 |
P0 |
@lh3/cli |
Lighthouse CI |
P1 |
审计结论
本次性能预算审计完成,共发现 15 个 P0 严重问题、13 个 P1 高优先级问题、22 个 P2 中等优先级问题、14 个 P3 低优先级问题。
核心建议:按第六部分"建议执行顺序"分 5 个 Phase 推进,Phase 1 配置基线(4 项单点改动,低风险高收益)应作为首期落地,Phase 2 Bundle 瘦身和 Phase 3 数据层重构为中期重点,Phase 4 组件渲染优化为长期持续工作,Phase 5 验收与基线校准闭环反馈到预算阈值。
关键提醒:当前 next build 因 Turbopack 编译错误未成功出包,本报告所有量化指标为静态估算。修复 Phase 1 + Phase 2 后必须重新构建,获取实际 Bundle 体积与运行时 Web Vitals 数据,再对照第五部分基线表进行第二轮校准。
架构图同步建议:本审计为只读研究,未修改源码,不需要同步更新 docs/architecture/004_architecture_impact_map.md 与 005_architecture_data.json。若后续落实任何修复(如新增 loading.tsx、修改 next.config.ts、新增 web-vitals 上报、引入 unstable_cache),需按项目规则同步架构图。