Files
zhuangang/plan/week07-frontend.md
T

1665 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<T> {
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<T>(method: Method, path: string, body?: unknown, signal?: AbortSignal): Promise<T> {
const headers: Record<string, string> = {};
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<T>;
if (env.code !== 0) throw new ApiError(res.status, String(env.code), env.message);
return env.data;
}
return data as T;
}
return {
request,
get: <T>(path: string, signal?: AbortSignal) => request<T>("GET", path, undefined, signal),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
patch: <T>(path: string, body?: unknown) => request<T>("PATCH", path, body),
del: <T = void>(path: string) => request<T>("DELETE", path),
};
}
export type Http = ReturnType<typeof createHttp>;
```
```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<string, string>).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<Uint8Array>): AsyncGenerator<SSEEvent> {
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<SSEEvent> {
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<string, string>; fetchImpl?: typeof fetch } = {},
): AsyncGenerator<SSEEvent> {
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<Uint8Array> {
const enc = new TextEncoder();
return new ReadableStream({
start(controller) {
for (const c of chunks) controller.enqueue(enc.encode(c));
controller.close();
},
});
}
async function collect<T>(gen: AsyncGenerator<T>): Promise<T[]> {
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<Uint8Array>({
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:`<script setup>`、`ref` / `reactive` / `computed` / `watch`、`defineProps` / `defineEmits`、组合式函数(composable)
- [ ] Pinia:setup store 写法;和 Vuex 的区别(没有 mutations、TS 友好、模块天然拆分)
- [ ] Vue Router:路由守卫做登录拦截、`meta`、懒加载路由组件
- [ ] 组件库:Element Plus 全量引入 vs 按需引入(按需可以减小包体积,用 `unplugin-vue-components`)
- [ ] 乐观更新:先改界面,请求失败再回滚
### 学习内容
- 说明:接第 3 周的 Go 接口(`/api/auth/login`、`/api/boards/:id/tasks`、`/api/boards/:id/tasks/:taskId/move`)。开发时用 Vite 的代理把 `/api` 转发到 `localhost:8080`,避免跨域;上线时由 Nginx 做同样的事(第 3 周的 `nginx.conf`)。
- 案例:
```bash
mkdir -p apps/board-vue && cd apps/board-vue && pnpm init
pnpm add vue vue-router pinia element-plus @web/shared@workspace:*
pnpm add -D vite @vitejs/plugin-vue vue-tsc @vue/test-utils
```
`apps/board-vue/package.json` 里的脚本:
```json
{
"name": "board-vue",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
}
}
```
```json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["vite/client"] },
"include": ["src/**/*.ts", "src/**/*.vue"]
}
```
(上面这段保存为 `apps/board-vue/tsconfig.json`。)
```ts
// 文件:web/apps/board-vue/vite.config.ts
/// <reference types="vitest/config" />
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue()],
server: { proxy: { "/api": "http://localhost:8080" } }, // 开发时转发到 Go 后端
test: { environment: "jsdom" },
});
```
```html
<!-- 文件:web/apps/board-vue/index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>任务看板</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
```
```ts
// 文件:web/apps/board-vue/src/env.d.ts
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
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<BoardTask[]>(`/boards/${boardId}/tasks`),
createTask: (boardId: number, title: string) => http.post<BoardTask>(`/boards/${boardId}/tasks`, { title }),
moveTask: (boardId: number, taskId: number, status: TaskStatus, position: number) =>
http.post<BoardTask>(`/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<string | null>(localStorage.getItem(TOKEN_KEY));
const user = ref<User | null>(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<BoardTask[]>([]);
const loading = ref(false);
const error = ref("");
const columns = computed(() => {
const cols: Record<TaskStatus, BoardTask[]> = { 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
<!-- 文件:web/apps/board-vue/src/App.vue -->
<template>
<router-view />
</template>
```
```vue
<!-- 文件:web/apps/board-vue/src/components/TaskCard.vue -->
<script setup lang="ts">
import { STATUSES, type BoardTask, type TaskStatus } from "../types";
const props = defineProps<{ task: BoardTask }>();
const emit = defineEmits<{ move: [taskId: number, status: TaskStatus] }>();
const targets = STATUSES.filter((s) => s.key !== props.task.status);
</script>
<template>
<div class="task-card">
<span class="title">{{ task.title }}</span>
<button v-for="s in targets" :key="s.key" type="button" @click="emit('move', task.id, s.key)">
移到{{ s.label }}
</button>
</div>
</template>
<style scoped>
.task-card {
padding: 8px;
margin-bottom: 8px;
border: 1px solid #ddd;
border-radius: 6px;
}
.title {
display: block;
margin-bottom: 4px;
}
</style>
```
```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
<!-- 文件:web/apps/board-vue/src/views/LoginView.vue -->
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const form = reactive({ email: "", password: "" });
const submitting = ref(false);
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
async function onSubmit() {
submitting.value = true;
try {
await auth.login(form.email, form.password);
await router.replace(String(route.query.redirect ?? "/"));
} catch (e) {
ElMessage.error((e as Error).message);
} finally {
submitting.value = false;
}
}
</script>
<template>
<el-card style="max-width: 360px; margin: 80px auto">
<el-form label-position="top" @submit.prevent="onSubmit">
<el-form-item label="邮箱"><el-input v-model="form.email" autocomplete="username" /></el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password autocomplete="current-password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="submitting">登录</el-button>
</el-form>
</el-card>
</template>
```
```vue
<!-- 文件:web/apps/board-vue/src/views/BoardView.vue -->
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import TaskCard from "../components/TaskCard.vue";
import { useBoardStore } from "../stores/board";
import { STATUSES } from "../types";
const route = useRoute();
const board = useBoardStore();
const title = ref("");
// 路由参数变化(切换看板)时重新加载;immediate 让首次进入也执行
watch(() => Number(route.params.id), (id) => board.load(id), { immediate: true });
async function addTask() {
if (!title.value.trim()) return;
await board.add(title.value.trim());
title.value = "";
}
</script>
<template>
<div style="padding: 16px">
<el-alert v-if="board.error" :title="board.error" type="error" show-icon />
<el-input v-model="title" placeholder="新任务,回车添加" style="max-width: 360px" @keyup.enter="addTask" />
<div v-loading="board.loading" style="display: flex; gap: 16px; margin-top: 16px">
<section v-for="col in STATUSES" :key="col.key" style="flex: 1">
<h3>{{ col.label }}({{ board.columns[col.key].length }})</h3>
<TaskCard v-for="t in board.columns[col.key]" :key="t.id" :task="t" @move="board.move" />
</section>
</div>
</div>
</template>
```
### 要建的文件
- `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
/// <reference types="vitest/config" />
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
<!-- 文件:web/apps/rag-react/index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>知识库问答</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</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<DocInfo[]>("/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<AbortController | null>(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 (
<p style={{ whiteSpace: "pre-wrap" }}>
{parts.map((part, i) => {
const m = /^\[(\d+)\]$/.exec(part);
return m ? (
<button key={i} type="button" className="cite" onClick={() => onCite(Number(m[1]))}>
{part}
</button>
) : (
<Fragment key={i}>{part}</Fragment>
);
})}
</p>
);
}
```
```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(<AnswerText text="事务有四个特性 [1],隔离级别见 [2]。" onCite={onCite} />);
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<number | null>(null);
const cited = state.citations.find((c) => c.n === active);
return (
<div style={{ display: "flex", gap: 24 }}>
<main style={{ flex: 2 }}>
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) void ask(input.trim());
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="基于文档提问" style={{ width: "70%" }} />
{state.status === "streaming" ? (
<button type="button" onClick={stop}>停止</button>
) : (
<button type="submit">提问</button>
)}
</form>
{state.question && <h3>{state.question}</h3>}
<AnswerText text={state.answer} onCite={setActive} />
{state.status === "error" && <p style={{ color: "red" }}>出错了:{state.error}</p>}
</main>
<aside style={{ flex: 1 }}>
<h4>出处</h4>
{cited ? (
<article>
<strong>[{cited.n}] {cited.filename} {cited.heading_path}</strong>
<p style={{ whiteSpace: "pre-wrap" }}>{cited.text}</p>
</article>
) : (
<p>点击回答里的编号查看原文</p>
)}
</aside>
</div>
);
}
```
```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<DocInfo[]>([]);
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 (
<section>
<input type="file" accept=".md,.txt,.pdf" onChange={(e) => void onFile(e.target.files?.[0])} />
{msg && <p>{msg}</p>}
<ul>
{docs.map((d) => (
<li key={d.doc_id}>
{d.filename}({d.chunks} 个片段)
<button type="button" onClick={() => void ragApi.remove(d.doc_id).then(refresh)}>删除</button>
</li>
))}
</ul>
</section>
);
}
```
```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 (
<BrowserRouter>
<nav style={{ display: "flex", gap: 12, marginBottom: 16 }}>
<Link to="/">问答</Link>
<Link to="/docs">文档</Link>
</nav>
<Suspense fallback={<p>加载中…</p>}>
<Routes>
<Route path="/" element={<ChatPage />} />
<Route path="/docs" element={<DocumentsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
```
```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(
<StrictMode>
<App />
</StrictMode>,
);
```
### 要建的文件
- `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 引入)
/// <reference types="vitest/config" />
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 (
<div style={{ height, overflow: "auto" }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
<div style={{ height: items.length * rowHeight, position: "relative" }}>
{items.slice(start, end).map((item, i) => (
<div key={start + i} style={{ position: "absolute", top: (start + i) * rowHeight, height: rowHeight }}>
{item}
</div>
))}
</div>
</div>
);
}
```
- 按需引入后插件会生成 `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 (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
```
```vue
<!-- 示意:Nuxt,pages/posts.vue -->
<script setup lang="ts">
// 服务端渲染时取数,结果随 HTML 一起发给浏览器,客户端水合时不会再请求一次
const { data: posts } = await useFetch<{ id: number; title: string }[]>("https://example.com/api/posts");
</script>
<template>
<ul>
<li v-for="p in posts" :key="p.id">{{ p.title }}</li>
</ul>
</template>
```
- 动手(可选,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 周。
---
## 本周自查清单(对照招聘“熟悉 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