From 9eeb10aede7d785b1e9e0b67bb171d788b54dac3 Mon Sep 17 00:00:00 2001 From: Shikui Wang Date: Fri, 23 Oct 2026 16:29:27 +0800 Subject: [PATCH] phase 5 part 2: vite 8, webpack 5, fe-testing-perf topics with verified examples --- .revision-progress | 1 + catalog.json | 494 +++++++++++++++++- .../fe-testing-perf/01-testing-pyramid.md | 80 +++ .../frontend/fe-testing-perf/02-web-vitals.md | 96 ++++ .../examples/search-box/e2e/search.spec.ts | 13 + .../examples/search-box/package.json | 22 + .../search-box/src/SearchBox.test.tsx | 52 ++ .../examples/search-box/src/SearchBox.tsx | 51 ++ .../examples/search-box/src/chunked.ts | 44 ++ .../examples/search-box/tsconfig.json | 15 + .../examples/search-box/vitest.config.ts | 9 + topics/frontend/fe-testing-perf/index.md | 34 +- topics/frontend/fe-testing-perf/interview.md | 67 +++ .../scenarios/perf-regression.md | 87 +++ topics/frontend/vite/01-dev-build-config.md | 104 ++++ .../vite/examples/vite-config/.env.example | 5 + .../vite/examples/vite-config/index.html | 11 + .../vite/examples/vite-config/package.json | 18 + .../vite/examples/vite-config/src/env.d.ts | 11 + .../vite/examples/vite-config/src/heavy.ts | 4 + .../vite/examples/vite-config/src/main.ts | 15 + .../vite/examples/vite-config/tsconfig.json | 12 + .../vite/examples/vite-config/vite.config.ts | 54 ++ topics/frontend/vite/index.md | 31 +- topics/frontend/vite/interview.md | 67 +++ .../vite/scenarios/webpack-to-vite.md | 77 +++ .../frontend/webpack/01-build-optimization.md | 102 ++++ .../webpack-config/loaders/md-loader.cjs | 7 + .../examples/webpack-config/package.json | 14 + .../examples/webpack-config/src/admin.js | 4 + .../examples/webpack-config/src/index.js | 7 + .../examples/webpack-config/src/notice.md | 5 + .../examples/webpack-config/src/report.js | 4 + .../examples/webpack-config/src/utils.js | 8 + .../examples/webpack-config/webpack.config.js | 49 ++ topics/frontend/webpack/index.md | 34 +- topics/frontend/webpack/interview.md | 67 +++ .../frontend/webpack/scenarios/build-speed.md | 85 +++ 38 files changed, 1823 insertions(+), 37 deletions(-) create mode 100644 topics/frontend/fe-testing-perf/01-testing-pyramid.md create mode 100644 topics/frontend/fe-testing-perf/02-web-vitals.md create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/e2e/search.spec.ts create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/package.json create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.test.tsx create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/src/SearchBox.tsx create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/src/chunked.ts create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/tsconfig.json create mode 100644 topics/frontend/fe-testing-perf/examples/search-box/vitest.config.ts create mode 100644 topics/frontend/fe-testing-perf/interview.md create mode 100644 topics/frontend/fe-testing-perf/scenarios/perf-regression.md create mode 100644 topics/frontend/vite/01-dev-build-config.md create mode 100644 topics/frontend/vite/examples/vite-config/.env.example create mode 100644 topics/frontend/vite/examples/vite-config/index.html create mode 100644 topics/frontend/vite/examples/vite-config/package.json create mode 100644 topics/frontend/vite/examples/vite-config/src/env.d.ts create mode 100644 topics/frontend/vite/examples/vite-config/src/heavy.ts create mode 100644 topics/frontend/vite/examples/vite-config/src/main.ts create mode 100644 topics/frontend/vite/examples/vite-config/tsconfig.json create mode 100644 topics/frontend/vite/examples/vite-config/vite.config.ts create mode 100644 topics/frontend/vite/interview.md create mode 100644 topics/frontend/vite/scenarios/webpack-to-vite.md create mode 100644 topics/frontend/webpack/01-build-optimization.md create mode 100644 topics/frontend/webpack/examples/webpack-config/loaders/md-loader.cjs create mode 100644 topics/frontend/webpack/examples/webpack-config/package.json create mode 100644 topics/frontend/webpack/examples/webpack-config/src/admin.js create mode 100644 topics/frontend/webpack/examples/webpack-config/src/index.js create mode 100644 topics/frontend/webpack/examples/webpack-config/src/notice.md create mode 100644 topics/frontend/webpack/examples/webpack-config/src/report.js create mode 100644 topics/frontend/webpack/examples/webpack-config/src/utils.js create mode 100644 topics/frontend/webpack/examples/webpack-config/webpack.config.js create mode 100644 topics/frontend/webpack/interview.md create mode 100644 topics/frontend/webpack/scenarios/build-speed.md 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 模块(` + + diff --git a/topics/frontend/vite/examples/vite-config/package.json b/topics/frontend/vite/examples/vite-config/package.json new file mode 100644 index 0000000..62835a4 --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/package.json @@ -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" + } +} diff --git a/topics/frontend/vite/examples/vite-config/src/env.d.ts b/topics/frontend/vite/examples/vite-config/src/env.d.ts new file mode 100644 index 0000000..59ce835 --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/src/env.d.ts @@ -0,0 +1,11 @@ +/// +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; +} diff --git a/topics/frontend/vite/examples/vite-config/src/heavy.ts b/topics/frontend/vite/examples/vite-config/src/heavy.ts new file mode 100644 index 0000000..3c8e6df --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/src/heavy.ts @@ -0,0 +1,4 @@ +// 不自动运行:被 main.ts 动态 import,构建后会成为单独的 chunk +export function summary(): string { + return "这个模块在点击按钮时才加载"; +} diff --git a/topics/frontend/vite/examples/vite-config/src/main.ts b/topics/frontend/vite/examples/vite-config/src/main.ts new file mode 100644 index 0000000..50bbb13 --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/src/main.ts @@ -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"); diff --git a/topics/frontend/vite/examples/vite-config/tsconfig.json b/topics/frontend/vite/examples/vite-config/tsconfig.json new file mode 100644 index 0000000..4120426 --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/tsconfig.json @@ -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"] +} diff --git a/topics/frontend/vite/examples/vite-config/vite.config.ts b/topics/frontend/vite/examples/vite-config/vite.config.ts new file mode 100644 index 0000000..075d655 --- /dev/null +++ b/topics/frontend/vite/examples/vite-config/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/ }], + }, + }, + }, + }, + }; +}); diff --git a/topics/frontend/vite/index.md b/topics/frontend/vite/index.md index 82daa55..9ed8b19 100644 --- a/topics/frontend/vite/index.md +++ b/topics/frontend/vite/index.md @@ -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/ diff --git a/topics/frontend/vite/interview.md b/topics/frontend/vite/interview.md new file mode 100644 index 0000000..921713d --- /dev/null +++ b/topics/frontend/vite/interview.md @@ -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 / 网关是否缓冲。 diff --git a/topics/frontend/vite/scenarios/webpack-to-vite.md b/topics/frontend/vite/scenarios/webpack-to-vite.md new file mode 100644 index 0000000..9016abb --- /dev/null +++ b/topics/frontend/vite/scenarios/webpack-to-vite.md @@ -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 跑核心流程冒烟,对比产物体积和性能指标;灰度上线,保留旧构建一个迭代。结果是开发启动和热更新明显变快(具体数字以你的项目实测为准)。” diff --git a/topics/frontend/webpack/01-build-optimization.md b/topics/frontend/webpack/01-build-optimization.md new file mode 100644 index 0000000..a5c32ea --- /dev/null +++ b/topics/frontend/webpack/01-build-optimization.md @@ -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 的原理和失效原因? +- 怎么优化构建速度和产物体积? diff --git a/topics/frontend/webpack/examples/webpack-config/loaders/md-loader.cjs b/topics/frontend/webpack/examples/webpack-config/loaders/md-loader.cjs new file mode 100644 index 0000000..b46bd3b --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/loaders/md-loader.cjs @@ -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)};`; +}; diff --git a/topics/frontend/webpack/examples/webpack-config/package.json b/topics/frontend/webpack/examples/webpack-config/package.json new file mode 100644 index 0000000..324475c --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/package.json @@ -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" + } +} diff --git a/topics/frontend/webpack/examples/webpack-config/src/admin.js b/topics/frontend/webpack/examples/webpack-config/src/admin.js new file mode 100644 index 0000000..709f623 --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/src/admin.js @@ -0,0 +1,4 @@ +// 不自动运行:由 webpack 打包 +import { formatPrice } from "./utils.js"; + +console.log("admin", formatPrice(500)); diff --git a/topics/frontend/webpack/examples/webpack-config/src/index.js b/topics/frontend/webpack/examples/webpack-config/src/index.js new file mode 100644 index 0000000..a355ae9 --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/src/index.js @@ -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]))); diff --git a/topics/frontend/webpack/examples/webpack-config/src/notice.md b/topics/frontend/webpack/examples/webpack-config/src/notice.md new file mode 100644 index 0000000..4db214e --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/src/notice.md @@ -0,0 +1,5 @@ + +# 公告 + +退货政策调整,详见帮助中心。 + diff --git a/topics/frontend/webpack/examples/webpack-config/src/report.js b/topics/frontend/webpack/examples/webpack-config/src/report.js new file mode 100644 index 0000000..6759481 --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/src/report.js @@ -0,0 +1,4 @@ +// 不自动运行:被动态 import +export function buildReport(nums) { + return { count: nums.length, sum: nums.reduce((a, b) => a + b, 0) }; +} diff --git a/topics/frontend/webpack/examples/webpack-config/src/utils.js b/topics/frontend/webpack/examples/webpack-config/src/utils.js new file mode 100644 index 0000000..94b072d --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/src/utils.js @@ -0,0 +1,8 @@ +// 不自动运行:由 webpack 打包 +export function formatPrice(cents) { + return `¥${(cents / 100).toFixed(2)}`; +} +// 没有被任何地方使用:生产构建时会被 tree shaking 删除 +export function unusedHelper() { + return "UNUSED_HELPER_MARKER"; +} diff --git a/topics/frontend/webpack/examples/webpack-config/webpack.config.js b/topics/frontend/webpack/examples/webpack-config/webpack.config.js new file mode 100644 index 0000000..596146c --- /dev/null +++ b/topics/frontend/webpack/examples/webpack-config/webpack.config.js @@ -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", + }; +}; diff --git a/topics/frontend/webpack/index.md b/topics/frontend/webpack/index.md index 056e210..946d736 100644 --- a/topics/frontend/webpack/index.md +++ b/topics/frontend/webpack/index.md @@ -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/ diff --git a/topics/frontend/webpack/interview.md b/topics/frontend/webpack/interview.md new file mode 100644 index 0000000..4058992 --- /dev/null +++ b/topics/frontend/webpack/interview.md @@ -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 ` 形式的 JS 代码;在 `module.rules` 中用 `test: /\.md$/` 配置。见 [examples/webpack-config](examples/webpack-config/)。 + +**追问**:如果要转成 HTML 并支持异步处理?→ 使用 `this.async()` 获取回调,调用 Markdown 解析库后回调结果;注意输出内容的 XSS 风险。 diff --git a/topics/frontend/webpack/scenarios/build-speed.md b/topics/frontend/webpack/scenarios/build-speed.md new file mode 100644 index 0000000..dd780aa --- /dev/null +++ b/topics/frontend/webpack/scenarios/build-speed.md @@ -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 加体积预算防止退化。数据以实际测量为准,我会给出优化前后的对比。”