Derived from a static audit of frontend/ and web/ at staging 87f32d59.
The audit ran without node_modules, so no build, eslint, or tsc was
executed; every size and timing claim is inferred and task 0 requires
the executing agent to re-measure before acting on it.
Sequencing follows the repository's own history. The React Compiler
route stays closed per PROGRESS-react-compiler-20260817.md and
BLOCKED.md: Home fails on upstream compiler Todo/Invariant errors, not
on repository code, and neither compiler version raised the
project-wide success count above 134. A render benchmark gates the two
performance tasks, because the same missing benchmark already blocks
the 44-component decision recorded in BLOCKED.md.
Dark mode is left out as a product decision rather than an agent task.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
18 KiB
TASK · 前端优化九条(codex/frontend-optimization-20260828)
来源与前提
本任务书来自一次前端静态审计,范围 frontend/ 与 web/,基线 staging @ 87f32d59。
审计方自己的边界(必须先知道): 审计时 frontend/node_modules 是空的,没有跑过 next build / eslint / tsc。
下面所有数字都来自源码静态检索、可复核,但凡涉及产物体积与运行时耗时的结论都是推断。
任务 0 的第一件事就是复核这些数字,对不上以你实测的为准,并在进度文档里写明差异——不要拿着一份没验证过的清单开工。
硬红线
- 不得手写
useCallback/useMemo去凑性能。 本轮的手段是拆组件 +React.memo边界。 理由见PROGRESS-react-compiler-20260817.md:手写记忆化在这个仓库里已经被明确排除, 拆分才是同时解决重渲染和解锁编译器的路径。 - 不得为了迎合 React Compiler 去改
page.tsx的写法。 那条路已经走到底并证伪(见下方"已关闭的路")。 - 不得在未跑
npx tsc --noEmit的情况下推staging。 这是 BUG-409 的防复发条款: validate 阶段跳过next build,类型错误要到 publish 才炸,公网会停在上一个 SHA。tsx --test全绿不能替代。 - 测试数只许 ≥ 任务 0 实测的基线,且
skipped=0、fail=0。 不得删测试、不得 skip 测试来换绿。 - 不得改 CI 配置(
.gitea/、.github/)。 - 不得动
frontend/tests/**与tests/**里的既有断言;新增测试可以,改既有断言不行——除非你能证明该断言锁的是错误行为,并在进度文档里单独论证。 - 不得在存在未提交修改的工作树上切分支 / stash / reset / 顺带提交用户变更。
让步顺序
功能与测试不回归 > 可被验证的性能收益 > 代码整洁度 > 构建速度。
任何一步做不到时,写进 BLOCKED.md 并停在那一步,不要绕路硬做。本仓库已经有过一次
"配置开了、构建绿了、实际零收益"的教训,宁可少交付也不要交付看起来完成的东西。
已关闭的路(别重跑)
PROGRESS-react-compiler-20260817.md 与 BLOCKED.md 记录了完整调查:
Home 无法被 React Compiler 编译,失败全部来自上游编译器的 Todo:(未实现语法)与 Invariant:(编译器内部断言,即编译器 bug),
本仓库代码没有写错。稳定版 1.0.0 与 experimental 版的全项目编译成功数都是 134,一个没多。
所以:不要再去升编译器版本、不要再去装 babel-plugin-react-compiler 做诊断、不要再开 reactCompiler: true 试。
那份文档给出的下一步就是本任务书的任务 4。
开工前置
按 AGENTS.md 第 5、6、7 节执行,不可省略:
git fetch origin --prune
git worktree add -b codex/frontend-optimization-20260828 \
../.worktrees/frontend-optimization-20260828 origin/staging
- 基线必须是
origin/staging,不得基于本地staging或本地main。 - 读
docs/research/pre_work_error_ledger.md。 - 跑
python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45。 - 读
frontend/AGENTS.md:这个 Next.js 版本与你训练数据里的不一样, 写任何 Next.js 代码前先读frontend/node_modules/next/dist/docs/里的对应指南。任务 3 尤其吃这一条。 - 本任务书涉及"重渲染、样式加载、字体不生效"等现象,按第 7 节先检索
docs/BUG_HISTORY.md(关键词建议:重渲染、force-dynamic、globals.css、字体、localStorage、chartLibrary), 命中记录的防复发措施逐条确认是否仍然存在。
任务 0 · 基线复核(必做,不产出代码)
npm install(worktree 是新的,没有 node_modules),然后逐项实测并填表:
| 复核项 | 审计给的数 | 你的实测 | 结论 |
|---|---|---|---|
Home 起止行 |
page.tsx:1090–4313 |
||
Home 行数 |
3,224 | ||
Home 内 useState |
61 | ||
Home 内 useEffect |
19 | ||
Home 内 useMemo / useCallback |
0 / 0 | ||
全仓库 React.memo |
0 | ||
globals.css 行数 / 选择器数 |
3,278 / 1,389 | ||
字体文件数 / @font-face / next/font |
0 / 0 / 0 | ||
public/ 未引用 SVG |
4 | ||
jyotish-logo.png 尺寸 / 体积 |
128×125 / 33,511 B | ||
frontend/tests 测试文件数 |
260 | ||
tsx --test 测试数 / 通过数 |
待实测 | ||
npx tsc --noEmit |
待实测 | ||
npx next build 退出码 / 耗时 |
待实测 | ||
/ 首屏 JS chunk 数 / gzip 体积 |
待实测 |
最后四行是任务 2、5 的收益对照基准,必须记下来,否则后面无法证明改动有收益。
任务 1 · 建渲染基准(门控任务,不通过则任务 3、4 不许开始)
这是本轮最重要的一步。 审计报告第 01、02 条的因果链是从代码结构推出来的,逻辑成立,
但"流式回复期间每个 token 重算多少毫秒"没有实测过。BLOCKED.md 里那 44 个组件的收益
也正是因为缺渲染基准而无法拍板——同一个缺口卡住了三处决策,补一次能全解开。
要求:
- 建立一个可重复的渲染基准,测量流式回复期间
Home的重渲染次数与累计渲染耗时。 手段自选(React Profiler API /react-scan/ 在Home里挂一个仅开发期生效的计数器均可), 但必须满足:同一份输入跑两次,结果偏差在可接受范围内,否则它证明不了任何事。 - 场景至少两个:
- 空会话:新建会话 + 一条约 800 字的流式回复。
- 长会话:≥ 40 条历史消息 + 一条约 800 字的流式回复。
长会话是第 02 条
O(消息数) × 每 token的判定场景,缺了它任务 3 就没有验收依据。
- 基准脚本与原始读数落到
frontend/tests/或frontend/scripts/下, 并在进度文档里贴出改动前的读数。
止损条款: 如果两次运行的偏差大到无法区分 20% 的改进,
说明基准不可用——把它写进 BLOCKED.md 并停止任务 3、4,只交付任务 2 和任务 5。
不要在没有基准的情况下做性能改动然后声称有收益,这正是上一轮踩过的坑。
任务 2 · 低风险清理批次(可独立交付)
五条互不相关的机械修复,一个提交批次。每条都要单独验证,不要打包声称"都改好了"。
2.1 删除根布局的 force-dynamic
frontend/src/app/layout.tsx:8 的 export const dynamic = "force-dynamic" 是多余的:
/login、/admin、/reports、/reports/[reportId] 各自都已经声明过一遍,
根部这行只是让整棵路由树永久失去静态优化与路由级缓存。
- 根布局本身不读
cookies()/headers()(已核实),page.tsx是"use client"且数据全在客户端拉。 - 但这条必须实测验证,不能想当然:改动后跑
npx next build, 逐条比对每个路由的渲染模式(○ Static/ƒ Dynamic), 确认没有任何一个本该动态的路由被静态化。尤其检查/:next.config.ts已对/和/login设了private, no-store,但静态化后的行为要亲自确认。 - 止损条款: 只要有一个路由的渲染模式变化说不清,还原这一条,其余四条照常交付。
2.2 Inter 声明了却从未加载
frontend/src/app/globals.css:92 与 frontend/src/components/admin/admin-app.tsx:54 都把 Inter
写进字体栈,但仓库里 0 个字体文件、0 处 @font-face、0 处 next/font——
Windows / Linux 用户一直静默落到 Segoe UI / 微软雅黑。
注意区分: Tiempos Headline 的缺席是有据的取舍,DESIGN.md 第 3 节写明了授权文件不可用、
宋体栈是声明的生产替代。不要动它。 只处理 Inter。
二选一,不许停在中间:
- (a) 用
next/font/google真的加载Inter(顺带拿到 preload 与font-display: swap), 两处引用都要覆盖; - (b) 从两处字体栈里删掉
Inter,让声明与渲染结果一致。
选哪个都要在 DESIGN.md 第 3 节记一行,说明这是一个决定而不是遗漏。
选 (a) 时必须实测确认字体确实被加载(Network 面板或构建产物里能看到 woff2),
否则等于把一个静默回退换成另一个。
2.3 删除 web/ 下三个无人引用的 HTML
web/index.html、web/rectification.html、web/evidence_packet.html
全仓库检索不到引用,mcp_server.py 也没有挂载静态目录。它们把 API 响应直接
JSON.stringify 打印给用户,主色 #006b6b 与现行设计系统无关,是更早的原型残留。
- 删除前必须自己再检索一遍(含
deploy/、.gitea/、Dockerfile、docs/),确认真的没有引用。 - 若确实还当调试夹具用,移到
scripts/下并在文件头写明用途,不要留在web/冒充产品界面。
2.4 清理 public/ 脚手架资源与 logo 体积
- 删除
vercel.svg、window.svg、file.svg、globe.svg(create-next-app默认资源,无引用)。next.svg的检索命中疑似误报(grep的.通配),删前逐个确认。 jyotish-logo.png实际 128×125 却有 33,511 B,登录页以 32px 显示。 同尺寸 PNG 合理体积在 4–6 KB。换 SVG,或按 2× 需求重导出并压缩。 换 SVG 时注意email-otp-login.tsx:285用的是next/image且alt=""(装饰性,DESIGN.md第 81 行有据),保持不变。
2.5 合并两个重叠的 effect
frontend/src/app/page.tsx:1302 与 page.tsx:1338 依赖数组都是 [accountId, profile],
都调用 upsertSelfChart 并写 chartLibraryStorageKey。
靠 chartLibraryLoadedAccount ref 提前 return 来分工,逻辑能跑通但没有注释。
代价是每次 profile 引用变化都在主线程同步做一次 JSON.stringify + localStorage.setItem。
- 合并为一个 effect,用显式分支表达"首次加载云端"与"本地 upsert"两条路径。
- 行为必须完全等价:云端拉取失败时本地库仍可用、
self记录仍被 upsert、 切换账号时仍清空。这三条各补一个针对性测试。 - 这块会随任务 4 的拆分一起被搬走,现在做掉能让后面那一刀更干净。
任务 2 验收: npx tsc --noEmit 无输出、npx eslint 无 error、
测试数 ≥ 基线且 skipped=0、npx next build 退出码 0。
任务 3 · 消息列表记忆化(依赖任务 1 通过)
frontend/src/app/page.tsx:4010 在 JSX 里直接调用
chatMessageViews(activeSession.messages, …).map(…)。
结合 Home 的零记忆化,长会话下这是 O(消息数) × 每个 token 的整表重建。
renderKey 用得是对的,React diff 会剪掉大部分 DOM 写入,但 chatMessageViews
本身的计算与数组分配躲不掉。
要求:
- 把消息列表提成独立组件并用
React.memo包住。 - 把"已完成的历史消息"与"正在流式的最后一条"拆成两个渲染单元——
只有后者需要跟着 token 走。这是本条的核心,只加
memo不拆是无效的。 - 注意
chatMessageViews的入参里有isLoading、activeStreamingText等会随 token 变的值, 拆分时要保证历史消息那一半的 props 在流式期间引用稳定,否则memo形同虚设。
验收取证(必须可被推翻): 用任务 1 的长会话场景,给出改动前后的
重渲染次数与累计渲染耗时两组读数。并做一次反向验证:
临时去掉 memo,确认读数退回改动前水平——
这一步是为了证明你的取证方法不是恒为真。上一轮的教训就在这里。
止损条款: 若长会话场景下改进不足 20%,说明瓶颈不在这里, 如实写进进度文档,还原改动,不要为了交付而保留一个没有收益的重构。
任务 4 · 拆 Home 第一刀(依赖任务 1 通过;只切一块)
page.tsx:1090–4313 是一个 3,224 行的客户端组件,61 个 useState、19 个 useEffect、
0 处记忆化。流式回复期间每个 token 都让整个函数体重跑一遍,
包括 page.tsx:1200 那串 sessions.filter().filter().sort(),
以及侧边栏、会话列表、输入框、全部弹层的重渲染。
本轮只切耦合最低的一块,不要试图拆完。 建议优先级:
- 账户 / 资料弹层(耦合最低,state 集中,UI 边界清晰)
- 图库 + 合盘
- 生时校正外壳(耦合最高,涉及
resumeRectificationSessionref 与多个 effect,本轮不要碰)
要求:
- 切出去的子树用
React.memo封边界,props 引用保持稳定。 Home的useState数量必须实际下降,并在进度文档里给出前后数字。 如果拆完 state 还留在Home里靠 props 往下传,那这一刀没有意义。- 不得改变任何用户可见行为。
DESIGN.md里该组件的无障碍契约 (44px 触达、focus-visible、live region、reduced-motion、forced-colors)逐条保留—— 这些在拆组件时最容易被顺手丢掉,交付前对着DESIGN.md第 4 节复核一遍。
验收取证: 同任务 3,给出改动前后读数 + 反向验证。
止损条款(硬): 单块拆分超出 400 行改动或触碰到第三类(生时校正外壳)时,
立刻停下并把已完成的部分交付,剩余写进 BLOCKED.md。
这个组件已经有一轮失败史,宁可切一小块并证明收益,也不要开一个收不了口的大重构。
任务 5 · 隔离 admin 的样式负担
globals.css 3,278 行、1,389 个选择器、25 处 !important,由根布局引入,每条路由都拿全量。
/admin 最冤:它用 antd 组件树,另外还要再加载 antd reset 与 antd 本体,
而这 3,278 行聊天 / 生时校正 / 会员页样式它几乎一条都用不上。
本轮只做最小动作: 把 admin 从根布局的 globals.css 里摘出去(给它一条自己的布局链)。
按现有的 8 个注释分区切成 shell / chat / rectification / membership 四个入口是下一轮的事,本轮不做。
- 摘出去后必须逐页目视验证 admin 各路由没有样式塌陷
(
/admin/users、/admin/orders、/admin/roles、/admin/feature-flags至少各看一眼)。 admin 可能隐性依赖了globals.css里的某些 token 或 reset—— 真依赖了就把那部分显式搬进admin.css,不要把整个globals.css搬回去。 - 验收取证: 给出
/admin首屏 CSS 体积的改动前后对比(来自任务 0 记下的 build 输出)。
止损条款: 出现说不清的样式塌陷就还原,写进 BLOCKED.md。
不在本轮范围
- 深色主题(审计第 09 条)。
globals.css是color-scheme: light、0 处prefers-color-scheme。 单一浅色主题完全可能是刻意选择——DESIGN.md把这套视觉定义为"私人阅读室",纸感暖白是它的立身之本。 问题是 23,000 字的设计文档通篇没提这件事。 这是产品决策,不是代码任务,需要人来拍板,agent 不要自行开工。 本轮唯一动作:在进度文档里把这个待决策项列出来,交给决策者。 globals.css的完整分包(任务 5 的下一步)。Home的完整拆分(任务 4 的第 2、3 块)。
收尾
docs/BUG_HISTORY.md:任务 2.5(重叠 effect)与任务 2.2(字体静默回退)属于第 7 节定义的 "异常 / 回归"范畴,需要建档。 写之前先检索当前最大编号——本地看到的是BUG-428,但origin/staging可能已经推进并占用了后续号, BUG-253 / BUG-255 记录过两次抢号失效。以远端实际最大编号顺延,并在记录里注明。- 进度文档:新建
PROGRESS-frontend-optimization-20260828.md(不要写PROGRESS.md, 根目录已有受版本控制的progress.md,且文件系统大小写不敏感,会覆盖清单外的文件—— 这个坑PROGRESS-react-compiler-20260817.md开头记过)。 内容:任务 0 的复核表、每个任务的取证读数与反向验证结果、止损触发情况、第 09 条待决策项。 BLOCKED.md:所有触发止损的任务,按既有格式追加。- 提交前:
npx tsc --noEmit无输出、npx eslint0 error、 测试数 ≥ 基线且skipped=0/fail=0、npx next build退出码 0。 - 推送:
git push origin HEAD:staging是快进推送,会触发backend-quality-gate, 没有路径过滤,任何改动(包括纯文档)都会跑完整构建与部署—— 所以同批改动合并成一次推送,不要分五次推。 推送后核对远端 SHA,并确认GET /api/health的.deployment.gitCommit等于本次 SHA, 否则视为未部署。 - 不要自行提升到
main。 交付到staging并在https://staging.jyotisha.chat完成验收后停下, 由人决定是否提升。
交付物清单
- 任务 0 复核表(含与审计数字的差异说明)
- 任务 1 渲染基准脚本 + 改动前读数(或止损说明)
- 任务 2 五条清理(每条独立验证;2.1 附 build 渲染模式对比)
- 任务 3 消息列表拆分 + 前后读数 + 反向验证(或止损说明)
- 任务 4 第一刀 +
Home的useState前后数字 + 前后读数(或止损说明) - 任务 5 admin 样式隔离 + CSS 体积前后对比(或止损说明)
docs/BUG_HISTORY.md建档(编号已核远端)PROGRESS-frontend-optimization-20260828.mdBLOCKED.md(若有止损触发)- 第 09 条深色主题待决策项已上交