phase 5 part 2: vite 8, webpack 5, fe-testing-perf topics with verified examples
This commit is contained in:
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"],
|
||||
},
|
||||
});
|
||||
@@ -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 分位对比(数据以实测为准)。”
|
||||
@@ -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 模块(`<script type="module">`),请求哪个文件,Vite 就即时转换哪个文件(TS → JS、`.vue` → JS)。第三方依赖会预构建(把 CommonJS 转成 ESM、把很多小文件合成一个),并强缓存。
|
||||
- **热更新(HMR)**:文件修改后只让对应模块失效并重新请求,速度与项目大小基本无关。
|
||||
- **生产构建**:仍然要打包(减少请求数、tree shaking、压缩、拆包)。
|
||||
|
||||
### 常用配置
|
||||
|
||||
| 需求 | 配置 |
|
||||
| --- | --- |
|
||||
| 路径别名 | `resolve.alias` |
|
||||
| 环境变量 | `.env`、`.env.[mode]`;浏览器代码只能读取 `VITE_` 前缀的变量(`import.meta.env.VITE_XXX`);配置文件中用 `loadEnv` |
|
||||
| 开发代理 | `server.proxy`,解决开发时的跨域,支持 `rewrite` |
|
||||
| 插件 | Rollup / Rolldown 兼容的插件钩子(`resolveId`、`load`、`transform`)+ Vite 专有钩子(`configureServer`、`transformIndexHtml`、`handleHotUpdate`) |
|
||||
| 拆包 | Vite 8:`build.rolldownOptions.output.codeSplitting.groups`;动态 `import()` 自动拆包 |
|
||||
| 兼容旧浏览器 | `@vitejs/plugin-legacy` |
|
||||
|
||||
### Monorepo
|
||||
|
||||
pnpm workspace + Vite 是常见组合:多个应用共用组件库和工具包,`workspace:*` 引用本地包。
|
||||
|
||||
## 案例
|
||||
|
||||
AI 客服前端项目:开发时 `/api` 代理到本地 FastAPI,`/stream` 代理 SSE 流式接口;构建时注入版本号(便于排查线上问题对应哪个版本);Vue 运行时单独拆包,长期缓存。
|
||||
|
||||
## 代码示例
|
||||
|
||||
完整项目:[examples/vite-config](examples/vite-config/)。已验证:`tsc` 类型检查通过;`vite build` 输出独立的 `vendor-vue` 和按需加载的 `heavy` chunk;开发服务器代理普通接口(`/api/hello` → 后端 `/hello`)和 SSE(每 0.5 秒一条,逐条到达,没有被缓冲)。
|
||||
|
||||
自定义插件——虚拟模块([vite.config.ts](examples/vite-config/vite.config.ts)):
|
||||
|
||||
```ts
|
||||
function buildInfo(version: string): Plugin {
|
||||
const id = "virtual:build-info";
|
||||
const resolved = "\0" + id;
|
||||
return {
|
||||
name: "build-info",
|
||||
resolveId(source) { return source === id ? resolved : null; },
|
||||
load(moduleId) {
|
||||
if (moduleId !== resolved) return null;
|
||||
return `export default ${JSON.stringify({ version, builtAt: new Date().toISOString() })};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
代理和拆包:
|
||||
|
||||
```ts
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": { target: apiTarget, changeOrigin: true, rewrite: (p) => p.replace(/^\/api/, "") },
|
||||
"/stream": { target: apiTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: { codeSplitting: { groups: [{ name: "vendor-vue", test: /node_modules[\\/]@?vue/ }] } },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
构建输出:
|
||||
|
||||
```text
|
||||
dist/assets/heavy-xxxx.js 0.08 kB
|
||||
dist/assets/index-xxxx.js 2.54 kB
|
||||
dist/assets/vendor-vue-xxxx.js 60.80 kB
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 把密钥放到 `VITE_` 前缀的变量中:会被打包进浏览器代码,任何人都能看到。
|
||||
- 以为开发代理在生产环境也生效:生产环境需要 Nginx 或网关转发。
|
||||
- 开发正常、构建后报错:开发不打包,CommonJS 依赖、动态路径、大小写敏感的路径在构建时才暴露问题,提交前要跑一次 `vite build`。
|
||||
- 照搬旧文章的 `rollupOptions.output.manualChunks`:Vite 8 中仍可用但已弃用,新项目使用 `codeSplitting`。
|
||||
- 拆包拆得太碎:请求数增加,反而变慢。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- Vite 为什么比 webpack 开发时快?生产构建为什么还要打包?
|
||||
- Vite 的环境变量是如何区分浏览器可见和不可见的?
|
||||
- 写过 Vite 插件吗?说说钩子。
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env 后使用(.env 已在 .gitignore 中,避免误提交)
|
||||
# 以 VITE_ 开头的变量会被打包进浏览器代码,绝对不要放密钥
|
||||
VITE_APP_TITLE=Vite 配置示例
|
||||
# 不带 VITE_ 前缀的变量只在 vite.config.ts 中可用
|
||||
API_TARGET=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Vite 配置示例</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "vite-config-demo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string;
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
declare module "virtual:build-info" {
|
||||
const info: { version: string; builtAt: string };
|
||||
export default info;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 不自动运行:被 main.ts 动态 import,构建后会成为单独的 chunk
|
||||
export function summary(): string {
|
||||
return "这个模块在点击按钮时才加载";
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// 不自动运行:浏览器入口,由 vite 构建
|
||||
import { createApp, h } from "vue";
|
||||
import info from "virtual:build-info";
|
||||
|
||||
const App = {
|
||||
setup() {
|
||||
return () =>
|
||||
h("main", [
|
||||
h("h1", import.meta.env.VITE_APP_TITLE),
|
||||
h("p", `版本 ${info.version},构建于 ${info.builtAt},模式 ${import.meta.env.MODE}`),
|
||||
h("button", { onClick: () => import("./heavy").then((m) => alert(m.summary())) }, "按需加载模块"),
|
||||
]);
|
||||
},
|
||||
};
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// 不自动运行:需要先 npm install,然后 npm run dev / npm run build
|
||||
// Vite 8 配置示例:环境变量、开发代理(含 SSE)、自定义插件(虚拟模块)、拆包。以官方文档为准:https://vite.dev/config/
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// 自定义插件:提供虚拟模块 virtual:build-info,在代码中拿到构建时间和版本
|
||||
function buildInfo(version: string): Plugin {
|
||||
const id = "virtual:build-info";
|
||||
const resolved = "\0" + id; // \0 前缀:约定表示虚拟模块,其他插件不要处理
|
||||
return {
|
||||
name: "build-info",
|
||||
resolveId(source) {
|
||||
return source === id ? resolved : null;
|
||||
},
|
||||
load(moduleId) {
|
||||
if (moduleId !== resolved) return null;
|
||||
return `export default ${JSON.stringify({ version, builtAt: new Date().toISOString() })};`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 第三个参数 "" 表示加载全部变量(包括没有 VITE_ 前缀的),只在配置文件里用,不会暴露给浏览器
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const apiTarget = env.API_TARGET ?? "http://127.0.0.1:8000";
|
||||
|
||||
return {
|
||||
plugins: [vue(), buildInfo(env.npm_package_version ?? "dev")],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// 普通接口:/api/xxx → 后端 /xxx
|
||||
"/api": {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api/, ""),
|
||||
},
|
||||
// SSE 流式接口:开发代理默认不会缓冲,但要确保后端返回 text/event-stream 并及时 flush
|
||||
"/stream": { target: apiTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: mode !== "production",
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
// Vite 8 基于 Rolldown:拆包用 codeSplitting.groups(Rollup 的 manualChunks 已不推荐)
|
||||
codeSplitting: {
|
||||
groups: [{ name: "vendor-vue", test: /node_modules[\\/]@?vue/ }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -4,12 +4,37 @@ title: Vite
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [vite]
|
||||
tags: []
|
||||
tags: [Vite, Rolldown, 构建工具]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# Vite
|
||||
|
||||
开发服务器原理、构建、配置、插件、环境变量。
|
||||
开发服务器原理、Vite 8(Rolldown)构建、环境变量、代理、插件和拆包。你已有多年前端经验,这里重点补“原理能讲清楚”和“迁移能落地”。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Vite 8:开发服务器原理、Rolldown 构建、环境变量、代理(含 SSE)、插件和拆包](01-dev-build-config.md) | 进阶 | 2.5 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [老项目从 webpack 迁移到 Vite:评估、步骤、风险和回滚](scenarios/webpack-to-vite.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [Vite 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/vite-config](examples/vite-config/):虚拟模块插件、`loadEnv`、`/api` 和 SSE 代理、`codeSplitting` 拆包(类型检查、构建、开发代理均已验证)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- https://vite.dev/guide/
|
||||
- https://rolldown.rs/
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.vite.interview
|
||||
title: Vite 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [vite]
|
||||
tags: [Vite, Rolldown, HMR, 插件, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.vite.dev-build-config, frontend.webpack.interview]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# Vite 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】Vite 为什么开发时启动快?
|
||||
|
||||
**参考答案**:开发时不打包,利用浏览器原生 ES 模块按需请求、按需转换;第三方依赖预构建并强缓存;热更新只更新修改的模块,与项目规模基本无关。
|
||||
|
||||
**追问**:预构建做了什么?→ 把 CommonJS / UMD 依赖转成 ESM,把有很多内部模块的依赖合并成少量文件,减少浏览器请求数。
|
||||
|
||||
### 【基础】既然浏览器支持 ESM,生产环境为什么还要打包?
|
||||
|
||||
**参考答案**:未打包时模块请求多、瀑布式加载慢;打包可以做 tree shaking、压缩、拆包、资源哈希命名以便长期缓存、兼容处理。
|
||||
|
||||
**追问**:Vite 8 生产构建用什么?→ Rolldown(Rust 实现、兼容 Rollup 插件接口);Vite 7 及以前是 Rollup。
|
||||
|
||||
### 【基础】Vite 中的环境变量怎么使用?
|
||||
|
||||
**参考答案**:`.env` / `.env.[mode]` 文件;浏览器代码通过 `import.meta.env` 读取,只有 `VITE_` 前缀的变量会暴露;配置文件中用 `loadEnv` 读取全部变量。
|
||||
|
||||
**追问**:密钥可以放进 `VITE_` 变量吗?→ 不可以,会被打包进前端代码。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】Vite 插件有哪些常用钩子?
|
||||
|
||||
**参考答案**:通用钩子(与 Rollup 兼容):`resolveId`、`load`、`transform`、`buildStart` / `buildEnd`;Vite 专有:`config`、`configResolved`、`configureServer`(给开发服务器加中间件)、`transformIndexHtml`、`handleHotUpdate`。可以用 `enforce` 控制顺序、`apply` 控制只在开发或构建时生效。
|
||||
|
||||
**追问**:虚拟模块怎么实现?→ `resolveId` 识别特定 id 返回带 `\0` 前缀的内部 id,`load` 返回模块源码。
|
||||
|
||||
### 【进阶】Vite 的 HMR 是如何工作的?
|
||||
|
||||
**参考答案**:开发服务器监听文件变化,通过 WebSocket 通知浏览器;客户端根据模块依赖图找到接受更新的边界模块(例如 Vue 组件),重新请求该模块并替换,组件状态尽量保留;找不到边界时刷新整页。
|
||||
|
||||
**追问**:为什么有时候修改后整页刷新?→ 修改的模块及其上层没有模块接受热更新(例如修改了入口文件或纯工具模块的上层没有 HMR 边界)。
|
||||
|
||||
### 【进阶】怎么做拆包?
|
||||
|
||||
**参考答案**:动态 `import()` 自动生成独立 chunk(路由懒加载);第三方库按变化频率分组(Vite 8 用 `codeSplitting.groups`),利于长期缓存;避免拆得过碎。
|
||||
|
||||
**追问**:如何分析产物体积?→ 使用打包分析插件生成可视化报告,找大依赖和重复依赖。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】老项目从 webpack 迁移到 Vite,你怎么推进?
|
||||
|
||||
**参考答案**:盘点 webpack 专有用法 → 两套配置并存 → 逐项替换(`require.context`、环境变量、自定义 loader)→ 冒烟测试和产物对比 → 灰度上线并保留回滚。详见[迁移场景](scenarios/webpack-to-vite.md)。
|
||||
|
||||
**追问**:迁移遇到的最大问题?→ 常见的是 CommonJS 依赖和隐式的 Node 全局变量,以及开发模式正常、生产构建报错。
|
||||
|
||||
### 【场景题】开发时用 Vite 代理调用 AI 流式接口,前端收到的内容是一次性出现的,怎么排查?
|
||||
|
||||
**参考答案**:先用 curl 直连后端确认后端是否真的逐条发送和 flush;再经过代理用 `curl -N` 验证;检查响应头是否是 `text/event-stream`、是否被压缩中间件缓冲;最后检查前端是否用流式方式读取(`fetch` 的 `body.getReader()` 或 `EventSource`),而不是 `await res.text()`。
|
||||
|
||||
**追问**:生产环境同样问题?→ 检查 Nginx 的 `proxy_buffering` 和 `X-Accel-Buffering` 响应头、CDN / 网关是否缓冲。
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
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]
|
||||
est_hours: 2
|
||||
related: [frontend.vite.interview, frontend.webpack.interview]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# 老项目从 webpack 迁移到 Vite:评估、步骤、风险和回滚
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
一个 5 年的 Vue 2 → Vue 3 后台项目(几百个页面)仍用 webpack 4 构建:开发启动要几分钟,改一行代码热更新要十几秒,团队抱怨效率低;依赖老旧,有安全漏洞告警。
|
||||
|
||||
## 约束
|
||||
|
||||
- 不能停止业务需求开发,迁移要渐进。
|
||||
- 有自定义的 webpack loader(处理内部 SVG 图标)和 `process.env.XXX` 的大量使用。
|
||||
- 线上不能出问题,要可以回滚。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **评估**:列出 webpack 配置中所有的 loader / plugin,逐个找 Vite 对应方案(多数有现成插件);扫描代码中的 `require()`、`require.context`、`process.env`、`module.hot`。
|
||||
2. **并行配置**:先新增 vite.config.ts,与 webpack 配置并存,开发者可以选择用哪个启动;CI 两种都构建。
|
||||
3. **逐项替换**:
|
||||
- `require.context` → `import.meta.glob`;
|
||||
- `process.env.VUE_APP_XXX` → `import.meta.env.VITE_XXX`(批量替换 + 类型声明);
|
||||
- 自定义 loader → Vite 插件的 `transform` 钩子;
|
||||
- 别名、代理配置平移。
|
||||
4. **验证**:页面冒烟测试(Playwright 跑核心流程);对比两份构建产物的体积和关键页面性能指标。
|
||||
5. **灰度上线**:先上测试环境,再让部分流量使用新产物;保留 webpack 构建一个迭代周期,出问题可以切回。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```ts
|
||||
// webpack:const modules = require.context("./icons", false, /\.svg$/);
|
||||
// Vite:
|
||||
const icons = import.meta.glob("./icons/*.svg", { query: "?raw", import: "default", eager: true });
|
||||
|
||||
// 自定义 loader 改写为插件
|
||||
function svgIcon(): Plugin {
|
||||
return {
|
||||
name: "svg-icon",
|
||||
transform(code, id) {
|
||||
if (!id.endsWith(".svg?icon")) return null;
|
||||
return { code: `export default ${JSON.stringify(optimizeSvg(code))};`, map: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
可运行的 Vite 配置:[examples/vite-config](../examples/vite-config/)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **迁移到 Vite(本方案)** | 开发体验大幅提升、配置简单 | 迁移成本、部分 webpack 专有特性需要替代 |
|
||||
| 升级 webpack 5 + 缓存 + 更快的 loader(如 swc-loader) | 改动小 | 开发启动仍然慢于 Vite |
|
||||
| Rspack(兼容 webpack 配置的 Rust 构建工具) | 配置几乎不用改 | 生态兼容性需要验证 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 只验证开发模式,没有验证生产构建。
|
||||
- 批量替换环境变量时遗漏了第三方库读取的 `process.env.NODE_ENV`(Vite 构建时会自动替换这个,但其他 `process.env` 引用不会)。
|
||||
- 迁移后 chunk 的划分和文件名变化导致 CDN 缓存策略失效。
|
||||
- 一次性切换,没有回滚方案。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “我主导过一个老项目从 webpack 迁到 Vite。先盘点配置和代码中的 webpack 专有用法,然后两套配置并存、CI 都构建,逐项替换 require.context、环境变量和自定义 loader;用 Playwright 跑核心流程冒烟,对比产物体积和性能指标;灰度上线,保留旧构建一个迭代。结果是开发启动和热更新明显变快(具体数字以你的项目实测为准)。”
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
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: []
|
||||
est_hours: 2.5
|
||||
related: [frontend.vite.dev-build-config, frontend.webpack.scenario.build-speed, frontend.webpack.interview]
|
||||
week: [7]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# webpack 5:loader 与 plugin、splitChunks、tree shaking、缓存、模块联邦
|
||||
|
||||
> 基于 webpack 5.112 + webpack-cli 7 编写并实际构建验证。很多公司的存量项目仍在用 webpack,面试也常问原理。以官方文档为准:https://webpack.js.org/concepts/ 。
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 核心概念
|
||||
|
||||
| 概念 | 一句话 |
|
||||
| --- | --- |
|
||||
| entry | 从哪里开始分析依赖 |
|
||||
| output | 产物放在哪里、叫什么名字 |
|
||||
| **loader** | 把非 JS 文件(或需要转换的 JS)**转换**成 JS 模块;从右到左(从下到上)链式执行 |
|
||||
| **plugin** | 在编译**生命周期的钩子**上做事(生成 HTML、压缩、提取 CSS、分析体积),基于 tapable 事件机制 |
|
||||
| chunk | 打包过程中的代码块;入口、动态 `import()`、`splitChunks` 都会产生 chunk |
|
||||
| mode | `development` / `production`,决定默认优化项 |
|
||||
|
||||
### 构建流程(面试常问)
|
||||
|
||||
初始化参数 → 创建 Compiler → 从 entry 开始,对每个模块调用匹配的 loader 转换 → 解析出依赖,递归构建模块依赖图 → 根据入口和拆包规则组装 chunk → 生成代码写入 output。过程中在各个阶段触发钩子,plugin 在钩子上执行。
|
||||
|
||||
### 优化手段
|
||||
|
||||
- **tree shaking**:依赖 ES 模块的静态结构,`usedExports` 标记未使用的导出,压缩时删除;`package.json` 的 `sideEffects` 声明哪些文件有副作用(如 CSS),其余可以整体删除。
|
||||
- **splitChunks**:提取公共代码和第三方库;`runtimeChunk: "single"` 把运行时单独拆出。
|
||||
- **contenthash**:文件名随内容变化,配合 CDN 长期缓存。
|
||||
- **持久化缓存**:`cache: { type: "filesystem" }`,第二次构建明显变快。
|
||||
- **构建提速**:缩小 loader 的处理范围(`include` / `exclude`)、用 swc / esbuild 类 loader 代替 babel、多进程、减少不必要的 source map。
|
||||
- **Module Federation(模块联邦)**:多个独立构建的应用在运行时共享模块,用于微前端。
|
||||
|
||||
## 案例
|
||||
|
||||
一个主站和后台两个入口共享工具函数:通过 `splitChunks` 提取出 `shared`,运行时拆出 `runtime`,报表模块用动态 `import()` 按需加载。
|
||||
|
||||
## 代码示例
|
||||
|
||||
完整项目:[examples/webpack-config](examples/webpack-config/),`npm install && npm run build` 输出:
|
||||
|
||||
```text
|
||||
[size] runtime.981552ae.js 2.74 KiB
|
||||
[size] main.38cd14b7.js 0.33 KiB
|
||||
[size] 531.30ecc347.js 0.21 KiB ← 动态 import 的 report.js
|
||||
[size] admin.e2a620fd.js 0.20 KiB
|
||||
[size] shared.5f4a3ab4.js 0.18 KiB ← 两个入口共用的 utils.js
|
||||
Entrypoint main 3.25 KiB = runtime + shared + main
|
||||
Entrypoint admin 3.12 KiB = runtime + shared + admin
|
||||
```
|
||||
|
||||
已验证:`utils.js` 中未被使用的 `unusedHelper` 在产物中搜索不到(tree shaking 生效)。
|
||||
|
||||
自定义 loader([loaders/md-loader.cjs](examples/webpack-config/loaders/md-loader.cjs)):
|
||||
|
||||
```js
|
||||
module.exports = function mdLoader(source) {
|
||||
const { trim = false } = this.getOptions();
|
||||
const text = trim ? source.trim() : source;
|
||||
return `export default ${JSON.stringify(text)};`;
|
||||
};
|
||||
```
|
||||
|
||||
自定义 plugin([webpack.config.js](examples/webpack-config/webpack.config.js)):
|
||||
|
||||
```js
|
||||
class SizeReportPlugin {
|
||||
apply(compiler) {
|
||||
compiler.hooks.done.tap("SizeReportPlugin", (stats) => {
|
||||
const assets = stats.toJson({ all: false, assets: true }).assets ?? [];
|
||||
for (const a of assets) console.log(`[size] ${a.name} ${(a.size / 1024).toFixed(2)} KiB`);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 混淆 loader 和 plugin 的职责。
|
||||
- loader 的执行顺序写反(`use: ["style-loader", "css-loader"]` 中 css-loader 先执行)。
|
||||
- 用了 CommonJS(`require` / `module.exports`)导致 tree shaking 失效;`sideEffects: false` 配置错误导致 CSS 被删掉。
|
||||
- 用 `[hash]` 而不是 `[contenthash]`,每次构建所有文件名都变,缓存失效。
|
||||
- 生产环境开启了暴露源码的 source map 并公开访问。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- loader 和 plugin 的区别?写过吗?
|
||||
- webpack 的构建流程?
|
||||
- tree shaking 的原理和失效原因?
|
||||
- 怎么优化构建速度和产物体积?
|
||||
@@ -0,0 +1,7 @@
|
||||
// 不自动运行:webpack loader,由 webpack 调用
|
||||
// loader 是一个函数:输入文件内容,返回 JS 代码字符串
|
||||
module.exports = function mdLoader(source) {
|
||||
const { trim = false } = this.getOptions();
|
||||
const text = trim ? source.trim() : source;
|
||||
return `export default ${JSON.stringify(text)};`;
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "webpack-config-demo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": ["*.css"],
|
||||
"scripts": {
|
||||
"build": "webpack --mode production",
|
||||
"analyze": "webpack --mode production --json > stats.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"webpack": "^5.112.0",
|
||||
"webpack-cli": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 不自动运行:由 webpack 打包
|
||||
import { formatPrice } from "./utils.js";
|
||||
|
||||
console.log("admin", formatPrice(500));
|
||||
@@ -0,0 +1,7 @@
|
||||
// 不自动运行:由 webpack 打包
|
||||
import { formatPrice } from "./utils.js";
|
||||
import notice from "./notice.md";
|
||||
|
||||
console.log(formatPrice(1999), notice.split("\n")[0]);
|
||||
// 动态 import:单独的 chunk,用到时才加载
|
||||
import("./report.js").then((m) => console.log(m.buildReport([1, 2, 3])));
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
# 公告
|
||||
|
||||
退货政策调整,详见帮助中心。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// 不自动运行:被动态 import
|
||||
export function buildReport(nums) {
|
||||
return { count: nums.length, sum: nums.reduce((a, b) => a + b, 0) };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 不自动运行:由 webpack 打包
|
||||
export function formatPrice(cents) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
// 没有被任何地方使用:生产构建时会被 tree shaking 删除
|
||||
export function unusedHelper() {
|
||||
return "UNUSED_HELPER_MARKER";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// 不自动运行:需要先 npm install,然后 npm run build
|
||||
// webpack 5 配置示例:loader、plugin、splitChunks、contenthash、tree shaking、持久化缓存。以官方文档为准:https://webpack.js.org/concepts/
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 自定义 plugin:构建结束后打印每个产物的大小(plugin = 在编译生命周期的钩子上做事)
|
||||
class SizeReportPlugin {
|
||||
apply(compiler) {
|
||||
compiler.hooks.done.tap("SizeReportPlugin", (stats) => {
|
||||
const assets = stats.toJson({ all: false, assets: true }).assets ?? [];
|
||||
for (const a of assets.sort((x, y) => y.size - x.size)) {
|
||||
console.log(`[size] ${a.name.padEnd(36)} ${(a.size / 1024).toFixed(2)} KiB`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default (env, argv) => {
|
||||
const isProd = argv.mode === "production";
|
||||
return {
|
||||
entry: { main: "./src/index.js", admin: "./src/admin.js" }, // 两个入口,共享 utils 和第三方代码
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
filename: isProd ? "[name].[contenthash:8].js" : "[name].js", // 内容变了文件名才变,利于长期缓存
|
||||
clean: true,
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
// 自定义 loader:把 .md 文件转成导出字符串的 JS 模块(loader = 把某类文件转换成 JS 模块)
|
||||
{ test: /\.md$/, use: [{ loader: path.resolve(__dirname, "loaders/md-loader.cjs"), options: { trim: true } }] },
|
||||
],
|
||||
},
|
||||
optimization: {
|
||||
usedExports: true, // 标记未使用的导出,压缩时删除(tree shaking)
|
||||
runtimeChunk: "single", // webpack 运行时单独一个文件,避免业务代码变化影响 vendor 的哈希
|
||||
splitChunks: {
|
||||
chunks: "all",
|
||||
cacheGroups: {
|
||||
shared: { name: "shared", minChunks: 2, minSize: 0, priority: 10 }, // 两个入口都用到的代码
|
||||
},
|
||||
},
|
||||
},
|
||||
cache: { type: "filesystem" }, // 持久化缓存:第二次构建明显变快
|
||||
plugins: [new SizeReportPlugin()],
|
||||
devtool: isProd ? false : "eval-cheap-module-source-map",
|
||||
};
|
||||
};
|
||||
@@ -1,15 +1,39 @@
|
||||
---
|
||||
id: frontend.webpack.index
|
||||
title: Webpack
|
||||
title: webpack
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [webpack]
|
||||
tags: []
|
||||
tags: [webpack, 构建工具, 性能优化]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# Webpack
|
||||
# webpack
|
||||
|
||||
核心概念、Loader 与 Plugin、拆包、构建优化。
|
||||
存量项目和面试中仍然很常见:loader 与 plugin、构建流程、tree shaking、拆包和缓存、构建提速。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [webpack 5:loader 与 plugin、splitChunks、tree shaking、缓存、模块联邦](01-build-optimization.md) | 进阶 | 2.5 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [大型项目构建慢、包体积大:度量、优化和防退化](scenarios/build-speed.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [webpack 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/webpack-config](examples/webpack-config/):双入口 + `splitChunks` 提取公共代码 + 动态导入 + 自定义 loader 和 plugin(`npm run build` 已验证,未使用的导出被删除)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- https://webpack.js.org/concepts/
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.webpack.interview
|
||||
title: webpack 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [webpack]
|
||||
tags: [webpack, loader, plugin, tree shaking, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.webpack.build-optimization, frontend.vite.interview]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# webpack 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】loader 和 plugin 有什么区别?
|
||||
|
||||
**参考答案**:loader 负责把某类文件转换成 webpack 能处理的 JS 模块,本质是转换函数,在 `module.rules` 中配置;plugin 通过钩子介入整个编译生命周期,做打包优化、资源管理、注入环境变量等,在 `plugins` 中配置。
|
||||
|
||||
**追问**:多个 loader 的执行顺序?→ 从右到左(数组从后往前);每个 loader 还有 pitch 阶段,从左到右执行。
|
||||
|
||||
### 【基础】hash、chunkhash、contenthash 的区别?
|
||||
|
||||
**参考答案**:hash 与整次构建相关,任何改动都会变;chunkhash 与所在 chunk 相关;contenthash 与文件内容相关,最适合长期缓存(例如 CSS 文件不随 JS 变化)。
|
||||
|
||||
**追问**:只改了业务代码,vendor 的文件名也变了,为什么?→ 运行时代码或模块 id 混在 vendor 中;用 `runtimeChunk: "single"` 并使用确定性的模块 id(webpack 5 生产模式默认)。
|
||||
|
||||
### 【基础】什么是 tree shaking?
|
||||
|
||||
**参考答案**:删除未使用代码。依赖 ES 模块的静态导入导出,构建时标记未使用的导出,压缩阶段删除;`sideEffects` 声明哪些模块有副作用。
|
||||
|
||||
**追问**:什么情况会失效?→ 使用 CommonJS、模块有副作用但未声明、babel 把 ESM 转成了 CommonJS、动态访问导出对象。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】讲讲 webpack 的构建流程。
|
||||
|
||||
**参考答案**:合并配置 → 创建 Compiler 并注册插件 → 从入口开始构建模块:调用 loader 转换、解析 AST 收集依赖、递归 → 形成模块依赖图 → 按入口、动态导入和 splitChunks 规则生成 chunk → 生成代码、写入文件。全程通过 tapable 钩子通知插件。
|
||||
|
||||
**追问**:Compiler 和 Compilation 的区别?→ Compiler 代表整个 webpack 实例,生命周期贯穿始终;Compilation 代表一次构建(监听模式下每次重新构建创建新的 Compilation)。
|
||||
|
||||
### 【进阶】如何提升 webpack 的构建速度?
|
||||
|
||||
**参考答案**:持久化缓存;缩小 loader 范围;更快的转译器(swc / esbuild 类 loader);减少或简化 source map;类型检查并行;合理的 `resolve` 配置;必要时考虑 Rspack。
|
||||
|
||||
**追问**:怎么确定瓶颈?→ 先用速度分析工具或 `--profile` 统计各阶段和 loader 耗时,再有针对性地优化。
|
||||
|
||||
### 【进阶】什么是模块联邦?适用场景?
|
||||
|
||||
**参考答案**:webpack 5 提供的能力,让独立构建和部署的应用在运行时互相加载模块,并共享依赖(如同一份 Vue)。用于微前端、多团队独立发布。
|
||||
|
||||
**追问**:有什么风险?→ 共享依赖版本不一致、运行时加载失败要降级、类型共享麻烦、调试复杂。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】CI 构建十几分钟、首页 JS 几 MB,你怎么优化并防止反弹?
|
||||
|
||||
**参考答案**:先度量(速度分析、产物分析)→ 构建提速(缓存、swc、并行类型检查)→ 体积优化(懒加载、按需引入、拆包、tree shaking)→ CI 体积预算。详见[构建优化场景](scenarios/build-speed.md)。
|
||||
|
||||
**追问**:优化效果怎么证明?→ 对比优化前后的构建时间和关键页面的产物大小、LCP 等指标,用数据说话。
|
||||
|
||||
### 【场景题】要求你写一个 loader,把团队内部的 `.md` 文档直接作为字符串导入组件,怎么做?
|
||||
|
||||
**参考答案**:loader 导出一个函数,参数是文件内容,用 `this.getOptions()` 读取配置,返回 `export default <JSON 字符串>` 形式的 JS 代码;在 `module.rules` 中用 `test: /\.md$/` 配置。见 [examples/webpack-config](examples/webpack-config/)。
|
||||
|
||||
**追问**:如果要转成 HTML 并支持异步处理?→ 使用 `this.async()` 获取回调,调用 Markdown 解析库后回调结果;注意输出内容的 XSS 风险。
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
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]
|
||||
est_hours: 2
|
||||
related: [frontend.webpack.interview, frontend.vite.scenario.webpack-to-vite]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# 大型项目构建慢、包体积大:度量、优化和防退化
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
一个中台项目(webpack 5,几百个页面):CI 构建要十几分钟,发版排队;首页 JS 有几 MB,弱网用户首屏很慢;优化过一次,几个月后又变回去了。
|
||||
|
||||
## 约束
|
||||
|
||||
- 短期内不能换构建工具(有大量自定义 loader 和插件)。
|
||||
- 优化要可量化,并且防止退化。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **先度量**:
|
||||
- 构建速度:统计各 loader / plugin 耗时(速度分析插件或 `--profile`);
|
||||
- 体积:生成产物分析报告(`webpack --json` + 可视化工具),找出最大的依赖和重复依赖。
|
||||
2. **构建提速**:
|
||||
- 开启 `cache: { type: "filesystem" }`,CI 中缓存缓存目录;
|
||||
- babel-loader 换成 swc 类 loader,并用 `include` 只处理源码;
|
||||
- 生产构建关闭不需要的 source map 或使用更快的类型;
|
||||
- 类型检查与构建并行(单独的 `tsc --noEmit` 任务)。
|
||||
3. **体积优化**:
|
||||
- 路由级懒加载;
|
||||
- 大依赖按需引入(图表库、组件库、日期库)或替换为更小的库;
|
||||
- `splitChunks` 按变化频率拆分第三方库;
|
||||
- 检查 `sideEffects` 让 tree shaking 生效。
|
||||
4. **防退化**:在 CI 中设置体积预算(`performance.maxEntrypointSize` 或脚本对比上次的产物大小),超出就失败并提示。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```js
|
||||
// webpack.config.js 片段
|
||||
export default {
|
||||
cache: { type: "filesystem", buildDependencies: { config: [import.meta.filename] } }, // 配置变化时缓存失效
|
||||
module: {
|
||||
rules: [{ test: /\.[jt]sx?$/, include: path.resolve("src"), use: "swc-loader" }],
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: "single",
|
||||
splitChunks: {
|
||||
chunks: "all",
|
||||
cacheGroups: {
|
||||
framework: { test: /[\\/]node_modules[\\/](vue|vue-router|pinia)[\\/]/, name: "framework", priority: 20 },
|
||||
vendors: { test: /[\\/]node_modules[\\/]/, name: "vendors", priority: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
performance: { hints: "error", maxEntrypointSize: 500 * 1024, maxAssetSize: 300 * 1024 }, // 超预算直接构建失败
|
||||
};
|
||||
```
|
||||
|
||||
可运行的基础配置:[examples/webpack-config](../examples/webpack-config/)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **在 webpack 内优化(本方案)** | 风险小,见效快 | 上限受 webpack 限制 |
|
||||
| 迁移到 Rspack | 配置大体兼容,速度提升大 | 部分插件需要验证 |
|
||||
| 迁移到 Vite | 开发体验最好 | 迁移成本高,见[迁移场景](../../vite/scenarios/webpack-to-vite.md) |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 没有度量就开始优化,改了很多却不知道哪项有效。
|
||||
- 持久化缓存没有配置 `buildDependencies`,改了配置却用了旧缓存。
|
||||
- 把所有第三方库打成一个巨大的 vendor,任何依赖升级都让整个文件缓存失效。
|
||||
- 只优化一次,没有预算和 CI 检查,很快退化。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “先度量:用速度分析和产物分析找到瓶颈,主要是 babel 处理了 node_modules 和一个整包引入的图表库。然后开启持久化缓存并在 CI 中缓存、换成 swc 类 loader、类型检查并行;体积上做路由懒加载、图表库按需引入、按变化频率拆包。最后在 CI 加体积预算防止退化。数据以实际测量为准,我会给出优化前后的对比。”
|
||||
Reference in new issue
Block a user