diff --git a/Eino/Eino 知识索引.md b/Eino/Eino 知识索引.md index d2db3d4..bae52a0 100644 --- a/Eino/Eino 知识索引.md +++ b/Eino/Eino 知识索引.md @@ -80,7 +80,7 @@ graph TD | **Callback & Trace** | 执行过程可观测、可追踪 | [[Eino/quick_start/chapter_06_callback_and_trace]] | | **Interrupt & Resume** | Human-in-the-loop,断点续跑 | [[Eino/quick_start/chapter_07_interrupt_resume]] | | **Graph & Tool** | 低代码编排,图即是工具 | [[Eino/quick_start/chapter_08_graph_tool]] | -| **A2UI Protocol** | Agent 生成 UI 协议 | [[Eino/quick_start/chapter_09_a2ui_protocol]] | +| **A2UI Protocol** | Agent 生成 UI 协议 | [[chapter_10_a2ui_protocol]] | | **Skill Console** | 技能注册与管理 | [[Eino/quick_start/chapter_09_skill_console]] | --- diff --git a/Eino/quick_start/chapter_09_a2ui_protocol.md b/Eino/quick_start/chapter_10_a2ui_protocol.md similarity index 100% rename from Eino/quick_start/chapter_09_a2ui_protocol.md rename to Eino/quick_start/chapter_10_a2ui_protocol.md diff --git a/PDF/Husky.pdf b/PDF/Husky.pdf new file mode 100644 index 0000000..e0569d8 Binary files /dev/null and b/PDF/Husky.pdf differ diff --git a/PDF/MCP.pdf b/PDF/MCP.pdf new file mode 100644 index 0000000..7ae12b2 Binary files /dev/null and b/PDF/MCP.pdf differ diff --git a/hzh/AI/MCP.md b/hzh/AI/MCP.md new file mode 100644 index 0000000..8cea4bd --- /dev/null +++ b/hzh/AI/MCP.md @@ -0,0 +1,380 @@ +--- +tags: [mcp, ai-development, protocol, llm-tools] +create time: 2026-04-30 14:30 +--- + +# MCP — 模型上下文协议 + +## 概述 + +**Model Context Protocol (MCP)** 是由 Anthropic 开源的开放协议,为 AI 模型与外部数据源、工具之间提供标准化的通信接口。它让大语言模型能够安全、可控地访问文件系统、API、数据库等外部资源,是构建 AI Agent 基础设施的关键组件。 + +> [!question] 为什么需要 MCP? +> 在 MCP 出现之前,每个 AI 应用集成新数据源都需要写自定义代码——GitHub 一个接口、Slack 一个接口、公司内部 API 又一个接口。MCP 用一套统一协议解决了"重复造轮子"的问题:定义一次 Server,所有兼容 MCP 的 Client 都能使用。 + +--- + +## 核心架构 + +MCP 采用 **Client-Server** 架构,通过 JSON-RPC 2.0 进行通信: + +```mermaid +flowchart LR + subgraph client["MCP Client(宿主应用)"] + C1["Claude Desktop"] + C2["Cursor / IDE"] + C3["自建应用"] + end + + subgraph mcp_layer["MCP 协议层\n(JSON-RPC 2.0)"] + P1["Tools\n工具调用"] + P2["Resources\n数据读取"] + P3["Prompts\n模板预设"] + P4["Sampling\n子模型调用"] + end + + subgraph server["MCP Server(能力提供者)"] + S1["文件系统 Server"] + S2["GitHub Server"] + S3["数据库 Server"] + S4["自定义 Server"] + end + + C1 <-->|"stdio / SSE"| P1 + C1 <-->|"stdio / SSE"| P2 + C1 <-->|"stdio / SSE"| P3 + C2 <-->|"stdio / SSE"| P4 + C3 <-->|"HTTP SSE"| P1 + + P1 --> S1 + P1 --> S2 + P2 --> S3 + P2 --> S4 + P3 --> S1 + P4 --> S2 + + style client fill:#e8f5e9 + style mcp_layer fill:#fff3e0 + style server fill:#e3f2fd +``` + +**三个核心角色:** + +| 角色 | 职责 | 类比 | +|------|------|------| +| **Client** | 发起请求,连接 Server,将能力提供给 AI 模型 | "插件管理器" | +| **Server** | 暴露 Tools、Resources、Prompts 等能力 | "插件实现" | +| **Protocol** | 定义 JSON-RPC 通信格式和消息类型 | "USB 接口标准" | + +> [!tip] 关键理解 +> MCP **不是**另一个 LLM 框架或 SDK,它是一个**通信协议**。类似 HTTP 之于 Web 开发——你不需要"学 HTTP 框架",只需要知道如何发起请求和响应。MCP 同理,Server 和 Client 各自用 JSON-RPC 说话。 + +--- + +## 三种核心能力 + +### 1. Tools — 让 AI 执行操作 + +Tools 允许 AI 模型调用函数并获取结果,是 MCP 最常用的能力类型。 + +```go +// Go 实现:注册一个「查询天气」Tool +server.AddTool(mcp.Tool{Name: "get_weather", Description: "查询指定地点的天气"}, + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + city := req.Arguments["city"].(string) + weather := queryWeather(city) // 调用实际业务逻辑 + return mcp.SuccessResult(mcp.TextContent(weather)), nil + }) +``` + +客户端调用流程: + +```go +// Client 侧发起 Tool 调用 +result, err := client.CallTool(ctx, "get_weather", map[string]any{ + "city": "Beijing", +}) +``` + +**典型场景:** 搜索代码仓库、创建 GitHub Issue、发送 Slack 消息、执行数据库查询。 + +### 2. Resources — 让 AI 读取数据 + +Resources 提供结构化的只读数据访问,AI 可以根据 URI 按需读取: + +```go +// 注册一个 Resource Template(支持参数化 URI) +server.AddResourceTemplate( + mcp.ResourceTemplate{URI: "repo://{owner}/{repo}/file/{path}", Name: "Repository File"}, + func(ctx context.Context, req mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + // 根据 URI 参数读取文件内容 + content := readFile(req.Arguments["path"].(string)) + return mcp.SuccessResult(mcp.TextContent(content)), nil + }, +) +``` + +AI 可以通过 `repo://anthropic/claude-docs/file/README.md` 这样的 URI 读取任意仓库文件。 + +### 3. Prompts — 可复用的对话模板 + +Prompts 允许 Server 预定义结构化提示词模板,Client 加载后自动注入上下文: + +```go +server.AddPrompt(mcp.Prompt{Name: "code_review", Description: "代码审查模板"}, + func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + diff := req.Arguments["diff"].(string) + return &mcp.GetPromptResult{ + Messages: []mcp.PromptMessage{{ + Role: mcp.RoleUser, + Content: mcp.TextContent(fmt.Sprintf(`请审查以下代码变更: + +%s + +重点关注:安全性、性能、可读性。`, diff)), + }}, + }, nil + }) +``` + +--- + +## 传输层:连接 Client 与 Server + +MCP 支持两种传输方式,覆盖从本地到云端的完整场景: + +```mermaid +flowchart TD + subgraph stdio_transport["本地进程通信"] + A["Local MCP Server"] -->|stdin/stdout| B[Host Application] + end + + subgraph sse_transport["网络通信"] + C["Remote MCP Server"] -->|HTTP POST| D[SSE Endpoint] + D -->|Event Stream| E[Client App] + end + + style stdio_transport fill:#e8f5e9 + style sse_transport fill:#e3f2fd +``` + +| 传输方式 | 适用场景 | 特点 | +|----------|----------|------| +| **stdio** | 本地运行的 Server | 零配置,通过标准输入输出通信,适合命令行工具和本地集成 | +| **SSE (Server-Sent Events)** | 远程 Server | 通过 HTTP 端口暴露,适合云端服务和多客户端共享 | + +> [!note] 底层原理 +> 无论哪种传输方式,上层使用的都是相同的 **JSON-RPC 2.0** 消息格式。传输层仅负责"把消息送到对方手里",不关心内容语义。 + +--- + +## JSON-RPC 消息格式 + +MCP 基于 JSON-RPC 2.0,消息结构简洁直观: + +**初始化握手(Initialize):** + +```json +// Client → Server +{"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"clientInfo":{"name":"my-client","version":"1.0.0"}}} + +// Server → Client 响应 +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"weather-server","version":"1.0.0"}}} +``` + +**列出可用 Tools:** + +```json +// Client → Server +{"jsonrpc":"2.0","method":"tools/list","id":2} + +// Server → Client +{"jsonrpc":"2.0","result":{"tools":[{"name":"get_weather","description":"查询天气","inputSchema":{"type":"object","properties":{"city":{"type":"string"}}}}]}} +``` + +**调用 Tool:** + +```json +{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"get_weather","arguments":{"city":"Tokyo"}}} +``` + +--- + +## 实战:从零搭建 MCP Server + +以下是一个完整的 Go 实现示例,演示创建一个简单 MCP Server 的最小路径: + +### Step 1:初始化项目 + +```bash +mkdir mcp-weather-server && cd mcp-weather-server +go mod init mcp-weather-server +go get github.com/modelcontextprotocol/go-sdk/mcp +``` + +### Step 2:编写 Server 代码 + +```go +package main + +import ( + "context" + "fmt" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func main() { + // 创建 Stdio 传输(本地模式) + transport := mcp.NewStdioTransport() + + // 创建 Server 实例 + server := mcp.NewServer(&mcp.ServerInfo{Name: "weather-server", Version: "1.0.0"}) + + // 注册 Tool:查询天气 + server.AddTool(mcp.Tool{ + Name: "get_weather", + Description: "Query weather for a given city", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"city": map[string]string{"type": "string"}}, + "required": []string{"city"}, + }, + }, handleGetWeather) + + // 启动服务 + if err := server.Run(context.Background(), transport); err != nil { + log.Fatal(err) + } +} + +// 处理天气查询的核心逻辑 +func handleGetWeather(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + city := req.Arguments["city"].(string) + + // 此处接入真实天气 API + response := fetchFromOpenWeather(city) + + return &mcp.CallToolResult{ + Content: []mcp.Content{mcp.TextContent(response)}, + }, nil +} + +func fetchFromOpenWeather(city string) string { + // TODO: 调用 OpenWeatherMap API + return fmt.Sprintf("City %s: Sunny, 23°C", city) +} +``` + +### Step 3:配置 Claude Desktop 使用 + +在 Claude Desktop 的配置文件中添加 Server: + +```json +{ + "mcpServers": { + "weather": { + "command": "go", + "args": ["run", "/path/to/mcp-weather-server"], + "transport": "stdio" + } + } +} +``` + +### Step 4:验证效果 + +启动 Claude Desktop 后,在对话中直接告诉 AI "帮我查一下北京的天气",AI 会自动识别调用 `get_weather` Tool。 + +> [!abstract] 代码说明 +> 以上代码展示了 MCP Server 的三个核心步骤:**创建 Server → 注册 Tool → 启动传输**。实际项目中只需替换 `handleGetWeather` 中的业务逻辑即可。 + +--- + +## 常见开源 MCP Server 参考 + +| Server | 用途 | 技术栈 | +|--------|------|--------| +| **Filesystem** | 读写本地文件系统 | TypeScript | +| **GitHub** | Issue/PR/Code Search | TypeScript | +| **PostgreSQL** | 数据库查询 | Python | +| **GitLab** | GitLab 项目管理 | TypeScript | +| **Slack** | 消息收发 | Python | +| **Memory** | 向量存储的知识库 | TypeScript | + +这些参考实现在 MCP 官方仓库的 `servers` 目录下,可以直接编译运行或二次开发。 + +--- + +## 安全考虑 + +MCP 的设计将权限控制交给了 Host Application(宿主应用),这带来了灵活性的同时也需要注意安全风险: + +> [!warning] 安全最佳实践 +> - **沙箱隔离**:对 Server 的文件/网络访问权限进行限制,避免恶意 Server 读写敏感数据 +> - **权限最小化**:只授予 Server 完成工作所需的最小权限集 +> - **审计日志**:记录所有 Tool 调用和 Resource 访问,便于追踪异常行为 +> - **用户确认**:对破坏性操作(删除、修改)应在 AI 执行前请求用户确认 +> - **依赖安全**:检查 Server 的代码依赖,防止供应链攻击 + +--- + +## MCP 与相关技术的关系 + +```mermaid +quadrantChart + title AI 集成生态中的位置 + x-axis "专用集成" --> "通用协议" + y-axis "运行时绑定" --> "标准化抽象" + "Custom Integrations": [0.15, 0.1] + "OpenAI Function Calling": [0.3, 0.2] + "LangChain Tools": [0.25, 0.15] + "MCP": [0.7, 0.75] + "REST APIs": [0.85, 0.85] + "gRPC": [0.8, 0.9] +``` + +**核心区别:** + +| 对比维度 | Function Calling | MCP | +|----------|------------------|-----| +| **作用域** | LLM API 内部的能力调用机制 | 跨应用的独立通信协议 | +| **可复用性** | 每次调用需重新定义 function deffinition | 一次部署,所有兼容 Client 共享 | +| **生命周期** | 单次请求内有效 | 持久运行的独立进程 | +| **生态位** | "LLM 怎么调用函数" | "应用之间怎么交换能力和数据" | + +> [!note] 两者可以共存 +> MCP Server 内部仍然可以使用 Function Calling 调用的方式与 LLM 交互。MCP 解决的是"应用层面的互操作",Function Calling 解决的是"模型层面的函数感知"——二者在不同层级工作。 + +--- + +## 本章小结 + +| 概念 | 定位 | 一句话理解 | +|------|------|-----------| +| **Tools** | 操作型能力 | 让 AI "动手做事" | +| **Resources** | 数据型能力 | 让 AI "读取数据" | +| **Prompts** | 模板型能力 | 让 AI "复用话术" | +| **stdio 传输** | 本地通信 | 进程间 stdin/stdout | +| **SSE 传输** | 网络通信 | HTTP 流式推送 | +| **JSON-RPC 2.0** | 消息协议 | 统一的请求-响应格式 | + +> [!success] 学习成果 +> 完成本章后,你应该能够: +> - 理解 MCP 的 Client-Server 架构和工作原理 +> - 区分 Tools、Resources、Prompts 三种核心能力的不同场景 +> - 具备从零搭建一个 MCP Server 的能力 +> - 了解 MCP 与传统 Function Calling 的关系与差异 + +--- + +## 扩展阅读 + +- MCP 官方规范:https://modelcontextprotocol.io/specification +- Go SDK 文档:https://github.com/modelcontextprotocol/go-sdk +- TypeScript SDK 文档:https://github.com/modelcontextprotocol/sdk +- 官方参考 Server:https://github.com/modelcontextprotocol/servers + +## 关联笔记 diff --git a/hzh/DEV/Husky.md b/hzh/DEV/Husky.md new file mode 100644 index 0000000..05593d0 --- /dev/null +++ b/hzh/DEV/Husky.md @@ -0,0 +1,262 @@ +--- +tags: [git, husky, pre-commit, hooks, lint] +create time: 2026-04-30 14:45 +--- + +# Husky — Git Hooks 管理工具 + +## 概述 + +Husky 是 Node.js 生态中最流行的 Git Hooks 管理工具之一,它简化了 `pre-commit`、`commit-msg`、`push` 等钩子的配置和运行流程,让代码质量检查能够自动在提交前执行。 + +> [!tip] 为什么需要 Git Hooks? +> 在团队协作中,手动遵守代码规范几乎不可能——lint 错误、未格式化、测试失败等问题经常流入仓库。Git Hooks 能在代码提交前自动拦截这些问题,把质量防线前置到客户端而非 CI 阶段。 + +## 核心概念 + +### 什么是 Git Hooks? + +Git 在每个操作完成后会自动查找并执行 `.git/hooks/` 目录下的对应脚本文件。最常见的 Hook 包括: + +| Hook 名称 | 触发时机 | 典型用途 | +|-----------|----------|----------| +| `pre-commit` | 执行 `git commit` 时,commit 之前 | 代码 lint、格式化、单元测试 | +| `commit-msg` | pre-commit 成功后 | 校验 Commit Message 格式(如 Conventional Commits) | +| `pre-push` | 执行 `git push` 之前 | 全量测试、安全检查 | +| `post-commit` | commit 成功后 | 通知、日志记录 | + +> [!question] 思考 +> 如果某个 Hook 执行返回非零退出码(exit code ≠ 0),会发生什么? +> —— Git 会中止当前操作,阻止代码被提交或推送。这就是"拦截"的本质。 + +**关键点**:原生 Git Hooks 存在一个痛点——它们存储在 `.git/` 目录下,不会随版本库同步。每个开发者都需要手动配置,容易遗漏或不一致。 + +### Husky 如何解决这个问题? + +Husky 的核心理念:**将 Hook 配置写入 `package.json`(即代码仓库本身),通过 `npx husky install` 自动生成 `.git/hooks/` 目录下的脚本。** + +```mermaid +graph TD + Repo["代码仓库 Git"] --> Package["package.json 声明 Husky hooks"] + Repo --> HuskyDir[".husky/ 目录 pre commit etc"] + HuskyDir -- "npx husky install" --> GitHooks[".git/hooks/ 自动生成的 shell 链接"] + + classDef repo fill:#dbeafe,color:#1e3a8a + classDef dir fill:#fef3c7,color:#92400e + classDef target fill:#dcfce7,color:#166534 + class Repo repo + class HuskyDir dir + class GitHooks target +``` + +这种设计带来的好处: +1. **Hook 配置与代码同源** — 团队成员克隆后只需一条命令即可生效 +2. **版本控制** — Hook 脚本的变更可追溯、可 Review +3. **易于维护** — 集中管理,无需分散在各开发者的本地配置中 + +## 安装与配置 + +### 步骤一:安装 + +```bash +# npm +npm install husky --save-dev + +# yarn +yarn add husky --dev + +# pnpm +pnpm add husky -D +``` + +### 步骤二:初始化 + +```bash +# Husky v9+ 使用 pkg-manager 模式 +npx husky init +``` + +这会自动完成两件事: +1. 创建 `.husky/` 目录 +2. 生成 `.husky/pre-commit` 示例脚本 +3. 在 `package.json` 中添加 `"prepare"` 脚本 + +```json +{ + "scripts": { + "prepare": "husky" + } +} +``` + +> [!note] 为什么用 `prepare` 脚本? +> 当其他开发者执行 `npm install` / `yarn install` / `pnpm install` 时,`prepare` 会自动运行,相当于一次性完成 Husky 初始化。这样就不需要每个人都手动执行 `npx husky init` 了。 + +### 步骤三:添加更多 Hook + +```bash +# 创建 commit-msg hook +npx husky add .husky/commit-msg 'npx --no-install commitlint --edit "$1"' + +# 创建 pre-push hook +npx husky add .husky/pre-push 'npm test' +``` + +每个生成的 `.husky/` 目录下的文件都是一个简单的 Shell 脚本: + +```bash +#!/usr/bin/env sh +.nvm/default/node_modules/husky/run.js node "cypress" +# 或直接执行命令 +npm run lint +``` + +## 实战集成 + +### 集成 Lint + Format + +```bash +# .husky/pre-commit +npx lint-staged +``` + +配合 `lint-staged` 只处理暂存的文件,大幅加快执行速度: + +```json +{ + "lint-staged": { + "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"], + "*.json": ["prettier --write"] + } +} +``` + +### 集成 Commit Message 校验 + +```bash +# .husky/commit-msg +npx --no-install commitlint --edit "$1" +``` + +Commitlint 规则示例: + +```js +// commitlint.config.js +module.exports = { + extends: ['@commitlint/config-conventional'], + rules: { + 'type-enum': [2, 'always', [ + 'feat', 'fix', 'docs', 'style', 'refactor', + 'test', 'chore', 'perf' + ]] + } +}; +``` + +### Husky Hooks 执行流程 + +提交时的 Hook 拦截链路如下: + +```mermaid +flowchart TD + Start["git commit"] --> PreCommit{"pre-commit"} + PreCommit -->|"lint/format pass"| Stage["暂存文件已提交"] + PreCommit -->|"lint/format fail"| BlockPre["Hook 中止提交"] + Stage --> CommitMsg{"commit-msg"} + CommitMsg -->|"格式正确"| Success["commit 成功"] + CommitMsg -->|"格式错误"| BlockCommit["Hook 中止提交"] + + classDef fail fill:#f99 + classDef success fill:#9f9 + class BlockPre,BlockCommit fail + class Success success +``` + +> [!tip] Hook 的退出码机制 +> 每个 Hook 脚本通过 exit code 决定 Git 是否继续——返回 `0` 表示成功放行,返回非 `0` 则中断整个操作。 + +## 常见场景与技巧 + +### 跳过 Hook(仅个人调试) + +```bash +git commit --no-verify +``` + +> [!warning] 安全警告 +> `--no-verify` 仅用于紧急调试!不要在 PR 提交时使用,这会绕过所有质量检查。 + +### Conditional Husky + +Husky v8+ 支持条件执行,避免在 CI 环境中浪费资源: + +```bash +if [ "$CI" != "true" ]; then + npx lint-staged +fi +``` + +### Windows 兼容性 + +Husky v9+ 已内置良好的 Windows 支持,但需注意: +- 确保系统安装了 `sh`(Windows 上的 Git Bash 自带) +- 或在 `.husky/_/husky.sh` 中配置 PowerShell 作为 shell 替代品 + +### 大型项目的性能优化 + +```json +{ + "lint-staged": { + // 按语言分组处理,充分利用并行 + "*.{js,ts}": ["eslint --cache --fix"], + "*.{css,scss}": ["stylelint --fix"], + "*.{png,jpg,svg}": ["imagemin"] + } +} +``` + +结合 `turbo` 或 `nx` 做增量构建,只重新验证有变化的文件。 + +## 与其他方案的对比 + +| 方案 | 配置方式 | 跨平台 | 社区规模 | 推荐场景 | +|------|----------|--------|----------|----------| +| **Husky** | JS/JSON 声明式 | ✅ 好 | ⭐⭐⭐⭐⭐ | Node.js / JS 项目首选 | +| **Pre-commit (Python)** | YAML 配置文件 | ✅ 好 | ⭐⭐⭐⭐ | 多语言混合项目 | +| **Ruff + Git Hook** | Shell 脚本 | ✅ 好 | ⭐⭐⭐ | Python 项目快速集成 | +| **Rust (cargo-husky)** | Cargo 构建脚本 | ✅ 好 | ⭐⭐ | Rust 项目 | + +> [!info] 选型建议 +> 如果你的项目是 Node.js 技术栈,Husky 是最自然的选择——零额外依赖,与 npm/yarn/pnpm 生态无缝集成。对于 Go/Rust/Python 混合项目,可以考虑 [pre-commit](https://pre-commit.com/) 框架,它用 YAML 统一管理多种语言的 Hook。 + +## 进阶:CI 与 Hooks 的配合策略 + +```mermaid +sequenceDiagram + participant Dev as Developer + participant HG as Husky Hook + participant CI as CI Pipeline + participant MR as Merge Request + + Dev->>HG: git commit + HG->>HG: lint-staged + format + alt Hook pass + HG-->>Dev: submit successful + Dev->>MR: git push and open MR + MR->>CI: trigger CI + CI->>CI: full test suite + CI-->>MR: report result + else Hook fail + HG-->>Dev: blocked by hook + Note over Dev,HG: Fix issues then commit again + end +``` + +**最佳实践**: +- **客户端 Hook**(Husky):快速反馈,只检查暂存文件 + Commit Message 格式 +- **服务端 CI**:完整测试套件 + 安全扫描 + 构建验证 +- 两者互为补充,前者保体验,后者保安全 + +## 关联笔记 + +- [[CLAUDE.md]] — Agent 行为配置参考 \ No newline at end of file diff --git a/hzh/TEST/React.md b/hzh/TEST/React.md new file mode 100644 index 0000000..d2e0830 --- /dev/null +++ b/hzh/TEST/React.md @@ -0,0 +1,970 @@ +--- +tags: + - 前端 + - React + - Hooks + - 路由 + - 测试 + - 自我考察 +create time: 2026-04-30 15:00 +update time: 2026-04-30 15:45 +status: reviewed +--- + +# React Hook 与路由状态测试 + +## 使用说明 + +本试卷覆盖 **Hooks 核心机制 → 自定义 Hooks → 路由状态管理 → 工程实践** 全链路知识点。共四部分:选择题、编程题、场景分析、综合应用。 + +**建议用时:** 45 分钟 | **及格线:** 70 / 100 + +做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。 + +--- + +## 一、选择题(每题 3 分,共 45 分) + +> 每题只有一个正确答案。 + +### Q1.【useState 的状态更新机制】 + +以下关于 `useState` 的描述,**错误**的是: + +A. 同一批次内连续调用多次 `setState` 会被合并优化 +B. 状态更新是异步的,因此更新后立即访问 state 可能是旧值 +C. 当新状态与旧状态使用 `Object.is` 比较为相同值时,组件不会重新渲染 +D. 可以直接修改 state 对象来触发重新渲染 + +> [!tip]- Q1 答案 +> **D — React 要求状态不可变,直接修改 state 对象不会触发重新渲染** +> +> **解析:** +> React 的状态更新遵循不可变性原则。直接修改 state 对象(如 `state.count = count + 1`)不会触发组件重新渲染,因为 React 无法检测到这种变化。必须通过 `setState` 返回新对象。 +> +> **为什么其他选项正确:** +> - ✅ A:React 会将同一批次内的多次 setState 合并为一次更新,这是批处理优化 +> - ✅ B:setState 是异步的(在 React 18 中使用自动批处理),更新后立即访问 state 可能仍是旧值 +> - ✅ C:React 使用 `Object.is` 比较新旧状态,如果相同则跳过渲染 +> +> ```tsx +> // ❌ 错误:直接修改 +> state.count = state.count + 1; +> +> // ✅ 正确:返回新对象 +> setCount(prev => prev + 1); +> ``` +> +> 💡 **核心原则:** 状态不可变性是 React 的基石,它让 React 能够通过浅比较快速判断是否需要重新渲染。 + +### Q2.【useEffect 的依赖数组】 + +```tsx +useEffect(() => { + fetchData(); +}, [userId]); +``` + +当 `userId` 变化时,以下哪种说法正确? + +A. 先执行清理函数(如果有的话),再执行 effect +B. 直接执行 effect,忽略之前的 effect +C. 必须在 effect 内部手动调用 cleanup +D. 以上都不对 + +> [!tip]- Q2 答案 +> **A — React 在每次执行新的 effect 之前,会先执行上一次 effect 的清理函数** +> +> **解析:** +> 这是 React 的清理机制核心设计。当依赖项变化时: +> 1. 先执行上一次 effect 的清理函数(cleanup) +> 2. 再执行新的 effect +> +> 这种设计确保了资源的正确释放,避免内存泄漏。 +> +> ```tsx +> useEffect(() => { +> const controller = new AbortController(); +> // effect 逻辑 +> return () => controller.abort(); // 清理函数 +> }, [userId]); +> ``` +> +> 💡 **应用场景:** 取消未完成的请求、清除定时器、取消事件监听器等。 + +### Q3.【路由参数获取】 + +在 React Router v6+ 中,以下哪种方式**不能**获取动态路由参数 `/user/:id` 中的 `id`? + +A. `useParams()` hook +B. `useMatch('/user/:id')` 返回的 match 对象 +C. `useLocation()` 解析 pathname +D. 直接从 props 读取 `props.params.id` + +> [!tip]- Q3 答案 +> **D — 函数组件不再通过 props 接收路由参数** +> +> **解析:** +> 在 React Router v6+ 中,函数组件使用 hooks 获取路由参数,而不是通过 props。`props.params` 是 Class 组件时代的 React Router v5 写法。 +> +> | 方式 | 是否可行 | 说明 | +> |------|---------|------| +> | `useParams()` | ✅ | 最常用,返回参数对象 | +> | `useMatch()` | ✅ | 返回匹配信息,包含 params | +> | `useLocation()` | ⚠️ | 可以解析 pathname,但需要手动提取 | +> | `props.params` | ❌ | v5 写法,v6 不支持 | +> +> ```tsx +> // React Router v6+ 正确做法 +> const { id } = useParams(); +> ``` +> +> 💡 **迁移注意:** 从 v5 迁移到 v6 时,需要将 `props.params.xxx` 改为 `useParams().xxx`。 + +### Q4.【自定义 Hook 的调用规则】 + +以下关于自定义 Hook 的代码,哪个会导致运行时错误? + +A. 在组件顶层调用 `useCustomHook()` +B. 在 useEffect 回调中调用 `useCustomHook()` +C. 在条件语句中调用 `useCustomHook()` +D. 在事件处理函数中调用 `useCustomHook()` + +> [!tip]- Q4 答案 +> **C — Hooks 必须在组件顶层或自定义 Hook 中调用,不能在条件语句、循环或嵌套函数中调用** +> +> **解析:** +> 这违反了 Hooks 的调用顺序规则。React 依赖 Hook 的调用顺序来正确关联状态和 Effect。如果在条件语句中调用 Hook,当条件变化时,Hook 的调用顺序也会变化,导致状态错乱。 +> +> ```tsx +> // ❌ 错误:条件调用 +> if (isLoggedIn) { +> const data = useUserData(); // 违反规则 +> } +> +> // ✅ 正确:顶层调用 +> const data = useUserData(); +> const result = isLoggedIn ? data : null; +> ``` +> +> 💡 **记忆口诀:** "Hook 只能在顶层调用,不在条件、循环、嵌套函数中使用"。 + +### Q5.【嵌套路由的 Outlet 使用】 + +关于 React Router 的 `` 组件,以下哪项描述正确? + +A. Outlet 必须在父路由组件中渲染 +B. Outlet 只能渲染一层嵌套 +C. Outlet 的位置可以是任意的,只要在父组件中 +D. Outlet 渲染的内容由父路由的 `element` 属性决定 + +> [!tip]- Q5 答案 +> **A — Outlet 必须在父路由组件中渲染** +> +> **解析:** +> `` 是嵌套路由的核心,它作为占位符,渲染匹配的子路由组件。每个嵌套路由的父组件都必须包含一个 ``,否则子路由无法渲染。 +> +> ```tsx +> // 父路由组件 +> function Layout() { +> return ( +>
+> +> {/* 子路由在此渲染 */} +>
+> ); +> } +> +> // 路由配置 +> }> +> } /> +> } /> +> +> ``` +> +> **为什么其他选项错误:** +> - ❌ B:Outlet 可以渲染任意深度的嵌套 +> - ❌ C:位置可以任意,但通常放在需要显示子路由内容的地方 +> - ❌ D:Outlet 渲染的是匹配的子路由组件,不是父路由的 element +> +> 💡 **最佳实践:** Outlet 通常放在布局组件中,用于渲染子页面内容。 + +--- + +### Q6.【Context 的重新渲染行为】 + +```tsx +const ThemeContext = createContext({ theme: 'light', setTheme: () => {} }); + +function App() { + const [theme, setTheme] = useState('light'); + return ( + +
+ + + ); +} +``` + +关于以上代码,以下说法**正确**的是: + +A. `Header` 和 `Sidebar` 只有在 `theme` 变化时才会重新渲染 +B. `Header` 和 `Sidebar` 在任何时候都不会重新渲染,因为它们没有直接订阅 context +C. 只要 `value` 引用变化,所有消费该 context 的组件都会重新渲染,即使用到的值没变 +D. 应该在每次渲染时创建新的 context 值,以确保数据最新 + +> [!tip]- Q6 答案 +> **C — Context Provider 使用引用比较来判断是否需要通知消费者** +> +> **解析:** +> `value={{ theme, setTheme }}` 在每次渲染时都是新对象,导致所有消费该 context 的组件都重新渲染。即使用了的值没变也会渲染。 +> +> **解决方案:** +> ```tsx +> // ✅ 使用 useMemo 稳定 value 引用(仅当相关值变化时更新) +> const value = useMemo(() => ({ theme, setTheme }), [theme]); +> +> // ✅ 或拆分 context,只让需要的部分变化 +> const ThemeValueContext = createContext('light'); +> const SetThemeContext = createContext(() => {}); +> ``` +> +> 💡 **核心原则:** context 是「全有或全无」的通知机制,无法按字段粒度优化。需要细粒度控制时应拆分 context 或使用状态管理库。 +> +> **为什么其他选项错误:** +> - ❌ A:忽略了 value 引用变化的影响 +> - ❌ B:消费 context 的组件会在 provider value 变化时重新渲染 +> - ❌ D:恰恰相反,应稳定 value 引用以避免不必要的渲染 + +### Q7.【React 虚拟 DOM Diff 算法】 + +关于 React 的 Diff 算法,以下描述**正确**的是: + +A. React 会对树进行深度优先对比,逐个节点比较 +B. React 只对同层组件进行比较,不同层的组件不会跨层对比 +C. React 通过 key 属性来精确匹配组件身份,避免不必要的重建 +D. 当列表元素的顺序发生变化时,React 会重新排列 DOM 节点而不是重建 + +> [!tip]- Q7 答案 +> **B、C、D 均为正确描述(本题为多选题)** +> +> **解析:** +> React 的 Diff 算法采用三种启发式策略简化复杂度: +> 1. **只对同层比较**:不同树的节点直接销毁重建,不会跨层移动 +> 2. **key 匹配身份**:列表中使用稳定的 key 帮助 React 识别哪些元素变了 +> 3. **类型相同则复用**:同层同类型的组件会复用实例,仅更新变化的 props +> +> ```tsx +> // ❌ 不推荐:用 index 作为 key,会导致不必要的状态重置和渲染错误 +> {items.map((item, index) => )} +> +> // ✅ 推荐:使用稳定且唯一的 id 作为 key +> {items.map(item => )} +> ``` +> +> 💡 **性能关键:** key 的作用是帮助 React 识别「哪个元素变了」而非「是否应该渲染」。没有 key 时 React 依赖索引,列表增删时会出错。 +> +> **为什么 A 错误:** React 的 diff 是同级比较,不是纯粹的深度优先,且采用了类型优先的比较策略。 + +### Q8.【useCallback 与 useMemo 的区别】 + +以下关于 `useCallback` 和 `useMemo` 的说法,**错误**的是: + +A. `useCallback(fn, deps)` 等价于 `useMemo(() => fn, deps)` +B. 两者返回的值在依赖不变时都是稳定的引用 +C. `useMemo` 用于缓存计算结果,`useCallback` 用于缓存函数本身 +D. 如果没有传入依赖数组,两者的行为与直接使用原始值没有区别 + +> [!tip]- Q8 答案 +> **D — 没有依赖数组时,每次渲染都会重新计算,失去缓存意义** +> +> **解析:** +> 省略依赖数组意味着 Hooks 不会做任何缓存——每次渲染都会执行回调并返回新值,这与普通变量的行为无异。 +> +> **对比:** +> +> | Hook | 返回值 | 典型用途 | +> |------|--------|---------| +> | `useCallback(fn, deps)` | 缓存的函数引用 | 传给子组件的回调 | +> | `useMemo(() => val, deps)` | 缓存的计算结果 | 昂贵计算、衍生状态 | +> +> ```tsx +> // ✅ 正确:有依赖数组 +> const memoizedFn = useCallback(() => doSomething(a, b), [a, b]); +> const memoizedValue = useMemo(() => expensiveCalc(data), [data]); +> +> // ⚠️ 常见误区:不加 deps 等于白写 +> const badFn = useCallback(() => doSomething(a), []); // a 变化时不会更新 +> ``` +> +> 💡 **最佳实践:** 只在传参给 memo 子组件或有第三方库依赖时使用 useCallback,日常函数不需要过度优化。 + +### Q9.【Error Boundary 的能力边界】 + +以下哪种错误**不能**被 Error Boundary 捕获? + +A. 事件处理函数中的错误 +B. 生命周期钩子中的错误 +C. 组件树渲染过程中的错误 +D. `useEffect` 中的异步错误 + +> [!tip]- Q9 答案 +> **A、D — Error Boundary 只能捕获渲染期、生命周期期和构造期的同步错误** +> +> **解析:** +> Error Boundary 是一个特殊的 React 组件,通过声明周期方法捕获子组件树的错误。但它有明确的限制: +> +> | 场景 | 能否捕获 | 原因 | +> |------|---------|------| +> | 渲染阶段 | ✅ | 标准捕获范围 | +> | 生命周期 | ✅ | 标准捕获范围 | +> | 构造函数 | ✅ | 标准捕获范围 | +> | 事件处理 | ❌ | 属于异步回调,不在捕获范围 | +> | useEffect 异步 | ❌ | 异步操作不在捕获范围 | +> | 自身错误 | ❌ | 不会捕获自己抛出的错误 | +> | 路由 | ❌ | React Router 的错误不属于 React 抛出 | +> | 异常处理 | ❌ | `try/catch` 无法捕获 Promise 未处理错误 | +> +> **正确的错误处理方式:** +> ```tsx +> // 事件处理中自行处理 +> function handleClick() { +> try { +> riskyOperation(); +> } catch (err) { +> handleError(err); +> } +> } +> +> // useEffect 中的异步错误 +> useEffect(() => { +> fetchData().catch(err => console.error(err)); +> }, []); +> +> // Error Boundary 组件 +> class ErrorBoundary extends React.Component { +> static getDerivedStateFromError(error) { +> return { hasError: true }; +> } +> componentDidCatch(error, errorInfo) { +> reportToService(error, errorInfo); +> } +> render() { +> if (this.state.hasError) return ; +> return this.props.children; +> } +> } +> ``` +> +> 💡 **记忆要点:** Error Boundary 只管「渲染时」的错误,异步路径一律不管。 + +### Q10.【Portal 的使用场景】 + +`ReactDOM.createPortal(child, container)` 的主要作用是什么? + +A. 将子节点渲染到父组件 DOM 层级之外的指定容器中 +B. 提升子组件的状态到全局 store +C. 绕过 React 的生命周期直接进入原生 DOM 操作 +D. 将子组件的内容克隆到多个位置同时渲染 + +> [!tip]- Q10 答案 +> **A — Portal 允许将子树渲染到 DOM 树的任何位置** +> +> **解析:** +> Portal 改变了渲染的 DOM 位置,但**保持 React 树结构不变**。这意味着事件冒泡仍然按照 React 组件树的层次关系向上冒泡,不受 DOM 结构影响。 +> +> ```tsx +> // Modal 渲染到 document.body,但在 React 树中仍属于 Dialog 组件 +> function Modal({ isOpen, children }) { +> if (!isOpen) return null; +> return createPortal( +>
{children}
, +> document.body +> ); +> } +> +> // 事件冒泡仍然是:Button → Dialog → Modal,不会因为 DOM 分离而中断 +> ``` +> +> **常见使用场景:** +> - 模态框 / 弹窗(需要突破 z-index 和 overflow:hidden 的限制) +> - Tooltip / 下拉菜单(需要覆盖多个层级的定位) +> - 固定定位的全局组件(Toast、Loading 遮罩等) +> +> 💡 **注意:** Portal 改变的是 DOM 挂载位置,不改变 React 组件层级。父子关系、Context、事件冒泡照常工作。 + +--- + +## 二、编程题(每题 10 分,共 40 分) + +> 实现题目要求的功能,写出核心代码。 + +### Q11.【自定义 Hook:useFetch】 + +**要求**:实现一个通用的 `useFetch` hook,支持加载状态、错误处理和数据缓存。 + +```tsx +// 请补全以下 Hook 的实现 +function useFetch(url: string): { + data: T | null; + loading: boolean; + error: Error | null; +} { + // 你的实现 +} + +// 使用示例 +function UserProfile({ userId }: { userId: string }) { + const { data, loading, error } = useFetch(`/api/users/${userId}`); + + if (loading) return ; + if (error) return ; + return
{data?.name}
; +} +``` + +> [!tip]- Q11 参考实现 +> +> ```tsx +> function useFetch(url: string) { +> const [data, setData] = useState(null); +> const [loading, setLoading] = useState(true); +> const [error, setError] = useState(null); +> +> useEffect(() => { +> const controller = new AbortController(); +> setLoading(true); +> +> fetch(url, { signal: controller.signal }) +> .then(res => { +> if (!res.ok) throw new Error(`HTTP ${res.status}`); +> return res.json(); +> }) +> .then(setData) +> .catch(err => { +> if (err.name !== 'AbortError') setError(err); +> }) +> .finally(() => setLoading(false)); +> +> return () => controller.abort(); +> }, [url]); +> +> return { data, loading, error }; +> } +> ``` +> +> **考察要点:** +> - useState 多状态管理 +> - useEffect 副作用处理 +> - AbortController 取消请求 +> - 错误边界处理 +> - loading 状态管理 +> +> 💡 **进阶优化:** 可以添加缓存机制、防抖、重试功能等。 + +### Q12.【路由守卫实现】 + +**要求**:实现一个路由守卫组件,根据用户认证状态决定是否允许访问。 + +```tsx +// 要求: +// 1. 已登录用户:直接渲染 children +// 2. 未登录用户:重定向到 /login,并保存当前路径以便登录后返回 +// 3. 登录后自动跳转回原页面 + +// 请实现以下组件 +function ProtectedRoute({ children }: { children: React.ReactNode }) { + // 你的实现 +} + +// 路由配置示例 + + + +} /> +``` + +> [!tip]- Q12 参考实现 +> +> ```tsx +> function ProtectedRoute({ children }: { children: React.ReactNode }) { +> const { isAuthenticated } = useAuth(); +> const location = useLocation(); +> +> if (!isAuthenticated) { +> // 保存当前路径,登录后可返回 +> return ; +> } +> +> return <>{children}; +> } +> +> // 登录页处理 +> function LoginPage() { +> const navigate = useNavigate(); +> const location = useLocation(); +> const { login } = useAuth(); +> +> const handleLogin = async (credentials: LoginCredentials) => { +> await login(credentials); +> // 从 state 获取原始路径 +> const from = (location.state as any)?.from?.pathname || '/'; +> navigate(from, { replace: true }); +> }; +> +> // ... 登录表单 +> } +> ``` +> +> **考察要点:** +> - `useNavigate` 编程式导航 +> - `useLocation` 获取当前路径 +> - `Navigate` 组件的 `state` 和 `replace` 属性 +> - 登录后返回原页面的实现思路 +> +> 💡 **安全考虑:** 生产环境应验证 state.from 的合法性,防止开放重定向漏洞。 + +### Q13.【购物车状态同步】 + +假设你正在开发一个电商应用,购物车状态需要在以下地方同步: +1. 商品列表页的"加入购物车"按钮 +2. 购物车页面的商品列表和总价 +3. 导航栏的购物车数量徽章 + +**要求**:使用 Context API 实现这个功能,写出核心代码结构。 + +> [!tip]- Q13 参考实现 +> +> ```tsx +> // CartContext.tsx +> interface CartItem { +> id: string; +> name: string; +> price: number; +> quantity: number; +> } +> +> interface CartState { +> items: CartItem[]; +> total: number; +> addItem: (item: Omit) => void; +> removeItem: (id: string) => void; +> updateQuantity: (id: string, quantity: number) => void; +> } +> +> const CartContext = createContext(null); +> +> export function CartProvider({ children }: { children: React.ReactNode }) { +> const [items, setItems] = useState([]); +> +> const addItem = useCallback((product: Omit) => { +> setItems(prev => { +> const existing = prev.find(item => item.id === product.id); +> if (existing) { +> return prev.map(item => +> item.id === product.id +> ? { ...item, quantity: item.quantity + 1 } +> : item +> ); +> } +> return [...prev, { ...product, quantity: 1 }]; +> }); +> }, []); +> +> const total = useMemo( +> () => items.reduce((sum, item) => sum + item.price * item.quantity, 0), +> [items] +> ); +> +> return ( +> +> {children} +> +> ); +> } +> +> // 自定义 Hook +> export function useCart() { +> const context = useContext(CartContext); +> if (!context) throw new Error('useCart must be used within CartProvider'); +> return context; +> } +> ``` +> +> **考察要点:** +> - Context 创建和 Provider +> - 自定义 Hook 封装 +> - useCallback 和 useMemo 优化 +> - 状态更新逻辑 +> +> 💡 **性能优化:** 可以拆分 Context,将频繁变化的数据和不常变化的函数分离。 + +### Q14.【useLocalStorage Hook】 + +**要求**:实现一个与 `useState` API 完全兼容的 `useLocalStorage` hook,支持自动同步到 localStorage。 + +```tsx +// 使用示例 +const [theme, setTheme] = useLocalStorage('theme', 'light'); +// 等同于 useState,但值会持久化到 localStorage +``` + +**要求**: +1. 支持懒初始化(与 useState 相同) +2. 支持函数式更新 +3. 当 key 变化时,从对应 key 读取值 +4. 处理 localStorage 不可用的情况 + +> [!tip]- Q14 参考实现 +> +> ```tsx +> function useLocalStorage( +> key: string, +> initialValue: T | (() => T) +> ): [T, React.Dispatch>] { +> // 懒初始化:从 localStorage 读取 +> const [storedValue, setStoredValue] = useState(() => { +> try { +> const item = window.localStorage.getItem(key); +> return item ? JSON.parse(item) : +> initialValue instanceof Function ? initialValue() : initialValue; +> } catch { +> return initialValue instanceof Function ? initialValue() : initialValue; +> } +> }); +> +> // 当 key 变化时,重新从 localStorage 读取 +> useEffect(() => { +> try { +> const item = window.localStorage.getItem(key); +> if (item) setStoredValue(JSON.parse(item)); +> } catch (error) { +> console.error(`Error reading localStorage key "${key}":`, error); +> } +> }, [key]); +> +> // 包装 setter,同时更新 localStorage +> const setValue: React.Dispatch> = useCallback((value) => { +> setStoredValue(prev => { +> const nextValue = value instanceof Function ? value(prev) : value; +> try { +> window.localStorage.setItem(key, JSON.stringify(nextValue)); +> } catch (error) { +> console.error(`Error setting localStorage key "${key}":`, error); +> } +> return nextValue; +> }); +> }, [key]); +> +> return [storedValue, setValue]; +> } +> ``` +> +> **考察要点:** +> - useState 懒初始化模式 +> - useCallback 优化函数引用稳定性 +> - useEffect 依赖 key 变化重新同步 +> - localStorage 错误处理(容量超限、隐私模式等) +> +> 💡 **扩展:** 可以添加跨标签页同步功能,监听 `storage` 事件。 + +--- + +## 三、场景分析(每题 10 分,共 20 分) + +> 分析问题原因并给出解决方案。 + +### Q15.【避免无关组件重新渲染】 + +当购物车组件频繁更新时,如何避免无关组件重新渲染?列出至少两种优化方案。 + +> [!tip]- Q15 参考答案 +> +> **方案 1:拆分 Context** +> +> ```tsx +> // 分离频繁变化的数据和不常变化的函数 +> const CartItemsContext = createContext([]); +> const CartActionsContext = createContext<{ addItem: Function }>({ addItem: () => {} }); +> +> // 使用时只订阅需要的部分 +> function CartBadge() { +> const items = useContext(CartItemsContext); // 只在 items 变化时渲染 +> return ; +> } +> ``` +> +> **方案 2:使用 useReducer + useMemo** +> +> ```tsx +> function cartReducer(state: CartItem[], action: CartAction): CartItem[] { +> switch (action.type) { +> case 'ADD': /* ... */ +> } +> } +> +> function CartProvider({ children }) { +> const [items, dispatch] = useReducer(cartReducer, []); +> +> const actions = useMemo(() => ({ +> addItem: (product) => dispatch({ type: 'ADD', payload: product }), +> // ... +> }), []); // dispatch 稳定,actions 不会变 +> +> return {children}; +> } +> ``` +> +> **方案 3:使用 memo 和 useMemo** +> +> ```tsx +> const CartItem = React.memo(({ item }: { item: CartItem }) => { +> // 只在 item 变化时重新渲染 +> return
{item.name}
; +> }); +> ``` +> +> 💡 **核心原则:** 最小化渲染范围,只让真正需要更新的组件重新渲染。 + +### Q16.【路由状态持久化】 + +**问题**:用户在 A 页面填写了表单但未提交,跳转到 B 页面后再返回,表单数据丢失。请分析原因并给出解决方案。 + +```tsx +// A 页面 +function PageA() { + const [formData, setFormData] = useState({ name: '', email: '' }); + return
...
; +} + +// 路由配置 +} /> +} /> +``` + +> [!tip]- Q16 参考答案 +> +> **原因分析:** +> - 当路由从 `/a` 切换到 `/b` 时,`PageA` 组件卸载 +> - `useState` 的状态是组件实例的一部分,卸载后状态丢失 +> - 返回 `/a` 时重新挂载 `PageA`,状态重置为初始值 +> +> **解决方案 1:状态提升到父组件** +> +> ```tsx +> function FormContainer() { +> const [formData, setFormData] = useState({ name: '', email: '' }); +> return ( +> +> } /> +> } /> +> +> ); +> } +> ``` +> +> **解决方案 2:URL 搜索参数持久化** +> +> ```tsx +> function PageA() { +> const [searchParams, setSearchParams] = useSearchParams(); +> const name = searchParams.get('name') || ''; +> +> const updateName = (value: string) => { +> setSearchParams(prev => { +> prev.set('name', value); +> return prev; +> }); +> }; +> } +> ``` +> +> **解决方案 3:使用 sessionStorage** +> +> ```tsx +> function usePersistedState(key: string, initialValue: T) { +> const [state, setState] = useState(() => { +> const saved = sessionStorage.getItem(key); +> return saved ? JSON.parse(saved) : initialValue; +> }); +> +> useEffect(() => { +> sessionStorage.setItem(key, JSON.stringify(state)); +> }, [key, state]); +> +> return [state, setState]; +> } +> ``` +> +> **方案对比:** +> +> | 方案 | 优点 | 缺点 | 适用场景 | +> |------|------|------|---------| +> | 状态提升 | 简单直接 | 父组件会重新渲染 | 少量表单 | +> | URL 参数 | 可分享、可书签 | 有长度限制 | 简单表单 | +> | sessionStorage | 自动同步 | 需要手动清理 | 复杂表单 | +> +> 💡 **推荐:** 简单表单用 URL 参数,复杂表单用 sessionStorage 或全局状态管理。 + +--- + +## 四、综合应用(每题 10 分,共 10 分) + +> 综合运用所学知识解决复杂问题。 + +### Q17.【完整的路由认证系统】 + +设计一个完整的路由认证系统,包括: +1. 登录页面 +2. 受保护的路由 +3. 角色权限控制(admin/user) +4. 自动登过期处理 + +**要求**:画出架构图并写出核心代码。 + +> [!tip]- Q17 参考答案 +> +> **架构图:** +> +> ```mermaid +> graph TD +> A["用户访问"] --> B{"是否已登录?"} +> B -->|否| C["重定向到 /login"] +> B -->|是| D{"检查 Token 过期"} +> D -->|过期| E["自动刷新 Token"] +> D -->|有效| F{"检查角色权限"} +> F -->|权限不足| G["重定向到 /403"] +> F -->|权限足够| H["渲染页面"] +> E -->|刷新成功| D +> E -->|刷新失败| C +> C --> I["登录页面"] +> I -->|登录成功| J["保存 Token"] +> J --> H +> ``` +> +> **核心代码:** +> +> ```tsx +> // AuthContext.tsx +> interface AuthState { +> user: User | null; +> token: string | null; +> isAuthenticated: boolean; +> login: (credentials: LoginCredentials) => Promise; +> logout: () => void; +> } +> +> const AuthContext = createContext(null); +> +> export function AuthProvider({ children }: { children: React.ReactNode }) { +> const [user, setUser] = useState(null); +> const [token, setToken] = useState(null); +> +> // Token 过期检查 +> useEffect(() => { +> if (!token) return; +> const expiration = decodeToken(token).exp * 1000; +> const timeout = expiration - Date.now() - 60000; // 提前 1 分钟刷新 +> +> const timer = setTimeout(async () => { +> try { +> const newToken = await refreshToken(token); +> setToken(newToken); +> } catch { +> logout(); +> } +> }, timeout); +> +> return () => clearTimeout(timer); +> }, [token]); +> +> const value = { +> user, +> token, +> isAuthenticated: !!token, +> login: async (credentials) => { +> const { user, token } = await authService.login(credentials); +> setUser(user); +> setToken(token); +> }, +> logout: () => { +> setUser(null); +> setToken(null); +> }, +> }; +> +> return {children}; +> } +> +> // ProtectedRoute.tsx +> function ProtectedRoute({ +> children, +> requiredRole, +> }: { +> children: React.ReactNode; +> requiredRole?: 'admin' | 'user'; +> }) { +> const { isAuthenticated, user } = useAuth(); +> const location = useLocation(); +> +> if (!isAuthenticated) { +> return ; +> } +> +> if (requiredRole && user?.role !== requiredRole) { +> return ; +> } +> +> return <>{children}; +> } +> +> // 路由配置 +> +> } /> +> path="/admin" +> element={ +> +> +> +> } +> /> +> path="/user" +> element={ +> +> +> +> } +> /> +> +> ``` +> +> **考察要点:** +> - Context 状态管理 +> - Token 过期处理 +> - 角色权限控制 +> - 路由守卫实现 +> - 登录后跳转 +> +> 💡 **安全建议:** +> - Token 存储在 httpOnly cookie 中比 localStorage 更安全 +> - 定期刷新 Token,避免长时间使用同一个 Token +> - 服务端也要验证权限,不能只依赖前端 + +--- + +## 评分参考 + +| 题目类型 | 题目 | 满分 | 权重 | +|---------|------|------|------| +| 选择题 | Q1-Q10 | 45 分 | 45% | +| 编程题 | Q11-Q14 | 40 分 | 40% | +| 场景分析 | Q15-Q16 | 20 分 | 20% | +| 综合应用 | Q17 | 10 分 | 10% | +| **总计** | | **100 分** | **100%** | + +**及格线:** ≥70 分 +**优秀线:** ≥85 分