1665 lines
58 KiB
Markdown
1665 lines
58 KiB
Markdown
---
|
||
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
|