diff --git a/.revision-progress b/.revision-progress
index 989e568..779bd9e 100644
--- a/.revision-progress
+++ b/.revision-progress
@@ -8,3 +8,4 @@ phase3 done (6650f7e)
phase4a done (caaec83)
phase4 done (7a32d2c)
phase5a done (frontend ts/vue/react/ssr)
+phase5b done (frontend vite/webpack/fe-testing-perf)
diff --git a/catalog.json b/catalog.json
index 7f68e60..6a2a9e1 100644
--- a/catalog.json
+++ b/catalog.json
@@ -519,14 +519,16 @@
"domain": "frontend",
"title": "前端测试与性能",
"index": "topics/frontend/fe-testing-perf/index.md",
- "lessons": 0,
- "scenarios": 0,
+ "lessons": 2,
+ "scenarios": 1,
"interview_questions": {
- "基础": 0,
- "进阶": 0,
- "场景题": 0
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
},
- "examples": []
+ "examples": [
+ "topics/frontend/fe-testing-perf/examples/search-box"
+ ]
},
{
"stack": "react",
@@ -581,14 +583,16 @@
"domain": "frontend",
"title": "Vite",
"index": "topics/frontend/vite/index.md",
- "lessons": 0,
- "scenarios": 0,
+ "lessons": 1,
+ "scenarios": 1,
"interview_questions": {
- "基础": 0,
- "进阶": 0,
- "场景题": 0
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
},
- "examples": []
+ "examples": [
+ "topics/frontend/vite/examples/vite-config"
+ ]
},
{
"stack": "vue",
@@ -609,16 +613,18 @@
{
"stack": "webpack",
"domain": "frontend",
- "title": "Webpack",
+ "title": "webpack",
"index": "topics/frontend/webpack/index.md",
- "lessons": 0,
- "scenarios": 0,
+ "lessons": 1,
+ "scenarios": 1,
"interview_questions": {
- "基础": 0,
- "进阶": 0,
- "场景题": 0
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
},
- "examples": []
+ "examples": [
+ "topics/frontend/webpack/examples/webpack-config"
+ ]
},
{
"stack": "asyncio",
@@ -5498,7 +5504,13 @@
"stack": [
"fe-testing-perf"
],
- "tags": [],
+ "tags": [
+ "Vitest",
+ "Testing Library",
+ "Playwright",
+ "Web Vitals",
+ "性能优化"
+ ],
"level": null,
"prerequisites": [],
"related": [],
@@ -5508,7 +5520,173 @@
],
"order": null,
"path": "topics/frontend/fe-testing-perf/index.md",
- "sections": []
+ "sections": [
+ "学习路线",
+ "企业场景",
+ "面试题",
+ "示例代码",
+ "在 16 周计划中",
+ "官方资料"
+ ]
+ },
+ {
+ "id": "frontend.fe-testing-perf.interview",
+ "title": "前端测试与性能面试题",
+ "type": "interview",
+ "domain": "frontend",
+ "stack": [
+ "fe-testing-perf"
+ ],
+ "tags": [
+ "测试",
+ "性能优化",
+ "Web Vitals",
+ "面试"
+ ],
+ "level": "intermediate",
+ "prerequisites": [],
+ "related": [
+ "frontend.fe-testing-perf.testing",
+ "frontend.fe-testing-perf.web-vitals"
+ ],
+ "est_hours": null,
+ "week": [
+ 7,
+ 16
+ ],
+ "order": null,
+ "path": "topics/frontend/fe-testing-perf/interview.md",
+ "sections": [
+ "基础",
+ "进阶",
+ "场景题"
+ ],
+ "questions": {
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
+ }
+ },
+ {
+ "id": "frontend.fe-testing-perf.scenario.perf-regression",
+ "title": "性能治理:真实用户监控、定位慢页面、CI 性能预算和测试防回归",
+ "type": "scenario",
+ "domain": "frontend",
+ "stack": [
+ "fe-testing-perf",
+ "cicd"
+ ],
+ "tags": [
+ "性能监控",
+ "RUM",
+ "Lighthouse CI",
+ "性能预算",
+ "回归测试",
+ "INP"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.fe-testing-perf.web-vitals",
+ "frontend.fe-testing-perf.testing"
+ ],
+ "related": [
+ "frontend.fe-testing-perf.interview",
+ "frontend.webpack.scenario.build-speed"
+ ],
+ "est_hours": 2.5,
+ "week": [
+ 7
+ ],
+ "order": null,
+ "path": "topics/frontend/fe-testing-perf/scenarios/perf-regression.md",
+ "sections": [
+ "业务背景与问题",
+ "约束",
+ "方案设计",
+ "关键代码",
+ "取舍",
+ "易错点",
+ "面试怎么讲"
+ ]
+ },
+ {
+ "id": "frontend.fe-testing-perf.testing",
+ "title": "前端测试:Vitest 单元测试、Testing Library 组件测试、Playwright 端到端测试",
+ "type": "lesson",
+ "domain": "frontend",
+ "stack": [
+ "fe-testing-perf"
+ ],
+ "tags": [
+ "Vitest",
+ "Testing Library",
+ "Playwright",
+ "假定时器",
+ "Mock",
+ "竞态",
+ "测试金字塔"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.react.hooks-rendering"
+ ],
+ "related": [
+ "frontend.fe-testing-perf.web-vitals",
+ "frontend.fe-testing-perf.interview",
+ "ai.ai-coding.verify-evidence"
+ ],
+ "est_hours": 2.5,
+ "week": [
+ 7
+ ],
+ "order": 1,
+ "path": "topics/frontend/fe-testing-perf/01-testing-pyramid.md",
+ "sections": [
+ "解释说明",
+ "案例",
+ "代码示例",
+ "易错点",
+ "面试常问"
+ ]
+ },
+ {
+ "id": "frontend.fe-testing-perf.web-vitals",
+ "title": "前端性能:Core Web Vitals(LCP / INP / CLS)、长任务和优化清单",
+ "type": "lesson",
+ "domain": "frontend",
+ "stack": [
+ "fe-testing-perf"
+ ],
+ "tags": [
+ "性能优化",
+ "Core Web Vitals",
+ "LCP",
+ "INP",
+ "CLS",
+ "长任务",
+ "监控"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.ssr.rendering-modes"
+ ],
+ "related": [
+ "frontend.fe-testing-perf.scenario.perf-regression",
+ "frontend.webpack.scenario.build-speed"
+ ],
+ "est_hours": 2.5,
+ "week": [
+ 7
+ ],
+ "order": 2,
+ "path": "topics/frontend/fe-testing-perf/02-web-vitals.md",
+ "sections": [
+ "解释说明",
+ "案例",
+ "代码示例",
+ "易错点",
+ "面试常问"
+ ]
},
{
"id": "frontend.react.hooks-rendering",
@@ -6016,6 +6194,48 @@
"面试常问"
]
},
+ {
+ "id": "frontend.vite.dev-build-config",
+ "title": "Vite 8:开发服务器原理、Rolldown 构建、环境变量、代理(含 SSE)、插件和拆包",
+ "type": "lesson",
+ "domain": "frontend",
+ "stack": [
+ "vite"
+ ],
+ "tags": [
+ "Vite",
+ "Rolldown",
+ "ESM",
+ "HMR",
+ "环境变量",
+ "代理",
+ "插件",
+ "虚拟模块",
+ "拆包"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.typescript.type-system"
+ ],
+ "related": [
+ "frontend.webpack.build-optimization",
+ "frontend.vite.scenario.webpack-to-vite",
+ "frontend.vite.interview"
+ ],
+ "est_hours": 2.5,
+ "week": [
+ 7
+ ],
+ "order": 1,
+ "path": "topics/frontend/vite/01-dev-build-config.md",
+ "sections": [
+ "解释说明",
+ "案例",
+ "代码示例",
+ "易错点",
+ "面试常问"
+ ]
+ },
{
"id": "frontend.vite.index",
"title": "Vite",
@@ -6024,7 +6244,11 @@
"stack": [
"vite"
],
- "tags": [],
+ "tags": [
+ "Vite",
+ "Rolldown",
+ "构建工具"
+ ],
"level": null,
"prerequisites": [],
"related": [],
@@ -6034,7 +6258,94 @@
],
"order": null,
"path": "topics/frontend/vite/index.md",
- "sections": []
+ "sections": [
+ "学习路线",
+ "企业场景",
+ "面试题",
+ "示例代码",
+ "在 16 周计划中",
+ "官方资料"
+ ]
+ },
+ {
+ "id": "frontend.vite.interview",
+ "title": "Vite 面试题",
+ "type": "interview",
+ "domain": "frontend",
+ "stack": [
+ "vite"
+ ],
+ "tags": [
+ "Vite",
+ "Rolldown",
+ "HMR",
+ "插件",
+ "面试"
+ ],
+ "level": "intermediate",
+ "prerequisites": [],
+ "related": [
+ "frontend.vite.dev-build-config",
+ "frontend.webpack.interview"
+ ],
+ "est_hours": null,
+ "week": [
+ 7,
+ 16
+ ],
+ "order": null,
+ "path": "topics/frontend/vite/interview.md",
+ "sections": [
+ "基础",
+ "进阶",
+ "场景题"
+ ],
+ "questions": {
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
+ }
+ },
+ {
+ "id": "frontend.vite.scenario.webpack-to-vite",
+ "title": "老项目从 webpack 迁移到 Vite:评估、步骤、风险和回滚",
+ "type": "scenario",
+ "domain": "frontend",
+ "stack": [
+ "vite",
+ "webpack"
+ ],
+ "tags": [
+ "迁移",
+ "构建工具",
+ "CommonJS",
+ "环境变量",
+ "灰度"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.vite.dev-build-config",
+ "frontend.webpack.build-optimization"
+ ],
+ "related": [
+ "frontend.vite.interview",
+ "frontend.webpack.interview"
+ ],
+ "est_hours": 2,
+ "week": [
+ 7
+ ],
+ "order": null,
+ "path": "topics/frontend/vite/scenarios/webpack-to-vite.md",
+ "sections": [
+ "业务背景与问题",
+ "约束",
+ "方案设计",
+ "关键代码",
+ "取舍",
+ "易错点",
+ "面试怎么讲"
+ ]
},
{
"id": "frontend.vue.index",
@@ -6195,15 +6506,58 @@
"面试怎么讲"
]
},
+ {
+ "id": "frontend.webpack.build-optimization",
+ "title": "webpack 5:loader 与 plugin、splitChunks、tree shaking、缓存、模块联邦",
+ "type": "lesson",
+ "domain": "frontend",
+ "stack": [
+ "webpack"
+ ],
+ "tags": [
+ "webpack",
+ "loader",
+ "plugin",
+ "splitChunks",
+ "tree shaking",
+ "contenthash",
+ "持久化缓存",
+ "Module Federation"
+ ],
+ "level": "intermediate",
+ "prerequisites": [],
+ "related": [
+ "frontend.vite.dev-build-config",
+ "frontend.webpack.scenario.build-speed",
+ "frontend.webpack.interview"
+ ],
+ "est_hours": 2.5,
+ "week": [
+ 7
+ ],
+ "order": 1,
+ "path": "topics/frontend/webpack/01-build-optimization.md",
+ "sections": [
+ "解释说明",
+ "案例",
+ "代码示例",
+ "易错点",
+ "面试常问"
+ ]
+ },
{
"id": "frontend.webpack.index",
- "title": "Webpack",
+ "title": "webpack",
"type": "index",
"domain": "frontend",
"stack": [
"webpack"
],
- "tags": [],
+ "tags": [
+ "webpack",
+ "构建工具",
+ "性能优化"
+ ],
"level": null,
"prerequisites": [],
"related": [],
@@ -6213,7 +6567,95 @@
],
"order": null,
"path": "topics/frontend/webpack/index.md",
- "sections": []
+ "sections": [
+ "学习路线",
+ "企业场景",
+ "面试题",
+ "示例代码",
+ "在 16 周计划中",
+ "官方资料"
+ ]
+ },
+ {
+ "id": "frontend.webpack.interview",
+ "title": "webpack 面试题",
+ "type": "interview",
+ "domain": "frontend",
+ "stack": [
+ "webpack"
+ ],
+ "tags": [
+ "webpack",
+ "loader",
+ "plugin",
+ "tree shaking",
+ "面试"
+ ],
+ "level": "intermediate",
+ "prerequisites": [],
+ "related": [
+ "frontend.webpack.build-optimization",
+ "frontend.vite.interview"
+ ],
+ "est_hours": null,
+ "week": [
+ 7,
+ 16
+ ],
+ "order": null,
+ "path": "topics/frontend/webpack/interview.md",
+ "sections": [
+ "基础",
+ "进阶",
+ "场景题"
+ ],
+ "questions": {
+ "基础": 3,
+ "进阶": 3,
+ "场景题": 2
+ }
+ },
+ {
+ "id": "frontend.webpack.scenario.build-speed",
+ "title": "大型项目构建慢、包体积大:度量、优化和防退化",
+ "type": "scenario",
+ "domain": "frontend",
+ "stack": [
+ "webpack",
+ "fe-testing-perf",
+ "cicd"
+ ],
+ "tags": [
+ "构建速度",
+ "包体积",
+ "splitChunks",
+ "持久化缓存",
+ "体积预算",
+ "CI"
+ ],
+ "level": "intermediate",
+ "prerequisites": [
+ "frontend.webpack.build-optimization"
+ ],
+ "related": [
+ "frontend.webpack.interview",
+ "frontend.vite.scenario.webpack-to-vite"
+ ],
+ "est_hours": 2,
+ "week": [
+ 7
+ ],
+ "order": null,
+ "path": "topics/frontend/webpack/scenarios/build-speed.md",
+ "sections": [
+ "业务背景与问题",
+ "约束",
+ "方案设计",
+ "关键代码",
+ "取舍",
+ "易错点",
+ "面试怎么讲"
+ ]
},
{
"id": "plan.index",
diff --git a/topics/frontend/fe-testing-perf/01-testing-pyramid.md b/topics/frontend/fe-testing-perf/01-testing-pyramid.md
new file mode 100644
index 0000000..5dec112
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/01-testing-pyramid.md
@@ -0,0 +1,80 @@
+---
+id: frontend.fe-testing-perf.testing
+title: 前端测试:Vitest 单元测试、Testing Library 组件测试、Playwright 端到端测试
+type: lesson
+domain: frontend
+stack: [fe-testing-perf]
+tags: [Vitest, Testing Library, Playwright, 假定时器, Mock, 竞态, 测试金字塔]
+level: intermediate
+prerequisites: [frontend.react.hooks-rendering]
+est_hours: 2.5
+related: [frontend.fe-testing-perf.web-vitals, frontend.fe-testing-perf.interview, ai.ai-coding.verify-evidence]
+week: [7]
+order: 1
+---
+
+# 前端测试:Vitest 单元测试、Testing Library 组件测试、Playwright 端到端测试
+
+> BOSS直聘 2026-10-23 的调研中,多个岗位要求“用 AI 写代码并保证质量”:测试是证明 AI 生成代码正确的最直接证据(见 [AI 编程:验证与证据](../../ai/ai-coding/02-verify-evidence.md))。工具版本以官方文档为准:https://vitest.dev/ 、https://testing-library.com/ 、https://playwright.dev/ 。
+
+## 解释说明
+
+### 测试金字塔
+
+| 层级 | 工具 | 测什么 | 数量 |
+| --- | --- | --- | --- |
+| 单元测试 | Vitest | 纯函数、状态机、工具函数、组合式函数 / Hooks | 最多 |
+| 组件测试 | Vitest + Testing Library(jsdom) | 组件在用户操作下的表现 | 适中 |
+| 端到端测试 | Playwright | 真实浏览器中的关键业务流程 | 少而精 |
+
+### Testing Library 的原则
+
+**按用户的方式测试**:通过角色、标签文字查找元素(`getByRole`、`getByLabelText`),而不是 CSS 类名或组件内部状态;断言用户能看到的结果。好处是重构实现时测试不用改,同时促使组件具备可访问性。
+
+### 常用技巧
+
+- **假定时器**:`vi.useFakeTimers()` + `vi.advanceTimersByTimeAsync()` 测试防抖、轮询、超时,不需要真的等待。
+- **Mock**:`vi.fn()` 模拟依赖(接口请求函数作为参数注入,测试时传入假的实现);端到端测试中用 `page.route` 拦截接口返回固定数据。
+- **测竞态**:让旧请求比新请求慢,断言最终显示新结果。
+- **端到端测试稳定性**:使用自动等待的断言(`await expect(locator).toHaveCount(2)`),不要写固定的 `sleep`。
+
+## 案例
+
+订单搜索框:防抖 300ms;输入变化时中止旧请求;失败显示错误。三个测试分别覆盖防抖、竞态和失败;把组件中的 `controller.abort()` 删除后,竞态测试会失败——证明测试真的在保护这个行为。
+
+## 代码示例
+
+完整示例:[examples/search-box](examples/search-box/)(`vitest run`:3 个测试通过;`tsc` 通过;删除中止逻辑后竞态测试失败,已验证)。
+
+```tsx
+it("竞态:慢的旧请求晚返回,不会覆盖新结果", async () => {
+ const search: SearchFn = (q, signal) =>
+ new Promise((resolve) => {
+ const ms = q === "old" ? 1000 : 100;
+ const t = setTimeout(() => resolve([`结果-${q}`]), ms);
+ signal.addEventListener("abort", () => clearTimeout(t));
+ });
+ render();
+ typeInto("old");
+ await act(() => vi.advanceTimersByTimeAsync(300));
+ typeInto("new");
+ await act(() => vi.advanceTimersByTimeAsync(2000));
+ expect(screen.getByRole("list", { name: "搜索结果" }).textContent).toBe("结果-new");
+});
+```
+
+Playwright 端到端测试(示意,未在浏览器中实际运行):[e2e/search.spec.ts](examples/search-box/e2e/search.spec.ts)。
+
+## 易错点
+
+- 测试实现细节(组件内部 state、私有方法),重构就挂。
+- 用真实定时器等待,测试又慢又不稳定。
+- 组件内部直接 `fetch`,测试只能 mock 全局对象:把依赖作为参数或通过上下文注入更好测。
+- 端到端测试依赖测试环境的真实数据,数据一变就失败。
+- 只追求覆盖率数字:没有断言的测试也能提高覆盖率。
+
+## 面试常问
+
+- 你们项目怎么做前端测试?测试金字塔每层测什么?
+- 怎么测试防抖、定时器、异步请求?
+- 端到端测试不稳定怎么办?
diff --git a/topics/frontend/fe-testing-perf/02-web-vitals.md b/topics/frontend/fe-testing-perf/02-web-vitals.md
new file mode 100644
index 0000000..ee57b70
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/02-web-vitals.md
@@ -0,0 +1,96 @@
+---
+id: frontend.fe-testing-perf.web-vitals
+title: 前端性能:Core Web Vitals(LCP / INP / CLS)、长任务和优化清单
+type: lesson
+domain: frontend
+stack: [fe-testing-perf]
+tags: [性能优化, Core Web Vitals, LCP, INP, CLS, 长任务, 监控]
+level: intermediate
+prerequisites: [frontend.ssr.rendering-modes]
+est_hours: 2.5
+related: [frontend.fe-testing-perf.scenario.perf-regression, frontend.webpack.scenario.build-speed]
+week: [7]
+order: 2
+---
+
+# 前端性能:Core Web Vitals(LCP / INP / CLS)、长任务和优化清单
+
+> 指标定义和阈值以官方文档为准:https://web.dev/articles/vitals 。
+
+## 解释说明
+
+### Core Web Vitals
+
+| 指标 | 含义 | “良好”阈值(按 75 分位统计) | 常见原因 |
+| --- | --- | --- | --- |
+| **LCP**(最大内容绘制) | 视口中最大的图片 / 文本块渲染完成的时间 | ≤ 2.5 秒 | 服务器慢、渲染阻塞资源、首屏大图没优化、客户端渲染 |
+| **INP**(交互到下一次绘制) | 用户交互到页面视觉响应的延迟,取整个访问期间较差的值 | ≤ 200 毫秒 | 主线程长任务、事件处理太重、大范围重新渲染 |
+| **CLS**(累计布局偏移) | 页面元素意外移动的程度 | ≤ 0.1 | 图片 / 广告没有预留尺寸、字体切换、动态插入内容 |
+
+INP 在 2024 年取代了 FID。
+
+### 实验室数据和真实用户数据
+
+- **实验室数据**:Lighthouse、浏览器性能面板,可复现,适合调试和 CI 回归。
+- **真实用户数据(RUM)**:在线上用 `web-vitals` 库采集上报,反映真实设备和网络;以 75 分位判断。
+
+### 长任务
+
+主线程上超过 50 毫秒的任务。期间无法响应点击,导致 INP 变差。办法:把计算切块并让出主线程(`scheduler.yield()` 或 `setTimeout`)、移到 Web Worker、减少重新渲染的范围、虚拟列表。
+
+### 优化清单
+
+- **LCP**:SSR / SSG、关键资源预加载(``)、图片压缩和现代格式、首屏图不要懒加载、CDN。
+- **INP**:拆分长任务、防抖节流、React 的 `useTransition` / `useDeferredValue`、虚拟列表。
+- **CLS**:图片和视频写宽高或 `aspect-ratio`、为动态内容预留空间、字体 `font-display`。
+- **通用**:减少 JS 体积(懒加载、按需引入)、HTTP 缓存、压缩。
+
+## 案例
+
+AI 对话页面,回答很长时流式追加 Markdown 并实时高亮代码,输入框打字卡顿(INP 变差):每收到一个 token 就重新解析整段 Markdown。改为按时间批量合并 token(如每 50ms 渲染一次)、只重新解析最后一个段落、代码高亮放到 Worker。
+
+## 代码示例
+
+把大量处理切块、让出主线程([examples/search-box/src/chunked.ts](examples/search-box/src/chunked.ts),`node src/chunked.ts` 自检通过):
+
+```ts
+export async function processInChunks(items: readonly T[], fn: (item: T) => R,
+ { budgetMs = 8, yieldFn = defaultYield, now = () => performance.now() } = {}) {
+ const results: R[] = [];
+ let start = now();
+ for (const item of items) {
+ results.push(fn(item));
+ if (now() - start >= budgetMs) { // 本块用完预算,让浏览器先处理交互和渲染
+ await yieldFn();
+ start = now();
+ }
+ }
+ return results;
+}
+```
+
+线上采集(示意,API 以 `web-vitals` 库文档为准):
+
+```ts
+import { onCLS, onINP, onLCP } from "web-vitals";
+
+function report(metric: { name: string; value: number; id: string }) {
+ navigator.sendBeacon("/api/rum", JSON.stringify({ ...metric, page: location.pathname }));
+}
+onLCP(report);
+onINP(report);
+onCLS(report);
+```
+
+## 易错点
+
+- 只看本地 Lighthouse 分数,不看真实用户的数据。
+- 把首屏大图也懒加载了,LCP 反而变差。
+- 用平均值而不是分位数衡量。
+- 优化完没有监控和预算,几个版本后退化。
+
+## 面试常问
+
+- 说说 LCP、INP、CLS 和各自的优化方法。
+- 页面交互卡顿怎么排查?
+- 你做过的性能优化,数据是多少?怎么测的?
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/e2e/search.spec.ts b/topics/frontend/fe-testing-perf/examples/search-box/e2e/search.spec.ts
new file mode 100644
index 0000000..45f7a46
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/e2e/search.spec.ts
@@ -0,0 +1,13 @@
+// 不自动运行:Playwright 端到端测试示意(需要 npx playwright install 并启动被测站点;本仓库未在浏览器中实际运行)
+// 以官方文档为准:https://playwright.dev/docs/intro
+import { expect, test } from "@playwright/test";
+
+test("用户搜索订单并看到结果", async ({ page }) => {
+ // 拦截接口返回固定数据:端到端测试只验证前端流程,不依赖后端数据
+ await page.route("**/api/orders/search**", (route) =>
+ route.fulfill({ json: ["A001", "A002"] }),
+ );
+ await page.goto("http://localhost:5173/orders");
+ await page.getByLabel("搜索订单").fill("A00");
+ await expect(page.getByRole("list", { name: "搜索结果" }).getByRole("listitem")).toHaveCount(2);
+});
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/package.json b/topics/frontend/fe-testing-perf/examples/search-box/package.json
new file mode 100644
index 0000000..63c83a1
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "search-box-demo",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "vitest run",
+ "e2e": "playwright test"
+ },
+ "dependencies": {
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.64.0",
+ "@testing-library/react": "^16.3.0",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
+ "jsdom": "^30.0.0",
+ "typescript": "^5.9.0",
+ "vitest": "^5.0.0"
+ }
+}
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.test.tsx b/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.test.tsx
new file mode 100644
index 0000000..89cb26c
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.test.tsx
@@ -0,0 +1,52 @@
+// 不自动运行(需要 npm install 后执行 npx vitest run)
+import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SearchBox, type SearchFn } from "./SearchBox.tsx";
+
+beforeEach(() => vi.useFakeTimers());
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+function typeInto(text: string) {
+ fireEvent.change(screen.getByLabelText("搜索订单"), { target: { value: text } });
+}
+
+describe("SearchBox", () => {
+ it("防抖:连续输入只发一次请求", async () => {
+ const search = vi.fn().mockResolvedValue(["A001"]);
+ render();
+ typeInto("A");
+ typeInto("A0");
+ typeInto("A00");
+ await act(() => vi.advanceTimersByTimeAsync(300));
+ expect(search).toHaveBeenCalledTimes(1);
+ expect(search.mock.calls[0]?.[0]).toBe("A00");
+ expect(screen.getByRole("list", { name: "搜索结果" }).textContent).toContain("A001");
+ });
+
+ it("竞态:慢的旧请求晚返回,不会覆盖新结果", async () => {
+ const search: SearchFn = (q, signal) =>
+ new Promise((resolve) => {
+ const ms = q === "old" ? 1000 : 100; // 旧关键词的请求更慢
+ const t = setTimeout(() => resolve([`结果-${q}`]), ms);
+ signal.addEventListener("abort", () => clearTimeout(t));
+ });
+ render();
+ typeInto("old");
+ await act(() => vi.advanceTimersByTimeAsync(300)); // 发出 old 请求
+ typeInto("new");
+ await act(() => vi.advanceTimersByTimeAsync(2000)); // 两个请求都有时间完成
+ const list = screen.getByRole("list", { name: "搜索结果" });
+ expect(list.textContent).toBe("结果-new");
+ });
+
+ it("失败时显示错误提示", async () => {
+ const search = vi.fn().mockRejectedValue(new Error("服务繁忙"));
+ render();
+ typeInto("x");
+ await act(() => vi.advanceTimersByTimeAsync(300));
+ expect(screen.getByRole("alert").textContent).toBe("服务繁忙");
+ });
+});
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.tsx b/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.tsx
new file mode 100644
index 0000000..998abad
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.tsx
@@ -0,0 +1,51 @@
+// 不自动运行:React 组件,由 vitest + Testing Library 测试(npx vitest run)
+// 要点:防抖减少请求;新请求开始时中止旧请求,避免“旧结果覆盖新结果”的竞态;可访问的标签,便于测试和读屏
+import { useEffect, useState } from "react";
+
+export type SearchFn = (q: string, signal: AbortSignal) => Promise;
+
+export function SearchBox({ search, delayMs = 300 }: { search: SearchFn; delayMs?: number }) {
+ const [q, setQ] = useState("");
+ const [items, setItems] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const keyword = q.trim();
+ if (!keyword) {
+ setItems([]);
+ return;
+ }
+ const controller = new AbortController();
+ const timer = setTimeout(() => {
+ search(keyword, controller.signal)
+ .then((r) => {
+ if (!controller.signal.aborted) {
+ setItems(r);
+ setError(null);
+ }
+ })
+ .catch((e: unknown) => {
+ if (!controller.signal.aborted) setError(e instanceof Error ? e.message : "搜索失败");
+ });
+ }, delayMs);
+ return () => { // 输入变化或卸载:清除定时器并中止进行中的请求
+ clearTimeout(timer);
+ controller.abort();
+ };
+ }, [q, search, delayMs]);
+
+ return (
+
+
+ {error &&
{error}
}
+
+ {items.map((it) => (
+ - {it}
+ ))}
+
+
+ );
+}
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/src/chunked.ts b/topics/frontend/fe-testing-perf/examples/search-box/src/chunked.ts
new file mode 100644
index 0000000..3c14598
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/src/chunked.ts
@@ -0,0 +1,44 @@
+// 把耗时的循环切成小块,每块之间让出主线程,避免长任务(> 50ms)拖慢交互(INP)
+// 只用标准库;`node src/chunked.ts` 可以直接运行自检
+
+export type Yield = () => Promise;
+
+// 浏览器中优先使用 scheduler.yield()(较新的 API,以 MDN 为准),否则退回 setTimeout(0)
+export const defaultYield: Yield = () => {
+ const s = (globalThis as { scheduler?: { yield?: () => Promise } }).scheduler;
+ if (s?.yield) return s.yield();
+ return new Promise((resolve) => setTimeout(resolve, 0));
+};
+
+export async function processInChunks(
+ items: readonly T[],
+ fn: (item: T) => R,
+ options: { budgetMs?: number; yieldFn?: Yield; now?: () => number } = {},
+): Promise<{ results: R[]; yields: number }> {
+ const { budgetMs = 8, yieldFn = defaultYield, now = () => performance.now() } = options;
+ const results: R[] = [];
+ let yields = 0;
+ let start = now();
+ for (const item of items) {
+ results.push(fn(item));
+ if (now() - start >= budgetMs) { // 本块用完预算:让出主线程,让浏览器处理点击和渲染
+ await yieldFn();
+ yields++;
+ start = now();
+ }
+ }
+ return { results, yields };
+}
+
+// 自检
+if (import.meta.url === `file://${process.argv[1]}`) {
+ let fake = 0;
+ const { results, yields } = await processInChunks(
+ Array.from({ length: 100 }, (_, i) => i),
+ (x) => x * 2,
+ { budgetMs: 10, now: () => (fake += 1), yieldFn: async () => {} }, // 假时钟:每次调用 +1ms
+ );
+ if (results.length !== 100 || results[99] !== 198) throw new Error("结果错误");
+ if (yields < 5) throw new Error(`应该多次让出主线程,实际 ${yields}`);
+ console.log(`处理 ${results.length} 条,让出主线程 ${yields} 次`);
+}
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/tsconfig.json b/topics/frontend/fe-testing-perf/examples/search-box/tsconfig.json
new file mode 100644
index 0000000..2ec19a4
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowImportingTsExtensions": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/topics/frontend/fe-testing-perf/examples/search-box/vitest.config.ts b/topics/frontend/fe-testing-perf/examples/search-box/vitest.config.ts
new file mode 100644
index 0000000..0edb4f5
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/examples/search-box/vitest.config.ts
@@ -0,0 +1,9 @@
+// 不自动运行:vitest 的配置文件
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "jsdom", // 组件测试需要 DOM
+ include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
+ },
+});
diff --git a/topics/frontend/fe-testing-perf/index.md b/topics/frontend/fe-testing-perf/index.md
index a3699e2..73fa955 100644
--- a/topics/frontend/fe-testing-perf/index.md
+++ b/topics/frontend/fe-testing-perf/index.md
@@ -4,12 +4,40 @@ title: 前端测试与性能
type: index
domain: frontend
stack: [fe-testing-perf]
-tags: []
+tags: [Vitest, Testing Library, Playwright, Web Vitals, 性能优化]
week: [7]
---
# 前端测试与性能
-Vitest、组件测试、Web Vitals、性能优化。
+测试(证明代码正确,也是验证 AI 生成代码的手段)和性能(Core Web Vitals、长任务)。七年前端经验最能体现深度的部分之一。
-> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
+## 学习路线
+
+| 顺序 | 课程 | 级别 | 预计 |
+| --- | --- | --- | --- |
+| 1 | [前端测试:Vitest 单元测试、Testing Library 组件测试、Playwright 端到端测试](01-testing-pyramid.md) | 进阶 | 2.5 小时 |
+| 2 | [前端性能:Core Web Vitals(LCP / INP / CLS)、长任务和优化清单](02-web-vitals.md) | 进阶 | 2.5 小时 |
+
+## 企业场景
+
+- [性能治理:真实用户监控、定位慢页面、CI 性能预算和测试防回归](scenarios/perf-regression.md)
+
+## 面试题
+
+- [前端测试与性能面试题](interview.md)
+
+## 示例代码
+
+- [examples/search-box](examples/search-box/):防抖 + 中止旧请求的搜索组件及 3 个组件测试(vitest 通过)、长任务切块 `chunked.ts`(可直接运行)、Playwright 端到端测试示意
+
+## 在 16 周计划中
+
+- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
+
+## 官方资料
+
+- https://vitest.dev/
+- https://testing-library.com/
+- https://playwright.dev/
+- https://web.dev/articles/vitals
diff --git a/topics/frontend/fe-testing-perf/interview.md b/topics/frontend/fe-testing-perf/interview.md
new file mode 100644
index 0000000..ae3edee
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/interview.md
@@ -0,0 +1,67 @@
+---
+id: frontend.fe-testing-perf.interview
+title: 前端测试与性能面试题
+type: interview
+domain: frontend
+stack: [fe-testing-perf]
+tags: [测试, 性能优化, Web Vitals, 面试]
+level: intermediate
+related: [frontend.fe-testing-perf.testing, frontend.fe-testing-perf.web-vitals]
+week: [7, 16]
+---
+
+# 前端测试与性能面试题
+
+## 基础
+
+### 【基础】单元测试、组件测试、端到端测试的区别?
+
+**参考答案**:单元测试验证函数和模块,快、数量多;组件测试在模拟 DOM 中验证组件与用户交互的行为;端到端测试在真实浏览器中走完整业务流程,最接近用户但慢且脆弱,只覆盖关键路径。
+
+**追问**:你们项目各层大概怎么分配?→ 以单元和组件测试为主,端到端只覆盖登录、下单、核心流程等少量用例。
+
+### 【基础】LCP、INP、CLS 分别是什么?
+
+**参考答案**:LCP 衡量加载(最大内容渲染时间,良好 ≤ 2.5 秒);INP 衡量交互响应(良好 ≤ 200 毫秒);CLS 衡量视觉稳定性(良好 ≤ 0.1)。按 75 分位评估。
+
+**追问**:INP 和 FID 的区别?→ FID 只测第一次交互的输入延迟;INP 考察整个访问期间的交互,并包含处理和渲染时间,2024 年起取代 FID。
+
+### 【基础】怎么测试带防抖的组件?
+
+**参考答案**:使用假定时器,触发多次输入后推进时间,断言请求只发一次且参数是最后一次输入;推进时间要包在 `act` 中让状态更新生效。
+
+**追问**:为什么不用真实等待?→ 慢且不稳定,CI 机器负载高时容易超时。
+
+## 进阶
+
+### 【进阶】Testing Library 为什么推荐用 `getByRole` 查找元素?
+
+**参考答案**:贴近用户和辅助技术的使用方式,不依赖实现细节(类名、结构),重构时测试稳定;同时倒逼组件有正确的语义和可访问性。
+
+**追问**:什么时候用 `data-testid`?→ 实在没有语义化的查找方式时作为最后的选择。
+
+### 【进阶】页面交互卡顿怎么排查和优化?
+
+**参考答案**:用性能面板录制交互,找长任务和调用栈;看是否大量重新渲染、同步计算过重、布局抖动。优化:切分任务让出主线程、Web Worker、虚拟列表、减小渲染范围、防抖节流、React 的并发特性。
+
+**追问**:线上怎么发现?→ RUM 采集 INP 和长任务,按页面和版本聚合。
+
+### 【进阶】端到端测试不稳定(时好时坏)怎么办?
+
+**参考答案**:使用自动等待的定位器和断言而不是固定等待;隔离测试数据(每个用例自己准备数据或拦截接口);避免用例之间共享状态;失败时保存截图、录像和 trace 方便排查;对确实不稳定的用例标记并修复,而不是无限重试。
+
+**追问**:怎么让 CI 跑得快?→ 并行、分片执行,只在关键流程上跑全量浏览器矩阵。
+
+## 场景题
+
+### 【场景题】客服工作台“越用越卡”,你怎么治理并防止退化?
+
+**参考答案**:RUM 采集定位最差页面 → 性能面板找长任务 → 针对性优化(虚拟列表、流式输出批量渲染、状态拆分)→ CI 中加 Lighthouse 和体积预算、关键逻辑测试 → 用 75 分位前后对比汇报。详见[性能治理场景](scenarios/perf-regression.md)。
+
+**追问**:“越用越卡”还可能是什么原因?→ 内存泄漏:事件监听、定时器、订阅没有清理,缓存无限增长;用内存快照对比排查。
+
+### 【场景题】AI 帮你写了一个组件,你怎么证明它是对的?
+
+**参考答案**:先写清楚预期行为(包括边界:空输入、失败、竞态),为这些行为写测试;运行测试,并故意破坏关键逻辑(例如删除中止请求的代码)确认测试会失败——证明测试有效;再做类型检查、代码审查和手动验证。
+
+**追问**:如何把这个过程作为面试材料?→ 保留会话记录(脱敏)、测试代码、失败和通过的输出,作为“AI 编程 + 验证方法”的案例,见 [AI 编程专题](../../ai/ai-coding/index.md)。
diff --git a/topics/frontend/fe-testing-perf/scenarios/perf-regression.md b/topics/frontend/fe-testing-perf/scenarios/perf-regression.md
new file mode 100644
index 0000000..538d48a
--- /dev/null
+++ b/topics/frontend/fe-testing-perf/scenarios/perf-regression.md
@@ -0,0 +1,87 @@
+---
+id: frontend.fe-testing-perf.scenario.perf-regression
+title: 性能治理:真实用户监控、定位慢页面、CI 性能预算和测试防回归
+type: scenario
+domain: frontend
+stack: [fe-testing-perf, cicd]
+tags: [性能监控, RUM, Lighthouse CI, 性能预算, 回归测试, INP]
+level: intermediate
+prerequisites: [frontend.fe-testing-perf.web-vitals, frontend.fe-testing-perf.testing]
+est_hours: 2.5
+related: [frontend.fe-testing-perf.interview, frontend.webpack.scenario.build-speed]
+week: [7]
+---
+
+# 性能治理:真实用户监控、定位慢页面、CI 性能预算和测试防回归
+
+## 业务背景与问题
+
+SaaS 客服工作台(Vue / React 单页应用):客户投诉“越用越卡”,坐席在工单列表和 AI 回复面板之间切换时明显卡顿;每次大促前临时优化,之后又退化;没人说得清哪个页面、哪类设备慢。
+
+## 约束
+
+- 不能大规模重写。
+- 坐席电脑配置参差不齐。
+- 需要用数据向业务方说明优化效果。
+
+## 方案设计
+
+1. **可观测**:上线 RUM 采集 LCP / INP / CLS + 页面路径 + 版本号 + 设备信息;按页面和版本看 75 分位;同时采集长任务(Long Animation Frames / Long Tasks API,以 MDN 为准)。
+2. **定位**:从 INP 最差的页面入手,在性能面板录制,找到长任务的调用栈。常见发现:
+ - 工单列表一次渲染上千行 → 虚拟列表;
+ - AI 回复每个 token 重新渲染整个面板 → 批量合并更新 + 局部渲染;
+ - 全局状态变化导致整页重新渲染 → 拆分状态、记忆化。
+3. **防退化**:
+ - CI 中对关键页面跑 Lighthouse(实验室数据)设置预算,超出则失败;
+ - 产物体积预算;
+ - 对“竞态、防抖、批量更新”等逻辑写单元 / 组件测试;
+ - 每个版本对比 RUM 指标,退化告警。
+4. **汇报**:用优化前后同类设备的 75 分位对比说明效果。
+
+## 关键代码
+
+批量合并流式 token,降低渲染频率:
+
+```ts
+export function createBatcher(flush: (text: string) => void, intervalMs = 50) {
+ let buffer = "";
+ let timer: ReturnType | null = null;
+ return {
+ push(token: string) {
+ buffer += token;
+ timer ??= setTimeout(() => { // 一个时间窗口内的 token 合并成一次渲染
+ flush(buffer);
+ buffer = "";
+ timer = null;
+ }, intervalMs);
+ },
+ end() {
+ if (timer) clearTimeout(timer);
+ if (buffer) flush(buffer);
+ buffer = "";
+ timer = null;
+ },
+ };
+}
+```
+
+防抖与竞态的组件测试:[examples/search-box](../examples/search-box/)。
+
+## 取舍
+
+| 选择 | 优点 | 缺点 |
+| --- | --- | --- |
+| **RUM + 实验室预算(本方案)** | 既知道真实情况,又能在合并前拦截退化 | 需要搭建采集和看板 |
+| 只用 Lighthouse | 简单 | 与真实用户体验有差距,尤其是 INP |
+| 使用第三方性能监控平台 | 开箱即用 | 成本、数据合规(客服数据不能随便上报) |
+
+## 易错点
+
+- 上报数据中带了客户的敏感信息(URL 参数中的手机号、工单内容)。
+- 只看平均值。
+- 批量渲染的定时器在组件卸载时没有清理。
+- 优化了实验室分数,真实 INP 没变化:交互卡顿往往在登录后的复杂页面,Lighthouse 默认测不到。
+
+## 面试怎么讲
+
+> “先建立度量:线上采集 Web Vitals 按页面和版本看 75 分位,发现工作台的 INP 最差。用性能面板定位到两个长任务:工单列表全量渲染和 AI 回复逐 token 重渲染,分别改成虚拟列表和 50ms 批量更新。然后在 CI 加 Lighthouse 和体积预算、给关键交互逻辑写测试防止退化。效果用优化前后同类设备的 75 分位对比(数据以实测为准)。”
diff --git a/topics/frontend/vite/01-dev-build-config.md b/topics/frontend/vite/01-dev-build-config.md
new file mode 100644
index 0000000..bd7c3bc
--- /dev/null
+++ b/topics/frontend/vite/01-dev-build-config.md
@@ -0,0 +1,104 @@
+---
+id: frontend.vite.dev-build-config
+title: Vite 8:开发服务器原理、Rolldown 构建、环境变量、代理(含 SSE)、插件和拆包
+type: lesson
+domain: frontend
+stack: [vite]
+tags: [Vite, Rolldown, ESM, HMR, 环境变量, 代理, 插件, 虚拟模块, 拆包]
+level: intermediate
+prerequisites: [frontend.typescript.type-system]
+est_hours: 2.5
+related: [frontend.webpack.build-optimization, frontend.vite.scenario.webpack-to-vite, frontend.vite.interview]
+week: [7]
+order: 1
+---
+
+# Vite 8:开发服务器原理、Rolldown 构建、环境变量、代理(含 SSE)、插件和拆包
+
+> 基于 Vite 8.3 编写并实际构建验证。Vite 8 的生产构建改为基于 Rust 编写的 **Rolldown**(Vite 7 及以前:开发时用 esbuild 预构建依赖,生产用 Rollup 打包);`build.rollupOptions` 改名为 `build.rolldownOptions`(旧名仍可用但已标记弃用),拆包推荐 `output.codeSplitting`。以官方文档为准:https://vite.dev/guide/ 、https://rolldown.rs/ 。
+
+## 解释说明
+
+### 为什么开发时快
+
+- **开发**:不打包。浏览器原生支持 ES 模块(`
+