phase 5 part 1: typescript, vue, react, ssr(Next.js 16) topics with lessons, scenarios, interviews, verified examples
This commit is contained in:
1 parent
7a32d2c9a6
commit
7b5cae7d24
39 files changed
+2482
-48
No files matched your search
@@ -0,0 +1,100 @@
|
||||
---
|
||||
id: frontend.react.hooks-rendering
|
||||
title: React 渲染机制、Hooks 规则、状态管理和 React 19 新特性
|
||||
type: lesson
|
||||
domain: frontend
|
||||
stack: [react]
|
||||
tags: [React, Hooks, useEffect, useReducer, 渲染, memo, 并发特性, React 19, React Compiler]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.typescript.type-system]
|
||||
est_hours: 3
|
||||
related: [frontend.react.scenario.long-task-ui, frontend.ssr.nextjs-app-router, frontend.react.interview]
|
||||
week: [7]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# React 渲染机制、Hooks 规则、状态管理和 React 19 新特性
|
||||
|
||||
> 基于 React 19 编写,以官方文档为准:https://zh-hans.react.dev/ 。如果你主要用 Vue,这一课帮你建立 React 的心智模型,面试时能对比两者。
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 渲染机制
|
||||
|
||||
- **组件每次渲染都会重新执行函数体**。状态变化(`setState`)、父组件渲染、Context 变化都会触发渲染。
|
||||
- 渲染 → 生成新的元素树 → 和上一次比较(协调,reconciliation)→ 提交最小的 DOM 变更。列表的 `key` 决定元素的身份。
|
||||
- 状态更新是**批量的**(React 18 起在 Promise、定时器中也自动批量)。
|
||||
- 状态是**快照**:在一次渲染中读取的 state 是固定的,`setCount(count + 1)` 连续调用三次只加 1,要用函数式更新 `setCount(c => c + 1)`。
|
||||
|
||||
### Hooks 规则和常见坑
|
||||
|
||||
| Hook | 要点 |
|
||||
| --- | --- |
|
||||
| `useState` / `useReducer` | 复杂状态转换用 reducer,便于测试(见示例) |
|
||||
| `useEffect` | 用于**与外部系统同步**(订阅、请求、DOM 操作),返回清理函数;依赖数组要完整;开发模式下严格模式会执行“挂载 → 卸载 → 挂载”帮助发现缺少清理的问题 |
|
||||
| `useMemo` / `useCallback` / `memo` | 性能优化手段,不是语义保证;React Compiler 可以自动做记忆化(以官方文档为准) |
|
||||
| `useRef` | 保存不触发渲染的可变值、DOM 引用 |
|
||||
| 自定义 Hook | 复用有状态逻辑;必须以 `use` 开头,只在顶层调用 |
|
||||
|
||||
**规则**:只在组件或自定义 Hook 的顶层调用 Hook,不能放在条件、循环里——因为 React 按调用顺序识别每个 Hook 的状态。
|
||||
|
||||
**很多 `useEffect` 是不需要的**:从 props 计算出来的值直接在渲染中计算;用户事件引起的逻辑放在事件处理函数里。
|
||||
|
||||
### 状态管理选择
|
||||
|
||||
局部状态 `useState`;跨组件共享且变化不频繁用 Context;复杂的客户端状态用 Zustand / Redux Toolkit;**服务端数据**(请求、缓存、重试、失效)用 TanStack Query 等,而不是放进全局 store。
|
||||
|
||||
### React 19 和并发特性(了解)
|
||||
|
||||
- 并发特性:`useTransition`、`useDeferredValue` 把非紧急更新标记为可中断,保持输入流畅。
|
||||
- React 19:Actions(异步函数作为 `action`,配合 `useActionState`、`useOptimistic` 处理表单提交、待定状态和乐观更新)、`use` 读取 Promise / Context、`ref` 可以作为普通 prop 传递。
|
||||
- 服务端组件(Server Components)主要通过 Next.js 等框架使用,见 [Next.js 课程](../ssr/02-nextjs-app-router.md)。
|
||||
|
||||
## 案例
|
||||
|
||||
报表 Agent 的任务面板:任务有排队、运行、等待输入、成功、失败、取消等状态,服务端通过 SSE 推送事件,网络重连后可能收到重复或乱序的事件。用 `useReducer` 集中管理状态转换,事件带序号,旧事件和终态之后的事件直接丢弃;订阅在 `useEffect` 中建立并在任务切换 / 卸载时取消。
|
||||
|
||||
## 代码示例
|
||||
|
||||
reducer(完整版本 [examples/task-panel/taskReducer.ts](examples/task-panel/taskReducer.ts),`node taskReducer-demo.ts` 自检):
|
||||
|
||||
```ts
|
||||
export function taskReducer(s: TaskState, e: TaskEvent): TaskState {
|
||||
switch (e.type) {
|
||||
case "submitted": return { ...initialState, id: e.id, status: "queued" };
|
||||
case "cancel": return FINAL.has(s.status) ? s : { ...s, status: "cancelled" };
|
||||
case "retry": return s.status === "failed" ? { ...s, status: "queued", error: null } : s;
|
||||
default:
|
||||
if (FINAL.has(s.status) || e.seq <= s.version) return s; // 丢弃重复、乱序、迟到的事件
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
组件([TaskPanel.tsx](examples/task-panel/TaskPanel.tsx),严格模式 `tsc` 检查通过):
|
||||
|
||||
```tsx
|
||||
const [state, dispatch] = useReducer(taskReducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "submitted", id: taskId });
|
||||
const unsubscribe = subscribe(taskId, dispatch); // dispatch 引用稳定
|
||||
return unsubscribe; // taskId 变化或卸载时取消订阅
|
||||
}, [taskId, subscribe]);
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- `useEffect` 依赖数组漏写,读到陈旧的值;或者为了“只执行一次”故意写空数组。
|
||||
- 订阅、定时器、请求没有清理,切换页面后旧数据覆盖新数据(竞态)。
|
||||
- 在渲染中直接修改 state 对象(`state.list.push(x)`),引用没变,不触发渲染。
|
||||
- 用数组下标作为可重排列表的 `key`。
|
||||
- 父组件每次渲染都创建新的对象 / 函数作为 props,导致 `memo` 子组件失效。
|
||||
- 把服务端数据放进 Redux,自己处理缓存、失效、重试,代码复杂且容易出错。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- React 组件什么时候会重新渲染?怎么避免不必要的渲染?
|
||||
- 为什么 Hooks 不能写在条件语句里?
|
||||
- `useEffect` 的执行时机和清理时机?
|
||||
- React 和 Vue 的响应式 / 更新机制有什么区别?
|
||||
@@ -0,0 +1,35 @@
|
||||
// 不自动运行(依赖 react,需要在 React 项目中使用)
|
||||
// 用 useReducer 管理任务状态,用 useEffect 订阅服务端事件,并在卸载 / 任务变化时清理。
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { initialState, taskReducer, type TaskEvent } from "./taskReducer.ts";
|
||||
|
||||
export function TaskPanel({ taskId, subscribe }: {
|
||||
taskId: string;
|
||||
subscribe: (id: string, onEvent: (e: TaskEvent) => void) => () => void; // 返回取消订阅函数
|
||||
}) {
|
||||
const [state, dispatch] = useReducer(taskReducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch({ type: "submitted", id: taskId });
|
||||
const unsubscribe = subscribe(taskId, dispatch); // dispatch 引用稳定,不需要放进依赖数组
|
||||
return unsubscribe; // 清理:taskId 变化或组件卸载时取消订阅,避免旧任务的事件串进来
|
||||
}, [taskId, subscribe]);
|
||||
|
||||
return (
|
||||
<section aria-live="polite">
|
||||
<p>状态:{state.status}</p>
|
||||
<ol>
|
||||
{state.steps.map((s) => (
|
||||
<li key={s.name}>{s.done ? "✓" : "…"} {s.name}</li>
|
||||
))}
|
||||
</ol>
|
||||
{state.status === "failed" && (
|
||||
<p role="alert">
|
||||
{state.error} <button onClick={() => dispatch({ type: "retry" })}>重试</button>
|
||||
</p>
|
||||
)}
|
||||
{state.result && <a href={state.result.url}>{state.result.title}</a>}
|
||||
{state.status === "running" && <button onClick={() => dispatch({ type: "cancel" })}>取消</button>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module"}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 运行:node taskReducer-demo.ts —— 验证乱序 / 重复 / 迟到事件被丢弃,失败后可重试
|
||||
import { initialState, taskReducer, type TaskEvent } from "./taskReducer.ts";
|
||||
|
||||
const events: TaskEvent[] = [
|
||||
{ type: "submitted", id: "T1" },
|
||||
{ type: "progress", seq: 1, step: "查询数据", done: false },
|
||||
{ type: "progress", seq: 2, step: "查询数据", done: true },
|
||||
{ type: "progress", seq: 1, step: "查询数据", done: false }, // 重连后收到的旧事件:丢弃
|
||||
{ type: "progress", seq: 3, step: "生成图表", done: true },
|
||||
{ type: "succeeded", seq: 4, result: { title: "9 月报表", url: "/reports/9" } },
|
||||
{ type: "failed", seq: 5, error: "迟到的失败事件", retryable: false }, // 终态之后:丢弃
|
||||
];
|
||||
const final = events.reduce(taskReducer, initialState);
|
||||
console.log(final.status, final.steps, final.result);
|
||||
if (final.status !== "succeeded" || final.steps.length !== 2 || !final.steps.every((x) => x.done)) {
|
||||
throw new Error("断言失败");
|
||||
}
|
||||
const failed = [{ type: "submitted", id: "T2" }, { type: "failed", seq: 1, error: "超时", retryable: true }, { type: "retry" }]
|
||||
.reduce((st, ev) => taskReducer(st, ev as TaskEvent), initialState);
|
||||
if (failed.status !== "queued" || failed.error !== null) throw new Error("断言失败");
|
||||
console.log("重试后:", failed.status);
|
||||
@@ -0,0 +1,54 @@
|
||||
// 长任务(例如 Agent 生成报表)的前端状态机:用 reducer 管理,所有状态转换集中在一处,便于测试。
|
||||
// 自检:node taskReducer-demo.ts
|
||||
|
||||
export type TaskStatus = "queued" | "running" | "waiting_input" | "succeeded" | "failed" | "cancelled";
|
||||
|
||||
export interface TaskState {
|
||||
id: string | null;
|
||||
status: TaskStatus | "idle";
|
||||
steps: { name: string; done: boolean }[];
|
||||
result: { title: string; url: string } | null;
|
||||
error: string | null;
|
||||
version: number; // 服务端事件的序号:丢弃过期 / 乱序的事件
|
||||
}
|
||||
|
||||
export type TaskEvent =
|
||||
| { type: "submitted"; id: string }
|
||||
| { type: "progress"; seq: number; step: string; done: boolean }
|
||||
| { type: "need_input"; seq: number; question: string }
|
||||
| { type: "succeeded"; seq: number; result: { title: string; url: string } }
|
||||
| { type: "failed"; seq: number; error: string; retryable: boolean }
|
||||
| { type: "cancel" }
|
||||
| { type: "retry" };
|
||||
|
||||
export const initialState: TaskState = { id: null, status: "idle", steps: [], result: null, error: null, version: 0 };
|
||||
|
||||
const FINAL: ReadonlySet<TaskState["status"]> = new Set(["succeeded", "failed", "cancelled"]);
|
||||
|
||||
export function taskReducer(s: TaskState, e: TaskEvent): TaskState {
|
||||
switch (e.type) {
|
||||
case "submitted":
|
||||
return { ...initialState, id: e.id, status: "queued" };
|
||||
case "cancel":
|
||||
return FINAL.has(s.status) ? s : { ...s, status: "cancelled" };
|
||||
case "retry":
|
||||
return s.status === "failed" ? { ...s, status: "queued", error: null } : s;
|
||||
default:
|
||||
// 服务端事件:终态之后、或者序号不大于当前版本的事件一律丢弃(重连后可能收到重复事件)
|
||||
if (FINAL.has(s.status) || e.seq <= s.version) return s;
|
||||
}
|
||||
switch (e.type) {
|
||||
case "progress": {
|
||||
const steps = s.steps.some((x) => x.name === e.step)
|
||||
? s.steps.map((x) => (x.name === e.step ? { ...x, done: e.done } : x))
|
||||
: [...s.steps, { name: e.step, done: e.done }];
|
||||
return { ...s, status: "running", steps, version: e.seq };
|
||||
}
|
||||
case "need_input":
|
||||
return { ...s, status: "waiting_input", version: e.seq };
|
||||
case "succeeded":
|
||||
return { ...s, status: "succeeded", result: e.result, version: e.seq };
|
||||
case "failed":
|
||||
return { ...s, status: "failed", error: e.retryable ? `${e.error}(可重试)` : e.error, version: e.seq };
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,37 @@ title: React
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [react]
|
||||
tags: []
|
||||
tags: [React, Hooks, 状态管理, React 19]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# React
|
||||
|
||||
Hooks、渲染与性能、状态管理、路由、流式界面。
|
||||
面向主要使用 Vue 的你:建立 React 的心智模型(渲染机制、Hooks 规则、状态管理),能和 Vue 对比,并用于 Next.js。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [React 渲染机制、Hooks 规则、状态管理和 React 19 新特性](01-hooks-rendering.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [长任务界面:Agent 任务的状态机、事件去重、乐观更新和恢复](scenarios/long-task-ui.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [React 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/task-panel](examples/task-panel/):任务状态机 reducer(`node taskReducer-demo.ts` 自检:乱序 / 重复 / 迟到事件丢弃、失败重试)和 `TaskPanel.tsx` 组件(严格模式类型检查通过)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- https://zh-hans.react.dev/
|
||||
- React Compiler:https://react.dev/learn/react-compiler
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.react.interview
|
||||
title: React 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [react]
|
||||
tags: [React, Hooks, 渲染, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.react.hooks-rendering]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# React 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】React 组件什么时候会重新渲染?
|
||||
|
||||
**参考答案**:自身 state 变化、父组件渲染(即使 props 没变)、使用的 Context 值变化。渲染不等于 DOM 更新,React 会比较后只提交变化的部分。
|
||||
|
||||
**追问**:怎么避免子组件不必要的渲染?→ `memo` 包裹子组件并保证 props 引用稳定(`useMemo` / `useCallback`),或者把状态下移、把变化的部分拆成独立组件;React Compiler 可以自动处理部分记忆化。
|
||||
|
||||
### 【基础】为什么 Hooks 不能写在条件语句中?
|
||||
|
||||
**参考答案**:React 用 Hook 的调用顺序来对应每个 Hook 的状态,条件调用会导致顺序变化,状态错位。
|
||||
|
||||
**追问**:需要条件逻辑怎么办?→ 在 Hook 内部写条件,或者拆分组件。
|
||||
|
||||
### 【基础】`useEffect` 的执行和清理时机?
|
||||
|
||||
**参考答案**:在渲染提交到屏幕之后执行;依赖变化时,先执行上一次的清理函数,再执行新的 effect;组件卸载时执行清理。开发环境的严格模式会额外执行一次“挂载 → 卸载 → 挂载”来暴露缺少清理的问题。
|
||||
|
||||
**追问**:`useLayoutEffect` 有什么区别?→ 在 DOM 更新后、浏览器绘制前同步执行,用于测量布局,避免闪烁;会阻塞绘制,谨慎使用。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】setState 是同步还是异步的?
|
||||
|
||||
**参考答案**:调用后不会立即改变当前渲染中的值(状态是快照),更新会被批量处理并在之后重新渲染。React 18 起在事件、Promise、定时器中都会自动批量。依赖旧值的更新用函数式写法 `setX(x => x + 1)`。
|
||||
|
||||
**追问**:需要立即拿到更新后的 DOM 怎么办?→ 极少数情况可以用 `flushSync`,一般在 effect 中处理。
|
||||
|
||||
### 【进阶】React 和 Vue 的更新机制有什么区别?
|
||||
|
||||
**参考答案**:Vue 通过响应式系统精确追踪依赖,数据变化时只更新依赖它的组件;React 状态变化后从该组件开始重新执行渲染函数并比较,默认会渲染子树,靠 memo 等手段优化。Vue 的 `setup` 只执行一次,React 函数组件每次渲染都执行。
|
||||
|
||||
**追问**:哪种更好?→ 各有取舍:Vue 自动优化、心智负担小;React 模型简单(UI = f(state))、生态和并发特性丰富。
|
||||
|
||||
### 【进阶】服务端数据为什么不建议放在 Redux 里?
|
||||
|
||||
**参考答案**:服务端数据需要缓存、失效、后台刷新、重试、去重请求、分页等能力,自己在 Redux 中实现复杂且容易出错。TanStack Query 等库专门处理这些;全局 store 只放真正的客户端状态。
|
||||
|
||||
**追问**:TanStack Query 的 `staleTime` 和 `gcTime` 是什么?→ `staleTime` 决定数据多久后被视为过期(过期才会重新请求);`gcTime` 决定不再使用的缓存保留多久后被回收。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】设计一个长任务(Agent 生成报表)的前端界面,要注意什么?
|
||||
|
||||
**参考答案**:以服务端为准(任务 ID、刷新后恢复、幂等提交);`useReducer` 状态机;事件序号去重;重连时先取快照;失败区分可重试;取消等待后端确认;等待用户输入的交互。详见[长任务界面场景](scenarios/long-task-ui.md)。
|
||||
|
||||
**追问**:怎么测试?→ reducer 是纯函数,直接写单元测试覆盖各种事件序列;组件用 Testing Library 模拟事件订阅。
|
||||
|
||||
### 【场景题】搜索框输入时列表过滤很卡,怎么优化?
|
||||
|
||||
**参考答案**:输入框状态和列表过滤分离;用 `useDeferredValue` 让列表渲染延后,保证输入流畅;列表虚拟化;过滤计算 `useMemo`;数据量大时防抖或放到 Web Worker / 服务端。
|
||||
|
||||
**追问**:防抖和 `useDeferredValue` 的区别?→ 防抖固定延迟后才执行;`useDeferredValue` 由 React 调度,空闲时尽快渲染,可以被新的输入中断,不需要选择延迟时间。
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: frontend.react.scenario.long-task-ui
|
||||
title: 长任务界面:Agent 任务的状态机、事件去重、乐观更新和恢复
|
||||
type: scenario
|
||||
domain: frontend
|
||||
stack: [react, typescript, fastapi]
|
||||
tags: [任务状态, 状态机, useReducer, SSE, 幂等, 乐观更新, 异常恢复]
|
||||
level: advanced
|
||||
prerequisites: [frontend.react.hooks-rendering]
|
||||
est_hours: 2.5
|
||||
related: [frontend.react.interview, ai.langgraph.scenario.refund-approval, frontend.vue.scenario.ai-chat-stream]
|
||||
week: [7, 12]
|
||||
---
|
||||
|
||||
# 长任务界面:Agent 任务的状态机、事件去重、乐观更新和恢复
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
报表 Agent 生成一份报表要 1~3 分钟,中间可能需要用户补充信息(“要按大区还是按城市?”)。前端第一版的问题:
|
||||
|
||||
1. 用户刷新页面后,看不到正在运行的任务,以为丢了,又提交了一次;
|
||||
2. 网络重连后收到重复的进度事件,步骤列表出现重复项;
|
||||
3. 任务失败只显示“出错了”,用户不知道能不能重试;
|
||||
4. 点击“取消”后界面立即显示已取消,但后端其实还在跑;
|
||||
5. 同时打开两个标签页,状态不一致。
|
||||
|
||||
## 约束
|
||||
|
||||
- 后端是 LangGraph + FastAPI,任务状态存在数据库中,事件通过 SSE 推送。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **任务以服务端为准**:提交后后端返回任务 ID;页面加载时先查询“我的进行中任务”并恢复显示;提交接口带幂等键(防止重复提交)。
|
||||
2. **前端状态机**:`useReducer` 集中管理状态转换(排队 / 运行 / 等待输入 / 成功 / 失败 / 取消),事件带递增序号,旧事件和终态后的事件丢弃。
|
||||
3. **重连**:断开后先调用查询接口拿到最新快照(包含当前序号),再从该序号之后订阅事件。
|
||||
4. **失败分类**:后端返回 `retryable`,前端据此显示“重试”按钮或“联系管理员”。
|
||||
5. **取消**:点击后进入“取消中”,后端确认(事件或接口返回)后再显示“已取消”;不做乐观的终态显示。
|
||||
6. **等待输入**:显示问题和输入框,提交后通过接口恢复任务(对应后端的 `interrupt` / `Command(resume=...)`)。
|
||||
7. **多标签页**:都以服务端事件为准;必要时用 `BroadcastChannel` 同步。
|
||||
|
||||
## 关键代码
|
||||
|
||||
状态机和组件见 [examples/task-panel](../examples/task-panel/)。重连时先取快照再订阅(示意):
|
||||
|
||||
```ts
|
||||
async function connect(taskId: string, dispatch: (e: TaskEvent) => void, signal: AbortSignal) {
|
||||
const snap = await fetchJSON<TaskSnapshot>(`/api/tasks/${taskId}`, { signal });
|
||||
dispatch({ type: "snapshot", snapshot: snap }); // 用快照覆盖本地状态
|
||||
await streamEvents(`/api/tasks/${taskId}/events?after=${snap.seq}`, dispatch, signal);
|
||||
}
|
||||
```
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **服务端为准 + 前端状态机(本方案)** | 刷新、重连、多标签页都一致 | 需要快照接口和事件序号 |
|
||||
| 只靠前端内存状态 | 简单 | 刷新即丢失 |
|
||||
| 轮询任务状态 | 实现简单、天然可恢复 | 延迟和请求量;可作为 SSE 不可用时的降级 |
|
||||
| 乐观更新所有操作 | 体验快 | 对“取消”这类不一定成功的操作会误导用户 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 状态转换分散在多个 `useState` 和事件处理函数中,难以保证一致。
|
||||
- 不区分“已取消”和“取消中”。
|
||||
- 重连后不取快照,直接订阅,错过断线期间的事件。
|
||||
- `useEffect` 中订阅没有清理,切换任务后旧任务的事件串进来。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “长任务界面我以服务端为准:提交带幂等键,页面加载时恢复进行中的任务。前端用 useReducer 做状态机,事件带序号,重复、乱序、终态后的事件都丢弃;断线重连先取快照再从序号之后订阅。失败按 retryable 显示重试,取消要等后端确认,不做乐观的终态。等待用户输入时,提交后调用接口恢复后端的 LangGraph 任务。reducer 是纯函数,状态转换都有单元测试。”
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: frontend.ssr.rendering-modes
|
||||
title: 渲染模式:CSR / SSR / SSG / ISR、水合、流式渲染和 Nuxt 基础
|
||||
type: lesson
|
||||
domain: frontend
|
||||
stack: [ssr]
|
||||
tags: [CSR, SSR, SSG, ISR, 水合, Hydration, 流式渲染, Nuxt, SEO]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.vue.reactivity-composition]
|
||||
est_hours: 2.5
|
||||
related: [frontend.ssr.nextjs-app-router, frontend.fe-testing-perf.index, frontend.ssr.interview]
|
||||
week: [7]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# 渲染模式:CSR / SSR / SSG / ISR、水合、流式渲染和 Nuxt 基础
|
||||
|
||||
## 解释说明
|
||||
|
||||
| 模式 | 在哪里生成 HTML | 优点 | 缺点 | 适合 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| CSR(客户端渲染) | 浏览器执行 JS 后生成 | 服务器简单,交互流畅 | 首屏慢、SEO 差 | 后台系统、登录后的应用 |
|
||||
| SSR(服务端渲染) | 每次请求时服务器生成 | 首屏快、SEO 好、数据实时 | 服务器压力、需要 Node 服务 | 内容和数据经常变化的公开页面 |
|
||||
| SSG(静态生成) | 构建时生成 | 最快、可放 CDN | 数据更新要重新构建 | 文档、官网、博客 |
|
||||
| ISR(增量静态再生成) | 构建时生成 + 过期后后台重新生成 | 兼顾速度和更新 | 有短暂的旧数据 | 商品页、文章页 |
|
||||
|
||||
**水合(Hydration)**:服务端返回 HTML 后,浏览器加载 JS,把事件监听和状态“接”到已有的 DOM 上,页面才可以交互。问题:
|
||||
|
||||
- 水合不匹配:服务端和客户端渲染结果不一致(用了 `Date.now()`、`Math.random()`、`window`、本地时区),会报警告甚至重新渲染。
|
||||
- 首屏能看到但点不动(JS 还没加载完):通过减少客户端 JS、部分水合 / 服务端组件、懒加载缓解。
|
||||
|
||||
**流式渲染**:服务器先发送页面框架,慢的数据块准备好后再流式补上(React 的 Suspense、Next.js 的 `loading.tsx`),首字节更快。
|
||||
|
||||
**Nuxt**(Vue 的全栈框架,当前大版本 Nuxt 4,以官方文档为准:https://nuxt.com/docs):基于文件的路由、`useFetch` / `useAsyncData` 在服务端取数并把结果传给客户端避免重复请求、`server/` 目录写接口(Nitro 引擎)、按路由配置渲染模式(`routeRules`:SSR、预渲染、ISR 类缓存)。
|
||||
|
||||
## 案例
|
||||
|
||||
电商的帮助中心:常见问题页需要 SEO 和最快的首屏,用 SSG / ISR;智能客服对话需要登录和实时交互,是客户端组件;订单页登录后才能看,用 CSR 或 SSR 都可以,但要注意不能把用户数据缓存到 CDN。
|
||||
|
||||
## 代码示例
|
||||
|
||||
避免水合不匹配:只在客户端执行的逻辑放到挂载之后。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
const now = ref<string | null>(null); // 服务端渲染时为 null,两端一致
|
||||
onMounted(() => {
|
||||
now.value = new Date().toLocaleString("zh-CN"); // 只在浏览器执行
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>{{ now ?? "加载中" }}</span>
|
||||
</template>
|
||||
```
|
||||
|
||||
Nuxt 中服务端取数(结果会序列化到页面中,客户端水合时不会再请求一次):
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const { data: faqs, error } = await useFetch("/api/faqs");
|
||||
</script>
|
||||
```
|
||||
|
||||
Next.js 的写法见下一课和 [examples/next-ai-chat](examples/next-ai-chat/)。
|
||||
|
||||
## 易错点
|
||||
|
||||
- 在服务端渲染阶段访问 `window`、`localStorage`。
|
||||
- 服务端渲染时使用了全局单例状态(Pinia、模块级变量),导致不同用户的数据串在一起:每个请求要创建新的实例。
|
||||
- 把带用户信息的 SSR 页面缓存到 CDN。
|
||||
- SSR 页面里调用很慢的接口,拖慢整个首屏:并行取数、流式渲染、超时降级。
|
||||
- 以为用了 SSR 就一定更快:服务器离用户远、取数慢时,TTFB 反而变差。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- CSR、SSR、SSG、ISR 的区别和适用场景?
|
||||
- 什么是水合?水合不匹配的原因?
|
||||
- SSR 项目有哪些常见的坑?
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: frontend.ssr.nextjs-app-router
|
||||
title: Next.js App Router:服务端组件、客户端组件、路由处理器做 AI 流式接口、缓存
|
||||
type: lesson
|
||||
domain: frontend
|
||||
stack: [ssr, react]
|
||||
tags: [Next.js, App Router, Server Components, use client, Route Handlers, Server Actions, 流式响应, Turbopack]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.react.hooks-rendering, frontend.ssr.rendering-modes]
|
||||
est_hours: 3
|
||||
related: [frontend.ssr.scenario.help-center, backend.nodejs.index, frontend.ssr.interview]
|
||||
week: [7]
|
||||
order: 2
|
||||
---
|
||||
|
||||
# Next.js App Router:服务端组件、客户端组件、路由处理器做 AI 流式接口、缓存
|
||||
|
||||
> 基于 Next.js 16 编写并实际构建通过。Next.js 大版本之间变化很多(例如 16 版默认使用 Turbopack 构建,`middleware` 文件约定改名为 `proxy`,缓存机制也在调整),以官方文档为准:https://nextjs.org/docs 。BOSS直聘 2026-10-23 的调研中有岗位提到 Next.js,它也是“前端出身做全栈”最常见的选择。
|
||||
|
||||
## 解释说明
|
||||
|
||||
### App Router 的核心概念
|
||||
|
||||
| 概念 | 说明 |
|
||||
| --- | --- |
|
||||
| 文件路由 | `app/` 目录:`page.tsx` 页面、`layout.tsx` 布局、`loading.tsx` 加载态、`error.tsx` 错误边界、`route.ts` 接口 |
|
||||
| **服务端组件**(默认) | 在服务器上运行,可以直接访问数据库 / 密钥,代码不会发到浏览器;不能用 `useState`、事件处理 |
|
||||
| **客户端组件** | 文件顶部写 `"use client"`,可以有状态和交互;它和它导入的模块会打包到浏览器 |
|
||||
| 路由处理器 | `app/api/xxx/route.ts` 导出 `GET` / `POST` 等函数,使用 Web 标准的 `Request` / `Response`,可以返回流 |
|
||||
| Server Actions | `"use server"` 的异步函数,可以直接作为表单的 `action`,在服务端执行 |
|
||||
| 缓存和重新验证 | 数据请求和页面的缓存策略(`revalidate`、按路径 / 标签失效等),不同版本默认值不同,以官方文档为准 |
|
||||
|
||||
**边界原则**:尽量让组件保持为服务端组件,只把需要交互的“叶子”做成客户端组件,减少浏览器端的 JS;不能把函数、类实例作为 props 从服务端组件传给客户端组件(必须可序列化)。
|
||||
|
||||
### 用 Next.js 做 AI 接口
|
||||
|
||||
路由处理器中调用模型的流式接口,返回 `ReadableStream`;模型密钥只在服务端;用 `request.signal` 感知用户断开并停止生成。复杂的 Agent 逻辑仍然建议放在 Python 服务中,Next.js 作为 BFF(面向前端的后端)转发和鉴权。
|
||||
|
||||
## 案例
|
||||
|
||||
帮助中心:常见问题由服务端组件渲染(构建时静态生成,SEO 好);智能客服是客户端组件,调用 `/api/chat` 路由处理器,后者流式返回答案。构建结果中 `/` 是静态页面,`/api/chat` 是按需执行的动态路由。
|
||||
|
||||
## 代码示例
|
||||
|
||||
完整项目:[examples/next-ai-chat](examples/next-ai-chat/)(`npm install && npm run build` 已构建通过,`next start` 后实际请求验证)。
|
||||
|
||||
路由处理器([app/api/chat/route.ts](examples/next-ai-chat/app/api/chat/route.ts)):
|
||||
|
||||
```ts
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const { question } = (await request.json()) as { question?: unknown };
|
||||
if (typeof question !== "string" || question.length === 0 || question.length > 500) {
|
||||
return Response.json({ error: "question 必须是 1~500 字的字符串" }, { status: 400 });
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const token of fakeLLM(question, request.signal)) { // signal:用户断开就停止
|
||||
controller.enqueue(encoder.encode(token));
|
||||
}
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "X-Accel-Buffering": "no" } });
|
||||
}
|
||||
```
|
||||
|
||||
服务端组件页面 + 客户端组件([app/page.tsx](examples/next-ai-chat/app/page.tsx)、[app/chat.tsx](examples/next-ai-chat/app/chat.tsx)):
|
||||
|
||||
```tsx
|
||||
// app/page.tsx —— 服务端组件:取数和渲染都在服务器
|
||||
export default async function Page() {
|
||||
const faqs = await getFaqs();
|
||||
return (
|
||||
<main>
|
||||
{faqs.map((f) => <details key={f.id}><summary>{f.q}</summary><p>{f.a}</p></details>)}
|
||||
<Chat /> {/* 客户端组件 */}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// app/chat.tsx
|
||||
"use client";
|
||||
export function Chat() { const [answer, setAnswer] = useState(""); /* fetch + 读取流 */ }
|
||||
```
|
||||
|
||||
构建输出:
|
||||
|
||||
```text
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
└ ƒ /api/chat
|
||||
○ (Static) prerendered as static content
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 在服务端组件中使用 `useState` 或事件处理,或者忘了写 `"use client"`。
|
||||
- 把整个页面都标记为 `"use client"`,失去服务端组件的好处。
|
||||
- 在客户端组件中导入了只能在服务端使用的模块(数据库客户端、密钥)。
|
||||
- 以为缓存行为和上个版本一样:升级大版本前阅读升级指南。
|
||||
- 部署在 Nginx 后面时流式响应被缓冲。
|
||||
- 把复杂的 Agent 长任务放在路由处理器中执行,受函数超时限制(尤其是 Serverless 部署)。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- 服务端组件和客户端组件的区别?边界怎么划分?
|
||||
- Next.js 的路由处理器和 Server Actions 分别用在哪里?
|
||||
- 用 Next.js 做 AI 应用的流式接口要注意什么?
|
||||
@@ -0,0 +1,35 @@
|
||||
// 不自动运行(Next.js 路由处理器,npm run dev 后 POST /api/chat)
|
||||
// 服务端调用大模型并以流的形式返回;模型密钥只在服务端,浏览器拿不到。
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function* fakeLLM(question: string, signal: AbortSignal): AsyncGenerator<string> {
|
||||
// 真实项目:调用模型 SDK 的流式接口,并把 signal 传进去
|
||||
const words = `关于“${question}”:签收 7 天内可以无理由退货。`.split("");
|
||||
for (const w of words) {
|
||||
if (signal.aborted) return; // 用户停止或断开:不再继续生成(节省费用)
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
yield w;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const { question } = (await request.json()) as { question?: unknown };
|
||||
if (typeof question !== "string" || question.length === 0 || question.length > 500) {
|
||||
return Response.json({ error: "question 必须是 1~500 字的字符串" }, { status: 400 });
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const token of fakeLLM(question, request.signal)) {
|
||||
controller.enqueue(encoder.encode(token));
|
||||
}
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
// 不自动运行
|
||||
// 客户端组件:有状态和事件处理。读取路由处理器返回的流,逐段显示。
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
export function Chat() {
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctrl = useRef<AbortController | null>(null);
|
||||
|
||||
async function ask(formData: FormData) {
|
||||
ctrl.current?.abort();
|
||||
ctrl.current = new AbortController();
|
||||
setAnswer("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const resp = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ question: formData.get("q") }),
|
||||
signal: ctrl.current.signal,
|
||||
});
|
||||
if (!resp.body) throw new Error("没有响应体");
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
setAnswer((a) => a + value);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") setAnswer("出错了,请重试");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={ask}>
|
||||
<input name="q" placeholder="问点什么" required />
|
||||
<button disabled={busy}>发送</button>
|
||||
{busy && <button type="button" onClick={() => ctrl.current?.abort()}>停止</button>}
|
||||
<p aria-live="polite">{answer}</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// 不自动运行
|
||||
export const metadata = { title: "帮助中心", description: "常见问题和智能客服" };
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// 不自动运行
|
||||
// 服务端组件(默认):在服务器上取数和渲染,HTML 直接包含常见问题,对 SEO 友好;不会把这段代码发到浏览器。
|
||||
import { Chat } from "./chat";
|
||||
|
||||
interface Faq {
|
||||
id: number;
|
||||
q: string;
|
||||
a: string;
|
||||
}
|
||||
|
||||
async function getFaqs(): Promise<Faq[]> {
|
||||
// 真实项目:await fetch("https://cms.example.com/faqs", { next: { revalidate: 300 } })(缓存策略以官方文档为准)
|
||||
return [
|
||||
{ id: 1, q: "多久内可以退货?", a: "签收 7 天内可以无理由退货。" },
|
||||
{ id: 2, q: "怎么开发票?", a: "在订单详情页申请电子发票。" },
|
||||
];
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const faqs = await getFaqs();
|
||||
return (
|
||||
<main>
|
||||
<h1>帮助中心</h1>
|
||||
<section>
|
||||
{faqs.map((f) => (
|
||||
<details key={f.id}>
|
||||
<summary>{f.q}</summary>
|
||||
<p>{f.a}</p>
|
||||
</details>
|
||||
))}
|
||||
</section>
|
||||
{/* 交互部分是客户端组件,只有它的 JS 会发到浏览器 */}
|
||||
<Chat />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 不自动运行(Next.js 项目配置,npm install 后 npm run build)
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "next-ai-chat",
|
||||
"private": true,
|
||||
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
|
||||
"dependencies": { "next": "^16.4.0", "react": "^19.2.0", "react-dom": "^19.2.0" },
|
||||
"devDependencies": { "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5.9" }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -4,12 +4,38 @@ title: SSR(Nuxt / Next.js)
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [ssr]
|
||||
tags: []
|
||||
tags: [SSR, SSG, 水合, Next.js, Nuxt]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# SSR(Nuxt / Next.js)
|
||||
|
||||
CSR / SSR / SSG、水合、Nuxt 与 Next.js 基础。
|
||||
渲染模式和水合原理(面试常问),以及 Next.js App Router(前端出身做全栈的常见选择,BOSS直聘 2026-10-23 的调研中有岗位明确提到)。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [渲染模式:CSR / SSR / SSG / ISR、水合、流式渲染和 Nuxt 基础](01-rendering-modes.md) | 进阶 | 2.5 小时 |
|
||||
| 2 | [Next.js App Router:服务端组件、客户端组件、路由处理器做 AI 流式接口、缓存](02-nextjs-app-router.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [帮助中心 + 智能客服:SEO、渲染模式组合和 BFF 设计](scenarios/help-center.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [SSR 与 Next.js 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/next-ai-chat](examples/next-ai-chat/):Next.js 16 最小项目——服务端组件渲染常见问题、客户端组件对话框、路由处理器流式返回并响应断开(`npm install && npm run build` 构建通过)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- Next.js:https://nextjs.org/docs
|
||||
- Nuxt:https://nuxt.com/docs
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.ssr.interview
|
||||
title: SSR 与 Next.js 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [ssr]
|
||||
tags: [SSR, Next.js, Nuxt, 水合, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.ssr.rendering-modes, frontend.ssr.nextjs-app-router]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# SSR 与 Next.js 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】CSR、SSR、SSG、ISR 有什么区别?
|
||||
|
||||
**参考答案**:区别在于 HTML 在哪里、什么时候生成:CSR 在浏览器;SSR 每次请求在服务器;SSG 在构建时;ISR 构建时生成并在过期后后台重新生成。按 SEO 需求、数据更新频率、个性化程度选择,一个站点可以混用。
|
||||
|
||||
**追问**:后台管理系统需要 SSR 吗?→ 一般不需要,没有 SEO 需求,CSR 更简单。
|
||||
|
||||
### 【基础】什么是水合?为什么会出现水合不匹配?
|
||||
|
||||
**参考答案**:浏览器加载 JS 后把交互能力附加到服务端渲染的 HTML 上。服务端和客户端第一次渲染结果不一致时出现不匹配,常见原因:时间、随机数、浏览器专有 API、时区和语言差异、无效的 HTML 嵌套。
|
||||
|
||||
**追问**:怎么修复?→ 浏览器专有逻辑放到挂载后执行,保证首次渲染两端一致;确实无法一致的地方使用框架提供的“仅客户端渲染”组件。
|
||||
|
||||
### 【基础】Next.js 中服务端组件和客户端组件的区别?
|
||||
|
||||
**参考答案**:服务端组件默认,在服务器上运行,可以直接访问数据和密钥,不增加浏览器端 JS,但不能使用状态和事件;客户端组件用 `"use client"` 声明,可以交互,会打包到浏览器。服务端组件可以渲染客户端组件,传递的 props 必须可序列化。
|
||||
|
||||
**追问**:边界怎么划分?→ 尽量把客户端组件下沉到需要交互的叶子节点。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】SSR 应用有哪些常见的坑?
|
||||
|
||||
**参考答案**:服务端访问浏览器 API;全局单例状态导致跨请求数据串用;个性化页面被 CDN 缓存;慢接口拖慢首屏;内存泄漏(服务端长期运行);环境变量误暴露到客户端。
|
||||
|
||||
**追问**:跨请求状态污染怎么避免?→ 每个请求创建新的 store 实例(Pinia、Redux),不使用模块级可变变量保存用户数据。
|
||||
|
||||
### 【进阶】Next.js 的路由处理器和 Server Actions 怎么选?
|
||||
|
||||
**参考答案**:路由处理器是标准的 HTTP 接口,适合给外部调用、Webhook、流式响应;Server Actions 适合表单提交和页面内的数据变更,可以直接在组件中调用并配合重新验证缓存。两者都要做鉴权和输入校验。
|
||||
|
||||
**追问**:Server Actions 安全吗?→ 它本质上也是公开的 HTTP 端点,必须校验身份、权限和参数,不能因为“只在页面里调用”就省略。
|
||||
|
||||
### 【进阶】什么是流式 SSR?
|
||||
|
||||
**参考答案**:服务器先发送页面外壳和已就绪的部分,慢数据对应的部分用 Suspense 边界包裹,准备好后通过同一个响应继续发送并插入页面。首字节和首屏更快,慢接口不阻塞整页。
|
||||
|
||||
**追问**:和 AI 的流式输出是一回事吗?→ 不是。流式 SSR 是页面 HTML 的分块发送;AI 流式输出是接口逐步返回生成的内容,两者可以同时使用。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】公司帮助中心 SEO 很差,同时要加智能客服,你怎么做?
|
||||
|
||||
**参考答案**:用 Next.js / Nuxt 重建,文章 SSG + 按需重新验证,个人页面动态渲染且禁止公共缓存;智能客服为客户端组件,通过 BFF 路由处理器鉴权、限流后转发到 AI 服务并透传流。详见[帮助中心场景](scenarios/help-center.md)。
|
||||
|
||||
**追问**:文章更新后多久能被看到?→ 取决于重新验证策略:基于时间的会有延迟;CMS 回调触发按路径重新验证可以做到几乎实时。
|
||||
|
||||
### 【场景题】Next.js 应用上线后,首屏可见但几秒内点击无反应,为什么?
|
||||
|
||||
**参考答案**:HTML 已经显示,但客户端 JS 还没加载和水合完成。排查:客户端组件过多、包体积大、第三方脚本阻塞、主线程长任务。优化:减少客户端组件、拆包和懒加载、延迟加载第三方脚本、关注 INP 指标。
|
||||
|
||||
**追问**:怎么量化?→ 看 Core Web Vitals 中的 INP 和 TBT,以及包体积分析报告。
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: frontend.ssr.scenario.help-center
|
||||
title: 帮助中心 + 智能客服:SEO、渲染模式组合和 BFF 设计
|
||||
type: scenario
|
||||
domain: frontend
|
||||
stack: [ssr, react, nginx, fastapi]
|
||||
tags: [SEO, SSG, ISR, BFF, 流式接口, 鉴权, CDN 缓存]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.ssr.nextjs-app-router]
|
||||
est_hours: 2.5
|
||||
related: [frontend.ssr.interview, devops.nginx.scenario.ai-gateway-sse-canary]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# 帮助中心 + 智能客服:SEO、渲染模式组合和 BFF 设计
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
公司的帮助中心是一个 Vue 单页应用,搜索引擎几乎收录不到文章;新增的智能客服直接从浏览器调用 Python 的 AI 服务,出现:
|
||||
|
||||
1. 帮助文章在搜索引擎中找不到,客服电话量居高不下;
|
||||
2. AI 服务的地址和临时令牌暴露在前端代码中;
|
||||
3. 文章更新后要重新部署整个前端;
|
||||
4. 用户在 CDN 上看到了别人的“我的工单”页面。
|
||||
|
||||
## 约束
|
||||
|
||||
- 文章由运营在 CMS 中维护,每天更新几十篇。
|
||||
- AI 服务(FastAPI + LangGraph)由另一个团队维护。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **Next.js 重建帮助中心**:
|
||||
- 文章页:静态生成 + 按时间或 CMS 回调(webhook)按路径重新验证,SEO 好、更新快;
|
||||
- 首页和分类页:静态生成;
|
||||
- “我的工单”:动态渲染,禁止 CDN 缓存(`Cache-Control: private, no-store`);
|
||||
- 智能客服:客户端组件。
|
||||
2. **BFF**:Next.js 的路由处理器作为 BFF,校验用户会话,转发到 AI 服务(服务间凭证只在服务端),透传流式响应;限流防刷。
|
||||
3. **SEO**:每篇文章生成标题、描述、结构化数据;生成站点地图。
|
||||
4. **部署**:Next.js 以 Node 服务部署在 Nginx 后,静态资源走 CDN;Nginx 对 `/api/chat` 关闭缓冲。
|
||||
|
||||
## 关键代码
|
||||
|
||||
BFF 转发流式响应(示意):
|
||||
|
||||
```ts
|
||||
// app/api/chat/route.ts
|
||||
export async function POST(request: Request) {
|
||||
const user = await getSessionUser(request); // 校验登录态
|
||||
if (!user) return Response.json({ error: "未登录" }, { status: 401 });
|
||||
if (!(await rateLimit(`chat:${user.id}`, 20, 60))) return Response.json({ error: "请求过于频繁" }, { status: 429 });
|
||||
const upstream = await fetch(`${process.env.AI_SERVICE_URL}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.AI_SERVICE_TOKEN}`, "X-User-Id": user.id },
|
||||
body: await request.text(),
|
||||
signal: request.signal, // 用户断开时取消上游请求
|
||||
});
|
||||
return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } });
|
||||
}
|
||||
```
|
||||
|
||||
可运行的最小项目:[examples/next-ai-chat](../examples/next-ai-chat/)。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **Next.js SSG/ISR + BFF(本方案)** | SEO、性能、安全兼顾 | 多了一个 Node 服务要运维 |
|
||||
| 保持 SPA + 预渲染服务 | 改动小 | 预渲染服务维护麻烦,效果一般 |
|
||||
| Nuxt | 团队熟悉 Vue | 同样可行;选择取决于团队技术栈 |
|
||||
| 浏览器直连 AI 服务 | 少一跳 | 凭证暴露、难以统一鉴权和限流 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 动态的个人页面被 CDN 缓存。
|
||||
- BFF 把上游的流读完再返回,失去流式效果。
|
||||
- 服务间令牌用 `NEXT_PUBLIC_` 前缀的环境变量(会被打包进浏览器代码)。
|
||||
- CMS 回调触发重新验证的接口没有鉴权。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “帮助中心我用 Next.js 重做:文章页静态生成,CMS 更新时通过回调按路径重新验证,SEO 和更新速度都解决了;我的工单是动态页面,明确禁止 CDN 缓存。智能客服前端是客户端组件,调用 Next.js 的路由处理器做 BFF:校验会话、限流,用服务端的凭证转发到 Python 的 AI 服务,直接透传流式响应,用户断开时通过 signal 取消上游。”
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
id: frontend.typescript.type-system
|
||||
title: TypeScript 类型系统进阶:泛型、条件类型、类型收窄、satisfies 和严格配置
|
||||
type: lesson
|
||||
domain: frontend
|
||||
stack: [typescript]
|
||||
tags: [TypeScript, 泛型, 条件类型, infer, 映射类型, 可辨识联合, satisfies, tsconfig]
|
||||
level: intermediate
|
||||
prerequisites: []
|
||||
est_hours: 3
|
||||
related: [frontend.typescript.scenario.api-contract, frontend.vue.index, frontend.typescript.interview]
|
||||
week: [7]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# TypeScript 类型系统进阶:泛型、条件类型、类型收窄、satisfies 和严格配置
|
||||
|
||||
> 你已有 7 年前端经验,这一课只查漏面试常追问的部分。示例基于 TypeScript 5.9 检查通过;TypeScript 7(Go 原生实现的编译器)已发布,语言特性一致,工具链细节以官方文档为准:https://www.typescriptlang.org/docs/
|
||||
|
||||
## 解释说明
|
||||
|
||||
| 主题 | 要点 |
|
||||
| --- | --- |
|
||||
| 泛型约束 | `function get<T, K extends keyof T>(o: T, k: K): T[K]` |
|
||||
| 条件类型 + `infer` | `type Awaited2<T> = T extends Promise<infer U> ? U : T`;联合类型上会**分配**执行 |
|
||||
| 映射类型 | `{ [K in keyof T]?: T[K] }`,`as` 重映射键名;内置 `Partial`、`Pick`、`Omit`、`Record` |
|
||||
| 模板字面量类型 | `` `${M} ${string}` `` 解析字符串结构 |
|
||||
| 可辨识联合 | 共同的字面量字段(`kind`、`ok`)+ `switch` 收窄 + `never` 穷尽检查 |
|
||||
| 类型收窄 | `typeof`、`in`、`instanceof`、自定义类型守卫 `x is T`、断言函数 `asserts x is T` |
|
||||
| `satisfies` | 检查值满足某个类型,同时保留更精确的推断类型(对比 `: Type` 会拓宽) |
|
||||
| `unknown` vs `any` | 外部数据用 `unknown`,必须校验后才能使用 |
|
||||
| 结构化类型 | 只看形状不看名字;需要区分同形状的 ID 时用“品牌类型” |
|
||||
|
||||
**严格配置**(新项目建议):`strict`、`noUncheckedIndexedAccess`(数组 / 索引访问带 `undefined`)、`exactOptionalPropertyTypes`、`verbatimModuleSyntax`;如果要让 Node 直接运行 TS,开启 `erasableSyntaxOnly`(禁止 `enum`、`namespace`、参数属性等需要编译的语法)。
|
||||
|
||||
**最重要的一点**:TS 类型只在编译期存在。后端返回的 JSON、`localStorage`、URL 参数都是 `unknown`,**必须在运行时校验**(手写守卫或 zod / valibot 等库)。
|
||||
|
||||
## 案例
|
||||
|
||||
前端调用订单接口时,后端某次发版把 `status` 新增了 `"refunding"`,前端类型没更新,页面显示空白。改进:接口返回值用运行时校验;状态用可辨识联合 + 穷尽检查,新增状态时编译报错提醒补齐展示逻辑;错误用 `Result` 类型表达,调用方必须处理失败分支。
|
||||
|
||||
## 代码示例
|
||||
|
||||
完整版本:[examples/api-client/api-client.ts](examples/api-client/api-client.ts)(`node api-client.ts` 运行,`tsc -p .` 严格模式检查通过)。
|
||||
|
||||
```ts
|
||||
type Result<T, E = ApiError> = { ok: true; data: T } | { ok: false; error: E };
|
||||
|
||||
type MethodOf<K extends string> = K extends `${infer M} ${string}` ? M : never;
|
||||
const m: MethodOf<"POST /orders"> = "POST";
|
||||
|
||||
const validators = {
|
||||
"GET /orders/:id": isOrder,
|
||||
"POST /orders": isOrder,
|
||||
} satisfies { [K in EndpointKey]: Validator<Endpoints[K]["res"]> }; // 少写一个接口会报错
|
||||
|
||||
function errorText(e: ApiError): string {
|
||||
switch (e.kind) {
|
||||
case "network": return "网络异常,请稍后重试";
|
||||
case "http": return e.status === 404 ? "订单不存在" : `服务异常(${e.status})`;
|
||||
case "validation": return `数据格式错误:${e.issues.join(";")}`;
|
||||
default: return assertNever(e); // 新增 kind 忘了处理会编译报错
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
开启 `noUncheckedIndexedAccess` 后,下面的写法会报错(`results[0]` 可能是 `undefined`),这正是它的价值:
|
||||
|
||||
```ts
|
||||
const [first] = results;
|
||||
if (!first || !first.ok) throw new Error("失败");
|
||||
console.log(first.data.amount); // 收窄后才能访问 data
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 用 `as` 断言代替校验:`resp.json() as Order` 只是骗过编译器。
|
||||
- 滥用 `any`,一处 `any` 会传染到调用链。
|
||||
- `enum` 在“只剥离类型”的运行方式(Node 直接运行、部分构建工具的隔离编译)下不可用,可以用字面量联合 + `as const` 对象代替。
|
||||
- 函数参数的类型推断:泛型参数无法从返回值推断。
|
||||
- `interface` 的声明合并可能导致意外的字段扩展;对外的类型库要注意。
|
||||
- 类型体操写得过于复杂,团队看不懂、编译变慢:类型是为了降低理解成本。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- `type` 和 `interface` 的区别?
|
||||
- `unknown`、`any`、`never` 的区别和使用场景?
|
||||
- 实现一个 `DeepPartial` / `Awaited`?条件类型的分配特性是什么?
|
||||
- `satisfies` 解决什么问题?
|
||||
@@ -0,0 +1,106 @@
|
||||
// 类型安全的 API 客户端:可辨识联合、泛型、类型收窄、穷尽检查、satisfies、运行时校验。
|
||||
// 运行:node api-client.ts(Node 23.6+ 可直接运行 TS,只做类型剥离不做类型检查)
|
||||
// 类型检查:npx tsc --noEmit --strict api-client.ts(也可以用项目的 tsconfig)
|
||||
// 注意:Node 的类型剥离不支持 enum、namespace、构造函数参数属性等需要编译的语法,这里都没有使用。
|
||||
|
||||
// ---------- 1. 用可辨识联合表达“结果”,不用抛异常 ----------
|
||||
type ApiError =
|
||||
| { kind: "network"; message: string }
|
||||
| { kind: "http"; status: number; message: string }
|
||||
| { kind: "validation"; issues: string[] };
|
||||
|
||||
type Result<T, E = ApiError> = { ok: true; data: T } | { ok: false; error: E };
|
||||
|
||||
// ---------- 2. 接口契约:路径 -> 请求 / 响应类型(真实项目可由 OpenAPI 生成) ----------
|
||||
type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
|
||||
interface Order {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: OrderStatus;
|
||||
}
|
||||
|
||||
interface Endpoints {
|
||||
"GET /orders/:id": { params: { id: string }; body: never; res: Order };
|
||||
"POST /orders": { params: Record<string, never>; body: { amount: number }; res: Order };
|
||||
}
|
||||
type EndpointKey = keyof Endpoints;
|
||||
|
||||
// 模板字面量类型 + infer:从 "GET /orders/:id" 中取出方法
|
||||
type MethodOf<K extends string> = K extends `${infer M} ${string}` ? M : never;
|
||||
const m: MethodOf<"POST /orders"> = "POST";
|
||||
|
||||
// ---------- 3. 运行时校验:TS 类型在运行时不存在,后端返回的数据必须校验 ----------
|
||||
type Validator<T> = (x: unknown) => Result<T>;
|
||||
|
||||
const isOrder: Validator<Order> = (x) => {
|
||||
const issues: string[] = [];
|
||||
const o = x as Partial<Order> | null;
|
||||
if (typeof o?.id !== "string") issues.push("id 应为字符串");
|
||||
if (typeof o?.amount !== "number" || o.amount < 0) issues.push("amount 应为非负数");
|
||||
if (!["pending", "paid", "shipped", "cancelled"].includes(o?.status as string)) issues.push("status 不合法");
|
||||
return issues.length ? { ok: false, error: { kind: "validation", issues } } : { ok: true, data: o as Order };
|
||||
};
|
||||
|
||||
const validators = {
|
||||
"GET /orders/:id": isOrder,
|
||||
"POST /orders": isOrder,
|
||||
} satisfies { [K in EndpointKey]: Validator<Endpoints[K]["res"]> }; // satisfies:检查完整性,同时保留字面量类型
|
||||
|
||||
type Fetcher = (method: string, url: string, body?: unknown) => Promise<{ status: number; json: unknown }>;
|
||||
|
||||
async function request<K extends EndpointKey>(
|
||||
fetcher: Fetcher,
|
||||
key: K,
|
||||
params: Endpoints[K]["params"],
|
||||
body?: Endpoints[K]["body"],
|
||||
): Promise<Result<Endpoints[K]["res"]>> {
|
||||
const [method, pattern] = key.split(" ") as [string, string];
|
||||
const url = pattern.replace(/:(\w+)/g, (_, p: string) => encodeURIComponent((params as Record<string, string>)[p] ?? ""));
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetcher(method, url, body);
|
||||
} catch (e) {
|
||||
return { ok: false, error: { kind: "network", message: String(e) } };
|
||||
}
|
||||
if (resp.status >= 400) return { ok: false, error: { kind: "http", status: resp.status, message: `HTTP ${resp.status}` } };
|
||||
return (validators[key] as Validator<Endpoints[K]["res"]>)(resp.json);
|
||||
}
|
||||
|
||||
// ---------- 4. 穷尽检查:新增错误类型而忘了处理时,编译报错 ----------
|
||||
function assertNever(x: never): never {
|
||||
throw new Error(`未处理的情况:${JSON.stringify(x)}`);
|
||||
}
|
||||
|
||||
function errorText(e: ApiError): string {
|
||||
switch (e.kind) {
|
||||
case "network":
|
||||
return "网络异常,请稍后重试";
|
||||
case "http":
|
||||
return e.status === 404 ? "订单不存在" : `服务异常(${e.status})`;
|
||||
case "validation":
|
||||
return `数据格式错误:${e.issues.join(";")}`;
|
||||
default:
|
||||
return assertNever(e);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 5. 演示 ----------
|
||||
const fakeServer: Fetcher = async (method, url) => {
|
||||
if (url === "/orders/A1") return { status: 200, json: { id: "A1", amount: 99, status: "paid" } };
|
||||
if (url === "/orders/BAD") return { status: 200, json: { id: "BAD", amount: -1, status: "unknown" } };
|
||||
if (url === "/orders/DOWN") throw new Error("ECONNRESET");
|
||||
return { status: 404, json: null };
|
||||
};
|
||||
|
||||
const results = await Promise.all(
|
||||
["A1", "BAD", "NOPE", "DOWN"].map((id) => request(fakeServer, "GET /orders/:id", { id })),
|
||||
);
|
||||
for (const r of results) {
|
||||
console.log(r.ok ? `订单 ${r.data.id} 状态 ${r.data.status}` : errorText(r.error));
|
||||
}
|
||||
// 开启 noUncheckedIndexedAccess 后 results[0] 的类型带 undefined,必须先判断;
|
||||
// 判断 ok 之后,TS 才知道是哪一个分支,才能访问 data / error(类型收窄)
|
||||
const [first, second] = results;
|
||||
if (!first || !first.ok || first.data.amount !== 99) throw new Error("断言失败");
|
||||
if (!second || second.ok || second.error.kind !== "validation") throw new Error("断言失败");
|
||||
console.log("方法类型:", m);
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module"}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"files": ["api-client.ts"]
|
||||
}
|
||||
@@ -4,12 +4,37 @@ title: TypeScript
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [typescript]
|
||||
tags: []
|
||||
tags: [TypeScript, 类型系统, 运行时校验, 接口契约]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# TypeScript
|
||||
|
||||
类型系统、泛型、类型收窄、工程配置。
|
||||
你的强项之一,这里只做查漏和面试准备:类型系统进阶、严格配置、运行时校验、前后端类型契约。BOSS直聘 2026-10-23 的调研中约 10 个岗位要求 TS / JS。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [TypeScript 类型系统进阶:泛型、条件类型、类型收窄、satisfies 和严格配置](01-type-system.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [前后端类型契约:OpenAPI 生成类型、运行时校验和接口变更管理](scenarios/api-contract.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [TypeScript 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/api-client](examples/api-client/):类型安全的 API 客户端(`Result` 可辨识联合、模板字面量 + `infer`、`satisfies`、穷尽检查、手写运行时校验),`node api-client.ts` 运行,严格 `tsconfig.json` 检查通过
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- https://www.typescriptlang.org/docs/
|
||||
- Node.js 直接运行 TS:https://nodejs.org/api/typescript.html
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.typescript.interview
|
||||
title: TypeScript 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [typescript]
|
||||
tags: [TypeScript, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.typescript.type-system]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# TypeScript 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】`type` 和 `interface` 有什么区别?
|
||||
|
||||
**参考答案**:都能描述对象形状。`interface` 可以声明合并、用 `extends` 继承,错误信息更友好;`type` 能表示联合、交叉、条件类型、映射类型、元组等任意类型。常见做法:对象结构用 `interface`,组合和复杂类型用 `type`,团队统一即可。
|
||||
|
||||
**追问**:声明合并有什么用?→ 扩展第三方库的类型,例如给 `Window` 或 Vue 的 `ComponentCustomProperties` 增加字段。
|
||||
|
||||
### 【基础】`any`、`unknown`、`never` 的区别?
|
||||
|
||||
**参考答案**:`any` 关闭类型检查;`unknown` 是安全的顶层类型,使用前必须收窄;`never` 表示不可能出现的值,用于穷尽检查、总是抛异常的函数返回值。
|
||||
|
||||
**追问**:外部数据应该用哪个?→ `unknown`,校验后再转为具体类型。
|
||||
|
||||
### 【基础】什么是类型收窄?有哪些方式?
|
||||
|
||||
**参考答案**:在条件分支中把宽类型缩小为具体类型:`typeof`、`instanceof`、`in`、字面量比较(可辨识联合)、自定义类型守卫 `x is T`、断言函数 `asserts x is T`。
|
||||
|
||||
**追问**:类型守卫写错了会怎样?→ 编译器会相信你的判断,运行时出错;类型守卫要和真实判断逻辑一致,最好有测试。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】条件类型的分配特性是什么?
|
||||
|
||||
**参考答案**:当条件类型的检查对象是“裸”类型参数且传入联合类型时,会对联合的每个成员分别计算再合并,例如 `T extends string ? 1 : 0` 传入 `string | number` 得到 `1 | 0`。用 `[T] extends [string]` 包一层可以阻止分配。
|
||||
|
||||
**追问**:实现 `Exclude<T, U>`?→ `type MyExclude<T, U> = T extends U ? never : T`,正是利用了分配特性。
|
||||
|
||||
### 【进阶】`satisfies` 解决什么问题?
|
||||
|
||||
**参考答案**:用 `: Type` 标注变量会把类型拓宽为标注的类型,丢失字面量信息;`as` 不安全。`satisfies` 检查值满足类型约束,同时保留推断出的精确类型,适合配置对象、路由表、映射表。
|
||||
|
||||
**追问**:举个例子?→ `const routes = {...} satisfies Record<string, Route>`,既保证每项是 `Route`,又能用 `keyof typeof routes` 得到精确的键名联合。
|
||||
|
||||
### 【进阶】TS 的类型在运行时存在吗?怎么保证接口数据安全?
|
||||
|
||||
**参考答案**:不存在,编译后被擦除。接口数据要在运行时校验:手写守卫、zod / valibot 等 Schema 库,或者从 OpenAPI 生成校验器;校验失败要降级和上报。
|
||||
|
||||
**追问**:每个接口都校验会不会太重?→ 关键接口和不可信来源必须校验;其余可以依赖生成类型 + 契约测试,权衡包体积和收益。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】前后端经常因为接口字段不一致出问题,怎么解决?
|
||||
|
||||
**参考答案**:以 OpenAPI 为契约,前端生成类型,关键接口运行时校验并上报违约,后端 CI 检测破坏性变更,统一错误格式。详见[前后端类型契约场景](scenarios/api-contract.md)。
|
||||
|
||||
**追问**:后端没有 OpenAPI 怎么办?→ 先从最常用的接口开始补;FastAPI 自带,Go 可以用注释生成或手写规范;同时前端先做运行时校验发现问题。
|
||||
|
||||
### 【场景题】大型项目 TS 编译很慢,怎么优化?
|
||||
|
||||
**参考答案**:开启 `skipLibCheck`;使用项目引用(project references)和增量编译;减少复杂的类型体操和巨大的联合类型;类型检查和构建分离(构建只剥离类型,类型检查在 CI 和编辑器中进行);关注 TS 原生编译器(TypeScript 7)带来的速度提升。
|
||||
|
||||
**追问**:怎么找出慢在哪里?→ `tsc --extendedDiagnostics` 查看各阶段耗时,`--generateTrace` 分析具体是哪些类型计算慢。
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: frontend.typescript.scenario.api-contract
|
||||
title: 前后端类型契约:OpenAPI 生成类型、运行时校验和接口变更管理
|
||||
type: scenario
|
||||
domain: frontend
|
||||
stack: [typescript, fastapi, gin]
|
||||
tags: [OpenAPI, 类型生成, 运行时校验, zod, 接口变更, 契约测试]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.typescript.type-system]
|
||||
est_hours: 2.5
|
||||
related: [frontend.typescript.interview, python.fastapi.index, backend.gin.scenario.unified-api-response]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# 前后端类型契约:OpenAPI 生成类型、运行时校验和接口变更管理
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
一个后台系统,前端 Vue + TS,后端有 Go(Gin)和 Python(FastAPI)两套服务。常见问题:
|
||||
|
||||
1. 前端的接口类型是手写的,和后端实际返回不一致(字段改名、可空性不同);
|
||||
2. 后端新增了枚举值,前端没有处理,页面空白;
|
||||
3. 接口改了,前端直到联调才发现;
|
||||
4. 错误响应的格式两个后端不一样,前端处理逻辑很乱。
|
||||
|
||||
## 约束
|
||||
|
||||
- 两个后端技术栈不同。
|
||||
- 不能要求后端一次性全部改造。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **以 OpenAPI 为契约**:FastAPI 自动生成 OpenAPI;Gin 使用 swag 等工具从注释生成,或者先写 OpenAPI 再生成代码(以对应工具文档为准)。
|
||||
2. **生成前端类型和客户端**:CI 中用 `openapi-typescript` 等工具从 OpenAPI 生成 TS 类型,提交到前端仓库;类型变化在 PR 中可见。
|
||||
3. **运行时校验**:关键接口的返回值用 zod / valibot 或生成的校验器校验,校验失败上报监控(发现后端违约),并降级展示。
|
||||
4. **统一错误格式**:两个后端统一为 `{ code, message, details }`(见 [Gin 统一响应场景](../../../backend/gin/scenarios/unified-api-response.md)),前端用可辨识联合处理。
|
||||
5. **变更管理**:后端改接口时,CI 对比新旧 OpenAPI,检测破坏性变更(删除字段、改类型、必填变化)并阻止合并,除非版本号升级。
|
||||
6. **枚举处理**:前端对未知枚举值有兜底展示,同时穷尽检查保证已知值都被处理。
|
||||
|
||||
## 关键代码
|
||||
|
||||
```ts
|
||||
// 由 OpenAPI 生成(示意):import type { paths } from "./generated/api";
|
||||
// type Order = paths["/orders/{id}"]["get"]["responses"]["200"]["content"]["application/json"];
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const OrderSchema = z.object({
|
||||
id: z.string(),
|
||||
amount: z.number().nonnegative(),
|
||||
status: z.enum(["pending", "paid", "shipped", "cancelled"]).or(z.string().transform(() => "unknown" as const)),
|
||||
});
|
||||
type Order = z.infer<typeof OrderSchema>;
|
||||
|
||||
export async function getOrder(id: string): Promise<Result<Order>> {
|
||||
const resp = await fetch(`/api/orders/${encodeURIComponent(id)}`);
|
||||
const json: unknown = await resp.json();
|
||||
const parsed = OrderSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
reportContractViolation("/orders/{id}", parsed.error.issues); // 上报:后端违约
|
||||
return { ok: false, error: { kind: "validation", issues: parsed.error.issues.map((i) => i.message) } };
|
||||
}
|
||||
return { ok: true, data: parsed.data };
|
||||
}
|
||||
```
|
||||
|
||||
不依赖库的手写校验和 `Result` 处理见 [examples/api-client](../examples/api-client/)。zod 的 API 以官方文档为准:https://zod.dev/
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **OpenAPI 生成类型 + 关键接口运行时校验(本方案)** | 类型准确,违约能发现 | 需要后端维护准确的 OpenAPI |
|
||||
| 手写类型 | 简单 | 容易和后端不一致 |
|
||||
| 所有接口都运行时校验 | 最安全 | 包体积和性能开销;Schema 维护成本 |
|
||||
| tRPC / 全栈 TS 共享类型 | 体验最好 | 要求后端也是 TS,本项目不适用 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 生成的类型和实际部署的后端版本不一致(生成时机不对)。
|
||||
- 校验失败直接让页面崩溃,没有降级。
|
||||
- 把后端的 `null` 和“字段不存在”混为一谈。
|
||||
- 破坏性变更检测只在前端做,后端合并时没有拦截。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “前后端类型我用 OpenAPI 做契约:后端生成 OpenAPI,前端在 CI 里生成 TS 类型,类型变化在 PR 里能看到;关键接口的返回值用 zod 做运行时校验,校验失败上报监控并降级,这样能发现后端违约;错误响应两个后端统一成一种格式,前端用可辨识联合处理;后端 CI 对比 OpenAPI 检测破坏性变更。新增枚举值时,前端有未知值兜底,已知值用穷尽检查保证都处理了。”
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
id: frontend.vue.reactivity-composition
|
||||
title: Vue 3 响应式原理、组合式函数、Pinia 和性能优化
|
||||
type: lesson
|
||||
domain: frontend
|
||||
stack: [vue]
|
||||
tags: [Vue 3, 响应式, Proxy, ref, reactive, computed, watch, 组合式函数, Pinia, 性能]
|
||||
level: intermediate
|
||||
prerequisites: [frontend.typescript.type-system]
|
||||
est_hours: 3
|
||||
related: [frontend.vue.scenario.ai-chat-stream, frontend.react.index, frontend.vue.interview]
|
||||
week: [7]
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Vue 3 响应式原理、组合式函数、Pinia 和性能优化
|
||||
|
||||
> 基于 Vue 3.5 编写,以官方文档为准:https://cn.vuejs.org/
|
||||
|
||||
## 解释说明
|
||||
|
||||
### 响应式原理(面试必问)
|
||||
|
||||
- `reactive()` 用 **Proxy** 拦截对象的读写:读取时**收集依赖**(当前正在运行的副作用:组件渲染、`computed`、`watch`),写入时**触发**依赖重新执行。
|
||||
- `ref()` 用对象的 `.value` getter / setter 实现,可以包装基本类型;模板中自动解包。
|
||||
- `computed` 是带缓存的副作用:依赖不变就不重新计算。
|
||||
- 更新是**异步批量**的:同一轮中多次修改只触发一次渲染,`await nextTick()` 之后才能拿到更新后的 DOM。
|
||||
- Vue 2 用 `Object.defineProperty`,无法检测新增属性和数组索引赋值;Vue 3 的 Proxy 解决了这个问题。
|
||||
|
||||
| API | 用途 | 注意 |
|
||||
| --- | --- | --- |
|
||||
| `ref` / `reactive` | 响应式状态 | `reactive` 解构会丢失响应性,用 `toRefs`;整体替换 `reactive` 对象会断开引用 |
|
||||
| `shallowRef` | 只追踪 `.value` 的替换 | 大对象、第三方实例、只整体替换的数据,性能更好 |
|
||||
| `computed` | 派生状态 | 不要在里面做副作用 |
|
||||
| `watch` / `watchEffect` | 副作用(请求、存储) | 异步副作用要处理竞态(`onWatcherCleanup` / `onCleanup`) |
|
||||
| `effectScope` / `onScopeDispose` | 管理一组副作用的生命周期 | 组合式函数里清理定时器、请求、订阅 |
|
||||
|
||||
### 组合式函数(Composables)
|
||||
|
||||
把有状态的逻辑封装成 `useXxx()`:内部用 `ref`、`watch`、生命周期钩子,返回响应式状态和方法。和 React Hooks 的区别:`setup` 只执行一次,没有“依赖数组”和闭包陈旧值的问题,但要注意返回值的响应性和副作用清理。
|
||||
|
||||
### Pinia
|
||||
|
||||
官方状态管理:`defineStore`,支持组合式写法;store 解构要用 `storeToRefs`;服务端渲染时每个请求要有独立的 Pinia 实例,避免跨请求状态污染。
|
||||
|
||||
### 性能
|
||||
|
||||
`v-memo`、`v-once`;长列表虚拟滚动;大对象用 `shallowRef` / `markRaw`;组件懒加载 `defineAsyncComponent`;`computed` 代替模板中的复杂表达式;避免在模板中创建新对象 / 函数导致子组件重复渲染。
|
||||
|
||||
## 案例
|
||||
|
||||
AI 对话页面:流式输出时每秒几十次追加文本,最初用 `reactive` 存整个消息数组并且每次替换,导致长对话卡顿。改为:已完成的消息用 `shallowRef` 数组(完成后整体替换一次),正在生成的消息单独用一个 `ref` 追加文本;Markdown 渲染做节流。把请求、解析、状态机封装成 `useChatStream` 组合式函数,组件卸载时自动停止生成。
|
||||
|
||||
## 代码示例
|
||||
|
||||
组合式函数的核心(完整版本 [examples/ai-chat-stream/useChatStream.ts](examples/ai-chat-stream/useChatStream.ts),带 vitest 测试):
|
||||
|
||||
```ts
|
||||
export function useChatStream(opts: ChatOptions) {
|
||||
const status = ref<ChatStatus>("idle");
|
||||
const text = ref("");
|
||||
const final = shallowRef<FinalPayload | null>(null); // 整体替换的对象用 shallowRef
|
||||
let controller: AbortController | null = null;
|
||||
|
||||
async function send(question: string) {
|
||||
controller?.abort(); // 新问题:取消上一次
|
||||
controller = new AbortController();
|
||||
status.value = "connecting";
|
||||
const resp = await fetch(opts.url, { method: "POST", body: JSON.stringify({ question }), signal: controller.signal });
|
||||
status.value = "streaming";
|
||||
const feed = createSSEParser((e) => {
|
||||
if (e.event === "token") text.value += e.data;
|
||||
else if (e.event === "final") final.value = parseFinal(e.data);
|
||||
});
|
||||
const reader = resp.body!.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
feed(value);
|
||||
}
|
||||
status.value = "done";
|
||||
}
|
||||
|
||||
onScopeDispose(() => controller?.abort()); // 组件卸载时停止,避免泄漏
|
||||
return { status: readonly(status), text: readonly(text), final, send, stop: () => controller?.abort() };
|
||||
}
|
||||
```
|
||||
|
||||
在组件中使用:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useChatStream } from "./useChatStream";
|
||||
const { status, text, final, send, stop } = useChatStream({ url: "/api/chat" });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="status === 'connecting'">连接中…</p>
|
||||
<article>{{ text }}</article>
|
||||
<ul v-if="final"><li v-for="c in final.citations" :key="c.id">{{ c.title }}</li></ul>
|
||||
<button v-if="status === 'streaming'" @click="stop">停止生成</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 易错点
|
||||
|
||||
- 解构 `reactive` 对象或 Pinia store 后丢失响应性。
|
||||
- `watch` 一个 `reactive` 对象的属性时写成 `watch(obj.count, ...)`:应该传 getter `() => obj.count`。
|
||||
- 异步 `watch` 没有处理竞态:快速切换时旧请求的结果覆盖新结果。
|
||||
- 在组合式函数里注册了定时器 / 订阅,没有在作用域销毁时清理。
|
||||
- 用 `v-html` 渲染模型输出的 Markdown,没有做 XSS 清洗(见 [Web 安全专题](../../cs/web-security/index.md))。
|
||||
- 把大量第三方对象(图表实例、编辑器)放进 `reactive`,造成深度代理的性能开销。
|
||||
|
||||
## 面试常问
|
||||
|
||||
- Vue 3 的响应式原理?和 Vue 2 有什么区别?
|
||||
- `ref` 和 `reactive` 的区别?
|
||||
- `computed` 和 `watch` 的区别?
|
||||
- 组合式函数和 React Hooks 有什么区别?
|
||||
@@ -0,0 +1 @@
|
||||
{"type":"module"}
|
||||
@@ -0,0 +1,25 @@
|
||||
// 运行:node sse-demo.ts —— 验证解析器能处理被切开的分块、多行 data、心跳注释
|
||||
import { createSSEParser, parseFinal, type SSEEvent } from "./sse.ts";
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
const feed = createSSEParser((e) => events.push(e));
|
||||
|
||||
const stream = [
|
||||
": ping\n\n",
|
||||
"id: 1\nevent: progress\ndata: {\"step\":\"检索资料\"}\n\n",
|
||||
"id: 2\nevent: token\ndata: 签收 7 天内",
|
||||
"可以无理由退货\n\nid: 3\nevent: tok",
|
||||
"en\ndata: 。\n\n",
|
||||
"id: 4\nevent: final\ndata: {\"answer\":\"签收 7 天内可以无理由退货。\",\n",
|
||||
"data: \"citations\":[{\"id\":1,\"title\":\"退货政策\"}]}\n\n",
|
||||
];
|
||||
for (const chunk of stream) feed(chunk);
|
||||
|
||||
console.log(events.map((e) => `${e.id}:${e.event}`).join(" "));
|
||||
const text = events.filter((e) => e.event === "token").map((e) => e.data).join("");
|
||||
const last = events.at(-1);
|
||||
const final = last ? parseFinal(last.data) : null;
|
||||
console.log("拼接文本:", text, "引用:", final?.citations);
|
||||
if (text !== "签收 7 天内可以无理由退货。" || events.length !== 4 || final?.citations[0]?.title !== "退货政策") {
|
||||
throw new Error("断言失败");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// 增量 SSE 解析器:网络分块可能把一个事件切成两半,必须缓冲到空行再解析。
|
||||
// 浏览器的 EventSource 只支持 GET 且不能带自定义请求头,AI 对话通常用 fetch + POST,再自己解析 SSE。
|
||||
// 格式参考 HTML 标准:https://html.spec.whatwg.org/multipage/server-sent-events.html
|
||||
|
||||
export interface SSEEvent {
|
||||
event: string; // 默认 "message"
|
||||
data: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function createSSEParser(onEvent: (e: SSEEvent) => void) {
|
||||
let buffer = "";
|
||||
return (chunk: string): void => {
|
||||
buffer += chunk.replace(/\r\n?/g, "\n");
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) >= 0) {
|
||||
const block = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const ev: SSEEvent = { event: "message", data: "" };
|
||||
const data: string[] = [];
|
||||
for (const line of block.split("\n")) {
|
||||
if (!line || line.startsWith(":")) continue; // 空行、注释(常用作心跳)
|
||||
const i = line.indexOf(":");
|
||||
const field = i < 0 ? line : line.slice(0, i);
|
||||
const value = i < 0 ? "" : line.slice(i + 1).replace(/^ /, "");
|
||||
if (field === "event") ev.event = value;
|
||||
else if (field === "data") data.push(value);
|
||||
else if (field === "id") ev.id = value;
|
||||
}
|
||||
if (data.length) {
|
||||
ev.data = data.join("\n");
|
||||
onEvent(ev);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 对话任务的状态:前端据此显示“连接中 / 生成中 / 完成 / 失败(可重试)/ 已停止”
|
||||
export type ChatStatus = "idle" | "connecting" | "streaming" | "done" | "error" | "aborted";
|
||||
|
||||
// 服务端约定的事件:token(增量文本)、progress(任务进度)、final(结构化结果)、error
|
||||
export interface FinalPayload {
|
||||
answer: string;
|
||||
citations: { id: number; title: string }[];
|
||||
}
|
||||
|
||||
export function parseFinal(data: string): FinalPayload {
|
||||
const v = JSON.parse(data) as Partial<FinalPayload>;
|
||||
if (typeof v.answer !== "string" || !Array.isArray(v.citations)) throw new Error("final 事件格式错误");
|
||||
return { answer: v.answer, citations: v.citations };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// 不自动运行(需要 npm i -D vitest vue 后执行 npx vitest run)
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectScope } from "vue";
|
||||
import { useChatStream } from "./useChatStream.ts";
|
||||
|
||||
function sseResponse(chunks: string[], failAfter?: number): Response {
|
||||
let i = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(ctrl) {
|
||||
if (failAfter !== undefined && i === failAfter) return ctrl.error(new Error("network down"));
|
||||
if (i >= chunks.length) return ctrl.close();
|
||||
ctrl.enqueue(new TextEncoder().encode(chunks[i++]));
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200, headers: { "Content-Type": "text/event-stream" } });
|
||||
}
|
||||
|
||||
const FINAL = 'id: 3\nevent: final\ndata: {"answer":"好","citations":[{"id":1,"title":"政策"}]}\n\n';
|
||||
|
||||
describe("useChatStream", () => {
|
||||
it("拼接 token 并解析 final", async () => {
|
||||
const scope = effectScope();
|
||||
const chat = scope.run(() =>
|
||||
useChatStream({ url: "/chat", fetchImpl: async () => sseResponse(["id: 1\nevent: token\ndata: 你", "好\n\n", FINAL]) }),
|
||||
)!;
|
||||
await chat.send("hi");
|
||||
expect(chat.status.value).toBe("done");
|
||||
expect(chat.text.value).toBe("你好");
|
||||
expect(chat.final.value?.citations[0]?.title).toBe("政策");
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it("断线后带 Last-Event-ID 重试", async () => {
|
||||
const seen: (string | null)[] = [];
|
||||
let n = 0;
|
||||
const fetchImpl = async (_: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(new Headers(init?.headers).get("Last-Event-ID"));
|
||||
return n++ === 0 ? sseResponse(["id: 1\nevent: token\ndata: 你\n\n", "x"], 1) : sseResponse([FINAL]);
|
||||
};
|
||||
const chat = useChatStream({ url: "/chat", fetchImpl, maxRetries: 1 });
|
||||
await chat.send("hi");
|
||||
expect(seen).toEqual([null, "1"]);
|
||||
expect(chat.status.value).toBe("done");
|
||||
});
|
||||
|
||||
it("stop 之后状态为 aborted", async () => {
|
||||
const fetchImpl = (_: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) =>
|
||||
init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))),
|
||||
);
|
||||
const chat = useChatStream({ url: "/chat", fetchImpl });
|
||||
const p = chat.send("hi");
|
||||
chat.stop();
|
||||
await p;
|
||||
expect(chat.status.value).toBe("aborted");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 不自动运行(依赖 vue,需要 npm install 后用 vitest 运行 useChatStream.test.ts)
|
||||
// Vue 3 组合式函数:POST + 流式读取、状态机、停止生成、断线后带 Last-Event-ID 重试。
|
||||
import { ref, shallowRef, readonly, onScopeDispose } from "vue";
|
||||
import { createSSEParser, parseFinal, type ChatStatus, type FinalPayload } from "./sse.ts";
|
||||
|
||||
export interface ChatOptions {
|
||||
url: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export function useChatStream(opts: ChatOptions) {
|
||||
const status = ref<ChatStatus>("idle");
|
||||
const text = ref("");
|
||||
const progress = ref("");
|
||||
const final = shallowRef<FinalPayload | null>(null);
|
||||
const error = ref("");
|
||||
let controller: AbortController | null = null;
|
||||
let lastEventId: string | undefined;
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
|
||||
async function send(question: string, attempt = 0): Promise<void> {
|
||||
if (attempt === 0) {
|
||||
controller?.abort(); // 新问题:取消上一次的生成
|
||||
text.value = "";
|
||||
final.value = null;
|
||||
error.value = "";
|
||||
lastEventId = undefined;
|
||||
}
|
||||
controller = new AbortController();
|
||||
status.value = "connecting";
|
||||
try {
|
||||
const resp = await doFetch(opts.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
|
||||
body: JSON.stringify({ question }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||||
status.value = "streaming";
|
||||
const feed = createSSEParser((e) => {
|
||||
if (e.id) lastEventId = e.id; // 记录进度,断线重连时服务端从这里继续
|
||||
if (e.event === "token") text.value += e.data;
|
||||
else if (e.event === "progress") progress.value = JSON.parse(e.data).step;
|
||||
else if (e.event === "final") final.value = parseFinal(e.data);
|
||||
else if (e.event === "error") throw new Error(e.data);
|
||||
});
|
||||
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
feed(value);
|
||||
}
|
||||
status.value = final.value ? "done" : "error";
|
||||
if (!final.value) error.value = "连接提前结束";
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
status.value = "aborted";
|
||||
return;
|
||||
}
|
||||
if (attempt < (opts.maxRetries ?? 2)) {
|
||||
await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
|
||||
return send(question, attempt + 1);
|
||||
}
|
||||
status.value = "error";
|
||||
error.value = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
controller?.abort();
|
||||
}
|
||||
|
||||
onScopeDispose(stop); // 组件卸载时停止,避免泄漏和继续计费
|
||||
|
||||
return {
|
||||
status: readonly(status),
|
||||
text: readonly(text),
|
||||
progress: readonly(progress),
|
||||
final,
|
||||
error: readonly(error),
|
||||
send,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,40 @@
|
||||
---
|
||||
id: frontend.vue.index
|
||||
title: Vue 3 + Pinia + Router
|
||||
title: Vue 3
|
||||
type: index
|
||||
domain: frontend
|
||||
stack: [vue]
|
||||
tags: []
|
||||
tags: [Vue 3, 响应式, 组合式函数, Pinia, AI 对话界面]
|
||||
week: [7]
|
||||
---
|
||||
|
||||
# Vue 3 + Pinia + Router
|
||||
# Vue 3
|
||||
|
||||
响应式、组合式 API、状态管理、路由与权限。
|
||||
你的强项,这里重点是面试常问的原理和“AI 原生前端”的实践(流式渲染、任务状态、断线续传)。BOSS直聘 2026-10-23 的调研中约 14 个岗位要求 Vue / React。
|
||||
|
||||
> 本专题建设中:课程、企业场景、面试题和示例代码会陆续补充。
|
||||
## 学习路线
|
||||
|
||||
| 顺序 | 课程 | 级别 | 预计 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [Vue 3 响应式原理、组合式函数、Pinia 和性能优化](01-reactivity-composition.md) | 进阶 | 3 小时 |
|
||||
|
||||
## 企业场景
|
||||
|
||||
- [AI 对话界面:流式渲染、任务状态、停止生成和断线续传](scenarios/ai-chat-stream.md)
|
||||
|
||||
## 面试题
|
||||
|
||||
- [Vue 面试题](interview.md)
|
||||
|
||||
## 示例代码
|
||||
|
||||
- [examples/ai-chat-stream](examples/ai-chat-stream/):增量 SSE 解析器(`node sse-demo.ts` 运行)、`useChatStream` 组合式函数(状态机、停止、`Last-Event-ID` 重试)和 vitest 测试(需要 `npm i -D vitest vue`)
|
||||
|
||||
## 在 16 周计划中
|
||||
|
||||
- [第 7 周:前端工程化查漏](../../../plan/week07-frontend.md)、[第 12 周:SSE 进度推送](../../../plan/week12-agent-m3-persistence.md)
|
||||
|
||||
## 官方资料
|
||||
|
||||
- https://cn.vuejs.org/
|
||||
- Pinia:https://pinia.vuejs.org/zh/
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
id: frontend.vue.interview
|
||||
title: Vue 面试题
|
||||
type: interview
|
||||
domain: frontend
|
||||
stack: [vue]
|
||||
tags: [Vue 3, 响应式, Pinia, 面试]
|
||||
level: intermediate
|
||||
related: [frontend.vue.reactivity-composition]
|
||||
week: [7, 16]
|
||||
---
|
||||
|
||||
# Vue 面试题
|
||||
|
||||
## 基础
|
||||
|
||||
### 【基础】Vue 3 的响应式原理是什么?
|
||||
|
||||
**参考答案**:`reactive` 用 Proxy 拦截读写,读取时把当前运行的副作用(渲染函数、computed、watch)记录为依赖,写入时触发这些副作用重新执行;`ref` 通过 `.value` 的访问器实现。更新是异步批量的,同一轮修改只渲染一次。
|
||||
|
||||
**追问**:和 Vue 2 有什么区别?→ Vue 2 用 `Object.defineProperty`,需要初始化时递归转换,无法检测新增属性和数组索引修改;Proxy 可以拦截这些操作,并且是惰性代理。
|
||||
|
||||
### 【基础】`ref` 和 `reactive` 有什么区别?
|
||||
|
||||
**参考答案**:`ref` 可以包装任何值,通过 `.value` 访问,模板中自动解包,可以整体替换;`reactive` 只能用于对象,直接访问属性,解构会丢失响应性,整体替换会断开引用。很多团队统一用 `ref`。
|
||||
|
||||
**追问**:解构 Pinia store 怎么保持响应性?→ 用 `storeToRefs(store)` 解构状态和 getter,方法直接从 store 解构。
|
||||
|
||||
### 【基础】`computed` 和 `watch` 的区别?
|
||||
|
||||
**参考答案**:`computed` 用于派生值,有缓存,依赖不变不重新计算,不应有副作用;`watch` 用于响应变化执行副作用(请求、写存储),可以拿到新旧值、控制立即执行和深度监听。
|
||||
|
||||
**追问**:`watch` 和 `watchEffect` 的区别?→ `watch` 显式指定数据源、惰性执行;`watchEffect` 自动收集依赖、立即执行一次。
|
||||
|
||||
## 进阶
|
||||
|
||||
### 【进阶】组合式函数和 React Hooks 有什么区别?
|
||||
|
||||
**参考答案**:Vue 的 `setup` 只执行一次,响应式系统自动追踪依赖,没有依赖数组和闭包陈旧值问题,也没有“Hooks 必须按顺序调用”的限制;React 组件每次渲染都执行函数体,靠依赖数组和 memo 控制。Vue 组合式函数要注意返回值保持响应性和副作用清理。
|
||||
|
||||
**追问**:组合式函数里怎么清理副作用?→ `onScopeDispose` 或 `onUnmounted` 中取消请求、清除定时器;`watch` 的清理回调处理竞态。
|
||||
|
||||
### 【进阶】`nextTick` 的作用和原理?
|
||||
|
||||
**参考答案**:Vue 把 DOM 更新放进异步队列批量执行,`nextTick` 返回一个在下一次 DOM 更新完成后 resolve 的 Promise(基于微任务),用于在修改数据后读取更新后的 DOM。
|
||||
|
||||
**追问**:为什么要批量异步更新?→ 避免同一轮中多次修改导致多次渲染,提高性能。
|
||||
|
||||
### 【进阶】Vue 应用有哪些性能优化手段?
|
||||
|
||||
**参考答案**:路由和组件懒加载;长列表虚拟滚动;`shallowRef` / `markRaw` 减少深度代理;`v-memo`、`v-once`;`computed` 缓存;避免不必要的组件重渲染(稳定的 props);按需引入组件库;图片懒加载和压缩;构建层面的拆包。
|
||||
|
||||
**追问**:怎么定位是哪个组件渲染慢?→ Vue DevTools 的性能面板、浏览器 Performance 面板录制,查看组件渲染耗时和次数。
|
||||
|
||||
## 场景题
|
||||
|
||||
### 【场景题】做一个 AI 对话界面,流式输出要注意什么?
|
||||
|
||||
**参考答案**:fetch + 增量 SSE 解析(POST、鉴权头);状态机;停止生成和卸载时取消;断线带 `Last-Event-ID` 续传;结构化的 final 事件返回引用;渲染节流和 XSS 清洗;网关关闭缓冲。详见[AI 对话界面场景](scenarios/ai-chat-stream.md)。
|
||||
|
||||
**追问**:为什么不用 EventSource?→ 只支持 GET、不能自定义请求头,问题内容和令牌不适合放在 URL 中。
|
||||
|
||||
### 【场景题】一个后台表格页面有 1 万行数据,滚动很卡,怎么优化?
|
||||
|
||||
**参考答案**:虚拟滚动只渲染可见行;数据用 `shallowRef` 存放;行组件保持 props 稳定、减少响应式开销;能分页就分页(服务端分页);复杂单元格延迟渲染;排序筛选放到 Web Worker 或服务端。
|
||||
|
||||
**追问**:虚拟滚动的原理?→ 根据滚动位置和行高计算可见范围,只渲染这一段,用占位高度撑开滚动条;行高不固定时需要测量和缓存高度。
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
id: frontend.vue.scenario.ai-chat-stream
|
||||
title: AI 对话界面:流式渲染、任务状态、停止生成和断线续传
|
||||
type: scenario
|
||||
domain: frontend
|
||||
stack: [vue, typescript, fastapi, nginx]
|
||||
tags: [流式输出, SSE, 任务状态, 断线重连, Last-Event-ID, 结构化输出, AI 原生前端]
|
||||
level: advanced
|
||||
prerequisites: [frontend.vue.reactivity-composition]
|
||||
est_hours: 3
|
||||
related: [frontend.vue.interview, python.fastapi.scenario.streaming-chat-api, devops.nginx.scenario.ai-gateway-sse-canary]
|
||||
week: [7, 12]
|
||||
---
|
||||
|
||||
# AI 对话界面:流式渲染、任务状态、停止生成和断线续传
|
||||
|
||||
## 业务背景与问题
|
||||
|
||||
企业知识库助手的前端(Vue 3),用户反馈:
|
||||
|
||||
1. 回答要等十几秒才一次性出现;
|
||||
2. 回答到一半网络抖动,整段内容丢失,只能重新提问(重新计费);
|
||||
3. 用户切换页面后,后台还在生成,浪费额度;
|
||||
4. 不知道助手在做什么(检索?调用工具?);
|
||||
5. 引用来源和答案混在文本里,无法点击跳转。
|
||||
|
||||
BOSS直聘 2026-10-23 的调研中,“AI 原生前后端”(流式、结构化输出、任务状态、异常恢复)是反复出现的要求,这个场景就是它的典型落地。
|
||||
|
||||
## 约束
|
||||
|
||||
- 需要 POST 请求携带问题和鉴权头(浏览器的 `EventSource` 只支持 GET、不能自定义请求头)。
|
||||
- 经过 Nginx 网关。
|
||||
|
||||
## 方案设计
|
||||
|
||||
1. **事件协议**(前后端约定):`progress`(当前步骤:检索、调用工具)、`token`(增量文本)、`final`(结构化结果:答案 + 引用列表)、`error`;每个事件带递增的 `id`。
|
||||
2. **前端**:`fetch` + `ReadableStream` 读取,增量 SSE 解析器(处理分块边界);状态机 `idle → connecting → streaming → done / error / aborted`,界面按状态显示。
|
||||
3. **停止生成**:`AbortController` 取消请求;组件卸载时自动取消;后端检测到断开后停止调用模型。
|
||||
4. **断线续传**:前端记录最后的事件 `id`,重试时带 `Last-Event-ID` 请求头;后端把生成中的事件缓存一段时间(例如 Redis 中按会话保存),从该 ID 之后继续发送,而不是重新生成。重试次数有限,带退避。
|
||||
5. **结构化结果**:引用来源通过 `final` 事件以 JSON 返回,前端渲染成可点击的卡片,而不是从文本里正则提取。
|
||||
6. **渲染性能**:正在生成的消息单独存放,Markdown 渲染节流;渲染前做 XSS 清洗。
|
||||
7. **网关**:Nginx 关闭代理缓冲(见 [Nginx SSE 场景](../../../devops/nginx/scenarios/ai-gateway-sse-canary.md))。
|
||||
|
||||
## 关键代码
|
||||
|
||||
增量解析器(完整版本 [examples/ai-chat-stream/sse.ts](../examples/ai-chat-stream/sse.ts),`node sse-demo.ts` 验证分块切开、多行 data、心跳注释):
|
||||
|
||||
```ts
|
||||
export function createSSEParser(onEvent: (e: SSEEvent) => void) {
|
||||
let buffer = "";
|
||||
return (chunk: string): void => {
|
||||
buffer += chunk.replace(/\r\n?/g, "\n");
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) >= 0) { // 一个事件以空行结束
|
||||
const block = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
// 解析 event / data / id 字段,忽略以 ":" 开头的注释(心跳)……
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
断线重试([useChatStream.ts](../examples/ai-chat-stream/useChatStream.ts)):
|
||||
|
||||
```ts
|
||||
headers: { "Content-Type": "application/json", ...(lastEventId ? { "Last-Event-ID": lastEventId } : {}) },
|
||||
...
|
||||
if (attempt < (opts.maxRetries ?? 2)) {
|
||||
await new Promise((r) => setTimeout(r, 300 * 2 ** attempt));
|
||||
return send(question, attempt + 1);
|
||||
}
|
||||
```
|
||||
|
||||
测试([useChatStream.test.ts](../examples/ai-chat-stream/useChatStream.test.ts))覆盖:拼接 token 和解析 final、断线后带 `Last-Event-ID` 重试、停止后状态为 aborted。
|
||||
|
||||
## 取舍
|
||||
|
||||
| 选择 | 优点 | 缺点 |
|
||||
| --- | --- | --- |
|
||||
| **fetch + 自己解析 SSE(本方案)** | 支持 POST、鉴权头、取消 | 要自己处理解析和重连 |
|
||||
| EventSource | 浏览器自动重连、自动带 Last-Event-ID | 只支持 GET,不能自定义请求头 |
|
||||
| WebSocket | 双向通信 | 对“一问一答流式返回”来说过重;网关和鉴权更复杂 |
|
||||
| 轮询 | 简单 | 延迟高、请求多 |
|
||||
|
||||
## 易错点
|
||||
|
||||
- 按网络分块直接 `JSON.parse`,分块切在事件中间就报错。
|
||||
- 只在 `done` 时更新状态,异常时界面一直转圈。
|
||||
- 断线重试时重新提问,重复计费,且用户看到内容“跳回开头”。
|
||||
- 组件卸载没有取消请求。
|
||||
- 引用来源从模型的文本里正则解析,格式一变就失效。
|
||||
- 流式过程中每个 token 都触发完整的 Markdown 解析和渲染,长回答卡顿。
|
||||
|
||||
## 面试怎么讲
|
||||
|
||||
> “AI 对话前端我做了一个组合式函数:用 fetch + ReadableStream 读 SSE,因为要 POST 和带鉴权头;自己写了增量解析器处理分块边界;状态机区分连接中、生成中、完成、失败、已停止;停止生成用 AbortController,组件卸载自动取消。断线时带 Last-Event-ID 重试,后端从缓存的事件继续发,不重新生成。引用来源通过 final 事件的结构化 JSON 返回,前端渲染成卡片。这些都有单元测试。”
|
||||
Reference in new issue
Block a user