Merge remote-tracking branch 'origin/main'
This commit is contained in:
+1
-1
@@ -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]] |
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
+380
@@ -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
|
||||
|
||||
## 关联笔记
|
||||
@@ -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 行为配置参考
|
||||
@@ -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 的 `<Outlet>` 组件,以下哪项描述正确?
|
||||
|
||||
A. Outlet 必须在父路由组件中渲染
|
||||
B. Outlet 只能渲染一层嵌套
|
||||
C. Outlet 的位置可以是任意的,只要在父组件中
|
||||
D. Outlet 渲染的内容由父路由的 `element` 属性决定
|
||||
|
||||
> [!tip]- Q5 答案
|
||||
> **A — Outlet 必须在父路由组件中渲染**
|
||||
>
|
||||
> **解析:**
|
||||
> `<Outlet>` 是嵌套路由的核心,它作为占位符,渲染匹配的子路由组件。每个嵌套路由的父组件都必须包含一个 `<Outlet>`,否则子路由无法渲染。
|
||||
>
|
||||
> ```tsx
|
||||
> // 父路由组件
|
||||
> function Layout() {
|
||||
> return (
|
||||
> <div>
|
||||
> <Sidebar />
|
||||
> <Outlet /> {/* 子路由在此渲染 */}
|
||||
> </div>
|
||||
> );
|
||||
> }
|
||||
>
|
||||
> // 路由配置
|
||||
> <Route path="/dashboard" element={<Layout />}>
|
||||
> <Route path="profile" element={<Profile />} />
|
||||
> <Route path="settings" element={<Settings />} />
|
||||
> </Route>
|
||||
> ```
|
||||
>
|
||||
> **为什么其他选项错误:**
|
||||
> - ❌ B:Outlet 可以渲染任意深度的嵌套
|
||||
> - ❌ C:位置可以任意,但通常放在需要显示子路由内容的地方
|
||||
> - ❌ D:Outlet 渲染的是匹配的子路由组件,不是父路由的 element
|
||||
>
|
||||
> 💡 **最佳实践:** Outlet 通常放在布局组件中,用于渲染子页面内容。
|
||||
|
||||
---
|
||||
|
||||
### Q6.【Context 的重新渲染行为】
|
||||
|
||||
```tsx
|
||||
const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });
|
||||
|
||||
function App() {
|
||||
const [theme, setTheme] = useState('light');
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<Header />
|
||||
<Sidebar />
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
关于以上代码,以下说法**正确**的是:
|
||||
|
||||
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) => <Item key={index} item={item} />)}
|
||||
>
|
||||
> // ✅ 推荐:使用稳定且唯一的 id 作为 key
|
||||
> {items.map(item => <Item key={item.id} item={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 <FallbackUI />;
|
||||
> 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(
|
||||
> <div className="modal-overlay">{children}</div>,
|
||||
> 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<T>(url: string): {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
} {
|
||||
// 你的实现
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
function UserProfile({ userId }: { userId: string }) {
|
||||
const { data, loading, error } = useFetch<User>(`/api/users/${userId}`);
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
if (error) return <Error message={error.message} />;
|
||||
return <div>{data?.name}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]- Q11 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> function useFetch<T>(url: string) {
|
||||
> const [data, setData] = useState<T | null>(null);
|
||||
> const [loading, setLoading] = useState(true);
|
||||
> const [error, setError] = useState<Error | null>(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 }) {
|
||||
// 你的实现
|
||||
}
|
||||
|
||||
// 路由配置示例
|
||||
<Route path="/dashboard" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
```
|
||||
|
||||
> [!tip]- Q12 参考实现
|
||||
>
|
||||
> ```tsx
|
||||
> function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
> const { isAuthenticated } = useAuth();
|
||||
> const location = useLocation();
|
||||
>
|
||||
> if (!isAuthenticated) {
|
||||
> // 保存当前路径,登录后可返回
|
||||
> return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
> }
|
||||
>
|
||||
> 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<CartItem, 'quantity'>) => void;
|
||||
> removeItem: (id: string) => void;
|
||||
> updateQuantity: (id: string, quantity: number) => void;
|
||||
> }
|
||||
>
|
||||
> const CartContext = createContext<CartState | null>(null);
|
||||
>
|
||||
> export function CartProvider({ children }: { children: React.ReactNode }) {
|
||||
> const [items, setItems] = useState<CartItem[]>([]);
|
||||
>
|
||||
> const addItem = useCallback((product: Omit<CartItem, 'quantity'>) => {
|
||||
> 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 (
|
||||
> <CartContext.Provider value={{ items, total, addItem, ... }}>
|
||||
> {children}
|
||||
> </CartContext.Provider>
|
||||
> );
|
||||
> }
|
||||
>
|
||||
> // 自定义 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<T>(
|
||||
> key: string,
|
||||
> initialValue: T | (() => T)
|
||||
> ): [T, React.Dispatch<React.SetStateAction<T>>] {
|
||||
> // 懒初始化:从 localStorage 读取
|
||||
> const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
> 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<React.SetStateAction<T>> = 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<CartItem[]>([]);
|
||||
> const CartActionsContext = createContext<{ addItem: Function }>({ addItem: () => {} });
|
||||
>
|
||||
> // 使用时只订阅需要的部分
|
||||
> function CartBadge() {
|
||||
> const items = useContext(CartItemsContext); // 只在 items 变化时渲染
|
||||
> return <Badge count={items.length} />;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **方案 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 <CartContext.Provider value={{ items, actions }}>{children}</CartContext.Provider>;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **方案 3:使用 memo 和 useMemo**
|
||||
>
|
||||
> ```tsx
|
||||
> const CartItem = React.memo(({ item }: { item: CartItem }) => {
|
||||
> // 只在 item 变化时重新渲染
|
||||
> return <div>{item.name}</div>;
|
||||
> });
|
||||
> ```
|
||||
>
|
||||
> 💡 **核心原则:** 最小化渲染范围,只让真正需要更新的组件重新渲染。
|
||||
|
||||
### Q16.【路由状态持久化】
|
||||
|
||||
**问题**:用户在 A 页面填写了表单但未提交,跳转到 B 页面后再返回,表单数据丢失。请分析原因并给出解决方案。
|
||||
|
||||
```tsx
|
||||
// A 页面
|
||||
function PageA() {
|
||||
const [formData, setFormData] = useState({ name: '', email: '' });
|
||||
return <form>...</form>;
|
||||
}
|
||||
|
||||
// 路由配置
|
||||
<Route path="/a" element={<PageA />} />
|
||||
<Route path="/b" element={<PageB />} />
|
||||
```
|
||||
|
||||
> [!tip]- Q16 参考答案
|
||||
>
|
||||
> **原因分析:**
|
||||
> - 当路由从 `/a` 切换到 `/b` 时,`PageA` 组件卸载
|
||||
> - `useState` 的状态是组件实例的一部分,卸载后状态丢失
|
||||
> - 返回 `/a` 时重新挂载 `PageA`,状态重置为初始值
|
||||
>
|
||||
> **解决方案 1:状态提升到父组件**
|
||||
>
|
||||
> ```tsx
|
||||
> function FormContainer() {
|
||||
> const [formData, setFormData] = useState({ name: '', email: '' });
|
||||
> return (
|
||||
> <Routes>
|
||||
> <Route path="/a" element={<PageA data={formData} onChange={setFormData} />} />
|
||||
> <Route path="/b" element={<PageB />} />
|
||||
> </Routes>
|
||||
> );
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **解决方案 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<T>(key: string, initialValue: T) {
|
||||
> const [state, setState] = useState<T>(() => {
|
||||
> 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<void>;
|
||||
> logout: () => void;
|
||||
> }
|
||||
>
|
||||
> const AuthContext = createContext<AuthState | null>(null);
|
||||
>
|
||||
> export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
> const [user, setUser] = useState<User | null>(null);
|
||||
> const [token, setToken] = useState<string | null>(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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
> }
|
||||
>
|
||||
> // ProtectedRoute.tsx
|
||||
> function ProtectedRoute({
|
||||
> children,
|
||||
> requiredRole,
|
||||
> }: {
|
||||
> children: React.ReactNode;
|
||||
> requiredRole?: 'admin' | 'user';
|
||||
> }) {
|
||||
> const { isAuthenticated, user } = useAuth();
|
||||
> const location = useLocation();
|
||||
>
|
||||
> if (!isAuthenticated) {
|
||||
> return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
> }
|
||||
>
|
||||
> if (requiredRole && user?.role !== requiredRole) {
|
||||
> return <Navigate to="/403" replace />;
|
||||
> }
|
||||
>
|
||||
> return <>{children}</>;
|
||||
> }
|
||||
>
|
||||
> // 路由配置
|
||||
> <Routes>
|
||||
> <Route path="/login" element={<LoginPage />} />
|
||||
> <Route
|
||||
> path="/admin"
|
||||
> element={
|
||||
> <ProtectedRoute requiredRole="admin">
|
||||
> <AdminDashboard />
|
||||
> </ProtectedRoute>
|
||||
> }
|
||||
> />
|
||||
> <Route
|
||||
> path="/user"
|
||||
> element={
|
||||
> <ProtectedRoute>
|
||||
> <UserDashboard />
|
||||
> </ProtectedRoute>
|
||||
> }
|
||||
> />
|
||||
> </Routes>
|
||||
> ```
|
||||
>
|
||||
> **考察要点:**
|
||||
> - 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 分
|
||||
Reference in New Issue
Block a user