建立主动的性能管理流程,通过定义性能预算、系统性 Profiling 和针对性优化,确保应用响应快速、资源高效,避免"上线后才发现慢"。
- 用户反馈页面慢/接口超时
- 监控指标触发性能告警
- 新功能上线前性能评估
- 定期性能巡检(每月/每季度)
- 数据量增长后评估瓶颈
在优化前先定义"够快是多少":
| 指标 | 目标值 | 测量方式 |
|---|---|---|
| 首屏加载 (LCP) | < 2.5s | Lighthouse / Web Vitals |
| 交互响应 (INP) | < 200ms | Web Vitals |
| 布局稳定 (CLS) | < 0.1 | Web Vitals |
| API 响应时间 (P95) | < 500ms | APM / 日志 |
| 数据库查询 | < 100ms | 慢查询日志 |
| 页面包大小 (JS) | < 200KB (gzip) | Bundle 分析 |
| 内存使用 | < 512MB/实例 | 监控 |
规则:
- 性能预算写入项目文档,团队共识
- CI 中检查包大小,超标则警告
- 新功能不能使指标退化
- 口径:懒加载 chunk(仅动态可达的
.js)单独判定 —— ① 族前缀清单等式(每个懒侧 chunk 必须归属某个已登记族,未归族即红;键用族前缀如vendor-katex-*,因为 Vite 的 chunk 名带内容 hash、不稳定)② 逐族 gzip 上限 ③ 懒侧总 gzip 上限(另加 chunk 数上限)。三条都只许降。 - 基线来源:
scripts/lazyBudget.json—— 首次写入值 = 2026-09-13 真构建实测 36 chunk / 634.32 kB(冻结现状,不是目标值;写乐观「预算值」会立刻红)。 - 🔴 一次性重冻记录(批 7 §C46.4;7a 段收口
90bb5f6a):首次基线取自批次中段快照cd800d63,此后同批合法功能工作(T13/T14 的 markdown 并入、T17 的卡片流包装件)改变了懒侧 ⇒ 按「每收口一次、重冻一次」重冻为 37 chunk / 636.24 kB:4 族按收口树真实构建实测值重冻(NoteMarkdown-3,278 ·NotesPage-48,087 ·RefineLaunchDialog-7,469 ·SessionsPage-21,168)+ 新族NoteCardFlowWithSeek-(391)登记 + chunk 数 36 → 37。🔴vendor-*五族逐族核对 = 零增长(58,784 / 208,585 / 41,161 / 79,915 / 50,622 逐字节不变)。批 8 的基线 = 本批收口时的重冻值(此后才是真正的硬棘轮;T22 全批收口时再做一次)。 - 🔴 最后一次重冻已落地(批 7 §C46.4 第 5 条的「批 8 基线」那一次;
55f502fa「按 T13/T14/T17 归属重冻」之后的全批收口重冻**):lazyTotalGzipBytesMax636,243 → 637,501(+1,258 B / +0.20%)·lazyChunkCountMax37 → 37 不变 · 族数 37 → 38(补登遗漏族lowConfidence-= 100 B —— chunk 本就在,只是未登记族)·KnowledgePage-27,620 → 28,466 ·NotesPage-48,087 → 48,627 ·generatedFrom真值修正为c0bff722(谱系cd800d63→950afd5a→c0bff722)。 · 🔴 触发原因(带时点,勿读成回潮):全批收口树c0bff722上第 ⑤ 闸曾exit 1** —— 懒侧 637,501 B > 636,243 + 64 ⇒ 超 1,194 B,具名 3 条 = 未归族lowConfidence-CbMOBOyG.js+KnowledgePage-+NotesPage-;重冻后转绿(unlisted = []/fails = []/ 逐族红 0 / 首屏 105.95 kB)。⇒ 🔴 「7a 段收口时八闸全绿」只对0a503cc8当时成立,全批终态以重冻后的绿为准。 · 🔴 闭合性质:各族实测之和 = 637,501 = 总量 = 总上限(逐字节相等);而Σ 逐族上限= 638,029(高 528 B)=shift-族的 212 B 死预算(该族chunkCount = 0/ 实测 0 / 上限 212)+ 其它族保留的 316 B 正 slack。⇒ 🔴 除 64 B 容差外零余量,不得读成「还有空间」;shift-的 212 B 死预算登记为批 8 清理项。 · ✅vendor-*五族上限一字未动(58,784 / 208,585 / 41,161 / 79,915 / 50,622),实测增量 ≤ +2 B(落在 64 B 容差内)⇒ 非回潮(§C46.4 条件⑤ 的 STOP 未触发)。 · 🔴 逐族上限的陈旧度披露(2026-09-13 · T21 补轮;独立全批复核提出、控制方独立复现、T21 逐族逐字节复算):重冻后真构建读数的逐族表里有 18 个族的实测已超各自上限、合计 +203 B ——ActionPage-+25 ·AiConversationDock-+39 ·CaptureFloatPanel-+2 ·CaptureOverlayPanel-+2 ·ChatPage-+39 ·RefineLaunchDialog-+2 ·RefineStrategyPicker-+16 ·SessionCardFlowView-+5 ·SessionProofView-+2 ·SessionScreenCard-+3 ·SessionTriTrackView-+35 ·SettingsPage-+3 ·ViewSwitcher-+3 ·colorPalette-+2 ·registry-+21 ·vendor-canvas-+2 ·vendor-gsap-+1 ·vendor-md-+1。🔴 均在 64 B/族容差内 ⇒ 未判红(闸的判定是对的;本条是逐族上限的陈旧度披露,不是「闸有问题」)⇒ 登记为批 8 的逐族重冻输入(§C32.3;总量上限不得抬高)。⚠️ 含 3 个vendor-*族 ⇒ 上一句「五族上限一字未动」与本句「实测已在其上限之上 1–2 B(容差内)」两句都要读。 - 🔴 口径:本判据只在「新鲜构建」的产物上成立 —— 旧
dist带着上一个时代的残渣(实测:顶层 katex 被删后旧产物仍多 64,497 B gzip)⇒--no-build的读数不可用于本判据;要判既有产物就显式--dist指认(不给--dist的--no-build只判首屏,懒侧打⏭ 未判)。 - 容差:逐族与总量判据都用「上限 + 64 B」—— 它是测量噪声余量(本机同源重建实测 0 B;参照批 4 的 CSS 顺序差 9 B),不是「漂移推导」;常量,不得随提交变大(改它 = 改判据)。
- 硬约束:上表「页面包大小 (JS) < 200KB (gzip)」与懒侧预算是两个独立读数 —— 懒侧判定不得影响首屏读数,两者也不得并成一个预算。
--json的顶层pass= 总体裁决(两个读数的与),与退出码一致。 - 逐族上限可「同提交重冻」(批 7 §C32.3):改动懒侧字节的单元可在同一条提交里把受影响族的
gzipBytesMax重冻为新实测值(条件:提交信息点名是哪处改动推高 · 报告给前后逐键 diff · 总量不得抬高 · 评审核「是该次改动的必然后果」)。🔴 懒侧总上限仍是硬棘轮、只许降。 - 工具:
node scripts/check-bundle-budget.mjs(不带--no-build⇒ 自行构建后再判两个预算,人类可读、分别打印两个 pass);加--json得平级的firstScreen/lazyBudget两栏。退出码1= 任一预算超标(首屏 / 懒侧)。 - 🔴 执行者缺口(技术债 → 批 8;批 7 §C25.5② + §C14.5 逐字落账):本判据(首屏 + 懒侧的唯一守卫)在
.github/workflows/pr-check.yml/.husky/pre-commit/scripts/validate-all.mjs三处都没有执行者 ⇒ 这道门禁的牙只在有人手工跑时才存在(批 7 期间的唯一执行者 = 段收口单元 T12 与全批收口单元 T22,均为人手工触发)。同源事实:pr-check.yml的 paths-filter 根本没有scripts/**glob(T8 实测缺scripts/lib/**;T11 新增scripts/check-exemption-prose.mjs后复查 ⇒ 缺口形态未变、逃逸文件集合 +1)⇒ 只改scripts/**的 PR 不被任何被过滤的 job 看见。🔴 批 7 裁决:不动.github/workflows/(AGENTS.md §10 的额外审查清单 +semantic-release的发布自动化连带面 + 本批的规格义务与登记项里没有 CI 工作;批 7 对scripts/**的门禁靠收口单元直接跑脚本,覆盖等效)。修复形态 = ① 给 paths-filter 补scripts/**glob ② 给本守卫(及同域脚本)接一个执行者;归属批 8。🔻 就地加注 · 执行者缺口的现状(2026-09-13 · 批 8 T17 落地后补记;上面那条原文一字未改,按批 7 当时的时态读,不得再读作现状):该缺口已被部分补上 ⇒ 现状分三面(本规范只写纪律与形态,当次数值读数一律不进本章):
- 首屏守卫的执行者 = ✅ 已有:落在
.github/workflows/pr-check.yml的app-frontendjob 内、Buildstep 之后,step 名逐字First-screen bundle budget (no-build; lazy side NOT judged — first screen only)、run: node scripts/check-bundle-budget.mjs --no-build。⇒ 上面「三处都没有执行者」里的首屏那一半已不成立。🔴 挂点的纪律:它必须留在产出app/dist的那个 job 内 —— 挂到不构建的 job 上,--no-build判的是不存在的产物 ⇒ 恒红(该反例本批已实测)。 - 懒侧判定的执行者 = 🔴 仍零执行者(本批裁定「本次不做」,依据 = 上文「
--dist换懒侧执行者 —— 裁定:本次不做」:跨构建环境字节漂移可能远超 64 B 容差 ⇒ 挂上极可能恒红):该 step 名逐字自带lazy side NOT judged — first screen only,而裸--no-build下懒侧本就打⏭ 未判。⇒ 上面「懒侧没有执行者」这一半仍然成立。 pr-check.yml的 paths-filterscripts/**glob 缺口 = 🔴 仍在:T17 有意不补,理由逐字写在pr-check.yml的注释里(新增的脚本域门禁改挂无条件的line-limitsjob,绕开「改了scripts/**却被 filter 判为无变更」这一二次缺口)⇒ 只改scripts/**的 PR 仍不被任何被过滤的 job 看见。- 原文点名的另两处(
.husky/pre-commit/scripts/validate-all.mjs):前者仍未接本守卫(其内容只有line-limits/docs-check/check-command-registry三条纯文本全树只读扫描);后者已在81304d52作为化石删除、路径今日不存在 ⇒ 两处都不构成上面任何一半的执行者。 ⇒ 修复形态的可追溯性(原文两条逐字保留,只补落地进度):① 给 paths-filter 补scripts/**glob = 🔴 未落地;② 给本守卫(及同域脚本)接一个执行者 = ✅ 已部分落地:只落了首屏那一半(懒侧那一半的前置 = 上文登记的「跨构建环境字节漂移」受控实测)。归属批 8 不变。
- 首屏守卫的执行者 = ✅ 已有:落在
· 🔴 字节闭合 + 逐族容差的互指(2026-09-13 · 收口后复核):各族实测之和 = 懒侧总量 = 总上限(逐字节相等);而 Σ 逐族上限 高于 Σ 实测 的部分里含其它族的正 slack ⇒ 它不是余量,是「逐族上限未随实测逐族重冻」的形态(本行只登记性质与去向,数值出处见上一行)。⇒ 逐族重冻时只许降总量上限、不得抬高;「总量绿」不等于「逐族都绿」。
· 🔴 零 chunk 死预算(shift- 族):该族无任何 chunk 可达,却占用逐族上限(值见上一行的出处)⇒ 它计入 Σ 逐族上限、因而不构成可用余量。🔴 清理它会降低总量上限(符合「只许降」);但若该族 chunk 复现,它会落在未归族(族前缀清单等式)与实测对上限两条判据上 ⇒ 清理前需有意的判断,不是机械动作。
· 🔴 红阈的适用域(承上):懒侧判定未被开启时(不给 --dist 的裸 --no-build)读数打 ⏭ 未判 ⇒ 🔴 「未判」既不是达标也不是超标,不得据此报绿;红阈只在判定真的执行了才有意义。
· 🔴 计数口径(防推断错误):族数 ≠ chunk 数(一个 chunk 恰好归一族,而允许存在零 chunk 的族)⇒ 禁止由族数推断 chunk 数(反之亦然);两者各自的读数出处见上一行。
· 🔴 --no-build + --dist 会开启懒侧判定 ⇒ 陈旧产物可假绿:判定式逐字 = JUDGE_LAZY = !NO_LAZY && (!NO_BUILD || has("--dist")) ⇒ --no-build --dist <目录> 让懒侧判定在「未被构建的既存产物」上执行,而脚本头注只警告了裸 --no-build。⇒ 🔴 一切懒侧读数必须同时登记 app/dist 的 mtime 与源码 sha;mtime 早于源码 ⇒ 该读数作废(本批 P-37)。
· 🔴 --dist 换懒侧执行者 —— 裁定:本次不做(2026-09-13 · 控制方 §16.2):提议的形态 = 在 CI 的**「紧跟 Build 之后」那个包体闸位置加 --dist app/dist(该处 dist 新鲜),让懒侧守卫首次获得执行者**(补上上面那条「三处零执行者」的缺口);🔴 裁「不做」的依据 = 跨构建环境字节漂移:两次不同环境构建的首屏读数差 +163 B(真源字节口径)⇒ 同比例外推懒侧 ≈ 981 B(若按四舍五入的 kB 口径则为 ≈ 1,023 B)⇒ 远超 64 B 容差 ⇒ 一旦挂上极可能恒红(同族失效:未模拟目标环境就挂门禁)。
· 🔴 该对比是「非受控」的(受控对比纪律 · 本批 §17):两侧的时点(源码内容)与环境(Node 版本 / 依赖安装方式 / 无 Actions 调度)都不相同 ⇒ +163 B 里「源码内容差」与「构建环境差」不可分,不得读成「环境漂移 = 163 B」;同批的懒侧实测差也只是同量级的一小部分,方向与首屏相反 ⇒ 真值未知。⇒ 🔴 「跨构建环境字节漂移实测」是必要前置(口径 = 源内容一致、仅环境不同),不是可选优化;任何 > 69 B 的向上漂移即红(容差 64 B + 当时的余量),据此重裁容差前不得挂懒侧执行者。
· 🔴 登记③(Surface 迁移的可见变化 → 像素面的未决项):圆角就地 6 → 8(9 处 = 6 处随原语 radius="panel" + 3 处就地改;计划面 254 处、其余 245 处维持现状)· 边框与近白底「冷灰 → 暖纸」 · boxShadow 字面量换 token(含 1 处有损:方向性边缘投影换双向环境投影)· 遮罩统一(0 0 0 9999px rgba(0,0,0,.45) 由巨扩散充当,--ed-shadow-* 承载不了)· 弱化文本墨度对比度(2.54:1 → 5.13:1)。🔴 这些量级与形态在 app/src/ui/primitives/ 的基线件与残留件里各有出处;本规范不重复登记数值 —— 🔴 它们的真判据是像素面(jsdom 不排版、不加载样式表)⇒ 「有读数」≠「已统一」(目标值是设计决定,不是规范能裁的)。
· 🔴 登记④(字号越界与弱化灰的存量 → 只许降的棘轮):字号越界(< 12px 六档)与弱化灰(#9ca3af)都已有棘轮件(冻结常量 + 逐文件表 + 锚三连通;各表逐文件合计 == 冻结总数 且 锚的条目数 == 冻结文件数,本次现场复核自洽)。⇒ 🔴 新增或迁移的 UI 不得扩充这两族存量(判据 = 沿既有棘轮收紧,不是按「看起来更统一」改观感)。🔴 迁移真降一处时要手工同步「冻结总数 + 逐文件表 + 锚」三处;常数与逐文件表必须恰等(不许把常数手工改成自洽)。
- 🔴 同批登记(不在本文件,仅互指):
scripts/check-exemption-prose.mjs(批 7 T11 新建的散文对拍探针,112 行)仍不入八闸 / husky,但批 8 T17 已单列转正为 CI 门禁(pr-check.yml的line-limitsjob 内 step「Exemption table prose check」:212)⇒ 登记为「批 8 T17 已落地的 CI 门禁」(§C11.8 ③),并同样进入上面那条 paths-filter 缺口名单;落点见line-limit-exemptions.md的批 7 收口加注。 - 🔻 2026-09-13 加注(原句留档):本条在批 7 收口时逐字写「已入库但不入八闸 / CI / husky ⇒ 登记为「批 8 的候选闸」(§C11.8 ③)」;🔴 该句在本条被就地更正时被替换掉了(批 8 T29,提交
9b1e8aa8,git diff --numstat为1/1)⇒ 按documentation.md的「原文 + 就地加注」形态补回留档。🔴 该句描述的是当时(批 7 收口)的登记,现状见本条上一段的加注。
先测量,后优化。不要凭感觉优化。
前端工具:
- Chrome DevTools → Performance / Network
- Lighthouse(综合评分)
- Web Vitals 库(真实用户数据)
- Bundle 分析:webpack-bundle-analyzer / source-map-explorer
后端工具:
- APM(应用性能监控)
- 慢查询日志(PostgreSQL:
pg_stat_statements) - CPU/内存 Profiler(node --inspect / pprof)
- 压力测试(k6 / wrk / autocannon)
Profiling 流程:
- 确定慢的环节(前端?网络?后端?数据库?)
- 使用对应工具深入分析
- 找到 Top 3 瓶颈(80/20 法则)
- 针对瓶颈优化
- 重新测量验证效果
加载性能:
- 代码分割(按路由/按需加载)
- 图片优化(WebP/AVIF + 响应式 + 懒加载)
- 字体优化(font-display: swap + 子集化)
- 关键 CSS 内联,非关键异步加载
- 第三方脚本延迟/异步加载
- CDN 加速静态资源
- 开启 Gzip/Brotli 压缩
- HTTP 缓存策略(强缓存 + 协商缓存)
运行时性能:
- 避免不必要的重渲染(React.memo / useMemo)
- 长列表虚拟化(react-window / vue-virtual-scroller)
- 防抖/节流高频事件
- Web Worker 处理密集计算
- 避免布局抖动(批量 DOM 操作)
API 性能:
- 数据库查询优化(索引/避免 N+1/只查需要的字段)
- 适当使用缓存(Redis / 内存缓存 / HTTP 缓存)
- 分页(不返回全量数据)
- 异步处理耗时操作(队列)
- 连接池(数据库/HTTP)
- 压缩响应体
数据库优化:
- 慢查询分析(EXPLAIN ANALYZE)
- 缺失索引补充
- 避免 SELECT *
- 大表考虑分区
- 读写分离(高并发场景)
- 定期 VACUUM / 统计更新
何时跑基准测试:
- 性能优化前后对比
- 重大架构变更后
- 数据量级变化后
- 发布前(关键接口)
基准测试方法:
# 使用 k6 示例
k6 run --vus 50 --duration 30s load-test.js
# 记录:
# - 平均响应时间
# - P95 / P99 响应时间
# - 吞吐量 (req/s)
# - 错误率对比记录:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| P95 响应 | 1200ms | 350ms | 71% |
| 吞吐量 | 120 req/s | 450 req/s | 275% |
- 部署后持续监控性能指标
- 设置告警阈值(P95 > 1s 告警)
- 性能退化自动通知
- 每月性能趋势报告
- 性能预算已定义并记录
- 已使用工具测量确认瓶颈(非猜测)
- 优化针对 Top 瓶颈(80/20)
- 优化后重新测量验证效果
- 前端 Core Web Vitals 达标
- API P95 响应时间达标
- 无 N+1 查询
- 包大小在预算内
- 基准测试已记录对比
- 持续监控和告警已配置
| 输出物 | 格式 | 存放位置 |
|---|---|---|
| 性能预算文档 | Markdown | docs/performance/budget.md |
| Profiling 报告 | 截图/数据 | docs/performance/profiles/ |
| 优化记录(前后对比) | 表格 | docs/performance/optimizations.md |
| 基准测试结果 | 数据 | docs/performance/benchmarks/ |
| 误区 | 正确做法 |
|---|---|
| 凭感觉优化 | 先 Profiling 找瓶颈,再针对性优化 |
| 优化不测量效果 | 优化前后都要有数据对比 |
| 过早优化 | 先跑起来,有数据证明慢了再优化 |
| 只关注平均时间 | P95/P99 才反映真实用户体验 |
| 一次优化所有 | 80/20 法则,先解决最大瓶颈 |
| 优化完就不管了 | 持续监控,防止退化 |