--- id: plan.week07 title: "第 7 周:前端工程化查漏补缺 + 两个项目的界面" type: week domain: plan stack: [nginx, cicd, vue, react, typescript, vite, webpack, ssr, fe-testing-perf] week: [7] est_hours: 12 order: 7 --- # 第 7 周:前端工程化查漏补缺 + 两个项目的界面 > 第二段的第 4 周。上周 `week06-rag-2.md`,下周 `week08-inventory-architecture.md`。 **为什么有这一周**:你有 7 年 Vue / React 经验,这是求职时的**差异化优势**(“能独立做全栈 AI 应用”)。但招聘写的“熟悉 Vue / React”背后是一串工程化要求:Vite、Webpack、TS、状态管理、路由、组件库、SSR、性能优化、代码规范、monorepo、单元测试。本周按清单**查漏**,同时把项目一(任务看板,Go 后端)和项目二(RAG 问答,Python 后端)的界面做出来。 **学法**:会的快速过(10 分钟确认),不熟的认真做。每天的“查漏清单”能脱稿讲清楚就打勾。 **技术栈和版本说明**(2026-10 实际安装验证):pnpm 10、Vite 8、Vue 3.5、Pinia 3、Vue Router 4、Element Plus 2、React 19、React Router 7、Vitest 5、ESLint 10(flat config)、Prettier 3、TypeScript **6.0**、Node.js 24 LTS。注意:jsdom 新版本需要较新的 Node(实测 Node 20 跑组件测试会报错),请用 Node 24 LTS。TypeScript 已发布 7.0(用 Go 重写的原生版本),但 typescript-eslint 当前只支持到 6.0.x,所以本周固定用 `typescript@~6.0`;以后升级前先看各工具的兼容说明。 ## 本周最终目录 ```text web/ # pnpm monorepo(仓库根目录下) ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json ├── eslint.config.js ├── .prettierrc.json ├── vitest.config.ts ├── packages/ │ └── shared/ # 两个应用共用:请求封装、SSE 解析、类型 │ ├── package.json │ ├── tsconfig.json │ ├── vitest.config.ts │ └── src/ │ ├── index.ts │ ├── http.ts │ ├── http.test.ts │ ├── sse.ts │ └── sse.test.ts └── apps/ ├── board-vue/ # 项目一界面:Vue 3 + Pinia + Router + Element Plus │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ ├── index.html │ └── src/ │ ├── env.d.ts │ ├── main.ts │ ├── App.vue │ ├── router.ts │ ├── api.ts │ ├── types.ts │ ├── stores/auth.ts │ ├── stores/board.ts │ ├── stores/board.test.ts │ ├── components/TaskCard.vue │ ├── components/TaskCard.test.ts │ ├── views/LoginView.vue │ └── views/BoardView.vue └── rag-react/ # 项目二界面:React 19 + React Router ├── package.json ├── tsconfig.json ├── vite.config.ts ├── index.html └── src/ ├── main.tsx ├── App.tsx ├── api.ts ├── chatReducer.ts ├── chatReducer.test.ts ├── useAskStream.ts ├── components/AnswerText.tsx ├── components/AnswerText.test.tsx ├── pages/ChatPage.tsx └── pages/DocumentsPage.tsx ``` --- ## 第 1 天:pnpm monorepo、TS 严格配置、ESLint + Prettier、共享请求封装 ### 查漏清单 - [ ] pnpm 和 npm 的区别:内容寻址存储 + 硬链接省空间;严格的 `node_modules` 结构,**不能使用没声明的依赖**(幽灵依赖) - [ ] monorepo:一个仓库多个包,`workspace:*` 互相引用;适合共享代码的多个应用 - [ ] `tsconfig` 关键选项:`strict`、`noUncheckedIndexedAccess`、`moduleResolution: "bundler"`、`isolatedModules` - [ ] ESLint 管“代码质量”,Prettier 管“格式”,两者分工(用 `eslint-config-prettier` 关掉 ESLint 里的格式规则,避免互相打架);ESLint 9 起默认 flat config(`eslint.config.js`) ### 学习内容 **1. 建 monorepo** - 说明:`pnpm-workspace.yaml` 声明哪些目录是包;根目录放公共的开发依赖和配置;`-w` 表示装到根目录,`--filter` 只对某个包执行。 - 案例: ```bash mkdir web && cd web && pnpm init pnpm add -Dw typescript@~6.0 vitest jsdom eslint @eslint/js typescript-eslint eslint-plugin-vue \ eslint-plugin-react-hooks globals prettier eslint-config-prettier ``` ```yaml # 文件:web/pnpm-workspace.yaml packages: - "packages/*" - "apps/*" ``` 根 `package.json` 里的脚本(依赖版本以 `pnpm add` 实际装上的为准): ```json { "name": "web", "private": true, "type": "module", "scripts": { "lint": "eslint .", "format": "prettier --write .", "typecheck": "pnpm -r typecheck", "test": "vitest run", "build": "pnpm -r build" } } ``` ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "isolatedModules": true, "verbatimModuleSyntax": true, "skipLibCheck": true, "noEmit": true } } ``` (上面这段保存为 `web/tsconfig.base.json`,各个包的 `tsconfig.json` 继承它。) ```js // 文件:web/eslint.config.js import js from "@eslint/js"; import reactHooks from "eslint-plugin-react-hooks"; import prettier from "eslint-config-prettier/flat"; import vue from "eslint-plugin-vue"; import { defineConfig } from "eslint/config"; import globals from "globals"; import tseslint from "typescript-eslint"; export default defineConfig([ { ignores: ["**/dist/**", "**/node_modules/**"] }, js.configs.recommended, tseslint.configs.recommended, vue.configs["flat/recommended"], { files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tseslint.parser } } }, { files: ["apps/rag-react/**/*.{ts,tsx}"], extends: [reactHooks.configs.flat.recommended] }, { languageOptions: { globals: globals.browser } }, prettier, // 放最后:关掉所有和 Prettier 冲突的格式类规则,格式只交给 Prettier ]); ``` ```json { "semi": true, "singleQuote": false, "printWidth": 110 } ``` (上面这段保存为 `web/.prettierrc.json`。) ```ts // 文件:web/vitest.config.ts import { defineConfig } from "vitest/config"; // 一条命令跑所有包的测试,每个包用自己的 vitest / vite 配置 export default defineConfig({ test: { projects: ["packages/*", "apps/*"] } }); ``` **2. 共享请求封装** - 说明:两个后端的响应格式不同:Go 项目统一返回 `{code, message, data}`(第 1 周约定),Python 项目直接返回数据、错误时返回 `{"error": {"code", "message"}}`。封装一个 `createHttp`,统一处理:带令牌、解包、错误转成 `ApiError`、401 时回调(跳登录)、超时取消。 - 案例: ```json { "name": "@web/shared", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit" } } ``` (上面这段保存为 `web/packages/shared/package.json`;直接导出 TS 源码,由使用方的 Vite 编译,省掉单独构建这一步。) ```json { "extends": "../../tsconfig.base.json", "include": ["src"] } ``` (上面这段保存为 `web/packages/shared/tsconfig.json`。) ```ts // 文件:web/packages/shared/vitest.config.ts import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node" } }); ``` ```ts // 文件:web/packages/shared/src/index.ts export * from "./http"; export * from "./sse"; ``` ```ts // 文件:web/packages/shared/src/http.ts export class ApiError extends Error { readonly status: number; readonly code: string; constructor(status: number, code: string, message: string) { super(message); this.name = "ApiError"; this.status = status; this.code = code; } } export interface HttpOptions { baseURL: string; /** envelope:Go 后端的 {code, message, data};plain:直接返回数据 */ format: "envelope" | "plain"; getToken?: () => string | null; onUnauthorized?: () => void; timeoutMs?: number; fetchImpl?: typeof fetch; } type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface Envelope { code: number; message: string; data: T; } function errorFrom(status: number, body: unknown): ApiError { if (body && typeof body === "object") { const b = body as { message?: unknown; code?: unknown; error?: { code?: string; message?: string } }; if (b.error) return new ApiError(status, b.error.code ?? "ERROR", b.error.message ?? "请求失败"); if (typeof b.message === "string") return new ApiError(status, String(b.code ?? status), b.message); } return new ApiError(status, String(status), `请求失败(${status})`); } export function createHttp(opts: HttpOptions) { const doFetch = opts.fetchImpl ?? fetch; async function request(method: Method, path: string, body?: unknown, signal?: AbortSignal): Promise { const headers: Record = {}; const token = opts.getToken?.(); if (token) headers.Authorization = `Bearer ${token}`; const isForm = body instanceof FormData; if (body !== undefined && !isForm) headers["Content-Type"] = "application/json"; const timeout = AbortSignal.timeout(opts.timeoutMs ?? 15000); const res = await doFetch(opts.baseURL + path, { method, headers, body: body === undefined ? undefined : isForm ? body : JSON.stringify(body), signal: signal ? AbortSignal.any([signal, timeout]) : timeout, }); if (res.status === 401) opts.onUnauthorized?.(); if (res.status === 204) return undefined as T; const data: unknown = await res.json().catch(() => null); if (!res.ok) throw errorFrom(res.status, data); if (opts.format === "envelope") { const env = data as Envelope; if (env.code !== 0) throw new ApiError(res.status, String(env.code), env.message); return env.data; } return data as T; } return { request, get: (path: string, signal?: AbortSignal) => request("GET", path, undefined, signal), post: (path: string, body?: unknown) => request("POST", path, body), patch: (path: string, body?: unknown) => request("PATCH", path, body), del: (path: string) => request("DELETE", path), }; } export type Http = ReturnType; ``` ```ts // 文件:web/packages/shared/src/http.test.ts import { describe, expect, it, vi } from "vitest"; import { ApiError, createHttp } from "./http"; function fakeFetch(status: number, body: unknown) { return vi.fn(async () => new Response(body === null ? null : JSON.stringify(body), { status })); } describe("createHttp", () => { it("带令牌并解包 envelope", async () => { const f = fakeFetch(200, { code: 0, message: "ok", data: { id: 1 } }); const http = createHttp({ baseURL: "/api", format: "envelope", getToken: () => "t1", fetchImpl: f }); await expect(http.get<{ id: number }>("/tasks/1")).resolves.toEqual({ id: 1 }); const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe("/api/tasks/1"); expect((init.headers as Record).Authorization).toBe("Bearer t1"); }); it("把 Python 后端的错误格式转成 ApiError", async () => { const http = createHttp({ baseURL: "", format: "plain", fetchImpl: fakeFetch(400, { error: { code: "HTTP", message: "不支持" } }) }); const err = await http.post("/documents").catch((e: unknown) => e); expect(err).toBeInstanceOf(ApiError); expect((err as ApiError).message).toBe("不支持"); }); it("401 时回调 onUnauthorized", async () => { const onUnauthorized = vi.fn(); const http = createHttp({ baseURL: "", format: "envelope", onUnauthorized, fetchImpl: fakeFetch(401, { code: 401, message: "未登录", data: null }) }); await expect(http.get("/me")).rejects.toThrow("未登录"); expect(onUnauthorized).toHaveBeenCalledOnce(); }); it("204 返回 undefined", async () => { const http = createHttp({ baseURL: "", format: "envelope", fetchImpl: fakeFetch(204, null) }); await expect(http.del("/tasks/1")).resolves.toBeUndefined(); }); }); ``` **易错点 / 面试常问(补充)** - 易错点:monorepo 内部包的依赖写 `"@web/shared": "workspace:*"`,否则 pnpm 会去公共仓库找同名包。 - 易错点:ESLint 9 以后默认用 flat config(`eslint.config.js`),旧的 `.eslintrc` 写法不再默认生效;格式问题交给 Prettier,用 `eslint-config-prettier` 关掉冲突的规则。 - 易错点:TypeScript 开 `strict` 后,`any` 和可能为 `null` 的值都会报错,不要用 `as any` 绕过,改成正确的类型或类型守卫。 - 面试常问:pnpm 和 npm / yarn 的区别(内容寻址存储、硬链接、严格的依赖隔离,避免“幽灵依赖”);monorepo 的好处和代价。 - 文档:pnpm workspace(https://pnpm.io/workspaces )、ESLint flat config(https://eslint.org/docs/latest/use/configure/ ),版本变化以官方文档为准。 ### 要建的文件 - 根目录:`package.json`、`pnpm-workspace.yaml`、`tsconfig.base.json`、`eslint.config.js`、`.prettierrc.json`、`vitest.config.ts` - `packages/shared/`:`package.json`、`tsconfig.json`、`vitest.config.ts`、`src/index.ts`、`src/http.ts`、`src/http.test.ts` ### 运行方式 ```bash pnpm install pnpm test && pnpm lint && pnpm --filter @web/shared typecheck ``` ### 完成标准 - 测试、lint、类型检查全部通过;能讲清幽灵依赖、monorepo 的好处和代价(工具链复杂、依赖版本要统一)。 --- ## 第 2 天:SSE 流式解析(fetch + ReadableStream) ### 查漏清单 - [ ] `EventSource` 的限制:只能 GET、不能自定义请求头(带不了 `Authorization`) - [ ] `fetch` 读流:`res.body.getReader()` + `TextDecoder`;**网络分包可能把一行切成两半**,要自己缓冲 - [ ] `AbortController` 取消请求(用户点“停止生成”、组件卸载) - [ ] SSE 格式:`event:`、`data:`(可多行)、`id:`、以 `:` 开头是注释,空行表示一条消息结束 ### 学习内容 - 说明:RAG 的流式问答是 `POST /ask/stream`,需要请求体,所以不能用 `EventSource`,要自己解析。写成异步生成器,调用方用 `for await` 一条条拿事件,和第 4 周 Python 的 `async for` 是同一个思路。 - 案例: ```ts // 文件:web/packages/shared/src/sse.ts import { ApiError } from "./http"; export interface SSEEvent { event: string; data: string; id?: string; } /** 把字节流解析成 SSE 事件。处理分包、\r\n、多行 data、注释行。 */ export async function* parseSSE(stream: ReadableStream): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; let current: { event: string; data: string[]; id?: string } = { event: "message", data: [] }; function* flushLine(line: string): Generator { if (line === "") { if (current.data.length > 0) { yield { event: current.event, data: current.data.join("\n"), id: current.id }; } current = { event: "message", data: [] }; return; } if (line.startsWith(":")) return; // 注释(常用作心跳) const idx = line.indexOf(":"); const field = idx === -1 ? line : line.slice(0, idx); const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^ /, ""); if (field === "event") current.event = value; else if (field === "data") current.data.push(value); else if (field === "id") current.id = value; } try { while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // stream: true 处理被切开的多字节汉字 const lines = buffer.split(/\r?\n/); buffer = lines.pop() ?? ""; // 最后一段可能不完整,留到下次 for (const line of lines) yield* flushLine(line); } if (buffer) yield* flushLine(buffer); yield* flushLine(""); } finally { reader.releaseLock(); } } /** POST 一个请求并以 SSE 事件的形式读取响应。 */ export async function* postSSE( url: string, body: unknown, init: { signal?: AbortSignal; headers?: Record; fetchImpl?: typeof fetch } = {}, ): AsyncGenerator { const res = await (init.fetchImpl ?? fetch)(url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...init.headers }, body: JSON.stringify(body), signal: init.signal, }); if (!res.ok || !res.body) throw new ApiError(res.status, String(res.status), `流式请求失败(${res.status})`); yield* parseSSE(res.body); } ``` ```ts // 文件:web/packages/shared/src/sse.test.ts import { describe, expect, it } from "vitest"; import { parseSSE, postSSE } from "./sse"; function streamOf(...chunks: string[]): ReadableStream { const enc = new TextEncoder(); return new ReadableStream({ start(controller) { for (const c of chunks) controller.enqueue(enc.encode(c)); controller.close(); }, }); } async function collect(gen: AsyncGenerator): Promise { const out: T[] = []; for await (const x of gen) out.push(x); return out; } describe("parseSSE", () => { it("处理被切开的行和多行 data", async () => { const events = await collect( parseSSE(streamOf("event: cita", "tions\ndata: [1]\n\n: 心跳\n\nid: 7\nda", "ta: 第一行\ndata: 第二行\n\n")), ); expect(events).toEqual([ { event: "citations", data: "[1]", id: undefined }, { event: "message", data: "第一行\n第二行", id: "7" }, ]); }); it("处理 \\r\\n 和没有结尾空行的流", async () => { const events = await collect(parseSSE(streamOf("event: delta\r\ndata: 你好\r\n\r\nevent: done\r\ndata: {}"))); expect(events.map((e) => e.event)).toEqual(["delta", "done"]); }); it("汉字的字节被切开也能正确解码", async () => { const bytes = new TextEncoder().encode("data: 汉字\n\n"); const stream = new ReadableStream({ start(c) { c.enqueue(bytes.slice(0, 8)); // 切在“汉”字中间 c.enqueue(bytes.slice(8)); c.close(); }, }); expect((await collect(parseSSE(stream)))[0]?.data).toBe("汉字"); }); it("postSSE 发送 JSON 并解析响应", async () => { const fetchImpl = async (_url: RequestInfo | URL, init?: RequestInit) => { expect(JSON.parse(String(init?.body))).toEqual({ question: "q" }); return new Response(streamOf("event: done\ndata: {}\n\n"), { status: 200 }); }; const events = await collect(postSSE("/ask/stream", { question: "q" }, { fetchImpl })); expect(events[0]?.event).toBe("done"); }); }); ``` ### 要建的文件 - `packages/shared/src/sse.ts`、`packages/shared/src/sse.test.ts`;在 `index.ts` 里导出 ### 运行方式 ```bash pnpm test ``` ### 完成标准 - 4 个用例通过;能讲清“为什么要缓冲半行”“`decode` 为什么要传 `stream: true`”。 - 面试常问:SSE 和 WebSocket 怎么选?(SSE 单向、基于 HTTP、自动重连、适合模型流式输出;WebSocket 双向、适合聊天室、协同编辑) --- ## 第 3 天:Vue 3 应用——项目一的任务看板 ### 查漏清单 - [ ] Vue 3:` ``` ```ts // 文件:web/apps/board-vue/src/env.d.ts declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent; export default component; } ``` ```ts // 文件:web/apps/board-vue/src/types.ts export type TaskStatus = "todo" | "doing" | "done"; export interface BoardTask { id: number; boardId: number; title: string; status: TaskStatus; position: number; } export interface User { id: number; email: string; } export const STATUSES: { key: TaskStatus; label: string }[] = [ { key: "todo", label: "待办" }, { key: "doing", label: "进行中" }, { key: "done", label: "已完成" }, ]; ``` (字段名要和你第 3 周 Go 结构体的 `json` 标签一致。) ```ts // 文件:web/apps/board-vue/src/api.ts import { createHttp } from "@web/shared"; import type { BoardTask, TaskStatus, User } from "./types"; export const TOKEN_KEY = "board_token"; export const http = createHttp({ baseURL: "/api", format: "envelope", getToken: () => localStorage.getItem(TOKEN_KEY), onUnauthorized: () => { localStorage.removeItem(TOKEN_KEY); if (location.pathname !== "/login") location.assign(`/login?redirect=${encodeURIComponent(location.pathname)}`); }, }); export const api = { login: (email: string, password: string) => http.post<{ token: string; user: User }>("/auth/login", { email, password }), listTasks: (boardId: number) => http.get(`/boards/${boardId}/tasks`), createTask: (boardId: number, title: string) => http.post(`/boards/${boardId}/tasks`, { title }), moveTask: (boardId: number, taskId: number, status: TaskStatus, position: number) => http.post(`/boards/${boardId}/tasks/${taskId}/move`, { status, position }), }; ``` ```ts // 文件:web/apps/board-vue/src/stores/auth.ts import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { api, TOKEN_KEY } from "../api"; import type { User } from "../types"; export const useAuthStore = defineStore("auth", () => { const token = ref(localStorage.getItem(TOKEN_KEY)); const user = ref(null); const loggedIn = computed(() => !!token.value); async function login(email: string, password: string) { const res = await api.login(email, password); token.value = res.token; user.value = res.user; localStorage.setItem(TOKEN_KEY, res.token); // 简化做法;更安全的是 HttpOnly Cookie,面试时要能讲取舍 } function logout() { token.value = null; user.value = null; localStorage.removeItem(TOKEN_KEY); } return { token, user, loggedIn, login, logout }; }); ``` ```ts // 文件:web/apps/board-vue/src/stores/board.ts import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { api } from "../api"; import type { BoardTask, TaskStatus } from "../types"; export const useBoardStore = defineStore("board", () => { const boardId = ref(0); const tasks = ref([]); const loading = ref(false); const error = ref(""); const columns = computed(() => { const cols: Record = { todo: [], doing: [], done: [] }; for (const t of tasks.value) cols[t.status].push(t); for (const list of Object.values(cols)) list.sort((a, b) => a.position - b.position); return cols; }); async function load(id: number) { boardId.value = id; loading.value = true; error.value = ""; try { tasks.value = await api.listTasks(id); } catch (e) { error.value = (e as Error).message; } finally { loading.value = false; } } async function add(title: string) { const t = await api.createTask(boardId.value, title); tasks.value.push(t); } /** 乐观更新:先改界面,失败再回滚 */ async function move(taskId: number, status: TaskStatus) { const task = tasks.value.find((t) => t.id === taskId); if (!task || task.status === status) return; const before = { status: task.status, position: task.position }; const position = columns.value[status].length; Object.assign(task, { status, position }); try { await api.moveTask(boardId.value, taskId, status, position); } catch (e) { Object.assign(task, before); error.value = `移动失败:${(e as Error).message}`; } } return { boardId, tasks, loading, error, columns, load, add, move }; }); ``` ```ts // 文件:web/apps/board-vue/src/stores/board.test.ts import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api"; import { useBoardStore } from "./board"; vi.mock("../api", () => ({ TOKEN_KEY: "board_token", api: { listTasks: vi.fn(), createTask: vi.fn(), moveTask: vi.fn() }, })); const tasks = [ { id: 1, boardId: 9, title: "写接口", status: "todo" as const, position: 1 }, { id: 2, boardId: 9, title: "写测试", status: "todo" as const, position: 0 }, ]; describe("board store", () => { beforeEach(() => { setActivePinia(createPinia()); vi.mocked(api.listTasks).mockResolvedValue(structuredClone(tasks)); }); it("按状态分列并按位置排序", async () => { const s = useBoardStore(); await s.load(9); expect(s.columns.todo.map((t) => t.id)).toEqual([2, 1]); }); it("移动失败时回滚", async () => { vi.mocked(api.moveTask).mockRejectedValue(new Error("无权限")); const s = useBoardStore(); await s.load(9); await s.move(1, "done"); expect(s.columns.done).toHaveLength(0); expect(s.tasks.find((t) => t.id === 1)?.status).toBe("todo"); expect(s.error).toContain("无权限"); }); }); ``` ```ts // 文件:web/apps/board-vue/src/router.ts import { createRouter, createWebHistory } from "vue-router"; import { useAuthStore } from "./stores/auth"; export const router = createRouter({ history: createWebHistory(), routes: [ { path: "/login", component: () => import("./views/LoginView.vue") }, // 懒加载:单独打包,用到才下载 { path: "/boards/:id", component: () => import("./views/BoardView.vue"), meta: { requiresAuth: true } }, { path: "/", redirect: "/boards/1" }, ], }); router.beforeEach((to) => { const auth = useAuthStore(); if (to.meta.requiresAuth && !auth.loggedIn) { return { path: "/login", query: { redirect: to.fullPath } }; } }); ``` ```ts // 文件:web/apps/board-vue/src/main.ts import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; import { createPinia } from "pinia"; import { createApp } from "vue"; import App from "./App.vue"; import { router } from "./router"; // 全量引入最简单;第 5 天做性能优化时改成按需引入,对比打包体积 createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app"); ``` ```vue ``` ```vue ``` ```ts // 文件:web/apps/board-vue/src/components/TaskCard.test.ts import { mount } from "@vue/test-utils"; import { expect, it } from "vitest"; import TaskCard from "./TaskCard.vue"; it("点击按钮发出 move 事件", async () => { const w = mount(TaskCard, { props: { task: { id: 3, boardId: 1, title: "部署", status: "todo", position: 0 } } }); expect(w.text()).toContain("部署"); const buttons = w.findAll("button"); expect(buttons.map((b) => b.text())).toEqual(["移到进行中", "移到已完成"]); await buttons[1]!.trigger("click"); expect(w.emitted("move")).toEqual([[3, "done"]]); }); ``` ```vue ``` ```vue ``` ### 要建的文件 - `apps/board-vue/` 下上面列出的全部文件 ### 运行方式 ```bash # 先按第 3 周启动 Go 后端(:8080) pnpm --filter board-vue dev # 打开 http://localhost:5173 pnpm test && pnpm --filter board-vue typecheck ``` ### 完成标准 - 能登录、看任务、添加任务、移动任务;断开后端时移动任务能看到回滚和错误提示。 - 选做:用拖拽库(如 `vuedraggable` 或原生 HTML5 拖拽)实现拖动排序。 - 面试常问:`ref` 和 `reactive` 的区别?`computed` 和 `watch` 的区别?Pinia 和 Vuex 的区别?路由守卫怎么做登录拦截?令牌存 localStorage 和 HttpOnly Cookie 的取舍(XSS 能读 localStorage;Cookie 要防 CSRF)? --- ## 第 4 天:React 应用——项目二的知识库问答界面 ### 查漏清单 - [ ] React 19:函数组件、`useState` / `useReducer` / `useEffect` / `useRef` / `useMemo` / `useCallback` - [ ] `useEffect` 清理函数:组件卸载时取消请求,避免内存泄漏和“卸载后还在 setState” - [ ] 状态管理选择:局部用 `useState` / `useReducer`,跨组件用 Context 或 Zustand / Redux Toolkit;服务端数据用 TanStack Query - [ ] React Router 7 的基础路由 - [ ] `key` 的作用、受控组件 ### 学习内容 - 说明:聊天状态比较复杂(流式追加文字、引用、出错、停止),用 `useReducer` 把“状态怎么变”写成**纯函数**,可以脱离界面单独测试;`useAskStream` 负责发请求和取消。 - 案例: ```bash mkdir -p apps/rag-react && cd apps/rag-react && pnpm init pnpm add react react-dom react-router @web/shared@workspace:* pnpm add -D vite @vitejs/plugin-react @types/react @types/react-dom @testing-library/react ``` `apps/rag-react/package.json` 里的脚本: ```json { "name": "rag-react", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit" } } ``` ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "types": ["vite/client"] }, "include": ["src"] } ``` (上面这段保存为 `apps/rag-react/tsconfig.json`。) ```ts // 文件:web/apps/rag-react/vite.config.ts /// import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [react()], server: { // 开发时 /rag/xxx 转发到 Python 后端的 /xxx proxy: { "/rag": { target: "http://localhost:8000", rewrite: (p) => p.replace(/^\/rag/, "") } }, }, test: { environment: "jsdom" }, }); ``` ```html 知识库问答
``` ```ts // 文件:web/apps/rag-react/src/api.ts import { createHttp } from "@web/shared"; export const RAG_BASE = "/rag"; export interface Citation { n: number; filename: string; heading_path: string; page: number | null; text: string; score: number; } export interface DocInfo { doc_id: string; filename: string; chunks: number; } export const http = createHttp({ baseURL: RAG_BASE, format: "plain" }); export const ragApi = { listDocs: () => http.get("/documents"), upload: (file: File) => { const fd = new FormData(); fd.append("file", file); return http.post<{ status: string; chunks: number }>("/documents", fd); }, remove: (docId: string) => http.del(`/documents/${docId}`), }; ``` ```ts // 文件:web/apps/rag-react/src/chatReducer.ts import type { Citation } from "./api"; export interface ChatState { question: string; answer: string; citations: Citation[]; status: "idle" | "streaming" | "done" | "error" | "stopped"; error?: string; } export type ChatAction = | { type: "start"; question: string } | { type: "citations"; citations: Citation[] } | { type: "delta"; text: string } | { type: "done" } | { type: "error"; message: string } | { type: "stop" }; export const initialChat: ChatState = { question: "", answer: "", citations: [], status: "idle" }; export function chatReducer(state: ChatState, action: ChatAction): ChatState { switch (action.type) { case "start": return { ...initialChat, question: action.question, status: "streaming" }; case "citations": return { ...state, citations: action.citations }; case "delta": return state.status === "streaming" ? { ...state, answer: state.answer + action.text } : state; case "done": return state.status === "streaming" ? { ...state, status: "done" } : state; case "error": return { ...state, status: "error", error: action.message }; case "stop": return state.status === "streaming" ? { ...state, status: "stopped" } : state; } } ``` ```ts // 文件:web/apps/rag-react/src/chatReducer.test.ts import { describe, expect, it } from "vitest"; import { chatReducer, initialChat, type ChatAction } from "./chatReducer"; const run = (...actions: ChatAction[]) => actions.reduce(chatReducer, initialChat); describe("chatReducer", () => { it("流式拼接回答", () => { const s = run({ type: "start", question: "q" }, { type: "delta", text: "你" }, { type: "delta", text: "好" }, { type: "done" }); expect(s).toMatchObject({ answer: "你好", status: "done" }); }); it("停止后忽略迟到的片段", () => { const s = run({ type: "start", question: "q" }, { type: "delta", text: "一" }, { type: "stop" }, { type: "delta", text: "二" }); expect(s).toMatchObject({ answer: "一", status: "stopped" }); }); it("新问题会清空上一次的结果", () => { const s = run({ type: "start", question: "a" }, { type: "delta", text: "旧" }, { type: "start", question: "b" }); expect(s).toMatchObject({ question: "b", answer: "" }); }); }); ``` ```ts // 文件:web/apps/rag-react/src/useAskStream.ts import { postSSE } from "@web/shared"; import { useCallback, useEffect, useReducer, useRef } from "react"; import { RAG_BASE, type Citation } from "./api"; import { chatReducer, initialChat } from "./chatReducer"; export function useAskStream() { const [state, dispatch] = useReducer(chatReducer, initialChat); const controllerRef = useRef(null); const stop = useCallback(() => { controllerRef.current?.abort(); dispatch({ type: "stop" }); }, []); const ask = useCallback(async (question: string) => { controllerRef.current?.abort(); // 新问题会取消上一个还没结束的请求 const controller = new AbortController(); controllerRef.current = controller; dispatch({ type: "start", question }); try { for await (const e of postSSE(`${RAG_BASE}/ask/stream`, { question }, { signal: controller.signal })) { const data: unknown = JSON.parse(e.data); if (e.event === "citations") dispatch({ type: "citations", citations: data as Citation[] }); else if (e.event === "delta") dispatch({ type: "delta", text: (data as { text: string }).text }); else if (e.event === "done") dispatch({ type: "done" }); else if (e.event === "error") dispatch({ type: "error", message: (data as { message: string }).message }); } } catch (err) { if (!controller.signal.aborted) dispatch({ type: "error", message: (err as Error).message }); } }, []); useEffect(() => () => controllerRef.current?.abort(), []); // 组件卸载时取消请求 return { state, ask, stop }; } ``` ```tsx // 文件:web/apps/rag-react/src/components/AnswerText.tsx import { Fragment } from "react"; /** 把回答里的 [1]、[2] 渲染成可点击的引用按钮 */ export function AnswerText({ text, onCite }: { text: string; onCite: (n: number) => void }) { const parts = text.split(/(\[\d+\])/g); return (

