62 KiB
id, title, type, domain, stack, week, est_hours, order
| id | title | type | domain | stack | week | est_hours | order | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan.week07 | 第 7 周:前端工程化查漏补缺 + 两个项目的界面 | week | plan |
|
|
12 | 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;以后升级前先看各工具的兼容说明。
本周最终目录
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只对某个包执行。 - 案例:
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
# 文件:web/pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
根 package.json 里的脚本(依赖版本以 pnpm add 实际装上的为准):
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "pnpm -r typecheck",
"test": "vitest run",
"build": "pnpm -r build"
}
}
{
"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 继承它。)
// 文件: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
]);
{ "semi": true, "singleQuote": false, "printWidth": 110 }
(上面这段保存为 web/.prettierrc.json。)
// 文件: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 时回调(跳登录)、超时取消。 - 案例:
{
"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 编译,省掉单独构建这一步。)
{ "extends": "../../tsconfig.base.json", "include": ["src"] }
(上面这段保存为 web/packages/shared/tsconfig.json。)
// 文件:web/packages/shared/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node" } });
// 文件:web/packages/shared/src/index.ts
export * from "./http";
export * from "./sse";
// 文件: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>;
// 文件: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
运行方式
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是同一个思路。 - 案例:
// 文件: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);
}
// 文件: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里导出
运行方式
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)。 - 案例:
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 里的脚本:
{
"name": "board-vue",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit"
}
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "types": ["vite/client"] },
"include": ["src/**/*.ts", "src/**/*.vue"]
}
(上面这段保存为 apps/board-vue/tsconfig.json。)
// 文件: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" },
});
<!-- 文件: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>
// 文件: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;
}
// 文件: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 标签一致。)
// 文件: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 }),
};
// 文件: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 };
});
// 文件: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 };
});
// 文件: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("无权限");
});
});
// 文件: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 } };
}
});
// 文件: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");
<!-- 文件:web/apps/board-vue/src/App.vue -->
<template>
<router-view />
</template>
<!-- 文件: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>
// 文件: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"]]);
});
<!-- 文件: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>
<!-- 文件: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/下上面列出的全部文件
运行方式
# 先按第 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负责发请求和取消。 - 案例:
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 里的脚本:
{
"name": "rag-react",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit" }
}
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "jsx": "react-jsx", "types": ["vite/client"] },
"include": ["src"]
}
(上面这段保存为 apps/rag-react/tsconfig.json。)
// 文件: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" },
});
<!-- 文件: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>
// 文件: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}`),
};
// 文件: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;
}
}
// 文件: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: "" });
});
});
// 文件: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 };
}
// 文件: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>
);
}
// 文件: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);
});
// 文件: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>
);
}
// 文件: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>
);
}
// 文件: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>
);
}
// 文件: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/下上面列出的全部文件
运行方式
# 先按第 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 配置(看懂即可,老项目面试会问):
// 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体积。
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
// 片段: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)。
// 片段:固定行高的虚拟列表原理
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 分数(数值以你自己测的为准)
运行方式
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 )。
// 示意: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>
);
}
<!-- 示意: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)。 - 案例:
# .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
# 把看板前端放进第 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 解析、乐观更新、懒加载)
运行方式
pnpm lint && pnpm typecheck && pnpm test && pnpm build
完成标准
- CI 通过;看板通过 Nginx 访问正常,刷新不 404。
- 在总纲进度表勾掉第 7 周。
本周相关专题
本节由
tools/gen_week_topics.py根据专题文件的week字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。
- AI 编程练习(每周):本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 AI 编程:工作流 写清需求和规则,按 验证与证据 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。
| 技术栈 | 课程 / 场景 / 面试题 |
|---|---|
| Node.js(了解级) | 课程:Node.js 后端(了解级) |
| Web 安全基础 | 课程:Web 安全基础;面试题 |
| CI/CD | 课程:流水线设计 |
| Nginx | 课程:反向代理、负载均衡和静态资源;课程:HTTPS、SSE / WebSocket、限流和灰度;场景:AI 应用网关;面试题 |
| 前端测试与性能 | 课程:前端测试;课程:前端性能;场景:性能治理;面试题 |
| React | 课程:React 渲染机制、Hooks 规则、状态管理和 React 19 新特性;场景:长任务界面;面试题 |
| SSR(Nuxt / Next.js) | 课程:渲染模式;课程:Next.js App Router;场景:帮助中心 + 智能客服;面试题 |
| TypeScript | 课程:TypeScript 类型系统进阶;场景:前后端类型契约;面试题 |
| Vite | 课程:Vite 8;场景:老项目从 webpack 迁移到 Vite;面试题 |
| Vue 3 | 课程:Vue 3 响应式原理、组合式函数、Pinia 和性能优化;场景:AI 对话界面;面试题 |
| webpack | 课程:webpack 5;场景:大型项目构建慢、包体积大;面试题 |
本周自查清单(对照招聘“熟悉 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