diff --git a/plan/week04-python.md b/plan/week04-python.md new file mode 100644 index 0000000..8617cfe --- /dev/null +++ b/plan/week04-python.md @@ -0,0 +1,862 @@ +# 第 4 周:Python 速成(给 JS 开发者)+ FastAPI 基础 + +> 第二段“Python 与 AI 应用”的第 1 周。从这周起 **Python 是求职主语言**。上周 `week03-project1.md`,下周 `week05-rag-1.md`。 + +**本周目标**:把写 AI 应用后端需要的 Python 补齐:包管理、类型标注、异常、asyncio、pydantic、httpx、pytest、FastAPI(Uvicorn、依赖注入、中间件、鉴权、生命周期、SSE)。只学项目马上要用的。 + +**学法**:每个知识点先想“JS / Go 里对应什么”。本周代码示例都在 Python 3.12 上实际运行过。 + +**参考资料** +- Python 教程(中文):https://docs.python.org/zh-cn/3/tutorial/ +- asyncio(中文):https://docs.python.org/zh-cn/3/library/asyncio.html +- uv:https://docs.astral.sh/uv/ ;pydantic:https://docs.pydantic.dev/ ;httpx:https://www.python-httpx.org/ +- FastAPI(中文):https://fastapi.tiangolo.com/zh/ + +## 本周最终目录 + +```text +py-learning/ +├── pyproject.toml +├── day1_basics.py +├── day2_typing.py +├── day3_errors.py +├── day4_async.py +├── day5_models.py +├── day6_http.py +├── app/ +│ ├── __init__.py +│ └── main.py # FastAPI 练习服务 +└── tests/ + ├── test_day2.py + ├── test_day5.py + ├── test_day6.py + └── test_app.py +``` + +--- + +## 第 1 天:包管理和基础语法 + +### 学习内容 + +**1. uv / pip / poetry** + +- 说明:`pip` 是官方安装工具,配合 `venv` 虚拟环境(相当于每个项目一个独立的 `node_modules`);`poetry` 和 `uv` 是“依赖声明 + 锁文件 + 虚拟环境”一体的工具,类似 npm/pnpm。uv 速度快、命令简单,本计划用它;招聘里提到 pip、poetry 时,概念是一样的。 +- 案例: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +uv init py-learning && cd py-learning +uv python pin 3.12 +uv add httpx pydantic pydantic-settings fastapi uvicorn pyjwt +uv add --dev pytest pytest-asyncio ruff pyright +uv run python day1_basics.py # 在项目虚拟环境里运行 +# 对照:pip 的做法 +# python -m venv .venv && source .venv/bin/activate && pip install httpx && pip freeze > requirements.txt +``` + +| npm / pnpm | uv | pip | poetry | +| --- | --- | --- | --- | +| `package.json` | `pyproject.toml` | `requirements.txt` | `pyproject.toml` | +| `pnpm-lock.yaml` | `uv.lock` | 无(`pip freeze` 近似) | `poetry.lock` | +| `pnpm add x` | `uv add x` | `pip install x` | `poetry add x` | +| `pnpm exec` | `uv run` | 激活 venv 后直接运行 | `poetry run` | + +**2. 基础语法对照 JS** + +- 说明:缩进代替大括号;`None/True/False`;列表推导式相当于 `map + filter`;解包相当于解构;f-string 相当于模板字符串。 +- 案例: + +```python +# 文件:py-learning/day1_basics.py +from collections import Counter + + +def word_count(text: str) -> dict[str, int]: + return dict(Counter(text.lower().split())) + + +def unique(items: list[str]) -> list[str]: + seen: set[str] = set() + return [x for x in items if not (x in seen or seen.add(x))] # 保持顺序去重 + + +def main() -> None: + nums = [1, 2, 3, 4, 5] + squares_of_even = [n * n for n in nums if n % 2 == 0] # JS: nums.filter(...).map(...) + first, *rest = nums # JS: const [first, ...rest] = nums + user = {"name": "小王", "years": 7} + merged = {**user, "city": "上海"} # JS: {...user, city: '上海'} + print(squares_of_even, first, rest, merged) + print(f"{user['name']} 做了 {user['years']} 年前端") # JS: `${...}` + print(word_count("Go is fun and go is fast")) + print(unique(["a", "b", "a", "c"])) + + +def append_bad(x, items=[]): # 坑:默认参数只在定义时创建一次,所有调用共享 + items.append(x) + return items + + +def append_good(x, items=None): + items = [] if items is None else items + items.append(x) + return items + + +if __name__ == "__main__": # 直接运行这个文件时才执行(被 import 时不执行) + main() + print(append_bad(1), append_bad(2)) # [1, 2] [1, 2] + print(append_good(1), append_good(2)) # [1] [2] +``` + +- 面试常问:`list` 和 `tuple` 的区别(可变 / 不可变);可变默认参数的坑;`is` 和 `==` 的区别。 + +### 要建的文件 + +- `pyproject.toml`(`uv init` 生成)、`day1_basics.py` + +### 运行方式 + +```bash +uv run python day1_basics.py +``` + +### 完成标准 + +- 能用推导式、解包、f-string 写出上面的例子;能解释可变默认参数的坑。 + +--- + +## 第 2 天:类型标注、dataclass、Protocol、pytest + +### 学习内容 + +**1. 类型标注和检查** + +- 说明:和 TS 一样,类型标注只是提示,运行时不检查,要靠 pyright / mypy 静态检查。常用:`list[str]`、`dict[str, int]`、`X | None`、`Literal["a", "b"]`、`TypedDict`。 +- **dataclass**:自动生成构造函数的数据类,相当于 Go 的结构体。 +- **Protocol**:结构化的“隐式接口”,和 Go 的 interface 一样,不用继承,方法对得上就算实现。后面 LLM 客户端、向量库都靠它做可替换实现。 +- 案例: + +```python +# 文件:py-learning/day2_typing.py +from dataclasses import dataclass, field +from typing import Literal, Protocol + +TaskStatus = Literal["todo", "doing", "done"] + + +@dataclass +class Task: + id: int + title: str + status: TaskStatus = "todo" + tags: list[str] = field(default_factory=list) # 可变默认值要用 default_factory + + +class TaskStore(Protocol): + def add(self, task: Task) -> None: ... + def all(self) -> list[Task]: ... + + +class MemoryTaskStore: # 没有继承 TaskStore,但方法签名一致,就满足协议 + def __init__(self) -> None: + self._items: list[Task] = [] + + def add(self, task: Task) -> None: + self._items.append(task) + + def all(self) -> list[Task]: + return list(self._items) + + +def count_by_status(store: TaskStore) -> dict[TaskStatus, int]: + result: dict[TaskStatus, int] = {"todo": 0, "doing": 0, "done": 0} + for t in store.all(): + result[t.status] += 1 + return result +``` + +**2. pytest 和参数化测试** + +- 说明:pytest 自动发现 `test_*.py` 里的 `test_*` 函数,直接用 `assert`;`@pytest.mark.parametrize` 相当于 Go 的表格驱动测试。 +- 案例: + +```python +# 文件:py-learning/tests/test_day2.py +import pytest + +from day2_typing import MemoryTaskStore, Task, count_by_status + + +@pytest.mark.parametrize( + "statuses, expected", + [ + ([], {"todo": 0, "doing": 0, "done": 0}), + (["todo", "done", "done"], {"todo": 1, "doing": 0, "done": 2}), + ], +) +def test_count_by_status(statuses, expected): + store = MemoryTaskStore() + for i, s in enumerate(statuses): + store.add(Task(id=i, title=f"t{i}", status=s)) + assert count_by_status(store) == expected +``` + +- 常见坑:故意写 `Task(id=1, title="x", status="deleted")`,Python 照样运行,只有 pyright 会报错——所以 CI 里要跑类型检查。 + +**易错点 / 面试常问(补充)** + +- 易错点:类型标注在运行时**不检查**,写错了程序照样跑;要靠 pyright / mypy 在编辑器和 CI 里检查。 +- 易错点:`@dataclass` 的可变默认值(列表、字典)要写 `field(default_factory=list)`,直接写 `= []` 会报错(普通函数参数写 `def f(x=[])` 则会让所有调用共用同一个列表)。 +- 易错点:pytest 的测试文件和函数必须以 `test_` 开头才会被发现;`assert` 失败时 pytest 会显示两边的值,不要写成 `assert x == y, "失败"` 后就不看输出。 +- 面试常问:`Protocol` 和抽象基类 `ABC` 的区别(前者只看方法签名对不对得上,后者要显式继承);pytest 的 fixture 是什么(可复用的准备 / 清理代码,按参数名注入)。 + +### 要建的文件 + +- `day2_typing.py`、`tests/test_day2.py`;`pyproject.toml` 里加: + +```toml +[tool.pytest.ini_options] +pythonpath = ["."] +asyncio_mode = "auto" +``` + +### 运行方式 + +```bash +uv run pytest -v && uv run pyright day2_typing.py && uv run ruff check . +``` + +### 完成标准 + +- 测试通过;故意写错 `status`,pyright 能报出来。 + +--- + +## 第 3 天:异常、上下文管理器、文件和日志 + +### 学习内容 + +- 说明: + - `raise NewError(...) from err` 保留原始异常链(相当于 Go 的 `%w`);自定义异常建一个基类,带上错误码,方便上层统一处理。 + - `with` 语句(上下文管理器)保证资源被释放,相当于 Go 的 `defer`;`@contextmanager` 可以快速写一个。 + - 写文件要“先写临时文件再改名”,防止写到一半崩溃导致文件损坏(第 12 周写 Agent 状态时是同一个道理)。 + - 用 `logging` 而不是 `print`,生产环境才能控制级别和格式。 +- 案例: + +```python +# 文件:py-learning/day3_errors.py +import json +import logging +import os +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +log = logging.getLogger("day3") + + +class AppError(Exception): + code = "APP_ERROR" + + +class BadDataError(AppError): + code = "BAD_DATA" + + +@contextmanager +def timer(name: str): + start = time.perf_counter() + try: + yield + finally: # 即使中间抛异常也会执行 + log.info("%s 用时 %.1f ms", name, (time.perf_counter() - start) * 1000) + + +def save_json_atomic(path: Path, data: object) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) # ensure_ascii=False 才能正常写中文 + os.replace(tmp, path) # 同一文件系统内改名是原子操作 + + +def load_json(path: Path) -> list: + if not path.exists(): + return [] + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as err: + raise BadDataError(f"{path} 不是合法 JSON") from err + + +if __name__ == "__main__": + p = Path(tempfile.gettempdir()) / "tasks_demo.json" + with timer("保存"): + save_json_atomic(p, [{"id": 1, "title": "学 Python"}]) + print(load_json(p)) + p.write_text("{坏数据", encoding="utf-8") + try: + load_json(p) + except AppError as e: + log.warning("错误码 %s:%s(原因:%r)", e.code, e, e.__cause__) +``` + +**易错点 / 面试常问(补充)** + +- 易错点:不要用裸 `except:` 或 `except Exception: pass` 吞掉异常;至少记录日志,能处理的才捕获。 +- 易错点:打开文件要指定 `encoding="utf-8"`,否则在 Windows 上默认编码不同,中文会乱码。 +- 易错点:日志用 `logging`,不要到处 `print`;库代码里只 `getLogger(__name__)`,日志格式和级别由程序入口统一配置。 +- 面试常问:`try / except / else / finally` 各自什么时候执行;为什么“先写临时文件再 `os.replace`”能避免写一半崩溃导致文件损坏。 + +### 要建的文件 + +- `day3_errors.py` + +### 运行方式 + +```bash +uv run python day3_errors.py +``` + +### 完成标准 + +- 能说清 `raise ... from` 保留了什么,`finally` 什么时候执行。 + +--- + +## 第 4 天:asyncio(本周最重要) + +### 学习内容 + +- 说明: + - `async def` / `await` 写法和 JS 很像,但**调用 async 函数只得到协程对象,不会自动开始执行**,必须 `await` 或 `asyncio.create_task`。 + - `asyncio.gather` ≈ `Promise.all`;`asyncio.timeout` 设置超时;`asyncio.Queue` ≈ Go 的 channel;`task.cancel()` 会在任务里抛 `CancelledError`,清理工作放 `finally`,**不要吞掉这个异常**。 + - 异步生成器(`async def` + `yield`)配合 `async for`,是 SSE 流式输出的基础。 + - 在 async 函数里调用阻塞代码(`time.sleep`、同步数据库驱动)会卡住整个事件循环,所有请求都变慢;实在要用就 `await asyncio.to_thread(...)`。 +- 案例:模拟并发调用模型、超时、生产者消费者、取消流式输出。 + +```python +# 文件:py-learning/day4_async.py +import asyncio +import time + + +async def fake_llm(name: str, delay: float) -> str: + await asyncio.sleep(delay) # 模拟一次模型调用 + return f"{name} 完成" + + +async def demo_gather() -> None: + start = time.perf_counter() + results = await asyncio.gather(fake_llm("A", 0.3), fake_llm("B", 0.3), fake_llm("C", 0.3)) + print(results, f"并发总耗时约 {time.perf_counter() - start:.1f}s") # 约 0.3s 而不是 0.9s + + +async def demo_timeout() -> None: + try: + async with asyncio.timeout(0.1): + await fake_llm("慢模型", 1) + except TimeoutError: + print("超时了,走降级逻辑") + + +async def demo_queue() -> None: + q: asyncio.Queue[int] = asyncio.Queue() + + async def worker(name: str) -> None: + while True: + n = await q.get() + await asyncio.sleep(0.05) + print(f"{name} 处理 {n}") + q.task_done() + + workers = [asyncio.create_task(worker(f"w{i}")) for i in range(2)] + for n in range(5): + q.put_nowait(n) + await q.join() # 等所有任务处理完 + for w in workers: + w.cancel() + await asyncio.gather(*workers, return_exceptions=True) + + +async def ticker(): + i = 0 + try: + while True: + await asyncio.sleep(0.05) + i += 1 + yield i + finally: + print("流被关闭,清理资源") # 客户端断开时这里会执行 + + +async def demo_cancel_stream() -> None: + async def consume(): + async for n in ticker(): + print("收到", n) + + task = asyncio.create_task(consume()) + await asyncio.sleep(0.18) + task.cancel() + try: + await task + except asyncio.CancelledError: + print("消费任务已取消") + + +async def main() -> None: + await demo_gather() + await demo_timeout() + await demo_queue() + await demo_cancel_stream() + print(await asyncio.to_thread(time.sleep, 0.05) or "阻塞调用放到线程里执行") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +| JS / Go | Python asyncio | +| --- | --- | +| `Promise.all` | `asyncio.gather` | +| `setTimeout` / `time.Sleep` | `await asyncio.sleep` | +| `go f()` | `asyncio.create_task(f())` | +| channel | `asyncio.Queue` | +| `context` 取消 | `task.cancel()` | +| `AbortController` 超时 | `asyncio.timeout()` | + +- 面试常问:协程、线程、进程的区别;GIL 是什么、为什么 IO 密集型任务用 asyncio 很合适而 CPU 密集型要用多进程。 + +### 要建的文件 + +- `day4_async.py` + +### 运行方式 + +```bash +uv run python day4_async.py +``` + +### 完成标准 + +- 能说清“调用 async 函数但没 await”会怎样,以及为什么不能在 async 函数里用 `time.sleep`。 + +--- + +## 第 5 天:pydantic 和配置 + +### 学习内容 + +- 说明:pydantic 是带运行时校验的数据模型(作用像前端的 zod)。外部进来的数据——请求体、配置、**大模型输出的 JSON**——都要先过校验。v2 常用:`BaseModel`、`Field` 约束、`model_validate` / `model_validate_json`、`model_dump`、`model_validator`。`pydantic-settings` 从环境变量和 `.env` 读配置。 +- 案例:解析“协调者”模型输出的决定(模型常常会在 JSON 外包一层代码块或多说几句话)。 + +```python +# 文件:py-learning/day5_models.py +import re +from typing import Literal + +from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Decision(BaseModel): + action: Literal["dispatch", "finish", "ask_human"] + target: Literal["proposer", "critic"] | None = None + instruction: str = "" + reason: str = Field(min_length=1) + + @model_validator(mode="after") + def check_target(self): + if self.action == "dispatch" and self.target is None: + raise ValueError("dispatch 必须指定 target") + return self + + +class BadModelOutput(Exception): + pass + + +def parse_decision(raw: str) -> Decision: + m = re.search(r"\{.*\}", raw, re.S) # 取出第一个 { 到最后一个 } 之间的内容 + if not m: + raise BadModelOutput(f"没有找到 JSON:{raw[:200]}") + try: + return Decision.model_validate_json(m.group(0)) + except ValidationError as e: + raise BadModelOutput(f"格式不对:{e.errors()[0]['msg']}") from e + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + llm_base_url: str = "https://api.example.com/v1" + llm_api_key: str = "" + llm_model: str = "demo-model" + llm_timeout_s: float = 30 + + +if __name__ == "__main__": + print(parse_decision('好的:```json\n{"action":"dispatch","target":"critic","reason":"需要评审"}\n```')) + print(Settings().model_dump(exclude={"llm_api_key"})) # 打印配置时不要输出密钥 +``` + +```python +# 文件:py-learning/tests/test_day5.py +import pytest + +from day5_models import BadModelOutput, parse_decision + + +@pytest.mark.parametrize( + "raw, ok", + [ + ('{"action":"finish","reason":"已达成共识"}', True), + ('```json\n{"action":"dispatch","target":"proposer","reason":"先出方案"}\n```', True), + ('我认为:{"action":"finish","reason":"够了"} 以上。', True), + ('{"action":"dispatch","reason":"缺 target"}', False), + ('{"action":"run","reason":"拼错了"}', False), + ("完全不是 JSON", False), + ], +) +def test_parse_decision(raw, ok): + if ok: + assert parse_decision(raw).reason + else: + with pytest.raises(BadModelOutput): + parse_decision(raw) +``` + +**易错点 / 面试常问(补充)** + +- 易错点:pydantic v2 的方法名和 v1 不同:`model_validate`、`model_dump`、`model_dump_json`(v1 是 `parse_obj`、`dict`、`json`)。网上很多旧教程还是 v1 写法。 +- 易错点:`BaseSettings` 在 v2 里拆到了单独的包 `pydantic-settings`,要单独安装。 +- 易错点:大模型返回的 JSON 经常带解释文字或代码块标记,解析前先提取 JSON 部分,解析失败要有兜底,不要直接让接口 500。 +- 面试常问:pydantic 和 dataclass 的区别(前者会校验和转换类型,适合处理外部输入);配置为什么放环境变量(12-Factor:同一份代码在不同环境只换配置)。 + +### 要建的文件 + +- `day5_models.py`、`tests/test_day5.py` + +### 运行方式 + +```bash +uv run pytest tests/test_day5.py -v +``` + +### 完成标准 + +- 6 个用例全部按预期;能说清“模型输出为什么必须当成不可信输入来校验”。 + +--- + +## 第 6 天:httpx 异步请求、重试、测试替身 + +### 学习内容 + +- 说明: + - `httpx.AsyncClient` 支持 `base_url`、`timeout`、连接复用,用 `async with` 管理生命周期。 + - 哪些错误重试:超时、连接失败、429、5xx(指数退避:每次等待时间翻倍,加一点随机);哪些不重试:400、401、403(重试也不会好)。 + - `httpx.MockTransport` 让测试不真正发请求,可以模拟任何响应。 +- 案例: + +```python +# 文件:py-learning/day6_http.py +import asyncio +import random + +import httpx + + +class HttpError(Exception): + def __init__(self, code: str, message: str, retryable: bool): + super().__init__(message) + self.code, self.retryable = code, retryable + + +def classify(resp: httpx.Response) -> HttpError: + s = resp.status_code + if s in (401, 403): + return HttpError("AUTH", "鉴权失败", False) + if s == 429: + return HttpError("RATE_LIMITED", "请求太频繁", True) + if s >= 500: + return HttpError("UPSTREAM", f"上游错误 {s}", True) + return HttpError("BAD_REQUEST", f"请求错误 {s}", False) + + +async def get_json(client: httpx.AsyncClient, url: str, retries: int = 2, base_delay: float = 0.5) -> dict: + last: HttpError | None = None + for attempt in range(retries + 1): + try: + resp = await client.get(url) + if resp.status_code == 200: + return resp.json() + last = classify(resp) + except httpx.TimeoutException: + last = HttpError("TIMEOUT", "请求超时", True) + except httpx.ConnectError: + last = HttpError("CONNECT", "连接失败", True) + if not last.retryable or attempt == retries: + break + await asyncio.sleep(base_delay * 2**attempt + random.uniform(0, base_delay)) + assert last is not None + raise last +``` + +```python +# 文件:py-learning/tests/test_day6.py +import httpx +import pytest + +from day6_http import HttpError, get_json + + +def client_with(handler) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="https://test") + + +async def test_retry_then_success(): + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(503) if calls["n"] < 3 else httpx.Response(200, json={"ok": True}) + + async with client_with(handler) as c: + assert await get_json(c, "/x", base_delay=0) == {"ok": True} + assert calls["n"] == 3 + + +async def test_auth_not_retried(): + calls = {"n": 0} + + def handler(request): + calls["n"] += 1 + return httpx.Response(401) + + async with client_with(handler) as c: + with pytest.raises(HttpError) as e: + await get_json(c, "/x", base_delay=0) + assert e.value.code == "AUTH" and calls["n"] == 1 + + +async def test_timeout(): + def handler(request): + raise httpx.ReadTimeout("slow", request=request) + + async with client_with(handler) as c: + with pytest.raises(HttpError) as e: + await get_json(c, "/x", retries=1, base_delay=0) + assert e.value.code == "TIMEOUT" +``` + +**易错点 / 面试常问(补充)** + +- 易错点:`httpx.AsyncClient` 要复用(服务启动时创建、关闭时 `aclose`),不要每次请求都新建,否则连接无法复用、性能差。 +- 易错点:一定要设置超时;httpx 默认超时是 5 秒,调用大模型这类慢接口要按需调大,并区分连接超时和读取超时。 +- 易错点:只对“可重试”的错误重试(超时、429、5xx),4xx 参数错误重试没有意义;重试要有次数上限和指数退避。 +- 面试常问:重试为什么可能导致重复写入(要配合幂等键);什么是测试替身(mock / fake / stub),为什么测试不能依赖真实外部服务。 + +### 要建的文件 + +- `day6_http.py`、`tests/test_day6.py` + +### 运行方式 + +```bash +uv run pytest tests/test_day6.py -v +``` + +### 完成标准 + +- 测试通过;能讲清重试策略和指数退避。 + +--- + +## 第 7 天:FastAPI(Uvicorn、依赖注入、中间件、鉴权、生命周期、SSE) + +### 学习内容 + +- 说明: + - **FastAPI** 基于类型标注自动做参数解析、校验和接口文档(`/docs`);**Uvicorn** 是运行它的 ASGI 服务器(相当于 Go 里的 `http.Server`)。Flask 是更老、更简单的同步框架,概念相通。 + - **依赖注入** `Depends`:把“取当前用户”“拿数据库连接”这类公共逻辑写成函数,接口参数里声明即可,测试时可以替换(`app.dependency_overrides`)。 + - **中间件**:和 Gin 一样包在请求外层,常用于日志、耗时、请求 ID。 + - **鉴权**:用 `HTTPBearer` 取令牌,用 PyJWT 校验,失败抛 `HTTPException(401)`。 + - **lifespan**:服务启动和关闭时执行的代码(创建 / 关闭连接池、启动后台任务)。 + - **SSE**:响应类型 `text/event-stream`,每条消息是 `id: ...\nevent: ...\ndata: ...\n\n`;浏览器 `EventSource` 断线会自动重连并带上 `Last-Event-ID`。下面先手写格式理解协议;新版 FastAPI 自带 `fastapi.sse.EventSourceResponse`(我验证时的 0.161 版本已有),用法以官方文档为准。 + - **后台任务和连接分开**:任务用 `asyncio.create_task` 在后台跑,SSE 只负责“读进度”,关掉页面任务也不停(这就是第 12 周 Agent 项目的核心思路)。 +- 案例: + +```python +# 文件:py-learning/app/__init__.py +``` + +```python +# 文件:py-learning/app/main.py +import asyncio +import json +import os +import time +import uuid +from contextlib import asynccontextmanager +from typing import Annotated + +import jwt +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field + +SECRET = os.environ.get("JWT_SECRET", "dev-only-insecure-secret-change-me-32b") # 生产环境必须通过环境变量设置 +jobs: dict[str, dict] = {} +background: set[asyncio.Task] = set() # 保存任务引用,防止被垃圾回收 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + print("启动:创建连接池等") + yield + for t in background: + t.cancel() + print("关闭:清理资源") + + +app = FastAPI(lifespan=lifespan) + + +@app.middleware("http") +async def timing(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + response.headers["X-Cost-Ms"] = f"{(time.perf_counter() - start) * 1000:.1f}" + return response + + +bearer = HTTPBearer(auto_error=False) + + +def current_user(cred: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> str: + if cred is None: + raise HTTPException(401, "未登录") + try: + return jwt.decode(cred.credentials, SECRET, algorithms=["HS256"])["sub"] + except jwt.PyJWTError: + raise HTTPException(401, "令牌无效或已过期") + + +class JobIn(BaseModel): + steps: int = Field(default=5, ge=1, le=20) + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/jobs", status_code=201) +async def create_job(body: JobIn, user: Annotated[str, Depends(current_user)]): + job_id = uuid.uuid4().hex[:8] + jobs[job_id] = {"owner": user, "events": [], "done": False} + + async def run(): + for i in range(1, body.steps + 1): + await asyncio.sleep(0.05) # 模拟每一步的工作 + jobs[job_id]["events"].append({"step": i}) + jobs[job_id]["done"] = True + + task = asyncio.create_task(run()) + background.add(task) + task.add_done_callback(background.discard) + return {"job_id": job_id} + + +@app.get("/jobs/{job_id}/events") +async def job_events(job_id: str, request: Request): + if job_id not in jobs: + raise HTTPException(404, "任务不存在") + last = int(request.headers.get("last-event-id", 0)) # 断线重连时从这里继续 + + async def stream(): + nonlocal last + while True: + if await request.is_disconnected(): + break # 客户端断开只影响这条连接,后台任务继续跑 + events = jobs[job_id]["events"] + while last < len(events): + last += 1 + yield f"id: {last}\nevent: progress\ndata: {json.dumps(events[last - 1])}\n\n" + if jobs[job_id]["done"]: + yield "event: end\ndata: {}\n\n" + break + await asyncio.sleep(0.05) + + return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"}) +``` + +```python +# 文件:py-learning/tests/test_app.py +import time + +import jwt +from fastapi.testclient import TestClient + +from app.main import SECRET, app + + +def token(sub: str = "u1") -> str: + return jwt.encode({"sub": sub, "exp": int(time.time()) + 60}, SECRET, algorithm="HS256") + + +def test_auth_and_sse(): + with TestClient(app) as client: # with 会触发 lifespan + assert client.post("/jobs", json={"steps": 3}).status_code == 401 + r = client.post("/jobs", json={"steps": 3}, headers={"Authorization": f"Bearer {token()}"}) + assert r.status_code == 201 + job_id = r.json()["job_id"] + with client.stream("GET", f"/jobs/{job_id}/events") as s: + body = "".join(s.iter_text()) + assert body.count("event: progress") == 3 and "event: end" in body + with client.stream("GET", f"/jobs/{job_id}/events", headers={"Last-Event-ID": "2"}) as s: + body = "".join(s.iter_text()) + assert body.count("event: progress") == 1 # 只补发第 3 条 +``` + +**易错点 / 面试常问(补充)** + +- 易错点:在 `async def` 接口里调用同步阻塞的库(如 `requests`、同步数据库驱动、`time.sleep`)会卡住整个事件循环;要么换异步库,要么把接口写成普通 `def`(FastAPI 会放到线程池执行)。 +- 易错点:`BackgroundTasks` 适合发邮件这类小任务,进程重启就丢;长任务要用持久化的作业表或队列(第 12 周会做)。 +- 易错点:用 `TestClient` 测试有 lifespan 的应用时,要写成 `with TestClient(app) as client:`,否则启动和关闭代码不会执行。 +- 面试常问:FastAPI 为什么快(基于 Starlette 和异步 I/O,Uvicorn 是 ASGI 服务器);依赖注入有什么好处(复用、可测试、可替换);SSE 和 WebSocket 的区别(SSE 单向、基于普通 HTTP、浏览器自动重连)。 +- 文档:FastAPI 更新较快,`fastapi.sse` 等新功能的用法以官方文档为准:https://fastapi.tiangolo.com/zh/ + +### 要建的文件 + +- `app/__init__.py`、`app/main.py`、`tests/test_app.py` + +### 运行方式 + +```bash +uv run uvicorn app.main:app --reload # 打开 http://localhost:8000/docs +uv run pytest -v +``` + +### 完成标准 + +- 测试通过;在浏览器控制台用 `new EventSource(...)` 订阅(`EventSource` 不能带请求头,练习时可以临时去掉鉴权或改用 `fetch` 读流),关掉页面后再打开,能补齐事件。 +- 提交推送;在总纲进度记录里勾掉第 4 周。 + +--- + +## 本周自查清单 + +- [ ] 会用 uv,知道 pip / venv / poetry 的对应关系 +- [ ] 熟悉推导式、解包、f-string、可变默认参数的坑 +- [ ] 会写类型标注、dataclass、Protocol,会用 pyright、ruff、pytest +- [ ] 会用自定义异常、`raise from`、上下文管理器、logging +- [ ] 理解 asyncio:`create_task`、`gather`、超时、取消、`Queue`、异步生成器、`to_thread`;能讲 GIL +- [ ] 会用 pydantic 校验外部数据和模型输出,会用 pydantic-settings +- [ ] 会用 httpx 异步请求、分类重试、`MockTransport` 测试 +- [ ] 会写 FastAPI:依赖注入、中间件、JWT 鉴权、lifespan、SSE、后台任务 diff --git a/plan/week06-python-basics.md b/plan/week06-python-basics.md deleted file mode 100644 index e2be44a..0000000 --- a/plan/week06-python-basics.md +++ /dev/null @@ -1,233 +0,0 @@ -# 转岗计划 · 第 6 周学习计划(Python 速成:给 JS 开发者) - -> 所属阶段:第二段“Python 与 AI 应用”的第一周。Python 是求职的**主语言**,后面的 RAG 项目(第 7~8 周)和多 Agent 项目(第 11~14 周)都用它。总纲见同目录 `README.md`,下周计划见 `rag-project.md`。 - -目标:一周内把写 AI 应用服务需要的 Python 补齐:基础语法、类型标注、asyncio、pydantic、httpx、pytest、FastAPI 和 SSE。不追求全面,只学项目里马上会用到的。 - -学法:你有 7 年 JS 和一个月 Go 的底子,每个知识点都先想“JS / Go 里对应的是什么”,再看 Python 的写法。每天的练习文件第一段写一张小对照表。 - -本周只学 Python,不开 RAG 和 Agent 项目(戒律:同一周不同时开新框架和新功能)。招聘里 FastAPI 和 Flask 都常见,本计划用 FastAPI;Flask 概念更少,会了 FastAPI 再看 Flask 半天就能上手。 - -参考资料: -- Python 官方教程(中文):https://docs.python.org/zh-cn/3/tutorial/ -- asyncio 官方文档(中文):https://docs.python.org/zh-cn/3/library/asyncio.html -- uv:https://docs.astral.sh/uv/ -- pydantic:https://docs.pydantic.dev/ -- FastAPI(中文):https://fastapi.tiangolo.com/zh/ -- httpx:https://www.python-httpx.org/ - ---- - -## 最终项目结构 - -``` -py-learning/ -├── pyproject.toml # uv 管理依赖 -├── .python-version -├── day1_basics.py -├── day2_typing.py -├── day3_errors_files.py -├── day4_async.py -├── day5_models.py -├── day6_http.py -├── fastapi_demo/ -│ ├── __init__.py -│ ├── main.py # FastAPI 应用 -│ └── static/ -│ └── sse.html # 用 EventSource 订阅 SSE 的测试页 -└── tests/ - ├── test_day2.py - ├── test_day5.py - ├── test_day6.py - └── test_fastapi_demo.py -``` - ---- - -## 第 1 天:环境和基础语法 - -**学习内容** -- 用 uv 管理 Python 版本和依赖(相当于 `nvm + npm` 合在一起):`curl -LsSf https://astral.sh/uv/install.sh | sh`。 -- 缩进代替大括号;`None` / `True` / `False`;`list`、`dict`、`tuple`、`set`。 -- f-string(相当于模板字符串)、切片 `a[1:3]`、解包 `a, *rest = items`。 -- 列表推导式和字典推导式(相当于 `map` + `filter`)。 -- 函数:默认参数、关键字参数、`*args`、`**kwargs`;默认参数不要用可变对象(`def f(x=[])` 是经典坑)。 -- `if __name__ == "__main__":` 的作用。 - -**要建的文件** -- `py-learning/`:运行 `uv init py-learning && cd py-learning && uv python pin 3.12`,生成 `pyproject.toml` 和 `.python-version`。 -- `day1_basics.py`: - - 文件开头用注释写一张 JS ↔ Python 对照表(至少 10 行:`const/let`、`===`、`null/undefined`、`arr.map`、`arr.filter`、对象展开、解构、模板字符串、`for...of`、箭头函数)。 - - `def word_count(text: str) -> dict[str, int]`:和第 1 周 Go 的 `WordCount` 做同一件事,对比代码长短。 - - `def unique(items: list[str]) -> list[str]`:去重并保持原顺序。 - - 演示可变默认参数的坑和正确写法(`def f(x=None)`)。 - -**运行方式**:`uv run python day1_basics.py` - -**完成标准**:能说清楚 Python 的 `list` 和 `tuple` 区别,以及为什么默认参数不能用 `[]`。 - ---- - -## 第 2 天:类型标注、dataclass 和 Protocol - -**学习内容** -- 类型标注只是提示,运行时不检查(和 TS 一样),要靠检查工具:`uv add --dev pyright ruff pytest`。 -- `list[str]`、`dict[str, int]`、`X | None`、`Literal["a", "b"]`、`TypedDict`。 -- `@dataclass`:相当于带构造函数的结构体。 -- `Protocol`:Python 的“隐式接口”,和 Go 的 `interface` 思路一样,Agent 项目里 LLM 客户端和存储层都会用它。 -- 类、`self`、`@property`、`@classmethod`。 - -**要建的文件** -- `day2_typing.py`: - - `TaskStatus = Literal["todo", "doing", "done"]` - - `@dataclass class Task`:`id: int`、`title: str`、`status: TaskStatus = "todo"`、`tags: list[str] = field(default_factory=list)`。 - - `class TaskStore(Protocol)`:`add(self, task: Task) -> None`、`list(self) -> list[Task]`。 - - `class MemoryTaskStore`:不继承 `TaskStore`,但实现同样的方法(体会“隐式实现”)。 - - `def count_by_status(tasks: list[Task]) -> dict[TaskStatus, int]` -- `tests/test_day2.py`:测试 `count_by_status`;用 `pytest.mark.parametrize` 写表格驱动测试(对应 Go 的表格驱动测试)。 - -**运行方式**:`uv run pytest -v`、`uv run pyright day2_typing.py`、`uv run ruff check .` - -**完成标准**:故意把 `status` 写成 `"deleted"`,pyright 能报错;能说清楚 `Protocol` 和 Go 接口的相同点。 - ---- - -## 第 3 天:异常、上下文管理器和文件 - -**学习内容** -- `try / except / else / finally`,`raise ... from err`(相当于 Go 的 `%w` 包装错误)。 -- 自定义异常层级:一个基类,下面分具体错误。 -- `with` 语句(上下文管理器):自动关闭文件、连接,相当于 Go 的 `defer`。 -- `pathlib.Path`、`json.loads` / `json.dumps`(注意 `ensure_ascii=False` 才能正常输出中文)。 -- `logging` 基本用法。 - -**要建的文件** -- `day3_errors_files.py`: - - `class AppError(Exception)`,带 `code: str` 属性;子类 `NotFoundError`、`ValidationError`。 - - `def load_tasks(path: Path) -> list[dict]`:文件不存在返回空列表;JSON 格式错误时 `raise ValidationError(...) from err`。 - - `def save_tasks(path: Path, tasks: list[dict]) -> None`:先写临时文件再 `replace`,避免写到一半崩溃导致文件损坏(Agent 项目里“写状态要原子”就是这个道理)。 - - 用 `contextlib.contextmanager` 写一个 `timer(name)`,在 `with timer("load"):` 结束时打印耗时。 - -**运行方式**:`uv run python day3_errors_files.py` - -**完成标准**:能说清楚 `raise X from err` 保留了什么信息,以及“先写临时文件再改名”为什么更安全。 - ---- - -## 第 4 天:asyncio(本周最重要的一天) - -**学习内容** -- `async def` / `await` 和 JS 很像,但要有事件循环:入口用 `asyncio.run(main())`。 -- 和 JS 的区别:调用 `async` 函数只得到协程对象,**不会自动开始执行**,要 `await` 或 `asyncio.create_task`。 -- `asyncio.gather`(相当于 `Promise.all`)、`asyncio.wait_for` / `asyncio.timeout`(超时)、`asyncio.Queue`(相当于 Go 的 channel)、`asyncio.Event`。 -- 取消:`task.cancel()` 会在任务里抛出 `CancelledError`,要在 `finally` 里清理,**不要吞掉这个异常**。 -- 异步生成器 `async def ... yield`,以及 `async for`:SSE 推送就是靠它。 -- 不能在 `async` 函数里调用会阻塞的同步代码(比如 `time.sleep`、同步的数据库驱动),否则整个服务卡住;实在要用就 `await asyncio.to_thread(...)`。 - -**要建的文件** -- `day4_async.py`: - - `async def fake_llm(name: str, delay: float) -> str`:`await asyncio.sleep(delay)` 后返回结果,模拟一次模型调用。 - - `async def run_parallel()`:用 `gather` 同时调 3 个 `fake_llm`,打印总耗时,和顺序调用对比。 - - `async def run_with_timeout()`:给一个 3 秒的调用设 1 秒超时,捕获 `TimeoutError` 并打印。 - - `async def producer_consumer()`:一个生产者往 `Queue` 放 5 个任务,两个消费者处理,全部处理完后退出(`queue.join()`)。 - - `async def ticker(n: int)`:异步生成器,每 0.5 秒 `yield` 一个数字;用 `async for` 消费,消费到第 3 个时取消,观察 `finally` 是否执行。 - - 文件开头写对照表:`Promise.all` ↔ `gather`、`setTimeout` ↔ `asyncio.sleep`、Go 的 `go f()` ↔ `create_task`、Go 的 channel ↔ `Queue`、Go 的 `context` 取消 ↔ `task.cancel()`。 - -**运行方式**:`uv run python day4_async.py` - -**完成标准**:能说清楚“调用 async 函数但没 await”会发生什么;能解释为什么在 async 服务里用同步阻塞调用会拖垮所有请求。 - ---- - -## 第 5 天:pydantic 数据模型 - -**学习内容** -- pydantic 是“带运行时校验的类型”,作用类似前端的 zod:外部进来的数据(请求体、模型输出的 JSON)都要先过一遍校验。 -- `BaseModel`、`Field(min_length=..., max_length=...)`、默认值、`model_validate`、`model_validate_json`、`model_dump`、`model_dump_json`。 -- `Literal` 字段和“按某个字段区分类型”的联合类型(discriminated union)。 -- `ValidationError` 里能拿到哪个字段、为什么错。 -- `pydantic-settings`:从环境变量和 `.env` 读配置。 - -**要建的文件** -- `uv add pydantic pydantic-settings` -- `day5_models.py`: - - `class Decision(BaseModel)`:`action: Literal["dispatch", "finish", "ask_human"]`、`target: str | None = None`、`instruction: str = ""`、`reason: str`。(这就是第 11~12 周多 Agent 项目里协调者输出的雏形) - - `def parse_decision(raw: str) -> Decision`:模型常常会在 JSON 外面包一层 ```` ```json ```` 代码块或多说几句话,先提取出第一个 `{...}` 再 `model_validate_json`;失败时抛出自定义的 `BadModelOutput`,错误信息里带上原始文本的前 200 个字。 - - `class Settings(BaseSettings)`:`llm_base_url: str`、`llm_api_key: str = ""`、`llm_model: str`、`llm_timeout_s: float = 30`,从 `.env` 读取。 -- `tests/test_day5.py`:参数化测试 `parse_decision`:正常 JSON、带代码块的 JSON、前后有废话的 JSON、`action` 拼错、缺少 `reason`、完全不是 JSON。 - -**运行方式**:`uv run pytest tests/test_day5.py -v` - -**完成标准**:6 种输入都按预期通过或报错;能说清楚“为什么模型输出必须当成不可信的外部输入来校验”。 - ---- - -## 第 6 天:httpx 异步请求和测试 - -**学习内容** -- `httpx.AsyncClient`:设置 `base_url`、`timeout`、请求头;用 `async with` 管理生命周期,复用连接。 -- 状态码和异常:`raise_for_status()`、`httpx.TimeoutException`、`httpx.ConnectError`。 -- 测试时不真的发请求:`httpx.MockTransport` 可以按请求返回任意响应,用来模拟超时、429、500。 -- pytest 跑异步测试:`uv add --dev pytest-asyncio`,在 `pyproject.toml` 里设置 `asyncio_mode = "auto"`。 - -**要建的文件** -- `uv add httpx` -- `day6_http.py`: - - `class HttpError(Exception)`,带 `code` 和 `retryable: bool`。 - - `async def get_json(client: httpx.AsyncClient, url: str, retries: int = 2) -> dict`:超时和 5xx、429 时等待后重试(等待时间逐次翻倍),401 / 400 不重试,最后抛出带错误码的 `HttpError`。 - - `main()` 里请求一个公开接口(例如 `https://httpbin.org/json`)打印结果。 -- `tests/test_day6.py`,用 `MockTransport` 测试: - - 200 直接返回。 - - 前两次 503、第三次 200,最终成功,并断言一共请求了 3 次。 - - 401 不重试,直接抛 `code == "AUTH"`。 - - 一直超时,最终抛 `code == "TIMEOUT"`。 - -**运行方式**:`uv run python day6_http.py`、`uv run pytest tests/test_day6.py -v` - -**完成标准**:四个测试都通过;能说清楚哪些错误值得重试、哪些不值得。 - ---- - -## 第 7 天:FastAPI 基础和 SSE - -**学习内容** -- FastAPI 路由、路径参数、查询参数,请求体直接用 pydantic 模型(对照第 2 周的 Gin)。 -- 依赖注入 `Depends`。 -- `lifespan`:服务启动和关闭时执行的代码(第 13 周用它在启动时恢复未完成的任务)。 -- 后台任务:用 `asyncio.create_task` 启动,**要把任务对象保存起来**,否则可能被垃圾回收。 -- SSE 格式:`id: 1\nevent: progress\ndata: {...}\n\n`;用 `StreamingResponse(生成器, media_type="text/event-stream")` 返回;浏览器用 `EventSource` 订阅,断线会自动重连并带上 `Last-Event-ID` 请求头。 -- 检测客户端断开:`await request.is_disconnected()`。 - -**要建的文件** -- `uv add fastapi uvicorn` -- `fastapi_demo/main.py`: - - `GET /health`。 - - `POST /jobs`:请求体 `{"n": 10}`,用 `create_task` 启动一个后台任务,每秒把进度追加到内存里的 `jobs[job_id]["events"]` 列表,返回 `{"job_id": ...}`。 - - `GET /jobs/{job_id}`:返回当前状态和已产生的事件数。 - - `GET /jobs/{job_id}/events`:SSE。先补发 `Last-Event-ID` 之后的事件,再等待新事件,任务完成后发 `event: done` 并结束;每 15 秒没有新事件就发一行 `: ping` 心跳。 - - **关键实验**:浏览器关掉后,后台任务要继续跑完(因为任务和 SSE 连接是分开的)。 -- `fastapi_demo/static/sse.html`:一个按钮启动任务,用 `EventSource` 显示进度。 -- `tests/test_fastapi_demo.py`:用 `fastapi.testclient.TestClient` 测 `/health` 和创建任务。 - -**运行方式**:`uv run uvicorn fastapi_demo.main:app --reload`,浏览器打开测试页;或者 `curl -N localhost:8000/jobs//events`。 - -**完成标准** -- 启动任务后关掉页面,再打开、重新订阅,能看到任务已经继续往前跑,而且能补齐中间漏掉的事件。 -- 能说清楚“编排为什么不能只活在浏览器里”——这就是重点 C,也是第 13 周要做的核心功能的缩小版。 -- `uv run ruff check . && uv run pytest` 通过,提交并推送到 `zhuangang` 仓库(路径 `py-learning/`)。 -- 在总纲 `README.md` 的进度记录里勾掉第 6 周。 - ---- - -## 本周自查清单 - -- [ ] 会用 uv 建项目、加依赖、跑脚本 -- [ ] 熟悉列表推导式、解包、f-string,知道可变默认参数的坑 -- [ ] 会写类型标注、dataclass、Protocol,会用 pyright 检查 -- [ ] 会用自定义异常和 `raise ... from` -- [ ] 理解 asyncio:协程、`create_task`、`gather`、超时、取消、`Queue`、异步生成器 -- [ ] 会用 pydantic 校验外部数据(包括模型输出) -- [ ] 会用 httpx 发异步请求、按错误类型决定是否重试,会用 `MockTransport` 测试 -- [ ] 会写 FastAPI 接口和 SSE,理解后台任务和 SSE 连接是两回事 - -下周预告:第 7~8 周做 RAG 知识库问答项目(FastAPI + Chroma),见 `rag-project.md`。本周写的 httpx 重试、pydantic 解析、SSE 都会直接用上。 diff --git a/plan/week07-frontend.md b/plan/week07-frontend.md new file mode 100644 index 0000000..891849b --- /dev/null +++ b/plan/week07-frontend.md @@ -0,0 +1,1653 @@ +# 第 7 周:前端工程化查漏补缺 + 两个项目的界面 + +> 第二段的第 4 周。上周 `week06-rag-2.md`,下周 `week08-inventory-architecture.md`。 + +**为什么有这一周**:你有 7 年 Vue / React 经验,这是求职时的**差异化优势**(“能独立做全栈 AI 应用”)。但招聘写的“熟悉 Vue / React”背后是一串工程化要求:Vite、Webpack、TS、状态管理、路由、组件库、SSR、性能优化、代码规范、monorepo、单元测试。本周按清单**查漏**,同时把项目一(任务看板,Go 后端)和项目二(RAG 问答,Python 后端)的界面做出来。 + +**学法**:会的快速过(10 分钟确认),不熟的认真做。每天的“查漏清单”能脱稿讲清楚就打勾。 + +**技术栈和版本说明**(2026-10 实际安装验证):pnpm 10、Vite 8、Vue 3.5、Pinia 3、Vue Router 4、Element Plus 2、React 19、React Router 7、Vitest 5、ESLint 10(flat config)、Prettier 3、TypeScript **6.0**、Node.js 24 LTS。注意:jsdom 新版本需要较新的 Node(实测 Node 20 跑组件测试会报错),请用 Node 24 LTS。TypeScript 已发布 7.0(用 Go 重写的原生版本),但 typescript-eslint 当前只支持到 6.0.x,所以本周固定用 `typescript@~6.0`;以后升级前先看各工具的兼容说明。 + +## 本周最终目录 + +```text +web/ # pnpm monorepo(仓库根目录下) +├── package.json +├── pnpm-workspace.yaml +├── tsconfig.base.json +├── eslint.config.js +├── .prettierrc.json +├── vitest.config.ts +├── packages/ +│ └── shared/ # 两个应用共用:请求封装、SSE 解析、类型 +│ ├── package.json +│ ├── tsconfig.json +│ ├── vitest.config.ts +│ └── src/ +│ ├── index.ts +│ ├── http.ts +│ ├── http.test.ts +│ ├── sse.ts +│ └── sse.test.ts +└── apps/ + ├── board-vue/ # 项目一界面:Vue 3 + Pinia + Router + Element Plus + │ ├── package.json + │ ├── tsconfig.json + │ ├── vite.config.ts + │ ├── index.html + │ └── src/ + │ ├── env.d.ts + │ ├── main.ts + │ ├── App.vue + │ ├── router.ts + │ ├── api.ts + │ ├── types.ts + │ ├── stores/auth.ts + │ ├── stores/board.ts + │ ├── stores/board.test.ts + │ ├── components/TaskCard.vue + │ ├── components/TaskCard.test.ts + │ ├── views/LoginView.vue + │ └── views/BoardView.vue + └── rag-react/ # 项目二界面:React 19 + React Router + ├── package.json + ├── tsconfig.json + ├── vite.config.ts + ├── index.html + └── src/ + ├── main.tsx + ├── App.tsx + ├── api.ts + ├── chatReducer.ts + ├── chatReducer.test.ts + ├── useAskStream.ts + ├── components/AnswerText.tsx + ├── components/AnswerText.test.tsx + ├── pages/ChatPage.tsx + └── pages/DocumentsPage.tsx +``` + +--- + +## 第 1 天:pnpm monorepo、TS 严格配置、ESLint + Prettier、共享请求封装 + +### 查漏清单 + +- [ ] pnpm 和 npm 的区别:内容寻址存储 + 硬链接省空间;严格的 `node_modules` 结构,**不能使用没声明的依赖**(幽灵依赖) +- [ ] monorepo:一个仓库多个包,`workspace:*` 互相引用;适合共享代码的多个应用 +- [ ] `tsconfig` 关键选项:`strict`、`noUncheckedIndexedAccess`、`moduleResolution: "bundler"`、`isolatedModules` +- [ ] ESLint 管“代码质量”,Prettier 管“格式”,两者分工(用 `eslint-config-prettier` 关掉 ESLint 里的格式规则,避免互相打架);ESLint 9 起默认 flat config(`eslint.config.js`) + +### 学习内容 + +**1. 建 monorepo** + +- 说明:`pnpm-workspace.yaml` 声明哪些目录是包;根目录放公共的开发依赖和配置;`-w` 表示装到根目录,`--filter` 只对某个包执行。 +- 案例: + +```bash +mkdir web && cd web && pnpm init +pnpm add -Dw typescript@~6.0 vitest jsdom eslint @eslint/js typescript-eslint eslint-plugin-vue \ + eslint-plugin-react-hooks globals prettier eslint-config-prettier +``` + +```yaml +# 文件:web/pnpm-workspace.yaml +packages: + - "packages/*" + - "apps/*" +``` + +根 `package.json` 里的脚本(依赖版本以 `pnpm add` 实际装上的为准): + +```json +{ + "name": "web", + "private": true, + "type": "module", + "scripts": { + "lint": "eslint .", + "format": "prettier --write .", + "typecheck": "pnpm -r typecheck", + "test": "vitest run", + "build": "pnpm -r build" + } +} +``` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + } +} +``` + +(上面这段保存为 `web/tsconfig.base.json`,各个包的 `tsconfig.json` 继承它。) + +```js +// 文件:web/eslint.config.js +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import prettier from "eslint-config-prettier/flat"; +import vue from "eslint-plugin-vue"; +import { defineConfig } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + { ignores: ["**/dist/**", "**/node_modules/**"] }, + js.configs.recommended, + tseslint.configs.recommended, + vue.configs["flat/recommended"], + { files: ["**/*.vue"], languageOptions: { parserOptions: { parser: tseslint.parser } } }, + { files: ["apps/rag-react/**/*.{ts,tsx}"], extends: [reactHooks.configs.flat.recommended] }, + { languageOptions: { globals: globals.browser } }, + prettier, // 放最后:关掉所有和 Prettier 冲突的格式类规则,格式只交给 Prettier +]); +``` + +```json +{ "semi": true, "singleQuote": false, "printWidth": 110 } +``` + +(上面这段保存为 `web/.prettierrc.json`。) + +```ts +// 文件:web/vitest.config.ts +import { defineConfig } from "vitest/config"; + +// 一条命令跑所有包的测试,每个包用自己的 vitest / vite 配置 +export default defineConfig({ test: { projects: ["packages/*", "apps/*"] } }); +``` + +**2. 共享请求封装** + +- 说明:两个后端的响应格式不同:Go 项目统一返回 `{code, message, data}`(第 1 周约定),Python 项目直接返回数据、错误时返回 `{"error": {"code", "message"}}`。封装一个 `createHttp`,统一处理:带令牌、解包、错误转成 `ApiError`、401 时回调(跳登录)、超时取消。 +- 案例: + +```json +{ + "name": "@web/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "scripts": { "typecheck": "tsc --noEmit" } +} +``` + +(上面这段保存为 `web/packages/shared/package.json`;直接导出 TS 源码,由使用方的 Vite 编译,省掉单独构建这一步。) + +```json +{ "extends": "../../tsconfig.base.json", "include": ["src"] } +``` + +(上面这段保存为 `web/packages/shared/tsconfig.json`。) + +```ts +// 文件:web/packages/shared/vitest.config.ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ test: { environment: "node" } }); +``` + +```ts +// 文件:web/packages/shared/src/index.ts +export * from "./http"; +export * from "./sse"; +``` + +```ts +// 文件:web/packages/shared/src/http.ts +export class ApiError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + } +} + +export interface HttpOptions { + baseURL: string; + /** envelope:Go 后端的 {code, message, data};plain:直接返回数据 */ + format: "envelope" | "plain"; + getToken?: () => string | null; + onUnauthorized?: () => void; + timeoutMs?: number; + fetchImpl?: typeof fetch; +} + +type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + +interface Envelope { + code: number; + message: string; + data: T; +} + +function errorFrom(status: number, body: unknown): ApiError { + if (body && typeof body === "object") { + const b = body as { message?: unknown; code?: unknown; error?: { code?: string; message?: string } }; + if (b.error) return new ApiError(status, b.error.code ?? "ERROR", b.error.message ?? "请求失败"); + if (typeof b.message === "string") return new ApiError(status, String(b.code ?? status), b.message); + } + return new ApiError(status, String(status), `请求失败(${status})`); +} + +export function createHttp(opts: HttpOptions) { + const doFetch = opts.fetchImpl ?? fetch; + + async function request(method: Method, path: string, body?: unknown, signal?: AbortSignal): Promise { + const headers: Record = {}; + const token = opts.getToken?.(); + if (token) headers.Authorization = `Bearer ${token}`; + const isForm = body instanceof FormData; + if (body !== undefined && !isForm) headers["Content-Type"] = "application/json"; + + const timeout = AbortSignal.timeout(opts.timeoutMs ?? 15000); + const res = await doFetch(opts.baseURL + path, { + method, + headers, + body: body === undefined ? undefined : isForm ? body : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + }); + + if (res.status === 401) opts.onUnauthorized?.(); + if (res.status === 204) return undefined as T; + const data: unknown = await res.json().catch(() => null); + if (!res.ok) throw errorFrom(res.status, data); + if (opts.format === "envelope") { + const env = data as Envelope; + if (env.code !== 0) throw new ApiError(res.status, String(env.code), env.message); + return env.data; + } + return data as T; + } + + return { + request, + get: (path: string, signal?: AbortSignal) => request("GET", path, undefined, signal), + post: (path: string, body?: unknown) => request("POST", path, body), + patch: (path: string, body?: unknown) => request("PATCH", path, body), + del: (path: string) => request("DELETE", path), + }; +} + +export type Http = ReturnType; +``` + +```ts +// 文件:web/packages/shared/src/http.test.ts +import { describe, expect, it, vi } from "vitest"; +import { ApiError, createHttp } from "./http"; + +function fakeFetch(status: number, body: unknown) { + return vi.fn(async () => new Response(body === null ? null : JSON.stringify(body), { status })); +} + +describe("createHttp", () => { + it("带令牌并解包 envelope", async () => { + const f = fakeFetch(200, { code: 0, message: "ok", data: { id: 1 } }); + const http = createHttp({ baseURL: "/api", format: "envelope", getToken: () => "t1", fetchImpl: f }); + await expect(http.get<{ id: number }>("/tasks/1")).resolves.toEqual({ id: 1 }); + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/api/tasks/1"); + expect((init.headers as Record).Authorization).toBe("Bearer t1"); + }); + + it("把 Python 后端的错误格式转成 ApiError", async () => { + const http = createHttp({ baseURL: "", format: "plain", fetchImpl: fakeFetch(400, { error: { code: "HTTP", message: "不支持" } }) }); + const err = await http.post("/documents").catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).message).toBe("不支持"); + }); + + it("401 时回调 onUnauthorized", async () => { + const onUnauthorized = vi.fn(); + const http = createHttp({ baseURL: "", format: "envelope", onUnauthorized, fetchImpl: fakeFetch(401, { code: 401, message: "未登录", data: null }) }); + await expect(http.get("/me")).rejects.toThrow("未登录"); + expect(onUnauthorized).toHaveBeenCalledOnce(); + }); + + it("204 返回 undefined", async () => { + const http = createHttp({ baseURL: "", format: "envelope", fetchImpl: fakeFetch(204, null) }); + await expect(http.del("/tasks/1")).resolves.toBeUndefined(); + }); +}); +``` + +**易错点 / 面试常问(补充)** + +- 易错点:monorepo 内部包的依赖写 `"@web/shared": "workspace:*"`,否则 pnpm 会去公共仓库找同名包。 +- 易错点:ESLint 9 以后默认用 flat config(`eslint.config.js`),旧的 `.eslintrc` 写法不再默认生效;格式问题交给 Prettier,用 `eslint-config-prettier` 关掉冲突的规则。 +- 易错点:TypeScript 开 `strict` 后,`any` 和可能为 `null` 的值都会报错,不要用 `as any` 绕过,改成正确的类型或类型守卫。 +- 面试常问:pnpm 和 npm / yarn 的区别(内容寻址存储、硬链接、严格的依赖隔离,避免“幽灵依赖”);monorepo 的好处和代价。 +- 文档:pnpm workspace(https://pnpm.io/workspaces )、ESLint flat config(https://eslint.org/docs/latest/use/configure/ ),版本变化以官方文档为准。 + +### 要建的文件 + +- 根目录:`package.json`、`pnpm-workspace.yaml`、`tsconfig.base.json`、`eslint.config.js`、`.prettierrc.json`、`vitest.config.ts` +- `packages/shared/`:`package.json`、`tsconfig.json`、`vitest.config.ts`、`src/index.ts`、`src/http.ts`、`src/http.test.ts` + +### 运行方式 + +```bash +pnpm install +pnpm test && pnpm lint && pnpm --filter @web/shared typecheck +``` + +### 完成标准 + +- 测试、lint、类型检查全部通过;能讲清幽灵依赖、monorepo 的好处和代价(工具链复杂、依赖版本要统一)。 + +--- + +## 第 2 天:SSE 流式解析(fetch + ReadableStream) + +### 查漏清单 + +- [ ] `EventSource` 的限制:只能 GET、不能自定义请求头(带不了 `Authorization`) +- [ ] `fetch` 读流:`res.body.getReader()` + `TextDecoder`;**网络分包可能把一行切成两半**,要自己缓冲 +- [ ] `AbortController` 取消请求(用户点“停止生成”、组件卸载) +- [ ] SSE 格式:`event:`、`data:`(可多行)、`id:`、以 `:` 开头是注释,空行表示一条消息结束 + +### 学习内容 + +- 说明:RAG 的流式问答是 `POST /ask/stream`,需要请求体,所以不能用 `EventSource`,要自己解析。写成异步生成器,调用方用 `for await` 一条条拿事件,和第 4 周 Python 的 `async for` 是同一个思路。 +- 案例: + +```ts +// 文件:web/packages/shared/src/sse.ts +import { ApiError } from "./http"; + +export interface SSEEvent { + event: string; + data: string; + id?: string; +} + +/** 把字节流解析成 SSE 事件。处理分包、\r\n、多行 data、注释行。 */ +export async function* parseSSE(stream: ReadableStream): AsyncGenerator { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let current: { event: string; data: string[]; id?: string } = { event: "message", data: [] }; + + function* flushLine(line: string): Generator { + if (line === "") { + if (current.data.length > 0) { + yield { event: current.event, data: current.data.join("\n"), id: current.id }; + } + current = { event: "message", data: [] }; + return; + } + if (line.startsWith(":")) return; // 注释(常用作心跳) + const idx = line.indexOf(":"); + const field = idx === -1 ? line : line.slice(0, idx); + const value = idx === -1 ? "" : line.slice(idx + 1).replace(/^ /, ""); + if (field === "event") current.event = value; + else if (field === "data") current.data.push(value); + else if (field === "id") current.id = value; + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); // stream: true 处理被切开的多字节汉字 + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ""; // 最后一段可能不完整,留到下次 + for (const line of lines) yield* flushLine(line); + } + if (buffer) yield* flushLine(buffer); + yield* flushLine(""); + } finally { + reader.releaseLock(); + } +} + +/** POST 一个请求并以 SSE 事件的形式读取响应。 */ +export async function* postSSE( + url: string, + body: unknown, + init: { signal?: AbortSignal; headers?: Record; fetchImpl?: typeof fetch } = {}, +): AsyncGenerator { + const res = await (init.fetchImpl ?? fetch)(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...init.headers }, + body: JSON.stringify(body), + signal: init.signal, + }); + if (!res.ok || !res.body) throw new ApiError(res.status, String(res.status), `流式请求失败(${res.status})`); + yield* parseSSE(res.body); +} +``` + +```ts +// 文件:web/packages/shared/src/sse.test.ts +import { describe, expect, it } from "vitest"; +import { parseSSE, postSSE } from "./sse"; + +function streamOf(...chunks: string[]): ReadableStream { + const enc = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(enc.encode(c)); + controller.close(); + }, + }); +} + +async function collect(gen: AsyncGenerator): Promise { + const out: T[] = []; + for await (const x of gen) out.push(x); + return out; +} + +describe("parseSSE", () => { + it("处理被切开的行和多行 data", async () => { + const events = await collect( + parseSSE(streamOf("event: cita", "tions\ndata: [1]\n\n: 心跳\n\nid: 7\nda", "ta: 第一行\ndata: 第二行\n\n")), + ); + expect(events).toEqual([ + { event: "citations", data: "[1]", id: undefined }, + { event: "message", data: "第一行\n第二行", id: "7" }, + ]); + }); + + it("处理 \\r\\n 和没有结尾空行的流", async () => { + const events = await collect(parseSSE(streamOf("event: delta\r\ndata: 你好\r\n\r\nevent: done\r\ndata: {}"))); + expect(events.map((e) => e.event)).toEqual(["delta", "done"]); + }); + + it("汉字的字节被切开也能正确解码", async () => { + const bytes = new TextEncoder().encode("data: 汉字\n\n"); + const stream = new ReadableStream({ + start(c) { + c.enqueue(bytes.slice(0, 8)); // 切在“汉”字中间 + c.enqueue(bytes.slice(8)); + c.close(); + }, + }); + expect((await collect(parseSSE(stream)))[0]?.data).toBe("汉字"); + }); + + it("postSSE 发送 JSON 并解析响应", async () => { + const fetchImpl = async (_url: RequestInfo | URL, init?: RequestInit) => { + expect(JSON.parse(String(init?.body))).toEqual({ question: "q" }); + return new Response(streamOf("event: done\ndata: {}\n\n"), { status: 200 }); + }; + const events = await collect(postSSE("/ask/stream", { question: "q" }, { fetchImpl })); + expect(events[0]?.event).toBe("done"); + }); +}); +``` + +### 要建的文件 + +- `packages/shared/src/sse.ts`、`packages/shared/src/sse.test.ts`;在 `index.ts` 里导出 + +### 运行方式 + +```bash +pnpm test +``` + +### 完成标准 + +- 4 个用例通过;能讲清“为什么要缓冲半行”“`decode` 为什么要传 `stream: true`”。 +- 面试常问:SSE 和 WebSocket 怎么选?(SSE 单向、基于 HTTP、自动重连、适合模型流式输出;WebSocket 双向、适合聊天室、协同编辑) + +--- + +## 第 3 天:Vue 3 应用——项目一的任务看板 + +### 查漏清单 + +- [ ] Vue 3:` + + +``` + +```ts +// 文件:web/apps/board-vue/src/env.d.ts +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent; + export default component; +} +``` + +```ts +// 文件:web/apps/board-vue/src/types.ts +export type TaskStatus = "todo" | "doing" | "done"; + +export interface BoardTask { + id: number; + boardId: number; + title: string; + status: TaskStatus; + position: number; +} + +export interface User { + id: number; + email: string; +} + +export const STATUSES: { key: TaskStatus; label: string }[] = [ + { key: "todo", label: "待办" }, + { key: "doing", label: "进行中" }, + { key: "done", label: "已完成" }, +]; +``` + +(字段名要和你第 3 周 Go 结构体的 `json` 标签一致。) + +```ts +// 文件:web/apps/board-vue/src/api.ts +import { createHttp } from "@web/shared"; +import type { BoardTask, TaskStatus, User } from "./types"; + +export const TOKEN_KEY = "board_token"; + +export const http = createHttp({ + baseURL: "/api", + format: "envelope", + getToken: () => localStorage.getItem(TOKEN_KEY), + onUnauthorized: () => { + localStorage.removeItem(TOKEN_KEY); + if (location.pathname !== "/login") location.assign(`/login?redirect=${encodeURIComponent(location.pathname)}`); + }, +}); + +export const api = { + login: (email: string, password: string) => http.post<{ token: string; user: User }>("/auth/login", { email, password }), + listTasks: (boardId: number) => http.get(`/boards/${boardId}/tasks`), + createTask: (boardId: number, title: string) => http.post(`/boards/${boardId}/tasks`, { title }), + moveTask: (boardId: number, taskId: number, status: TaskStatus, position: number) => + http.post(`/boards/${boardId}/tasks/${taskId}/move`, { status, position }), +}; +``` + +```ts +// 文件:web/apps/board-vue/src/stores/auth.ts +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { api, TOKEN_KEY } from "../api"; +import type { User } from "../types"; + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem(TOKEN_KEY)); + const user = ref(null); + const loggedIn = computed(() => !!token.value); + + async function login(email: string, password: string) { + const res = await api.login(email, password); + token.value = res.token; + user.value = res.user; + localStorage.setItem(TOKEN_KEY, res.token); // 简化做法;更安全的是 HttpOnly Cookie,面试时要能讲取舍 + } + + function logout() { + token.value = null; + user.value = null; + localStorage.removeItem(TOKEN_KEY); + } + + return { token, user, loggedIn, login, logout }; +}); +``` + +```ts +// 文件:web/apps/board-vue/src/stores/board.ts +import { defineStore } from "pinia"; +import { computed, ref } from "vue"; +import { api } from "../api"; +import type { BoardTask, TaskStatus } from "../types"; + +export const useBoardStore = defineStore("board", () => { + const boardId = ref(0); + const tasks = ref([]); + const loading = ref(false); + const error = ref(""); + + const columns = computed(() => { + const cols: Record = { todo: [], doing: [], done: [] }; + for (const t of tasks.value) cols[t.status].push(t); + for (const list of Object.values(cols)) list.sort((a, b) => a.position - b.position); + return cols; + }); + + async function load(id: number) { + boardId.value = id; + loading.value = true; + error.value = ""; + try { + tasks.value = await api.listTasks(id); + } catch (e) { + error.value = (e as Error).message; + } finally { + loading.value = false; + } + } + + async function add(title: string) { + const t = await api.createTask(boardId.value, title); + tasks.value.push(t); + } + + /** 乐观更新:先改界面,失败再回滚 */ + async function move(taskId: number, status: TaskStatus) { + const task = tasks.value.find((t) => t.id === taskId); + if (!task || task.status === status) return; + const before = { status: task.status, position: task.position }; + const position = columns.value[status].length; + Object.assign(task, { status, position }); + try { + await api.moveTask(boardId.value, taskId, status, position); + } catch (e) { + Object.assign(task, before); + error.value = `移动失败:${(e as Error).message}`; + } + } + + return { boardId, tasks, loading, error, columns, load, add, move }; +}); +``` + +```ts +// 文件:web/apps/board-vue/src/stores/board.test.ts +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api"; +import { useBoardStore } from "./board"; + +vi.mock("../api", () => ({ + TOKEN_KEY: "board_token", + api: { listTasks: vi.fn(), createTask: vi.fn(), moveTask: vi.fn() }, +})); + +const tasks = [ + { id: 1, boardId: 9, title: "写接口", status: "todo" as const, position: 1 }, + { id: 2, boardId: 9, title: "写测试", status: "todo" as const, position: 0 }, +]; + +describe("board store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.mocked(api.listTasks).mockResolvedValue(structuredClone(tasks)); + }); + + it("按状态分列并按位置排序", async () => { + const s = useBoardStore(); + await s.load(9); + expect(s.columns.todo.map((t) => t.id)).toEqual([2, 1]); + }); + + it("移动失败时回滚", async () => { + vi.mocked(api.moveTask).mockRejectedValue(new Error("无权限")); + const s = useBoardStore(); + await s.load(9); + await s.move(1, "done"); + expect(s.columns.done).toHaveLength(0); + expect(s.tasks.find((t) => t.id === 1)?.status).toBe("todo"); + expect(s.error).toContain("无权限"); + }); +}); +``` + +```ts +// 文件:web/apps/board-vue/src/router.ts +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "./stores/auth"; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: "/login", component: () => import("./views/LoginView.vue") }, // 懒加载:单独打包,用到才下载 + { path: "/boards/:id", component: () => import("./views/BoardView.vue"), meta: { requiresAuth: true } }, + { path: "/", redirect: "/boards/1" }, + ], +}); + +router.beforeEach((to) => { + const auth = useAuthStore(); + if (to.meta.requiresAuth && !auth.loggedIn) { + return { path: "/login", query: { redirect: to.fullPath } }; + } +}); +``` + +```ts +// 文件:web/apps/board-vue/src/main.ts +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import { createPinia } from "pinia"; +import { createApp } from "vue"; +import App from "./App.vue"; +import { router } from "./router"; + +// 全量引入最简单;第 5 天做性能优化时改成按需引入,对比打包体积 +createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app"); +``` + +```vue + + +``` + +```vue + + + + + + +``` + +```ts +// 文件:web/apps/board-vue/src/components/TaskCard.test.ts +import { mount } from "@vue/test-utils"; +import { expect, it } from "vitest"; +import TaskCard from "./TaskCard.vue"; + +it("点击按钮发出 move 事件", async () => { + const w = mount(TaskCard, { props: { task: { id: 3, boardId: 1, title: "部署", status: "todo", position: 0 } } }); + expect(w.text()).toContain("部署"); + const buttons = w.findAll("button"); + expect(buttons.map((b) => b.text())).toEqual(["移到进行中", "移到已完成"]); + await buttons[1]!.trigger("click"); + expect(w.emitted("move")).toEqual([[3, "done"]]); +}); +``` + +```vue + + + + +``` + +```vue + + + + +``` + +### 要建的文件 + +- `apps/board-vue/` 下上面列出的全部文件 + +### 运行方式 + +```bash +# 先按第 3 周启动 Go 后端(:8080) +pnpm --filter board-vue dev # 打开 http://localhost:5173 +pnpm test && pnpm --filter board-vue typecheck +``` + +### 完成标准 + +- 能登录、看任务、添加任务、移动任务;断开后端时移动任务能看到回滚和错误提示。 +- 选做:用拖拽库(如 `vuedraggable` 或原生 HTML5 拖拽)实现拖动排序。 +- 面试常问:`ref` 和 `reactive` 的区别?`computed` 和 `watch` 的区别?Pinia 和 Vuex 的区别?路由守卫怎么做登录拦截?令牌存 localStorage 和 HttpOnly Cookie 的取舍(XSS 能读 localStorage;Cookie 要防 CSRF)? + +--- + +## 第 4 天:React 应用——项目二的知识库问答界面 + +### 查漏清单 + +- [ ] React 19:函数组件、`useState` / `useReducer` / `useEffect` / `useRef` / `useMemo` / `useCallback` +- [ ] `useEffect` 清理函数:组件卸载时取消请求,避免内存泄漏和“卸载后还在 setState” +- [ ] 状态管理选择:局部用 `useState` / `useReducer`,跨组件用 Context 或 Zustand / Redux Toolkit;服务端数据用 TanStack Query +- [ ] React Router 7 的基础路由 +- [ ] `key` 的作用、受控组件 + +### 学习内容 + +- 说明:聊天状态比较复杂(流式追加文字、引用、出错、停止),用 `useReducer` 把“状态怎么变”写成**纯函数**,可以脱离界面单独测试;`useAskStream` 负责发请求和取消。 +- 案例: + +```bash +mkdir -p apps/rag-react && cd apps/rag-react && pnpm init +pnpm add react react-dom react-router @web/shared@workspace:* +pnpm add -D vite @vitejs/plugin-react @types/react @types/react-dom @testing-library/react +``` + +`apps/rag-react/package.json` 里的脚本: + +```json +{ + "name": "rag-react", + "private": true, + "type": "module", + "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit" } +} +``` + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "jsx": "react-jsx", "types": ["vite/client"] }, + "include": ["src"] +} +``` + +(上面这段保存为 `apps/rag-react/tsconfig.json`。) + +```ts +// 文件:web/apps/rag-react/vite.config.ts +/// +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + server: { + // 开发时 /rag/xxx 转发到 Python 后端的 /xxx + proxy: { "/rag": { target: "http://localhost:8000", rewrite: (p) => p.replace(/^\/rag/, "") } }, + }, + test: { environment: "jsdom" }, +}); +``` + +```html + + + + + + + 知识库问答 + + +
+ + + +``` + +```ts +// 文件:web/apps/rag-react/src/api.ts +import { createHttp } from "@web/shared"; + +export const RAG_BASE = "/rag"; + +export interface Citation { + n: number; + filename: string; + heading_path: string; + page: number | null; + text: string; + score: number; +} + +export interface DocInfo { + doc_id: string; + filename: string; + chunks: number; +} + +export const http = createHttp({ baseURL: RAG_BASE, format: "plain" }); + +export const ragApi = { + listDocs: () => http.get("/documents"), + upload: (file: File) => { + const fd = new FormData(); + fd.append("file", file); + return http.post<{ status: string; chunks: number }>("/documents", fd); + }, + remove: (docId: string) => http.del(`/documents/${docId}`), +}; +``` + +```ts +// 文件:web/apps/rag-react/src/chatReducer.ts +import type { Citation } from "./api"; + +export interface ChatState { + question: string; + answer: string; + citations: Citation[]; + status: "idle" | "streaming" | "done" | "error" | "stopped"; + error?: string; +} + +export type ChatAction = + | { type: "start"; question: string } + | { type: "citations"; citations: Citation[] } + | { type: "delta"; text: string } + | { type: "done" } + | { type: "error"; message: string } + | { type: "stop" }; + +export const initialChat: ChatState = { question: "", answer: "", citations: [], status: "idle" }; + +export function chatReducer(state: ChatState, action: ChatAction): ChatState { + switch (action.type) { + case "start": + return { ...initialChat, question: action.question, status: "streaming" }; + case "citations": + return { ...state, citations: action.citations }; + case "delta": + return state.status === "streaming" ? { ...state, answer: state.answer + action.text } : state; + case "done": + return state.status === "streaming" ? { ...state, status: "done" } : state; + case "error": + return { ...state, status: "error", error: action.message }; + case "stop": + return state.status === "streaming" ? { ...state, status: "stopped" } : state; + } +} +``` + +```ts +// 文件:web/apps/rag-react/src/chatReducer.test.ts +import { describe, expect, it } from "vitest"; +import { chatReducer, initialChat, type ChatAction } from "./chatReducer"; + +const run = (...actions: ChatAction[]) => actions.reduce(chatReducer, initialChat); + +describe("chatReducer", () => { + it("流式拼接回答", () => { + const s = run({ type: "start", question: "q" }, { type: "delta", text: "你" }, { type: "delta", text: "好" }, { type: "done" }); + expect(s).toMatchObject({ answer: "你好", status: "done" }); + }); + + it("停止后忽略迟到的片段", () => { + const s = run({ type: "start", question: "q" }, { type: "delta", text: "一" }, { type: "stop" }, { type: "delta", text: "二" }); + expect(s).toMatchObject({ answer: "一", status: "stopped" }); + }); + + it("新问题会清空上一次的结果", () => { + const s = run({ type: "start", question: "a" }, { type: "delta", text: "旧" }, { type: "start", question: "b" }); + expect(s).toMatchObject({ question: "b", answer: "" }); + }); +}); +``` + +```ts +// 文件:web/apps/rag-react/src/useAskStream.ts +import { postSSE } from "@web/shared"; +import { useCallback, useEffect, useReducer, useRef } from "react"; +import { RAG_BASE, type Citation } from "./api"; +import { chatReducer, initialChat } from "./chatReducer"; + +export function useAskStream() { + const [state, dispatch] = useReducer(chatReducer, initialChat); + const controllerRef = useRef(null); + + const stop = useCallback(() => { + controllerRef.current?.abort(); + dispatch({ type: "stop" }); + }, []); + + const ask = useCallback(async (question: string) => { + controllerRef.current?.abort(); // 新问题会取消上一个还没结束的请求 + const controller = new AbortController(); + controllerRef.current = controller; + dispatch({ type: "start", question }); + try { + for await (const e of postSSE(`${RAG_BASE}/ask/stream`, { question }, { signal: controller.signal })) { + const data: unknown = JSON.parse(e.data); + if (e.event === "citations") dispatch({ type: "citations", citations: data as Citation[] }); + else if (e.event === "delta") dispatch({ type: "delta", text: (data as { text: string }).text }); + else if (e.event === "done") dispatch({ type: "done" }); + else if (e.event === "error") dispatch({ type: "error", message: (data as { message: string }).message }); + } + } catch (err) { + if (!controller.signal.aborted) dispatch({ type: "error", message: (err as Error).message }); + } + }, []); + + useEffect(() => () => controllerRef.current?.abort(), []); // 组件卸载时取消请求 + + return { state, ask, stop }; +} +``` + +```tsx +// 文件:web/apps/rag-react/src/components/AnswerText.tsx +import { Fragment } from "react"; + +/** 把回答里的 [1]、[2] 渲染成可点击的引用按钮 */ +export function AnswerText({ text, onCite }: { text: string; onCite: (n: number) => void }) { + const parts = text.split(/(\[\d+\])/g); + return ( +

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

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

{state.question}

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

出错了:{state.error}

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

{msg}

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

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