phase 5 part 2: vite 8, webpack 5, fe-testing-perf topics with verified examples

This commit is contained in:
Shikui Wang committed 2026-10-23 16:29:27 +08:00
1 parent 7b5cae7d24
commit 9eeb10aede
38 files changed
+1823 -37

No files matched your search

@@ -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(<SearchBox search={search} />);
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 全局对象:把依赖作为参数或通过上下文注入更好测。
- 端到端测试依赖测试环境的真实数据,数据一变就失败。
- 只追求覆盖率数字:没有断言的测试也能提高覆盖率。
## 面试常问
- 你们项目怎么做前端测试?测试金字塔每层测什么?
- 怎么测试防抖、定时器、异步请求?
- 端到端测试不稳定怎么办?
@@ -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、关键资源预加载(`<link rel="preload">`)、图片压缩和现代格式、首屏图不要懒加载、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<T, R>(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 和各自的优化方法。
- 页面交互卡顿怎么排查?
- 你做过的性能优化,数据是多少?怎么测的?
@@ -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);
});
@@ -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"
}
}
@@ -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<SearchFn>().mockResolvedValue(["A001"]);
render(<SearchBox search={search} />);
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(<SearchBox search={search} />);
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<SearchFn>().mockRejectedValue(new Error("服务繁忙"));
render(<SearchBox search={search} />);
typeInto("x");
await act(() => vi.advanceTimersByTimeAsync(300));
expect(screen.getByRole("alert").textContent).toBe("服务繁忙");
});
});
@@ -0,0 +1,51 @@
// 不自动运行:React 组件,由 vitest + Testing Library 测试(npx vitest run)
// 要点:防抖减少请求;新请求开始时中止旧请求,避免“旧结果覆盖新结果”的竞态;可访问的标签,便于测试和读屏
import { useEffect, useState } from "react";
export type SearchFn = (q: string, signal: AbortSignal) => Promise<string[]>;
export function SearchBox({ search, delayMs = 300 }: { search: SearchFn; delayMs?: number }) {
const [q, setQ] = useState("");
const [items, setItems] = useState<string[]>([]);
const [error, setError] = useState<string | null>(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 (
<div>
<label>
搜索订单
<input value={q} onChange={(e) => setQ(e.target.value)} />
</label>
{error && <p role="alert">{error}</p>}
<ul aria-label="搜索结果">
{items.map((it) => (
<li key={it}>{it}</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,44 @@
// 把耗时的循环切成小块,每块之间让出主线程,避免长任务(> 50ms)拖慢交互(INP)
// 只用标准库;`node src/chunked.ts` 可以直接运行自检
export type Yield = () => Promise<void>;
// 浏览器中优先使用 scheduler.yield()(较新的 API,以 MDN 为准),否则退回 setTimeout(0)
export const defaultYield: Yield = () => {
const s = (globalThis as { scheduler?: { yield?: () => Promise<void> } }).scheduler;
if (s?.yield) return s.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
};
export async function processInChunks<T, R>(
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} 次`);
}
@@ -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"]
}
@@ -0,0 +1,9 @@
// 不自动运行:vitest 的配置文件
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // 组件测试需要 DOM
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
});
+31 -3
View File
@@ -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
@@ -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)。
@@ -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<typeof setTimeout> | 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 分位对比(数据以实测为准)。”