{parts.map((part, i) => { const m = /^\[(\d+)\]$/.exec(part); return m ? ( ) : ( {part} ); })}

); } ``` ```tsx // 文件:web/apps/rag-react/src/components/AnswerText.test.tsx import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import { AnswerText } from "./AnswerText"; afterEach(cleanup); it("引用编号可以点击", () => { const onCite = vi.fn(); render(); fireEvent.click(screen.getByRole("button", { name: "[2]" })); expect(onCite).toHaveBeenCalledWith(2); expect(screen.getAllByRole("button")).toHaveLength(2); }); ``` ```tsx // 文件:web/apps/rag-react/src/pages/ChatPage.tsx import { useState } from "react"; import { AnswerText } from "../components/AnswerText"; import { useAskStream } from "../useAskStream"; export function ChatPage() { const { state, ask, stop } = useAskStream(); const [input, setInput] = useState(""); const [active, setActive] = useState(null); const cited = state.citations.find((c) => c.n === active); return (
{ e.preventDefault(); if (input.trim()) void ask(input.trim()); }} > setInput(e.target.value)} placeholder="基于文档提问" style={{ width: "70%" }} /> {state.status === "streaming" ? ( ) : ( )}
{state.question &&

{state.question}

} {state.status === "error" &&

出错了:{state.error}

}
); } ``` ```tsx // 文件:web/apps/rag-react/src/pages/DocumentsPage.tsx import { useCallback, useEffect, useState } from "react"; import { ragApi, type DocInfo } from "../api"; export function DocumentsPage() { const [docs, setDocs] = useState([]); const [msg, setMsg] = useState(""); const refresh = useCallback(() => { ragApi.listDocs().then(setDocs, (e: Error) => setMsg(e.message)); }, []); useEffect(refresh, [refresh]); async function onFile(file: File | undefined) { if (!file) return; try { const r = await ragApi.upload(file); setMsg(`${file.name}:${r.status},${r.chunks} 个片段`); refresh(); } catch (e) { setMsg((e as Error).message); } } return (
void onFile(e.target.files?.[0])} /> {msg &&

{msg}

}
    {docs.map((d) => (
  • {d.filename}({d.chunks} 个片段)
  • ))}
); } ``` ```tsx // 文件:web/apps/rag-react/src/App.tsx import { lazy, Suspense } from "react"; import { BrowserRouter, Link, Route, Routes } from "react-router"; import { ChatPage } from "./pages/ChatPage"; // 文档管理页懒加载:首屏只下载问答页的代码 const DocumentsPage = lazy(() => import("./pages/DocumentsPage").then((m) => ({ default: m.DocumentsPage }))); export function App() { return ( 加载中…

}> } /> } />
); } ``` ```tsx // 文件:web/apps/rag-react/src/main.tsx import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; createRoot(document.getElementById("root")!).render( , ); ``` ### 要建的文件 - `apps/rag-react/` 下上面列出的全部文件 ### 运行方式 ```bash # 先按第 5、6 周启动 rag-qa(:8000) pnpm --filter rag-react dev pnpm test && pnpm --filter rag-react typecheck ``` ### 完成标准 - 能上传文档、流式问答、点击引用看原文、中途停止;切到文档页再切回来不会报“卸载后 setState”的警告。 - 面试常问:`useEffect` 的依赖数组和清理函数?`StrictMode` 下开发环境 effect 为什么执行两次?`useMemo` / `useCallback` 什么时候才值得用?React 19 有什么新东西(Actions、`use`、Server Components)? --- ## 第 5 天:构建工具和性能优化 ### 查漏清单 - [ ] Vite 为什么快:开发时不打包,浏览器按需请求 ES 模块;依赖预构建;生产构建用 Rolldown(Vite 8 起,之前是 Rollup) - [ ] Webpack 核心概念:entry、output、loader(处理非 JS 文件)、plugin(介入构建流程)、code splitting、HMR - [ ] 包体积分析、按需引入、路由懒加载、Tree Shaking 的前提(ES 模块、无副作用) - [ ] 性能指标:LCP(最大内容绘制)、INP(交互响应)、CLS(布局偏移) - [ ] 常见优化:长列表虚拟滚动、图片懒加载和尺寸、防抖节流、`v-memo` / `React.memo`、缓存(HTTP 缓存、文件名带哈希) ### 学习内容 **1. Vite vs Webpack** | | Vite | Webpack | | --- | --- | --- | | 开发启动 | 不打包,按需编译,启动快 | 先打包整个应用,项目大时慢 | | 热更新 | 只更新改动的模块 | 需要重新构建相关模块 | | 生产构建 | Rolldown(Vite 8) | 自身 | | 配置 | 约定多、配置少 | 灵活但复杂 | | 适用 | 新项目首选 | 老项目、需要特殊 loader 的场景 | - 案例:最小 Webpack 配置(看懂即可,老项目面试会问): ```js // webpack.config.js(示意:需要 webpack webpack-cli ts-loader html-webpack-plugin css-loader style-loader) const HtmlWebpackPlugin = require("html-webpack-plugin"); module.exports = { mode: "production", entry: "./src/main.ts", output: { filename: "[name].[contenthash].js", clean: true }, // 内容哈希:文件变了名字才变,便于长期缓存 resolve: { extensions: [".ts", ".js"] }, module: { rules: [ { test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ }, // loader:把 TS 转成 JS { test: /\.css$/, use: ["style-loader", "css-loader"] }, // 从右往左执行 ], }, plugins: [new HtmlWebpackPlugin({ template: "./index.html" })], // plugin:生成 HTML 并注入脚本 optimization: { splitChunks: { chunks: "all" } }, // 把第三方库拆成单独文件 }; ``` **2. 包体积优化实战** - 说明:先量再改。用 `rollup-plugin-visualizer` 看哪个依赖最大,再决定按需引入或懒加载。 - 案例:Element Plus 改成按需引入,对比 `dist/assets` 体积。 ```bash pnpm --filter board-vue build && du -sh apps/board-vue/dist/assets/* # 记下优化前的体积 # 全量引入 Element Plus 时,构建会提示有 chunk 超过 500 kB——这正是要优化的点 pnpm --filter board-vue add -D unplugin-vue-components unplugin-auto-import rollup-plugin-visualizer ``` ```ts // 片段:apps/board-vue/vite.config.ts 改成按需引入(同时删掉 main.ts 里的 use(ElementPlus) 和全量 CSS 引入) /// import vue from "@vitejs/plugin-vue"; import { visualizer } from "rollup-plugin-visualizer"; import AutoImport from "unplugin-auto-import/vite"; import { ElementPlusResolver } from "unplugin-vue-components/resolvers"; import Components from "unplugin-vue-components/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()] }), Components({ resolvers: [ElementPlusResolver()] }), visualizer({ filename: "dist/stats.html" }), // 构建后打开看依赖体积分布 ], server: { proxy: { "/api": "http://localhost:8080" } }, test: { environment: "jsdom" }, }); ``` **3. 长列表** - 说明:几千条数据一次渲染会卡,用虚拟滚动只渲染可见区域。下面这个最小实现说明原理(生产用 `@tanstack/react-virtual` 或 `vue-virtual-scroller`)。 ```tsx // 片段:固定行高的虚拟列表原理 import { useState } from "react"; export function VirtualList({ items, rowHeight = 32, height = 400 }: { items: string[]; rowHeight?: number; height?: number }) { const [scrollTop, setScrollTop] = useState(0); const start = Math.floor(scrollTop / rowHeight); const end = Math.min(items.length, start + Math.ceil(height / rowHeight) + 1); return (
setScrollTop(e.currentTarget.scrollTop)}>
{items.slice(start, end).map((item, i) => (
{item}
))}
); } ``` - 按需引入后插件会生成 `components.d.ts`、`auto-imports.d.ts`,要提交到仓库并加进 `tsconfig` 的 `include`。坑:`ElMessage` 这类“函数式调用”的组件,如果是手动 `import`,样式不会被自动引入,要么也交给 `AutoImport` 处理,要么手动引入它的样式。 **易错点 / 面试常问(补充)** - 易错点:Vite 开发时用原生 ES 模块、不打包,生产构建才打包,所以“开发正常、构建后出错”时要用 `vite build && vite preview` 复现。 - 易错点:只有 `VITE_` 开头的环境变量会暴露给前端代码,而且会被打进产物——密钥绝对不能放前端环境变量。 - 易错点:虚拟列表要求能算出(或估算)每行高度;行高不固定时要做测量和缓存,否则滚动会跳。 - 面试常问:Vite 和 Webpack 的区别(开发服务器原理、构建工具、配置复杂度、生态);常见性能优化(路由懒加载、组件库按需引入、拆包、图片压缩、缓存策略、长列表虚拟化);Tree Shaking 依赖什么(ES 模块的静态结构)。 - 文档:Vite(https://cn.vitejs.dev/ )、Webpack(https://webpack.js.org/ ),以官方文档为准。 ### 要建的文件 - `notes/frontend-perf.md`:记录优化前后的构建体积、Lighthouse 分数(数值以你自己测的为准) ### 运行方式 ```bash pnpm build npx serve apps/board-vue/dist # 或用第 3 周的 Nginx;浏览器开发者工具 → Lighthouse ``` ### 完成标准 - 有一组自己测的“优化前 / 优化后”数据,能讲清每一步做了什么。 --- ## 第 6 天:SSR 基础(Nuxt / Next.js) ### 查漏清单 - [ ] CSR、SSR、SSG、ISR 的区别和适用场景 - [ ] 水合(hydration)是什么,水合不一致的常见原因(服务端和客户端渲染结果不同,如使用了 `Date.now()`、`window`) - [ ] Next.js App Router:Server Components 默认在服务端运行,需要交互的组件加 `"use client"` - [ ] Nuxt:`useFetch` / `useAsyncData` 在服务端取数并传给客户端 ### 学习内容 - 说明: | 方式 | 何时生成 HTML | 适合 | | --- | --- | --- | | CSR | 浏览器里运行 JS 后 | 后台管理系统、强交互应用(本周两个项目) | | SSR | 每次请求时在服务器 | 需要 SEO、首屏快、数据经常变的页面 | | SSG | 构建时 | 文档站、博客 | | ISR | 构建时生成,过期后后台重新生成 | 商品页等“偶尔变”的页面 | - 案例:不用搭项目,读懂这两段就行(**接口以官方文档为准**:Next.js https://nextjs.org/docs ,Nuxt https://nuxt.com/docs )。 ```tsx // 示意:Next.js App Router,app/posts/page.tsx(Server Component,在服务端取数) export default async function PostsPage() { const res = await fetch("https://example.com/api/posts", { next: { revalidate: 60 } }); // 60 秒后重新生成(ISR) const posts: { id: number; title: string }[] = await res.json(); return (
    {posts.map((p) => (
  • {p.title}
  • ))}
); } ``` ```vue ``` - 动手(可选,1~2 小时):`pnpm create next-app` 建一个页面,用 Server Component 调 `rag-qa` 的 `/documents` 列出文档,体会“服务端取数”。 **易错点 / 面试常问(补充)** - 易错点:SSR 时组件代码会在服务端执行,服务端没有 `window`、`document`、`localStorage`,访问它们要放到只在客户端执行的生命周期里(Vue 的 `onMounted`,React 的 `useEffect`)。 - 易错点:“水合不匹配”(hydration mismatch):服务端渲染的 HTML 和客户端第一次渲染结果不一致(例如直接渲染当前时间、随机数)。 - 面试常问:CSR、SSR、SSG 的区别和适用场景(后台管理类用 CSR,SEO 和首屏要求高用 SSR / SSG);Next.js 的 App Router 和服务端组件是什么(了解即可)。 - 文档:Next.js(https://nextjs.org/docs )、Nuxt(https://nuxt.com/docs ),这两个框架变化快,以官方文档为准。 ### 要建的文件 - `notes/ssr.md`:用自己的话写 CSR / SSR / SSG / ISR 和水合 ### 完成标准 - 能回答:“你们的项目为什么不用 SSR?”(内部工具不需要 SEO,CSR 足够;对外的营销页、文档站才考虑 SSR / SSG) --- ## 第 7 天:前端 CI、构建产物部署、复盘 ### 学习内容 - 说明:把前端的 lint、类型检查、测试、构建加到第 3 周的 CI 里;构建产物放进 Nginx(第 3 周 `docker-compose.yml` 已经预留了 `./web-dist`)。 - 案例: ```yaml # .github/workflows/web.yml(Gitea Actions 语法基本兼容,路径改成 .gitea/workflows/) name: web on: push: paths: ["web/**"] jobs: check: runs-on: ubuntu-latest defaults: run: working-directory: web steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 10 - uses: actions/setup-node@v4 with: node-version: 24 cache: pnpm cache-dependency-path: web/pnpm-lock.yaml - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm typecheck - run: pnpm test - run: pnpm build ``` ```bash # 把看板前端放进第 3 周的 Nginx pnpm --filter board-vue build rm -rf ../todo-api/web-dist && cp -r apps/board-vue/dist ../todo-api/web-dist cd ../todo-api && docker compose up -d nginx ``` - 常见坑:Nginx 没配 `try_files $uri /index.html;`,刷新 `/boards/1` 会 404(第 3 周的配置已经有了);SSE 接口要关闭代理缓冲(`proxy_buffering off;`),否则流式输出会攒到最后一次性出来。 **易错点 / 面试常问(补充)** - 易错点:CI 里用 `pnpm install --frozen-lockfile`,保证和本地锁定的版本一致;Node 版本要和本地一致(本计划用 Node 24 LTS)。 - 易错点:带哈希的静态资源(`assets/*.js`)可以长缓存,`index.html` 不要长缓存,否则发布后用户拿到的还是旧页面。 - 面试常问:前端发布后用户还在用旧版本怎么办(文件名带哈希 + `index.html` 不缓存,必要时提示刷新);前端怎么做错误监控(全局错误事件、接口错误上报、Source Map 不公开)。 ### 要建的文件 - `.github/workflows/web.yml`(或 `.gitea/workflows/web.yml`) - 两个项目的 README 补“前端”一节:截图、启动方式、关键设计(SSE 解析、乐观更新、懒加载) ### 运行方式 ```bash pnpm lint && pnpm typecheck && pnpm test && pnpm build ``` ### 完成标准 - CI 通过;看板通过 Nginx 访问正常,刷新不 404。 - 在总纲进度表勾掉第 7 周。 --- ## 本周相关专题 > 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。 - **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。 | 技术栈 | 课程 / 场景 / 面试题 | | --- | --- | | [Node.js(了解级)](../topics/backend/nodejs/index.md) | 课程:[Node.js 后端(了解级)](../topics/backend/nodejs/01-express-nest-basics.md) | | [Web 安全基础](../topics/cs/web-security/index.md) | 课程:[Web 安全基础](../topics/cs/web-security/01-xss-csrf-sqli-cors-ssrf.md);[面试题](../topics/cs/web-security/interview.md) | | [CI/CD](../topics/devops/cicd/index.md) | 课程:[流水线设计](../topics/devops/cicd/01-pipeline.md) | | [Nginx](../topics/devops/nginx/index.md) | 课程:[反向代理、负载均衡和静态资源](../topics/devops/nginx/01-proxy-static.md);课程:[HTTPS、SSE / WebSocket、限流和灰度](../topics/devops/nginx/02-https-stream-limit.md);场景:[AI 应用网关](../topics/devops/nginx/scenarios/ai-gateway-sse-canary.md);[面试题](../topics/devops/nginx/interview.md) | | [前端测试与性能](../topics/frontend/fe-testing-perf/index.md) | 课程:[前端测试](../topics/frontend/fe-testing-perf/01-testing-pyramid.md);课程:[前端性能](../topics/frontend/fe-testing-perf/02-web-vitals.md);场景:[性能治理](../topics/frontend/fe-testing-perf/scenarios/perf-regression.md);[面试题](../topics/frontend/fe-testing-perf/interview.md) | | [React](../topics/frontend/react/index.md) | 课程:[React 渲染机制、Hooks 规则、状态管理和 React 19 新特性](../topics/frontend/react/01-hooks-rendering.md);场景:[长任务界面](../topics/frontend/react/scenarios/long-task-ui.md);[面试题](../topics/frontend/react/interview.md) | | [SSR(Nuxt / Next.js)](../topics/frontend/ssr/index.md) | 课程:[渲染模式](../topics/frontend/ssr/01-rendering-modes.md);课程:[Next.js App Router](../topics/frontend/ssr/02-nextjs-app-router.md);场景:[帮助中心 + 智能客服](../topics/frontend/ssr/scenarios/help-center.md);[面试题](../topics/frontend/ssr/interview.md) | | [TypeScript](../topics/frontend/typescript/index.md) | 课程:[TypeScript 类型系统进阶](../topics/frontend/typescript/01-type-system.md);场景:[前后端类型契约](../topics/frontend/typescript/scenarios/api-contract.md);[面试题](../topics/frontend/typescript/interview.md) | | [Vite](../topics/frontend/vite/index.md) | 课程:[Vite 8](../topics/frontend/vite/01-dev-build-config.md);场景:[老项目从 webpack 迁移到 Vite](../topics/frontend/vite/scenarios/webpack-to-vite.md);[面试题](../topics/frontend/vite/interview.md) | | [Vue 3](../topics/frontend/vue/index.md) | 课程:[Vue 3 响应式原理、组合式函数、Pinia 和性能优化](../topics/frontend/vue/01-reactivity-composition.md);场景:[AI 对话界面](../topics/frontend/vue/scenarios/ai-chat-stream.md);[面试题](../topics/frontend/vue/interview.md) | | [webpack](../topics/frontend/webpack/index.md) | 课程:[webpack 5](../topics/frontend/webpack/01-build-optimization.md);场景:[大型项目构建慢、包体积大](../topics/frontend/webpack/scenarios/build-speed.md);[面试题](../topics/frontend/webpack/interview.md) | ## 本周自查清单(对照招聘“熟悉 Vue / React”的延伸要求) - [ ] pnpm monorepo、workspace 依赖、幽灵依赖 - [ ] TS 严格模式、类型收窄、泛型封装请求 - [ ] ESLint flat config + Prettier - [ ] Vue 3 组合式 API、Pinia、Vue Router 守卫、Element Plus 按需引入 - [ ] React Hooks、`useReducer`、effect 清理、懒加载 - [ ] fetch 流式读取和 SSE 解析、`AbortController` - [ ] Vite 和 Webpack 原理与配置 - [ ] 性能指标和优化手段,有自己测的数据 - [ ] CSR / SSR / SSG / ISR、水合 - [ ] Vitest 单元测试和组件测试,前端 CI