Files
zhuangang/plan/week07-frontend.md
T

58 KiB
Raw Blame History

id, title, type, domain, stack, week, est_hours, order
id title type domain stack week est_hours order
plan.week07 第 7 周:前端工程化查漏补缺 + 两个项目的界面 week plan
nginx
cicd
vue
react
typescript
vite
webpack
ssr
fe-testing-perf
7
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 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 周。

本周自查清单(对照招聘“熟悉 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