# Lessons Learned(项目经验教训)
> 记录跨任务、跨阶段的工程经验教训,避免重复踩坑。按类别组织,定期更新。
## 目录
- [1. 技术选型与依赖](#1-技术选型与依赖)
- [2. 构建与部署](#2-构建与部署)
- [3. 样式与设计](#3-样式与设计)
- [4. 性能优化](#4-性能优化)
- [5. 测试](#5-测试)
- [6. 代码组织](#6-代码组织)
---
## 1. 技术选型与依赖
### 1.1 外部字体服务导致白屏
- **问题**:依赖 Google Fonts 等外部字体服务,网络加载失败时页面白屏。
- **根因**:字体加载阻塞首次渲染(FOUT/FOIT 未妥善处理)。
- **方案**:所有字体使用本地文件(`src/app/fonts/`),禁止外部字体 CDN。
- **来源**:`2026-03-04` 部署事故,`project_memory.md` Hard Constraints。
### 1.2 React 19 + Next.js 16 HMR 兼容性
- **问题**:开发模式下 HMR 报错 `module factory is not available`,需要频繁清除缓存。
- **根因**:React 19 与 Next.js 16 的 HMR 模块缓存机制不兼容。
- **方案**:禁用 `optimizeCss` 实验性功能;若持续影响开发,改用生产模式(`npm run build && npm run start`)。
- **来源**:`docs/HMR-ERROR-SOLUTIONS.md`。
### 1.3 webpackBuildWorker 导致静态资源 404
- **问题**:启用 `experimental.webpackBuildWorker: true` 后,客户端静态资源 404。
- **根因**:webpack 构建工作线程与 Next.js 静态导出不兼容。
- **方案**:强制禁用该功能,在 `next.config.ts` 中不启用或显式设为 `false`。
- **来源**:`project_memory.md` Hard Constraints。
---
## 2. 构建与部署
### 2.1 直接复制参考网站代码
- **问题**:从参考网站直接复制 HTML/CSS/JS 到项目中,导致样式冲突和技术债务。
- **根因**:参考网站的样式体系(Bootstrap/其他框架)与项目 Tailwind 体系冲突。
- **方案**:仅参考设计理念和布局,所有代码手动基于 Tailwind 实现。
- **来源**:`project_memory.md` Lessons Learned。
### 2.2 静态导出限制
- **问题**:`output: 'export'` 模式下,`next/image` 需要 `unoptimized`,某些 API 路由不可用。
- **根因**:Next.js 静态导出对 Server Components 和 API Routes 的限制。
- **方案**:移除 `output: 'export'`,改用 `standalone` 或混合模式;图片使用 `unoptimized` 或 `
` 标签。
- **来源**:`CLAUDE.md` Build Output 章节。
- **现状(2026-09-23 核对)**:方案已落地——`next.config.mjs:4` 为 `output: 'standalone'`,`/api/*` 与 `/admin/*` 由 Node 运行时承载(`nginx-static-production.conf:194-229`)。**但 `images.unoptimized: true` 的理由已换成新的一种**:产物经 Nginx 静态托管 + CDN 分发、无 Node 进程接管 `/_next/image`,改回 `false` 会让图片全量 404(`next.config.mjs:8-14` 已写明)。因此「unoptimized = 静态导出的限制」这句旧因果**不可再引用**,`CLAUDE.md` 与 `AGENTS.md` 的相应措辞已按此更正。
---
## 3. 样式与设计
### 3.1 光晕效果遮挡内容
- **问题**:Case Studies 区域卡片悬停光晕(halo effect)尺寸过大,遮挡文字。
- **根因**:光晕半径 256px + 不透明度 100%,超出卡片边界。
- **方案**:尺寸降至 192px,不透明度降至 0.08(降低 92%)。
- **来源**:`project_memory.md` Lessons Learned;后续推广为全局规则:所有光晕效果不透明度降低 30-50%。
### 3.2 未使用的大文件残留
- **问题**:项目中残留 4.2MB 的 AoyagiReisho 书法字体文件,增加页面加载时间。
- **根因**:早期设计尝试引入书法字体,切换方案后未清理。
- **方案**:定期检查 `public/fonts/` 和 `src/app/fonts/` 中未使用的字体文件,及时删除。
- **来源**:`project_memory.md` Lessons Learned。
### 3.3 品牌红使用过度
- **问题**:品牌红色(#C41E3A)在页面中占比超过 10%,视觉冲击过强。
- **根因**:缺乏品牌色使用规范约束。
- **方案**:制定品牌红贯穿规则——每页至少 3 处触达点,但面积 ≤ 10%;禁止作为段落文字色、大面积背景色。
- **来源**:`CONTEXT.md` 朱砂点睛章节。
### 3.4 `aria-hidden` 不豁免 WCAG 1.4.3 对比度
- **问题**:装饰序号 / 客户名首字占位等纯装饰文字用 `text-text-muted/10`(≈10% alpha)淡化,axe-core 仍持续报 `color-contrast`,A-12 无法清零。
- **根因**:仓库长期误以为「`aria-hidden`(或 `role="none"`、CSS `opacity`)可让装饰文字豁免对比度」。实测 axe-core 4.11.4 的 `color-contrast` 规则只看 DOM 中可见文字的颜色,不看语义豁免;变体矩阵(aria-hidden / role=none / opacity:0.1 / SVG `` / `::before`)中只有后两种非文本编码能通过。
- **方案**:装饰文字改用项目唯一在册的大字档令牌 `text-text-hint`(#7C8CA5 / 深色 #94A3B8,对各级卡片底色 ≥3:1,满足 AA 大字标准),不再靠低 alpha 淡化;并在 `scripts/utils/check-color-contrast.ts` 的 REQUIRED_GROUPS 中加入「大号装饰文本 × 各级卡片底色」契约组,把该口径固化为门禁。
- **来源**:2026-09-21 验收 A-12 残留清零,变体探针脚本实测。
---
## 4. 性能优化
### 4.1 高并发下内存溢出
- **问题**:200 VUs 并发时服务崩溃,单实例无法处理高并发请求。
- **根因**:缺乏负载均衡、缓存策略和资源限制。
- **方案**:多实例部署(Docker Compose 3 实例)+ PM2 进程管理 + Nginx 负载均衡。
- **来源**:`docs/PERFORMANCE_OPTIMIZATION.md`。
---
## 5. 测试
### 5.1 测试框架冗余
- **问题**:项目存在三个独立的测试框架(e2e/, e2e-tests/, test-framework/),维护成本高。
- **根因**:早期多次试验不同测试方案,未及时清理废弃框架。
- **方案**:统一到 Playwright + Jest 体系,废弃 Python Playwright 和独立测试框架。
- **来源**:`docs/OPTIMIZATION_REPORT.md`。
### 5.2 测试覆盖率门槛
- **问题**:早期测试覆盖率低(Lines 29%),无法有效保障质量。
- **根因**:缺乏测试文化,工具函数和 hooks 未覆盖。
- **方案**:设置覆盖率门槛(branches 35%, functions/lines/statements 45%),分阶段提升至 80%;后续通过 TDD 流程提升至 85%+。
- **来源**:`docs/test-coverage-improvement-plan.md`,`CLAUDE.md` 测试命令。
### 5.3 E2E 断言文本必须与渲染文本保持一致
- **问题**:`e2e/nav-dropdown.spec.ts` 中 hover 测试失败,断言查找 `睿新ERP管理系统` 与 `行业方案`,但实际渲染为 `ERP 管理系统` 与 `行业解决方案`。
- **根因**:UI 文案迭代后,E2E 测试的文本断言未同步更新,导致测试假阴性(功能正常但测试报错)。
- **方案**:
1. 将 UI 文案断言改为稳定标识(如 `data-testid`、角色/aria 属性)或语义化选择器,减少对文案的强依赖。
2. 若必须使用文本,在修改文案时同步搜索并更新 `e2e/` 中的对应断言。
3. 建立「文案变更检查清单」,将 E2E 文本断言纳入审查范围。
- **来源**:2026-07-26 dogfood 后续验证,`e2e/nav-dropdown.spec.ts` 修复。
### 5.4 视觉回归基线需在 UI 变更后主动更新
- **问题**:dogfood 修复后(列表卡片链接、案例筛选、联系表单反馈、新闻占位图等),视觉回归测试大面积失败。
- **根因**:上述修复属于有意的 UI/交互变更,导致已有快照与当前渲染不一致;若不更新基线,后续所有构建都会报告假阳性。
- **方案**:
1. 任何涉及视觉/布局的修复完成后,运行 `npx playwright test visual-regression.spec.ts --update-snapshots` 更新全项目视觉基线。
2. 更新前通过浏览器截图/人工复核确认差异符合预期,避免将未发现的回归写入基线。
3. 将快照变更作为独立提交或 PR 变更的一部分,方便评审时比对视觉差异。
- **来源**:2026-07-26 更新 105 张视觉回归快照。
### 5.5 Playwright `storageState` 路径必须以配置文件为基准
- **问题**:全量 E2E 运行时 firefox/webkit 下所有测试瞬间失败,报错 `Error reading storage state from ./e2e/storageState.json: ENOENT`。
- **根因**:`e2e/playwright.config.ts` 中 `storageState` 被写成 `./e2e/storageState.json`,而 `npm run test` 实际在 `e2e/` 目录下执行(`cd e2e && npx playwright test`),导致 Playwright 去查找 `e2e/e2e/storageState.json`。
- **方案**:
1. 配置文件中的相对路径应基于配置文件自身目录,使用 `path.resolve(__dirname, 'storageState.json')`。
2. 修改 `storageState`、快照目录、报告目录等路径时,必须同时验证 `npm run test` 与从项目根目录直接运行 `npx playwright test --config=e2e/playwright.config.ts` 两种方式。
3. 将 `storageState.json` 纳入版本控制并作为 E2E 环境准备检查项。
- **来源**:2026-07-27 全量 E2E 回归失败排查,`e2e/playwright.config.ts` 修复。
### 5.6 Firefox 中连续全页导航易触发 Playwright locator 超时
- **问题**:`e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」在 Firefox 中反复超时,报错 `waiting for "http://localhost:3000/" navigation to finish`,即使页面已渲染完成。
- **根因**:Playwright 的 locator API 会在页面存在未完成的导航时阻塞;Firefox 对 `window.location.href` 全页导航的处理比 Chromium 更慢,连续两次 `page.goto('/')` + 点击 StaticLink 触发全页导航后,locator 评估极易进入等待导航的竞态。
- **方案**:
1. 将涉及两次独立全页导航的测试拆分为两个独立 test case,减少单次测试内的导航次数。
2. 在 Firefox 等容易触发竞态的浏览器中,对必须触发的全页导航链接点击,改用 `page.evaluate(() => element.click())` 绕过 locator 的导航状态检查。
3. 优先使用 `data-testid` 或语义选择器定位,点击后通过 `page.waitForURL()` 显式等待目标 URL,而非依赖 locator 的隐式等待。
- **来源**:2026-07-27 `e2e/p2-functional-e2e.spec.ts` Footer 链接测试修复。
### 5.7 封版阶段依赖升级需独立评估,避免 `--force` 一次性修复
- **问题**:`npm audit fix --force` 无差别升级依赖到最新版本,导致 `@lhci/cli` 被降级到使用 git+ssh 拉取 Lighthouse 的古老版本,且 `eslint-config-next@16` 与当前 `eslint@8` 不兼容。
- **根因**:`--force` 会执行 major version 升级,引入 breaking change 风险。
- **方案**:
1. 封版阶段不执行 `--force` 修复,仅执行向后兼容的 `npm audit fix`。
2. major version 升级应作为独立专项任务,在封版前或上线后安排。
3. 修复前备份 `package-lock.json`,并制定回退策略。
- **来源**:2026-08-12 封版验收阶段 1-B。
### 5.8 性能测试脚本必须与 API 接口契约对齐
- **问题**:`stress-test.js` 向 `/api/contact` 发送 JSON body,但接口期望 `formData`,且 IP 限流每小时 5 次/IP,导致 66.7% 请求失败。
- **根因**:测试脚本编写时未审查 API 接口的具体实现(`request.formData()` 和 `isRateLimited`)。
- **方案**:
1. 新增/修改 API 后同步审查性能测试脚本,确保请求体格式、认证/限流机制与接口契约一致。
2. 将对依赖外部服务的接口(如 `formsubmit.co`)的压测排除在压力测试之外。
3. 重写后的脚本仅对本地静态页面进行 GET 压力测试,避免外部依赖干扰。
- **来源**:2026-08-12 封版验收阶段 5,stress-test.js 修复。
### 5.9 测试摘要文件需在迭代中保护,避免被失败运行覆盖
- **问题**:k6 测试运行时预览服务器被停止,后续重新运行写入的摘要文件包含无效数据(0% 通过率),覆盖了之前成功的运行结果。
- **根因**:k6 的 `handleSummary` 输出路径与 `--summary-export` 路径不一致,且无版本保护机制。
- **方案**:
1. 使用 `--summary-export` 指定稳定路径(如 `tests/performance/`),与脚本内 `handleSummary` 路径统一。
2. 每次运行前备份前一次摘要文件。
3. 服务器重启后重新运行性能测试前,确认端口可用性。
- **来源**:2026-08-12 封版验收阶段 5,k6 摘要文件保护。
### 5.10 触摸目标阈值不能把 AAA 当 AA 断言
- **问题**:`mobile-accessibility.spec.ts` / `mobile-performance.spec.ts` 以「WCAG 2.1 AA」为名断言所有交互元素 ≥44×44px,修复后仍有 30+ 个 24–43px 的元素无法通过,门禁长期红色。
- **根因**:44×44 是 SC **2.5.5 Target Size(AAA)**;AA 对应的是 WCAG 2.2 SC **2.5.8 Target Size(Minimum)= 24×24**,且带 Spacing / Equivalent / Inline / Essential 例外。测试把标准档位写错,导致断言比规范严两档。
- **方案**:抽出共享扫描 `e2e/touch-targets.ts`,输出两份清单——AA 2.5.8(<24px)作为**硬门禁**断言为 0,AAA 2.5.5(<44px)作为**建议清单**只打印不失败;并按 2.5.8 的 Inline 例外用 `closest('p,li,h1..h6') && display==='inline'` 排除正文内联链接。
- **来源**:2026-09-21 验收 A-12 触摸目标项,W3C Understanding SC 2.5.8 / 2.5.5 对照。
### 5.11 `mobile-*` spec 会被桌面 project 复用,视口须在 spec 内钉住
- **问题**:`mobile-accessibility.spec.ts` / `mobile-performance.spec.ts` 带 `@mobile` 标签,但同时在 `chromium` / `firefox` / `webkit` 桌面 project 下执行——同一份断言在桌面 project 量到的是桌面布局(实测小目标 17 处 vs 移动 11 处,可聚焦元素 62 vs 11),两次结果互相矛盾却被当成「移动端口径」。
- **根因**:只有 `chromium-mobile` project 在 `playwright.config.ts` 里设了 `devices['iPhone 14']`;spec 文件名与注释声称移动端,却没有自己的视口约束,project 决定视口这一隐式约定在跨 project 复用时失效。
- **方案**:`mobile-*` spec 在文件内显式 `test.use({ ...devices['iPhone 14'] })`,让四个引擎量同一条移动布局;凡依赖视口的断言(断点类名、抽屉是否挂载、触摸目标尺寸)都必须在 spec 内声明视口,不依赖 project 隐式配置。
- **来源**:2026-09-21 验收 A-12 复跑,四个引擎测量结果对比。
### 5.12 Next.js 的 `` 在路由 commit 之后 2–5 帧才落地
- **问题**:GA4 SPA pageview 用同步 `document.title` 上报,实测抓到空串或上一页标题,污染 GA4 报表的页面维度。
- **根因**:App Router 的 metadata 提交晚于路由 commit;生产实测 chromium-mobile 第 2 帧、桌面 chromium 第 3–5 帧才写入新标题。单帧 `requestAnimationFrame` 等待只是「碰巧在桌面通过」,移动端仍读到旧值。
- **方案**:`GoogleAnalytics.tsx` 改为带预算的帧循环(`MAX_TITLE_SETTLE_FRAMES = 10`):标题一旦变化立即发送,超过预算则兜底发送(绝不丢计数),新导航开始前先补发未送出的上一条 pageview。
- **来源**:2026-09-21 验收 A-17,`e2e/ga4-event-tracking.spec.ts` TC-GA4-004 实证 + `GoogleAnalytics.test.tsx` 竞态用例。
### 5.13 品牌审计失败要先判定「测试过期」还是「真实泄漏」
- **问题**:`p1-brand-visual-audit.spec.ts` 断言首页不含 `novalon` / 竞品字样而失败,初判为「文案改版后的过期测试」。
- **根因**:并非过期——CMS page copy 缺字段时代码回落到 `FALLBACK_FOUNDER_QUOTE`,而回落常量里写着未品牌化的团队名,属于真实对外可见的品牌泄漏。同时 `prisma/seed.ts` 也存了同一条文案,数据库行可能仍保留旧值。
- **方案**:先 `curl` 已构建 HTML 确认字面来源(是渲染值还是回落值),再改常量与 seed;对「疑似过期」断言必须给出证据链(现网渲染结果 + 期望口径来源)后才允许改测试,并在提交信息里留痕。
- **来源**:2026-09-21 验收 P1 品牌视觉审计,`home-content-v15.tsx` / `prisma/seed.ts` 修复。
### 5.14 视觉基线不能带上 `next dev` 的 devtools 指示器,且「装了守卫」必须被验证
- **问题**:83/115 视觉用例失败,且**页面高度完全不同的用例共享同一个差异簇** `{x:19-56, y:743-780}`(约 38px 圆盘)。
- **根因**:`next dev` 注入 ``,其 shadow DOM 在视口左下角渲染 Next 徽标指示器;`next start` 没有它。更糟的是指示器的**形态随当次 dev 会话累积的告警数变化**——无告警时是 38px 圆盘,有告警时展开成红色 "Issues" 胶囊(本分支 dev 下 CSP 禁 `eval`,React 开发模式必然告警)。于是同一份代码在两次 dev 采集之间也会差一簇。因为该元素 `position: fixed`,全页截图按初始视口坐标落位,所以「不同高度页面出现同一坐标簇」成为它的判别特征。
- **踩过的坑**:第一版守卫把 `MutationObserver` 挂在 `document.documentElement` 上——init script 运行得比 `documentElement` 创建还早(Playwright 官方文档明确),`observe(null)` 直接抛 `TypeError`;这个报错只在 `pageerror` 里可见,测试本身照常「通过」,于是重跑 115 例全绿的基线里其实仍带着 "Issues" 胶囊。改成挂 `document` 后,用单页 `-actual.png` 左下角裁剪复核才确认剥离生效。
- **方案**:`page.addInitScript` 在页面脚本之前挂 `MutationObserver(document)` 移除 `nextjs-portal`,使快照与 `E2E_TARGET`、与 dev 会话告警数都无关;**新增守卫后必须用一张实际截图验证它生效**,不能只看「测试通过」。(该逻辑现已上移到 `e2e/fixtures.ts` 的 `context` 夹具,功能与视觉 project 共用,见 §5.16。)
- **来源**:2026-09-21 验收视觉回归,`node_modules/next/dist/next-devtools/dev-overlay/components/devtools-indicator` + dev 页面 `document.querySelectorAll('*')` 探测到 `nextjs-portal`(0×0 host、带 shadowRoot)+ 新旧基线同坐标裁剪比对。
### 5.15 `npm run test` 把视觉与写库用例并发跑,基线会被瞬时数据污染
- **问题**:`/news`、`/home` 快照在列表区多出一张「UJ-03 测试新闻 <时间戳>」卡片(日期即当天),而 SQLite 中查无此行;`curl` 却能在服务端 HTML 里读到它。
- **根因**:`test` 脚本单次调用同时跑功能 project 与视觉 project,`fullyParallel: true` 下 CMS 工作流用例「建条目 → `POST /api/cms/revalidate` → 删条目」与视觉截图并发。用例结束后数据行已清,但生产 server 的 ISR/预渲染缓存仍保留含测试条目的那一版页面(响应头 `x-nextjs-cache: HIT`),视觉用例随即截到它。
- **方案**:`npm run test` 拆为两阶段串行——`test:functional`(4 个功能 project)跑完再跑 `test:visual:all`(5 个视觉 project);`test:e2e` 指向功能阶段。CI 早已按 L4 功能 / L4.4 视觉分 stage,本地口径与之对齐。
- **来源**:2026-09-21 验收,`sqlite3 "file:prisma/dev.db?mode=ro"` 计数 38 行 / news 仅 2 条,与 `curl -sI localhost:3000/news` 响应头互证。
### 5.16 dev 指示器的 shadow DOM 会被 Playwright 穿透,`locator('footer')` 因此解析到 2 个元素
- **问题**:功能 E2E 68 例失败里 56 例是同一条 `strict mode violation: locator('footer, [data-testid="footer"]') resolved to 2 elements`,第二个元素是 `