普通视图
LLM流式输出完全解析之socket
最近在面试,在整理之前做过的项目,整理的过程中我会整理相关技术栈的实现,本篇是任何对话式ai应用都会遇到的流式输出协议的其中之一socket,全双工协议
项目代码Learn-LLM
一、WebSocket 技术详解
1.1 什么是 WebSocket?
WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。它使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。
核心特性:
特性 | 说明 |
---|---|
🔄 双向通信 | 客户端和服务器可以同时发送和接收消息 |
🚀 低延迟 | 建立连接后无需重复握手,延迟极低 |
💡 实时性 | 服务器可主动推送数据,无需轮询 |
📦 轻量级 | 相比 HTTP 长轮询,开销更小 |
🔌 持久连接 | 一次握手,长期保持连接 |
1.2 WebSocket vs HTTP
HTTP 请求-响应模型:
客户端 ---请求---> 服务器
客户端 <--响应--- 服务器
(每次通信都需要新的请求)
WebSocket 双向通信:
客户端 <=========> 服务器
(建立连接后可双向实时通信)
对比表:
维度 | HTTP | WebSocket |
---|---|---|
通信方式 | 请求-响应 | 双向推送 |
连接状态 | 无状态 | 有状态 |
开销 | 每次请求都有 HTTP 头 | 握手后开销极小 |
实时性 | 需要轮询 | 主动推送 |
适用场景 | 传统 Web 应用 | 实时通信、流式输出 |
1.3 WebSocket 工作原理
握手过程:
# 1. 客户端发起升级请求
GET /api/websocket HTTP/1.1
Host: localhost:3000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
# 2. 服务器响应升级
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
# 3. 连接建立,开始双向通信
消息帧格式:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
二、为什么选择 WebSocket 实现流式输出?
2.1 流式输出的需求场景
在 AI 对话场景中,我们需要:
- 逐字输出:像 ChatGPT 一样实时显示生成的文本
- 低延迟:用户输入后立即看到响应
- 实时性:AI 每生成一个 token 就立即推送
- 良好体验:避免长时间等待,提供视觉反馈
2.2 技术方案对比
方案一:HTTP 轮询 ❌
// 客户端不断请求
setInterval(() => {
fetch('/api/status').then(res => res.json())
}, 1000); // 每秒请求一次
缺点:
- ❌ 延迟高(轮询间隔限制)
- ❌ 服务器压力大(大量无效请求)
- ❌ 浪费带宽(重复的 HTTP 头)
方案二:Server-Sent Events (SSE) ⚠️
const eventSource = new EventSource('/api/stream');
eventSource.onmessage = (event) => {
console.log(event.data);
};
优点:
- ✅ 服务器主动推送
- ✅ 实现简单
缺点:
- ❌ 单向通信(只能服务器推送)
- ❌ 不支持二进制数据
- ❌ 连接数限制(浏览器限制 6 个)
方案三:WebSocket ✅
const ws = new WebSocket('ws://localhost:3000/api/websocket');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 处理流式数据
};
优点:
- ✅ 双向实时通信
- ✅ 低延迟(无 HTTP 开销)
- ✅ 支持二进制数据
- ✅ 无连接数限制
- ✅ 完整的连接控制
2.3 WebSocket 在流式输出中的优势
传统 HTTP 流式输出流程:
用户输入 -> HTTP 请求 -> 等待完整响应 -> 一次性显示
⏰ 延迟 5-30 秒 ⏰
WebSocket 流式输出流程:
用户输入 -> WebSocket 发送 -> AI 生成 token 1 -> 立即推送 -> 显示
-> AI 生成 token 2 -> 立即推送 -> 显示
-> AI 生成 token 3 -> 立即推送 -> 显示
⚡ 每个 token 延迟 < 100ms ⚡
三、架构设计
3.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 客户端 (React) │
│ ┌────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ UI 组件 │ │ WebSocket │ │ 状态管理 │ │
│ │ - 输入框 │→ │ 连接管理 │→ │ - 流式内容累积 │ │
│ │ - 消息显示 │← │ 消息处理 │← │ - 连接状态 │ │
│ └────────────┘ └─────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ ws://localhost:3000/api/websocket
┌─────────────────────────────────────────────────────────────┐
│ 集成服务器 (Next.js + WebSocket) │
│ ┌────────────┐ ┌─────────────┐ ┌──────────────────────┐ │
│ │ HTTP 服务 │ │ WebSocket │ │ AI 处理 │ │
│ │ - Next.js │ │ - 连接管理 │ │ - LangChain 集成 │ │
│ │ - API 路由 │ │ - 消息路由 │ │ - 流式生成 │ │
│ │ │ │ - 心跳检测 │ │ - OpenAI 调用 │ │
│ └────────────┘ └─────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ API 调用
┌─────────────────────────────────────────────────────────────┐
│ OpenAI API / LangChain │
│ (GPT-3.5/4 等) │
└─────────────────────────────────────────────────────────────┘
3.2 核心设计原则
1. 单端口集成
// HTTP 和 WebSocket 共用 3000 端口
const server = createServer(nextHandler);
const wss = new WebSocket.Server({
server, // 附加到同一个 HTTP 服务器
path: '/api/websocket',
});
优势:
- ✅ 简化部署(只需暴露一个端口)
- ✅ 避免跨域问题
- ✅ 统一的服务管理
2. 类型安全的消息协议
interface WebSocketMessage {
type: 'chat' | 'chat-stream' | 'data-stream' | 'notification' | ...;
payload: any;
}
// 使用 TypeScript 确保消息格式正确
const message: WebSocketMessage = {
type: 'chat-stream',
payload: { message: '你好', modelName: 'gpt-3.5-turbo' }
};
3. 连接生命周期管理
interface ClientConnection {
ws: WebSocket;
id: string; // 唯一标识
connectedAt: number; // 连接时间
lastPing: number; // 最后心跳时间
}
const clients = new Map<string, ClientConnection>();
四、核心实现
4.1 服务器端 - WebSocket 服务器搭建
完整的服务器初始化:
import { createServer } from 'http';
import next from 'next';
import WebSocket from 'ws';
const app = next({ dev: true });
const handle = app.getRequestHandler();
app.prepare().then(() => {
// 1. 创建 HTTP 服务器
const server = createServer(async (req, res) => {
const parsedUrl = parse(req.url!, true);
await handle(req, res, parsedUrl);
});
// 2. 创建 WebSocket 服务器
const wss = new WebSocket.Server({
server,
path: '/api/websocket',
perMessageDeflate: { // 消息压缩
threshold: 1024, // 大于 1KB 才压缩
concurrencyLimit: 10, // 并发限制
},
});
// 3. 处理 WebSocket 连接
wss.on('connection', (ws: WebSocket, request) => {
console.log('✅ 新客户端连接');
// 连接处理逻辑...
});
// 4. 启动服务器
server.listen(3000, () => {
console.log('🚀 服务器运行在 http://localhost:3000');
console.log('📡 WebSocket: ws://localhost:3000/api/websocket');
});
});
4.2 连接管理
客户端注册与管理:
// 存储所有活跃连接
const clients = new Map<string, ClientConnection>();
wss.on('connection', (ws: WebSocket) => {
// 生成唯一 ID
const clientId = `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 创建连接对象
const client: ClientConnection = {
ws,
id: clientId,
connectedAt: Date.now(),
lastPing: Date.now(),
};
// 注册客户端
clients.set(clientId, client);
console.log(`✅ 客户端 ${clientId} 已连接,总连接数: ${clients.size}`);
// 发送欢迎消息
sendMessage(ws, {
type: 'status',
payload: {
message: '🎉 欢迎连接到 WebSocket 服务器',
clientId,
serverTime: new Date().toISOString(),
},
});
// 监听消息
ws.on('message', async (data) => {
const message = JSON.parse(data.toString());
await handleMessage(client, message);
});
// 监听关闭
ws.on('close', (code, reason) => {
console.log(`🔚 客户端 ${clientId} 断开: ${code}`);
clients.delete(clientId);
});
// 监听错误
ws.on('error', (error) => {
console.error(`❌ 客户端 ${clientId} 错误:`, error);
clients.delete(clientId);
});
});
4.3 心跳检测机制
为什么需要心跳检测?
- 检测死连接:客户端异常断开时,服务器可能无法立即感知
- 保持连接活跃:防止中间代理或防火墙关闭空闲连接
- 资源清理:及时释放无效连接占用的资源
实现方式:
// 1. 启动心跳定时器
function startHeartbeat() {
setInterval(() => {
const now = Date.now();
const timeout = 60000; // 60 秒超时
clients.forEach((client, clientId) => {
// 检查超时
if (now - client.lastPing > timeout) {
console.log(`💀 客户端 ${clientId} 心跳超时,断开连接`);
client.ws.terminate();
clients.delete(clientId);
}
// 发送心跳
else if (client.ws.readyState === WebSocket.OPEN) {
client.ws.ping();
console.log(`💓 向客户端 ${clientId} 发送心跳`);
}
});
}, 30000); // 每 30 秒检查一次
}
// 2. 监听心跳响应
ws.on('pong', () => {
client.lastPing = Date.now();
console.log(`💓 收到客户端 ${clientId} 心跳响应`);
});
// 3. 处理客户端主动心跳
async function handlePing(client: ClientConnection, payload: any) {
client.lastPing = Date.now();
sendMessage(client.ws, {
type: 'pong',
payload: {
timestamp: Date.now(),
originalTimestamp: payload?.timestamp,
latency: Date.now() - payload?.timestamp,
},
});
}
4.4 消息路由系统
// 消息类型定义
interface WebSocketMessage {
type: 'ping' | 'chat' | 'chat-stream' | 'data-stream'
| 'notification' | 'log-stream' | 'broadcast' | 'custom';
payload: any;
}
// 消息处理路由
async function handleMessage(
client: ClientConnection,
message: WebSocketMessage
): Promise<void> {
const { type, payload } = message;
try {
switch (type) {
case 'ping':
await handlePing(client, payload);
break;
case 'chat':
await handleChatMessage(client, payload);
break;
case 'chat-stream':
await handleStreamingChat(client, payload);
break;
case 'data-stream':
await handleDataStream(client, payload);
break;
case 'notification':
await handleNotification(client, payload);
break;
case 'log-stream':
await handleLogStream(client, payload);
break;
case 'broadcast':
await handleBroadcast(client, payload);
break;
case 'custom':
await handleCustomMessage(client, payload);
break;
default:
sendMessage(client.ws, {
type: 'error',
payload: { message: `未知消息类型: ${type}` },
});
}
} catch (error) {
console.error(`❌ 处理消息 ${type} 时出错:`, error);
sendMessage(client.ws, {
type: 'error',
payload: {
message: `处理失败: ${error.message}`,
},
});
}
}
五、AI 流式对话集成
5.1 LangChain 集成架构
用户消息
→ WebSocket 接收
→ 消息路由 (chat-stream)
→ LangChain 处理链
→ OpenAI 流式 API
→ 逐 token 生成
→ WebSocket 推送
→ 前端实时显示
5.2 流式 AI 对话实现
完整实现代码:
import { ChatOpenAI } from '@langchain/openai';
import {
ChatPromptTemplate,
SystemMessagePromptTemplate,
HumanMessagePromptTemplate,
} from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
interface ChatPayload {
message: string;
system?: string;
temperature?: number;
modelName?: string;
}
async function handleStreamingChat(
client: ClientConnection,
payload: ChatPayload
): Promise<void> {
const {
message,
system = 'You are a helpful AI assistant. Please respond in Chinese.',
temperature = 0.7,
modelName = 'gpt-3.5-turbo',
} = payload;
console.log(`🌊 开始流式 AI 对话:`, { clientId: client.id, message });
// 1. 验证环境变量
if (!process.env.OPEN_API_KEY) {
sendMessage(client.ws, {
type: 'chat-error',
payload: { message: '❌ 服务器未配置 OpenAI API 密钥' },
});
return;
}
// 2. 发送开始状态
sendMessage(client.ws, {
type: 'chat-start',
payload: { message: '🤖 正在思考您的问题...' },
});
try {
// 3. 初始化 ChatOpenAI(流式模式)
const llm = new ChatOpenAI({
openAIApiKey: process.env.OPEN_API_KEY!,
modelName: modelName,
temperature: temperature,
maxTokens: 2000,
streaming: true, // 🔥 关键:启用流式输出
configuration: {
baseURL: process.env.OPEN_API_BASE_URL,
},
});
// 4. 创建聊天提示模板
const chatPrompt = ChatPromptTemplate.fromMessages([
SystemMessagePromptTemplate.fromTemplate(system),
HumanMessagePromptTemplate.fromTemplate('{userMessage}'),
]);
// 5. 创建处理链
const chain = chatPrompt.pipe(llm).pipe(new StringOutputParser());
// 6. 流式调用 LLM
const stream = await chain.stream({
userMessage: message,
});
let totalTokens = 0;
let chunkCount = 0;
let fullResponse = '';
// 7. 逐块处理流式响应
for await (const chunk of stream) {
// 检查连接状态
if (client.ws.readyState !== WebSocket.OPEN) {
console.log(`⚠️ 客户端 ${client.id} 连接已断开,停止流式传输`);
break;
}
chunkCount++;
totalTokens += chunk.length;
fullResponse += chunk;
// 8. 发送流式内容到客户端
sendMessage(client.ws, {
type: 'chat-stream',
payload: {
content: chunk, // 本次生成的内容片段
chunkCount, // 已发送块数
totalTokens, // 总 token 数
},
});
// 添加小延迟,模拟打字效果(可选)
await new Promise((resolve) => setTimeout(resolve, 30));
}
console.log(
`✅ 流式响应完成: ${chunkCount} chunks, ${totalTokens} tokens`
);
// 9. 发送完成状态
sendMessage(client.ws, {
type: 'chat-complete',
payload: {
message: '✅ 回答生成完成',
fullResponse, // 完整回复内容
stats: {
chunks: chunkCount,
tokens: totalTokens,
model: modelName,
},
},
});
} catch (error) {
console.error(`❌ 流式聊天错误:`, error);
// 10. 发送错误消息
sendMessage(client.ws, {
type: 'chat-error',
payload: {
message: `流式 AI 处理失败: ${error.message}`,
originalMessage: message,
},
});
}
}
5.3 关键技术点解析
1. 流式输出的核心配置
const llm = new ChatOpenAI({
streaming: true, // 🔥 必须设置为 true
// ...其他配置
});
// 使用 stream() 而不是 invoke()
const stream = await chain.stream({ userMessage: message });
// 使用 for await 循环处理流式数据
for await (const chunk of stream) {
// 每个 chunk 是一小段文本
console.log(chunk); // "你", "好", ",", "我", "是"...
}
2. 连接状态检测
// 在流式循环中检查连接状态
for await (const chunk of stream) {
// 如果客户端断开,立即停止生成
if (client.ws.readyState !== WebSocket.OPEN) {
console.log('客户端已断开,停止流式传输');
break;
}
// 发送数据...
}
3. 错误处理和状态通知
// 发送开始状态
sendMessage(ws, { type: 'chat-start', ... });
// 流式发送内容
sendMessage(ws, { type: 'chat-stream', payload: { content: chunk } });
// 发送完成状态
sendMessage(ws, { type: 'chat-complete', ... });
// 发送错误状态
sendMessage(ws, { type: 'chat-error', ... });
5.4 消息流转时序图
客户端 WebSocket 服务器 LangChain/OpenAI
| | |
|--1. 发送聊天请求-------->| |
| {type:'chat-stream'} | |
| | |
|<--2. 开始状态消息--------| |
| {type:'chat-start'} | |
| | |
| |--3. 调用 LLM 流式 API--->|
| | |
| |<--4. token: "你" --------|
|<--5. 流式消息------------| |
| {type:'chat-stream', | |
| content: "你"} | |
| | |
| |<--6. token: "好" --------|
|<--7. 流式消息------------| |
| {content: "好"} | |
| | |
| |<--8. token: "," --------|
|<--9. 流式消息------------| |
| | |
| ... (循环继续,直到生成完成) ... |
| | |
| |<--10. 生成完成 ----------|
|<--11. 完成状态消息-------| |
| {type:'chat-complete'} | |
| | |
六、前端实现
6.1 React WebSocket 客户端
完整的 React Hook 实现:
'use client';
import { useState, useRef, useEffect } from 'react';
export default function WebSocketChat() {
// 状态管理
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<
'disconnected' | 'connecting' | 'connected' | 'error'
>('disconnected');
const [streamingContent, setStreamingContent] = useState('');
const [messages, setMessages] = useState<any[]>([]);
const [inputMessage, setInputMessage] = useState('');
// WebSocket 引用
const wsRef = useRef<WebSocket | null>(null);
// 组件卸载时清理
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);
// 连接 WebSocket
const connectWebSocket = () => {
if (isConnected || connectionStatus === 'connecting') {
return;
}
setConnectionStatus('connecting');
setStreamingContent('');
setMessages([]);
// 创建 WebSocket 连接
const wsUrl = `ws://${window.location.host}/api/websocket`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
// 连接打开
ws.onopen = () => {
console.log('✅ WebSocket 连接已建立');
setConnectionStatus('connected');
setIsConnected(true);
addMessage('info', '🚀 已连接到 WebSocket 服务器');
};
// 接收消息
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (error) {
console.error('消息解析错误:', error);
}
};
// 连接错误
ws.onerror = (error) => {
console.error('❌ WebSocket 错误:', error);
setConnectionStatus('error');
};
// 连接关闭
ws.onclose = (event) => {
console.log('🔚 WebSocket 连接已关闭:', event.code);
setConnectionStatus('disconnected');
setIsConnected(false);
addMessage('info', `🔚 连接已关闭 (${event.code})`);
};
};
// 处理 WebSocket 消息
const handleWebSocketMessage = (data: any) => {
const { type, payload } = data;
switch (type) {
case 'status':
addMessage('info', payload?.message || '状态更新');
break;
case 'chat-start':
addMessage('info', '🤖 AI 开始思考...');
setStreamingContent(''); // 清空之前的内容
break;
case 'chat-stream':
// 🔥 关键:累积流式内容
if (payload?.content) {
setStreamingContent((prev) => prev + payload.content);
}
break;
case 'chat-complete':
addMessage('success', '✅ 回答生成完成');
if (payload?.stats) {
addMessage(
'info',
`📊 统计: ${payload.stats.chunks} 块, ${payload.stats.tokens} tokens`
);
}
break;
case 'chat-error':
addMessage('error', payload?.message || '❌ AI 聊天出错');
break;
default:
addMessage('data', JSON.stringify(data));
}
};
// 发送聊天消息
const sendChatMessage = () => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
alert('WebSocket 未连接');
return;
}
if (!inputMessage.trim()) {
return;
}
const message = {
type: 'chat-stream',
payload: {
message: inputMessage,
system: 'You are a helpful AI assistant. Please respond in Chinese.',
temperature: 0.7,
modelName: 'gpt-3.5-turbo',
},
};
wsRef.current.send(JSON.stringify(message));
addMessage('info', `📤 发送消息: ${inputMessage}`);
setInputMessage('');
};
// 添加消息到历史
const addMessage = (type: string, content: string) => {
setMessages((prev) => [
...prev,
{
id: Date.now(),
type,
content,
timestamp: Date.now(),
},
]);
};
// 断开连接
const disconnectWebSocket = () => {
if (wsRef.current) {
wsRef.current.close();
}
};
return (
<div className="p-6 space-y-6">
{/* 连接控制 */}
<div className="flex gap-4">
<button
onClick={connectWebSocket}
disabled={isConnected}
className="px-6 py-2 bg-blue-600 text-white rounded-md disabled:bg-gray-400"
>
{connectionStatus === 'connecting' ? '连接中...' : '连接'}
</button>
<button
onClick={disconnectWebSocket}
disabled={!isConnected}
className="px-6 py-2 bg-red-600 text-white rounded-md disabled:bg-gray-400"
>
断开
</button>
<div className="flex items-center gap-2">
<span>状态:</span>
<span className={`px-3 py-1 rounded ${
connectionStatus === 'connected' ? 'bg-green-100 text-green-800' :
connectionStatus === 'connecting' ? 'bg-yellow-100 text-yellow-800' :
connectionStatus === 'error' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{connectionStatus}
</span>
</div>
</div>
{/* AI 对话展示区 */}
<div className="border rounded-lg p-6 bg-white">
<h3 className="text-lg font-bold mb-4">🤖 AI 对话</h3>
<div className="min-h-[300px] max-h-[500px] overflow-y-auto border rounded p-4 bg-gray-50">
{streamingContent ? (
<div className="prose prose-sm max-w-none">
{/* 使用 Markdown 渲染库或简单显示 */}
<pre className="whitespace-pre-wrap">{streamingContent}</pre>
</div>
) : (
<div className="text-center text-gray-500 py-20">
<div className="text-4xl mb-4">🤖</div>
<p>点击"连接"开始 AI 对话</p>
<p className="text-xs mt-2">AI 回复将在这里实时显示</p>
</div>
)}
</div>
</div>
{/* 输入区 */}
<div className="flex gap-2">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendChatMessage()}
placeholder="输入要对话的内容..."
className="flex-1 px-4 py-2 border rounded-md"
disabled={!isConnected}
/>
<button
onClick={sendChatMessage}
disabled={!isConnected || !inputMessage.trim()}
className="px-6 py-2 bg-green-600 text-white rounded-md disabled:bg-gray-400"
>
发送
</button>
</div>
{/* 消息历史 */}
<div className="border rounded-lg p-4 bg-gray-50">
<h4 className="font-medium mb-2">消息历史</h4>
<div className="h-64 overflow-y-auto space-y-2">
{messages.map((msg) => (
<div
key={msg.id}
className="p-2 bg-white rounded shadow-sm text-sm"
>
<span className="text-gray-500 text-xs">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
<p>{msg.content}</p>
</div>
))}
</div>
</div>
</div>
);
}
6.2 关键技术点
1. 流式内容累积
// 错误做法:直接替换
case 'chat-stream':
setStreamingContent(payload.content); // ❌ 只会显示最后一个字符
// 正确做法:累积追加
case 'chat-stream':
setStreamingContent((prev) => prev + payload.content); // ✅ 逐字累积
2. 连接状态管理
// 使用状态机模式
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
// 根据状态控制 UI
{connectionStatus === 'connected' && <ChatInterface />}
{connectionStatus === 'connecting' && <LoadingSpinner />}
{connectionStatus === 'error' && <ErrorMessage />}
3. 资源清理
// 组件卸载时清理 WebSocket
useEffect(() => {
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, []);
6.3 实时 Markdown 渲染
如果 AI 返回 Markdown 格式,可以使用 streamdown
库实现流式渲染:
import { Streamdown } from 'streamdown';
<Streamdown
parseIncompleteMarkdown={true} // 支持不完整的 Markdown
className="prose prose-sm max-w-none"
>
{streamingContent}
</Streamdown>
效果:
AI 正在输出: "# 标题\n\n这是一段..."
实时渲染为:
# 标题
这是一段...
🎯 总结
WebSocket 流式输出的核心要点
-
技术选型
- ✅ WebSocket 提供双向实时通信能力
- ✅ 相比 HTTP 轮询和 SSE,延迟更低、功能更强大
- ✅ 适合 AI 流式对话、实时数据推送等场景
-
架构设计
- ✅ 单端口集成 HTTP + WebSocket
- ✅ 类型安全的消息协议
- ✅ 完善的连接生命周期管理
- ✅ 心跳检测和自动重连
-
AI 集成
- ✅ LangChain 流式 API 集成
- ✅ 逐 token 推送到客户端
- ✅ 完整的错误处理和状态通知
- ✅ 连接中断时的优雅降级
-
前端实现
- ✅ React Hooks 管理 WebSocket 状态
- ✅ 流式内容累积显示
- ✅ 实时 Markdown 渲染
- ✅ 资源清理和错误处理
-
生产级实践
- ✅ 性能优化(消息压缩、连接池)
- ✅ 安全性(身份验证、速率限制)
- ✅ 监控与日志(结构化日志、性能指标)
- ✅ 部署方案(负载均衡、Docker)
适用场景
- 🤖 AI 对话系统:ChatGPT 风格的流式对话
- 📊 实时数据可视化:股票、监控数据实时更新
- 💬 即时通讯:聊天应用、协作工具
- 🔔 推送通知:系统通知、消息提醒
- 📋 日志流:实时日志监控、部署日志
📚 参考资源
ChatGPT流式输出完全解析之SSE
最近在面试,在整理之前做过的项目,整理的过程中我会整理相关技术栈的实现,本篇是任何对话式ai应用都会遇到的流式输出协议的其中之一SSE,chatpgt用的就是这个,它是单向的,服务端向客户端推送
我用langChain做了一个案例,大家可以clone下来看下具体的实现,不过大多数的ai应用中还是会选择socket的,因为需要双向通信,但是SSE相比socket而言没那么重,写简单项目还是很方便的,比如智能对话助手什么的
项目代码Learn-LLM
📡 SSE 核心原理
1.1 什么是 SSE?
Server-Sent Events (SSE) 是 HTML5 标准的一项服务端推送技术,允许服务器主动向客户端推送数据。
核心特点
✅ 单向通信 - 服务器 → 客户端(推送)
✅ 基于 HTTP - 无需额外协议支持
✅ 持久连接 - 长连接,持续推送
✅ 文本格式 - 简单的文本协议
✅ 自动重连 - 浏览器内置重连机制
✅ 事件驱动 - 支持自定义事件类型
技术优势
特性 | SSE | WebSocket | 长轮询 |
---|---|---|---|
通信方向 | 单向(服务器→客户端) | 双向 | 单向 |
协议 | HTTP | WebSocket | HTTP |
自动重连 | ✅ 内置 | ❌ 需手动实现 | ❌ 需手动实现 |
复杂度 | 简单 | 中等 | 简单 |
适用场景 | 推送通知、流式输出 | 实时聊天、游戏 | 简单轮询 |
🔧 SSE 实现详解
2.1 前端实现(EventSource API)
// 1. 创建 SSE 连接
const eventSource = new EventSource('/api/streaming/sse?message=hello');
// 2. 监听默认消息
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到数据:', data);
};
// 3. 监听自定义事件
eventSource.addEventListener('status', (event) => {
console.log('状态更新:', event.data);
});
eventSource.addEventListener('progress', (event) => {
console.log('进度更新:', event.data);
});
eventSource.addEventListener('complete', (event) => {
console.log('完成:', event.data);
eventSource.close(); // 关闭连接
});
// 4. 连接生命周期
eventSource.onopen = () => {
console.log('✅ SSE 连接已建立');
};
eventSource.onerror = (error) => {
console.error('❌ SSE 错误:', error);
// 浏览器会自动尝试重连
};
// 5. 手动关闭连接
eventSource.close();
EventSource 状态
EventSource.CONNECTING(0); // 连接中
EventSource.OPEN(1); // 已连接
EventSource.CLOSED(2); // 已关闭
// 检查状态
if (eventSource.readyState === EventSource.OPEN) {
console.log('连接正常');
}
2.2 后端实现(Next.js)
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
// 发送基本消息
controller.enqueue(
encoder.encode('data: {"content": "Hello World"}\n\n')
);
// 发送自定义事件
controller.enqueue(encoder.encode('event: status\ndata: 处理中\n\n'));
// 发送进度事件
controller.enqueue(encoder.encode('event: progress\ndata: 50%\n\n'));
// 发送完成事件
controller.enqueue(
encoder.encode('event: complete\ndata: 处理完成\n\n')
);
// 可选:主动关闭
// controller.close();
} catch (error) {
controller.error(error);
}
},
cancel(reason) {
console.log('客户端关闭连接:', reason);
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream', // ← SSE 的核心标识
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
},
});
}
2.3 SSE 数据格式规范
# 基本消息格式
data: Hello World\n\n
# 多行数据
data: 第一行\n
data: 第二行\n\n
# 自定义事件
event: notification\n
data: 新消息\n\n
# 带 ID 的消息(用于断线重连)
id: 123\n
data: 可重连的消息\n\n
# 设置重连时间(毫秒)
retry: 3000\n\n
# 重要规则:
# 1. 每个消息必须以两个换行符结尾 \n\n
# 2. data 字段可以多行
# 3. event 字段定义自定义事件类型
# 4. id 字段用于断线重连时的位置恢复
2.4 ChatGPT 流式对话实现
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const userMessage = searchParams.get('message') || 'Hello';
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
// 初始化 LangChain ChatOpenAI
const llm = new ChatOpenAI({
openAIApiKey: process.env.OPEN_API_KEY,
modelName: 'gpt-3.5-turbo',
temperature: 0.7,
streaming: true, // ← 开启流式输出
});
// 创建处理链
const chain = chatPrompt.pipe(llm).pipe(new StringOutputParser());
// 开始流式调用
const stream = await chain.stream({ userMessage });
// 逐块发送
for await (const chunk of stream) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: chunk })}\n\n`)
);
}
// 发送完成事件
controller.enqueue(
encoder.encode('event: complete\ndata: AI对话完成\n\n')
);
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
🏗️ 技术栈解析
┌─────────────────────────────────────────┐
│ SSE (Server-Sent Events) │ ← 应用层协议(HTML5 标准)
├─────────────────────────────────────────┤
│ 前端: EventSource API │ ← SSE 专属 API
│ 格式: data:, event:, id:, retry: │ ← SSE 专属格式
│ 头部: text/event-stream │ ← SSE 专属标识
└─────────────────────────────────────────┘
↓ 使用(但不专属)
┌─────────────────────────────────────────┐
│ Web Streams API (通用) │ ← 通用流处理 API
│ - ReadableStream │
│ - WritableStream │
│ - TransformStream │
│ - Controller (enqueue, close, error) │
└─────────────────────────────────────────┘
↓ 使用
┌─────────────────────────────────────────┐
│ Web Encoding API (通用) │ ← 通用编码 API
│ - TextEncoder (字符串 → 字节) │
│ - TextDecoder (字节 → 字符串) │
└─────────────────────────────────────────┘
↓ 基于
┌─────────────────────────────────────────┐
│ HTTP 协议 │ ← 传输层协议
└─────────────────────────────────────────┘
3.2 核心组件归属
技术 | 归属 | 用途 | 是否 SSE 专属 |
---|---|---|---|
EventSource |
SSE API | 接收 SSE 消息 | ✅ SSE 专属 |
text/event-stream |
SSE 标准 | 标识 SSE 响应 | ✅ SSE 专属 |
data:\n\n 格式 |
SSE 协议 | SSE 消息格式 | ✅ SSE 专属 |
TextEncoder |
Web Encoding API | 字符串转字节 | ❌ 通用工具 |
ReadableStream |
Streams API | 流式数据处理 | ❌ 通用工具 |
controller |
Streams API | 控制流的发送 | ❌ 通用工具 |
3.3 为什么 SSE 需要这些 API?
🤔 核心问题:HTTP Response 只能返回字节流
// ❌ HTTP 响应不能直接发送 JavaScript 字符串
return new Response('Hello World'); // 这会被自动转换为字节
// ✅ HTTP 响应必须是字节流
return new Response(ReadableStream<Uint8Array>); // 字节流
💡 SSE 的数据流转过程
1. JavaScript 字符串
↓ TextEncoder(字符串 → 字节)
2. Uint8Array 字节数组
↓ ReadableStream(组织成流)
3. ReadableStream<Uint8Array>
↓ HTTP Response
4. 浏览器接收字节流
↓ EventSource(解析 SSE 格式)
5. JavaScript 字符串事件
📝 为什么需要 Web Encoding API?
问题:HTTP 只传输字节,JavaScript 操作字符串
// SSE 格式是字符串
const sseMessage = 'data: Hello World\n\n';
// ❌ 不能直接发送字符串
controller.enqueue(sseMessage); // 类型错误!
// ✅ 必须先转换为字节
const encoder = new TextEncoder();
controller.enqueue(encoder.encode(sseMessage)); // Uint8Array
TextEncoder 的作用:
- 将 JavaScript 字符串编码为 UTF-8 字节数组
- 处理多语言字符(中文、emoji 等)
- 确保网络传输的正确性
const encoder = new TextEncoder();
// 示例:编码不同内容
encoder.encode('Hello'); // Uint8Array [72, 101, 108, 108, 111]
encoder.encode('你好'); // Uint8Array [228, 189, 160, 229, 165, 189]
encoder.encode('🎉'); // Uint8Array [240, 159, 142, 137]
🌊 为什么需要 Web Streams API?
问题 1:SSE 需要持续推送数据
// ❌ 普通响应只能一次性返回
return new Response('data: message\n\n'); // 发送后就结束了
// ✅ ReadableStream 可以持续推送
const readable = new ReadableStream({
async start(controller) {
// 可以多次发送
controller.enqueue(encoder.encode('data: message1\n\n'));
await sleep(1000);
controller.enqueue(encoder.encode('data: message2\n\n'));
await sleep(1000);
controller.enqueue(encoder.encode('data: message3\n\n'));
// 连接保持打开...
},
});
问题 2:SSE 需要控制发送时机
// ReadableStream 的 controller 提供精确控制
const readable = new ReadableStream({
async start(controller) {
// 1. 立即发送初始消息
controller.enqueue(encoder.encode('data: 开始\n\n'));
// 2. 等待异步操作
const result = await fetchData();
// 3. 根据结果发送
for await (const chunk of result) {
controller.enqueue(encoder.encode(`data: ${chunk}\n\n`));
}
// 4. 发送完成事件
controller.enqueue(encoder.encode('event: complete\ndata: 结束\n\n'));
// 5. 可选:关闭流
// controller.close();
},
});
问题 3:SSE 需要处理客户端断开
const readable = new ReadableStream({
async start(controller) {
const interval = setInterval(() => {
controller.enqueue(encoder.encode('data: tick\n\n'));
}, 1000);
// ✅ 关键:客户端断开时清理资源
return () => {
clearInterval(interval);
console.log('客户端断开,已清理定时器');
};
},
cancel(reason) {
// 客户端主动关闭时触发
console.log('客户端取消连接:', reason);
},
});
🔄 完整的 SSE 数据流
export async function GET(request: NextRequest) {
const encoder = new TextEncoder(); // ← 步骤1: 准备编码器
const readable = new ReadableStream({
// ← 步骤2: 创建流
async start(controller) {
// 步骤3: 准备字符串数据
const message = 'Hello World';
const sseFormat = `data: ${message}\n\n`;
// 步骤4: 字符串 → 字节
const bytes = encoder.encode(sseFormat);
// 步骤5: 推送到流中
controller.enqueue(bytes);
// 步骤6: 可以继续推送...
// controller.enqueue(encoder.encode('data: more\n\n'));
},
});
// 步骤7: 包装为 HTTP 响应
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream', // ← 告诉浏览器这是 SSE
},
});
}
// 客户端接收:
// ReadableStream<Uint8Array> → EventSource 解析 → JavaScript 事件
📊 对比:有无这些 API 的区别
场景 | 没有这些 API | 使用这些 API |
---|---|---|
发送数据 | ❌ 只能一次性返回 | ✅ 可以持续推送 |
字符串处理 | ❌ 需要手动处理编码 | ✅ TextEncoder 自动处理 |
流控制 | ❌ 无法控制发送时机 | ✅ controller 精确控制 |
资源清理 | ❌ 连接断开无通知 | ✅ cancel() 回调清理 |
背压处理 | ❌ 无法知道客户端状态 | ✅ desiredSize 指示队列 |
错误处理 | ❌ 只能返回错误响应 | ✅ 可以在流中发送错误 |
🌊 Web Streams API 完整介绍
4.1 三大核心流类型
ReadableStream(可读流)📖
const readable = new ReadableStream({
// 初始化
start(controller) {
controller.enqueue('数据块1');
controller.enqueue('数据块2');
controller.close();
},
// 消费者准备好接收更多数据时调用
pull(controller) {
// 可以延迟发送数据
},
// 消费者取消订阅时调用
cancel(reason) {
console.log('取消原因:', reason);
},
});
// 消费方式1: Reader
const reader = readable.getReader();
const { value, done } = await reader.read();
// 消费方式2: for await...of(推荐)
for await (const chunk of readable) {
console.log(chunk);
}
应用场景:
- ✅ SSE 服务器推送
- ✅ Fetch API 响应体
- ✅ 文件读取
- ✅ WebSocket 数据接收
WritableStream(可写流)✍️
const writable = new WritableStream({
start(controller) {
console.log('写入流初始化');
},
async write(chunk, controller) {
console.log('接收到:', chunk);
await saveToDatabase(chunk);
},
close() {
console.log('写入完成');
},
abort(reason) {
console.log('写入中止:', reason);
},
});
// 使用方式
const writer = writable.getWriter();
await writer.write('Hello');
await writer.write('World');
await writer.close();
应用场景:
- 文件下载保存
- 数据持久化
- 网络上传
TransformStream(转换流)🔄
// 创建转换流:将文本转为大写
const uppercase = new TransformStream({
transform(chunk, controller) {
controller.enqueue(chunk.toUpperCase());
},
});
// 使用管道连接
readable.pipeThrough(uppercase).pipeTo(writable);
// 实际例子:压缩流
const compressor = new TransformStream({
async transform(chunk, controller) {
const compressed = await compress(chunk);
controller.enqueue(compressed);
},
flush(controller) {
// 处理最后的数据
},
});
// 链式处理
fetch('/api/data')
.then((response) => response.body)
.pipeThrough(decompressor)
.pipeThrough(jsonParser)
.pipeTo(display);
应用场景:
- 数据压缩/解压
- 编码转换(Base64、UTF-8)
- 数据加密/解密
- 文本处理(过滤、替换)
4.2 核心控制器
ReadableStreamDefaultController
interface ReadableStreamDefaultController {
enqueue(chunk: any): void; // 添加数据块到队列
close(): void; // 关闭流
error(error: any): void; // 发送错误
readonly desiredSize: number; // 队列中可容纳的数据大小(背压)
}
// 使用示例
const readable = new ReadableStream({
start(controller) {
// 检查背压
if (controller.desiredSize <= 0) {
console.log('队列已满,暂停发送');
return;
}
controller.enqueue('数据');
// 发生错误
if (error) {
controller.error(new Error('处理失败'));
}
// 完成
controller.close();
},
});
WritableStreamDefaultController
interface WritableStreamDefaultController {
error(error: any): void; // 发送错误信号
}
TransformStreamDefaultController
interface TransformStreamDefaultController {
enqueue(chunk: any): void; // 输出转换后的数据
terminate(): void; // 终止流
readonly desiredSize: number; // 背压指示
}
4.3 读取器与写入器
ReadableStreamDefaultReader
const reader = readable.getReader();
// 读取数据
while (true) {
const { value, done } = await reader.read();
if (done) break;
console.log('读取到:', value);
}
// 取消读取
await reader.cancel('用户取消');
// 释放锁(允许其他 reader)
reader.releaseLock();
WritableStreamDefaultWriter
const writer = writable.getWriter();
// 写入数据
await writer.write('数据块1');
await writer.write('数据块2');
// 关闭
await writer.close();
// 中止
await writer.abort('发生错误');
// 释放锁
writer.releaseLock();
4.4 管道操作
// 1. pipeTo - 流向可写流
await readable.pipeTo(writable);
// 2. pipeThrough - 流经转换流
const transformed = readable
.pipeThrough(transformStream1)
.pipeThrough(transformStream2);
// 3. 实际应用:文件处理流水线
fetch('/api/large-file')
.then((response) => response.body) // ReadableStream
.pipeThrough(decompressStream) // 解压
.pipeThrough(decryptStream) // 解密
.pipeThrough(parseStream) // 解析
.pipeThrough(validateStream) // 验证
.pipeTo(saveToFileStream); // 保存
// 4. 带选项的管道
await readable.pipeTo(writable, {
preventClose: true, // 不自动关闭 writable
preventAbort: true, // 不传播 abort
preventCancel: true, // 不传播 cancel
signal: abortSignal, // 取消信号
});
4.5 完整 API 对照表
API | 类型 | 主要方法/属性 | 用途 |
---|---|---|---|
ReadableStream | 可读流 |
getReader() , pipeThrough() , pipeTo()
|
读取数据源 |
WritableStream | 可写流 |
getWriter() , abort()
|
写入数据 |
TransformStream | 转换流 |
readable , writable
|
数据转换 |
ReadableStreamDefaultController | 控制器 |
enqueue() , close() , error()
|
控制可读流 |
WritableStreamDefaultController | 控制器 | error() |
控制可写流 |
TransformStreamDefaultController | 控制器 |
enqueue() , terminate()
|
控制转换流 |
ReadableStreamDefaultReader | 读取器 |
read() , cancel() , releaseLock()
|
读取数据块 |
ReadableStreamBYOBReader | 字节读取器 | read(buffer) |
高效字节读取 |
WritableStreamDefaultWriter | 写入器 |
write() , close() , abort()
|
写入数据块 |
🔤 Web Encoding API 完整介绍
5.1 核心概念
Web Encoding API 提供字符串与字节之间的编码转换功能,是处理网络传输、文件操作的基础工具。
为什么需要编码?
问题:计算机只能处理数字(字节),网络也只能传输字节
解决:需要在 JavaScript 字符串 ↔ 字节数组 之间转换
JavaScript 字符串 "Hello"
↓ TextEncoder
Uint8Array [72, 101, 108, 108, 111] ← 可以通过网络传输
↓ TextDecoder
JavaScript 字符串 "Hello"
5.2 TextEncoder(编码器)
基本用法
// 创建编码器(默认 UTF-8)
const encoder = new TextEncoder();
// 字符串 → 字节数组
const bytes = encoder.encode('Hello World');
console.log(bytes); // Uint8Array(11) [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]
// 查看编码格式
console.log(encoder.encoding); // "utf-8"(只读,固定为 UTF-8)
API 详解
interface TextEncoder {
readonly encoding: string; // 固定为 "utf-8"
// 编码整个字符串
encode(input?: string): Uint8Array;
// 编码到已有缓冲区(流式编码)
encodeInto(
source: string,
destination: Uint8Array
): TextEncoderEncodeIntoResult;
}
interface TextEncoderEncodeIntoResult {
read: number; // 已读取的源字符数
written: number; // 已写入的字节数
}
encode() 方法
const encoder = new TextEncoder();
// 1. 基本使用
encoder.encode('Hello'); // Uint8Array [72, 101, 108, 108, 111]
// 2. 空字符串
encoder.encode(''); // Uint8Array []
// 3. 中文字符(UTF-8 编码,每个中文 3 字节)
encoder.encode('你好'); // Uint8Array [228, 189, 160, 229, 165, 189]
// 4. Emoji(UTF-8 编码,4 字节)
encoder.encode('🎉'); // Uint8Array [240, 159, 142, 137]
// 5. 混合内容
encoder.encode('Hello 你好 🎉'); // 包含所有字符的字节表示
encodeInto() 方法(流式编码)
const encoder = new TextEncoder();
// 创建目标缓冲区
const buffer = new Uint8Array(20);
// 编码到缓冲区
const result = encoder.encodeInto('Hello World', buffer);
console.log(result);
// { read: 11, written: 11 }
// read: 读取了 11 个字符
// written: 写入了 11 个字节
console.log(buffer);
// Uint8Array [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// H e l l o 空 W o r l d ← 后面是未使用的 0
// 缓冲区不足的情况
const smallBuffer = new Uint8Array(5);
const result2 = encoder.encodeInto('Hello World', smallBuffer);
console.log(result2);
// { read: 5, written: 5 } ← 只能写入 5 个字节
console.log(smallBuffer);
// Uint8Array [72, 101, 108, 108, 111] ← "Hello" 的字节
实际应用场景
// ✅ SSE 数据编码
const encoder = new TextEncoder();
controller.enqueue(encoder.encode('data: Hello\n\n'));
// ✅ WebSocket 发送文本
const message = encoder.encode(JSON.stringify({ type: 'chat', content: 'Hi' }));
websocket.send(message);
// ✅ 文件写入
const fileContent = encoder.encode('File content here');
await writeFile('output.txt', fileContent);
// ✅ 计算字符串字节长度
const text = 'Hello 你好';
const byteLength = encoder.encode(text).length;
console.log(byteLength); // 14 字节(5 + 1 + 6 + 2)
5.3 TextDecoder(解码器)
基本用法
// 创建解码器(默认 UTF-8)
const decoder = new TextDecoder();
// 字节数组 → 字符串
const bytes = new Uint8Array([72, 101, 108, 108, 111]);
const text = decoder.decode(bytes);
console.log(text); // "Hello"
// 查看解码格式
console.log(decoder.encoding); // "utf-8"
API 详解
interface TextDecoder {
readonly encoding: string; // 编码格式(如 "utf-8")
readonly fatal: boolean; // 是否抛出解码错误
readonly ignoreBOM: boolean; // 是否忽略字节序标记
// 解码字节数组
decode(
input?: BufferSource, // Uint8Array 或 ArrayBuffer
options?: TextDecodeOptions // 解码选项
): string;
}
interface TextDecodeOptions {
stream?: boolean; // 流式解码(保留不完整字符)
}
支持的编码格式
// UTF-8(默认,推荐)
const utf8Decoder = new TextDecoder('utf-8');
// 其他编码格式
const gbkDecoder = new TextDecoder('gbk'); // 中文 GBK
const latinDecoder = new TextDecoder('iso-8859-1'); // 拉丁文
const shiftJisDecoder = new TextDecoder('shift-jis'); // 日文
// 检查支持的编码
try {
new TextDecoder('unknown-encoding');
} catch (error) {
console.error('不支持的编码格式');
}
decode() 方法
const decoder = new TextDecoder();
// 1. 基本解码
const bytes1 = new Uint8Array([72, 101, 108, 108, 111]);
decoder.decode(bytes1); // "Hello"
// 2. 解码中文
const bytes2 = new Uint8Array([228, 189, 160, 229, 165, 189]);
decoder.decode(bytes2); // "你好"
// 3. 解码 Emoji
const bytes3 = new Uint8Array([240, 159, 142, 137]);
decoder.decode(bytes3); // "🎉"
// 4. 空字节
decoder.decode(new Uint8Array([])); // ""
// 5. 使用 ArrayBuffer
const buffer = new ArrayBuffer(5);
const view = new Uint8Array(buffer);
view.set([72, 101, 108, 108, 111]);
decoder.decode(buffer); // "Hello"
流式解码
const decoder = new TextDecoder();
// 问题:多字节字符可能被分割
const part1 = new Uint8Array([228, 189]); // "你" 的前 2 字节
const part2 = new Uint8Array([160]); // "你" 的最后 1 字节
// ❌ 不使用 stream 选项
decoder.decode(part1); // "�" (乱码,字符不完整)
decoder.decode(part2); // "�" (乱码)
// ✅ 使用 stream 选项
const streamDecoder = new TextDecoder();
const text1 = streamDecoder.decode(part1, { stream: true }); // ""(等待更多字节)
const text2 = streamDecoder.decode(part2, { stream: false }); // "你"(完成解码)
console.log(text1 + text2); // "你"
错误处理
// 1. fatal 模式(遇到无效字节抛出错误)
const fatalDecoder = new TextDecoder('utf-8', { fatal: true });
try {
const invalidBytes = new Uint8Array([0xff, 0xfe]); // 无效的 UTF-8
fatalDecoder.decode(invalidBytes);
} catch (error) {
console.error('解码失败:', error); // EncodingError
}
// 2. 非 fatal 模式(替换为 � )
const decoder = new TextDecoder('utf-8', { fatal: false });
const invalidBytes = new Uint8Array([0xff, 0xfe]);
decoder.decode(invalidBytes); // "��"(替换字符)
5.4 完整 API 对照表
API | 方法/属性 | 参数 | 返回值 | 用途 |
---|---|---|---|---|
TextEncoder | constructor() |
- | TextEncoder |
创建 UTF-8 编码器 |
encode(str) |
string |
Uint8Array |
字符串 → 字节数组 | |
encodeInto(str, buffer) |
string, Uint8Array |
{read, written} |
编码到已有缓冲区 | |
encoding |
- | "utf-8" |
编码格式(只读) | |
TextDecoder | constructor(label?, options?) |
string, {fatal?, ignoreBOM?} |
TextDecoder |
创建解码器 |
decode(buffer?, options?) |
BufferSource, {stream?} |
string |
字节数组 → 字符串 | |
encoding |
- | string |
编码格式(只读) | |
fatal |
- | boolean |
是否抛出错误(只读) | |
ignoreBOM |
- | boolean |
是否忽略 BOM(只读) |
5.5 总结
Web Encoding API 核心要点:
- TextEncoder:字符串 → 字节(仅支持 UTF-8)
- TextDecoder:字节 → 字符串(支持多种编码)
-
在 SSE 中的作用:
- 将 SSE 格式字符串编码为字节
- 使 HTTP Response 能够传输文本数据
-
性能优化:
- 复用编码器/解码器实例
- 大量编码时使用
encodeInto()
- 流式解码时使用
{ stream: true }
【前端工程化】脚手架篇 - 模板引擎 & 动态依赖管理脚手架
🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
在日常工作中,我们经常为会遇到需要创建新项目的需求,为了统计代码风格,项目配置,提升效率,我们可以创建一个cli工具,帮助我们实现这样的功能。你也可以搭建一个自己用,毕竟省下来的时间都是自己的
🥑 你能学到什么?
希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:
- cli工具的基本搭建流程
- 如何通过模板引擎实现可选依赖
- 模板系统如何设计
- 如何根据模板引擎生成所需项目
- 熟悉一个组件库的基本结构
- 熟悉一个类型库的基本结构
- 熟悉一个cli项目的基本结构
后续你可以在此项目ObjectX-CLI的基础上,扩展项目支持的技术栈,和项目类型,以此了解各种项目的搭建流程
实现效果
🍎 系列文章
本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action
实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp
分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等
- 【前端工程化】项目搭建篇-项目初始化&prettier、eslint、stylelint、lint-staged、husky
- 【前端工程化】项目搭建篇-配置changelog、webpack5打包
- 【前端工程化】项目搭建篇-引入react、ts、babel解析es6+、配置css module
- 【前端工程化】组件库搭建篇-引入storybook、rollup打包组件、本地测试组件库
- 【前端工程化】包管理器篇-三大包管理器、npm工程管理、npm发布流程
- 【前端工程化】自动化篇-Github Action基本使用、自动部署组件库文档、github3D指标统计
- 【前端工程化】自动化篇-手写脚本一键自动tag、发包、引导登录npm
- 【前端工程化】monorepo篇-rush管理monorepo实践
- 【前端工程化】monorepo篇-monorepo多包发布脚本实现
项目概述
ObjectX-CLI 是一个现代化的前端项目脚手架工具,支持快速创建以下三种类型的项目:
- 组件库项目 (component-lib):基于 React + TypeScript + Vite
- 工具包项目 (tool-lib):通用 JavaScript/TypeScript 工具库
- 类型包项目 (types-lib):纯 TypeScript 类型定义包
核心特性
- 🚀 零配置启动:开箱即用,一键创建项目
- 📦 模板化设计:基于 EJS 模板引擎,支持灵活配置
- 🎨 多样式方案:支持 Less、Tailwind CSS、CSS Modules
- 📚 文档集成:可选 Storybook 组件文档
- 🧩 Monorepo 支持:使用 pnpm workspace
- ⚡ 现代化打包:Rollup + TypeScript
核心架构设计
整体架构图
objectx-cli/
├── bin/ # CLI 入口
│ └── index.js # 可执行文件入口
├── src/ # 源代码
│ ├── index.ts # 主程序入口
│ ├── commands/ # 命令实现
│ │ └── create.ts # create 命令
│ └── utils/ # 工具函数
│ ├── generate.ts # 项目生成逻辑
│ └── validate.ts # 验证逻辑
├── templates/ # 项目模板
│ ├── component-lib/ # 组件库模板
│ ├── tool-lib/ # 工具库模板
│ └── types-lib/ # 类型库模板
├── lib/ # 编译输出(发布到 npm)
├── package.json # 包配置
├── rollup.config.js # 打包配置
└── tsconfig.json # TypeScript 配置
核心流程图
用户执行命令
↓
bin/index.js(可执行入口)
↓
src/index.ts(初始化 Commander)
↓
src/commands/create.ts(处理 create 命令)
↓
├─ validate.ts(验证项目名)
├─ inquirer 交互式问答
└─ generate.ts(生成项目文件)
↓
├─ 读取模板文件
├─ EJS 模板渲染
├─ 条件文件过滤
└─ 生成 package.json
↓
Git 初始化
↓
完成提示
技术栈分析
1. CLI 框架层
Commander.js - 命令行框架
// src/index.ts
import { Command } from 'commander';
const cli = new Command();
cli
.name('objectx-cli')
.description('前端项目脚手架工具')
.version(pkg.version);
cli
.command('create <project-name>')
.description('创建一个新的项目')
.option('-t, --template <template>', '指定项目模板')
.action(create);
作用:
- 定义 CLI 命令和参数
- 自动生成
--help
和--version
- 参数解析和验证
2. 交互层
@inquirer/prompts - 交互式问答
// src/commands/create.ts
import * as inquirer from '@inquirer/prompts';
// 确认覆盖
const overwriteResult = await inquirer.confirm({
message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
default: false
});
// 单选
const projectType = await inquirer.select({
message: '请选择项目类型:',
choices: [
{ value: 'component-lib', name: '组件库项目' },
{ value: 'tool-lib', name: '工具包项目' },
{ value: 'types-lib', name: '类型包项目' }
]
});
// 多选
const styles = await inquirer.checkbox({
message: '选择样式解决方案 (可多选):',
choices: [
{ value: 'less', name: 'Less', checked: true },
{ value: 'tailwind', name: 'Tailwind CSS' },
{ value: 'css-modules', name: 'CSS Modules', checked: true }
]
});
3. 用户体验层
Chalk - 终端颜色
import chalk from 'chalk';
console.log(`${chalk.bgBlue('OBJECTX CLI ')} 🚀 创建新项目...`);
console.log(chalk.green('✔') + ' 项目创建成功!');
console.log(chalk.red('✖') + ' 项目名称不能为空');
console.log(` cd ${chalk.cyan(projectName)}`);
Ora - 加载动画
import ora from 'ora';
const spinner = ora('正在生成项目文件...').start();
await generateProject(targetDir, projectOptions);
spinner.succeed('项目文件生成完成');
// 失败情况
spinner.fail('项目文件生成失败');
4. 文件处理层
fs-extra - 增强的文件系统
import fs from 'fs-extra';
// 确保目录存在
await fs.ensureDir(targetDir);
// 删除目录
await fs.remove(targetDir);
// 输出文件(自动创建父目录)
await fs.outputFile(targetPath, content);
// 检查文件是否存在
if (fs.existsSync(targetDir)) { ... }
fast-glob - 文件搜索
import glob from 'fast-glob';
// 读取所有模板文件(包括隐藏文件)
const templateFiles = await glob('**/*', {
cwd: templateDir,
dot: true, // 包括 .gitignore 等
ignore: ['**/node_modules/**', '**/.git/**']
});
5. 模板引擎层【核心】
EJS - 模板渲染
import ejs from 'ejs';
// 模板数据
const templateData = {
projectName: 'my-component',
hasLess: true,
hasTailwind: false,
year: 2024
};
// 渲染模板
const content = await fs.readFile(sourcePath, 'utf8');
const renderedContent = ejs.render(content, templateData);
EJS 模板示例:
// Button.tsx.ejs
<% if (hasCssModules) { %>
import styles from './Button.module.css';
<% } else if (hasLess) { %>
import './Button.less';
<% } else if (hasTailwind) { %>
// 使用Tailwind类名
<% } %>
export const Button: React.FC<ButtonProps> = ({ children }) => {
<% if (hasCssModules) { %>
return <button className={styles.button}>{children}</button>;
<% } else if (hasTailwind) { %>
return <button className="bg-blue-500 text-white">{children}</button>;
<% } %>
};
6. 子进程管理
execa - 执行外部命令
import { execa } from 'execa';
// 初始化 Git
await execa('git', ['init'], { cwd: targetDir });
实现原理深度解析【核心】
1. 可执行入口实现
package.json 配置
{
"name": "objectx-cli",
"type": "module",
"bin": {
"objectx-cli": "./bin/index.js"
},
"files": ["bin", "lib", "templates"]
}
-
bin
字段:指定命令名称和执行文件 -
type: "module"
:使用 ES Module -
files
字段:指定发布到 npm 的文件
bin/index.js
#!/usr/bin/env node
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
import '../lib/index.js';
关键点:
-
#!/usr/bin/env node
:Shebang,告诉系统用 Node.js 执行 -
createRequire
:在 ESM 中使用require
加载 JSON - 引入编译后的
lib/index.js
(不是源码src/index.ts
)
2. 项目名称验证
// src/utils/validate.ts
export function validateProjectName(projectName: string): void {
// 1. 空值检查
if (!projectName) {
console.log(chalk.red('✖') + ' 项目名称不能为空');
process.exit(1);
}
// 2. 空格检查
if (projectName.trim() !== projectName) {
console.log(chalk.red('✖') + ' 项目名称不能以空格开头或结尾');
process.exit(1);
}
// 3. npm 包名验证
const npmNameValidation = validateNpmPackageName(projectName);
if (!npmNameValidation.validForNewPackages) {
npmNameValidation.errors.forEach(err => {
console.log(' ' + chalk.red('✖') + ' ' + err);
});
process.exit(1);
}
// 4. 保留关键字检查
const RESERVED_KEYWORDS = ['node_modules', 'favicon.ico', '.git'];
if (RESERVED_KEYWORDS.includes(projectName.toLowerCase())) {
console.log(chalk.red('✖') + ` 项目名称不能使用保留关键字`);
process.exit(1);
}
}
function validateNpmPackageName(name: string) {
const errors: string[] = [];
if (name.length > 214) {
errors.push('名称不能超过214个字符');
}
if (name.match(/^[._]/)) {
errors.push('名称不能以 . 或 _ 开头');
}
if (name.match(/[/\\]/)) {
errors.push('名称不能包含斜杠');
}
return {
validForNewPackages: errors.length === 0,
errors
};
}
3. 目录覆盖处理
// src/commands/create.ts
const targetDir = path.join(process.cwd(), projectName);
// 检查目录是否已存在
if (fs.existsSync(targetDir)) {
const overwriteResult = await inquirer.confirm({
message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
default: false
});
if (!overwriteResult) {
console.log(chalk.red('✖') + ' 操作取消');
return;
}
const spinner = ora(`正在删除 ${chalk.cyan(targetDir)}...`).start();
await fs.remove(targetDir);
spinner.succeed(`已删除 ${chalk.cyan(targetDir)}`);
}
await fs.ensureDir(targetDir);
安全性考虑:
- 先询问用户确认
- 显示加载动画,提升体验
- 使用
fs.remove
完全删除旧目录
模板系统设计【核心】
1. 模板目录结构
templates/
├── component-lib/ # 组件库模板
│ ├── _gitignore.ejs # .gitignore(以 _ 开头)
│ ├── demo/ # 示例应用(无 Storybook 时使用)
│ │ ├── App.tsx.ejs
│ │ ├── index.html.ejs
│ │ └── main.tsx.ejs
│ ├── pnpm-workspace.yaml.ejs
│ ├── README.md.ejs
│ ├── src/
│ │ ├── components/
│ │ │ ├── Button/
│ │ │ │ ├── Button.tsx.ejs
│ │ │ │ ├── Button.module.css.ejs
│ │ │ │ ├── Button.module.less.ejs
│ │ │ │ └── Button.stories.tsx.ejs
│ │ │ └── Card/
│ │ │ ├── Card.tsx.ejs
│ │ │ └── Card.stories.tsx.ejs
│ │ ├── index.ts
│ │ ├── styles/
│ │ │ └── tailwind.css.ejs
│ │ └── types/
│ │ └── css.d.ts
│ ├── tailwind.config.js.ejs
│ ├── tsconfig.json.ejs
│ └── vite.config.ts.ejs
│
├── tool-lib/ # 工具库模板
│ ├── README.md.ejs
│ ├── src/
│ │ ├── index.ts
│ │ └── utils/
│ │ └── string.ts
│ ├── tsconfig.json.ejs
│ └── vite.config.ts.ejs
│
└── types-lib/ # 类型库模板
├── README.md.ejs
├── src/
│ ├── index.ts
│ └── types/
│ ├── api.ts
│ └── common.ts
├── tests/
│ └── api.test-d.ts
└── tsconfig.json.ejs
2. 特殊文件命名约定
隐藏文件处理
// src/utils/generate.ts
function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
let filename = file;
// 处理点文件(如 .gitignore)
// 模板中命名为 _gitignore.ejs,生成时转为 .gitignore
if (filename.startsWith('_')) {
filename = `.${filename.slice(1)}`;
}
return path.join(targetDir, filename);
}
为什么这样做?
- npm 发布时会忽略
.gitignore
文件 - 使用
_gitignore
绕过这个限制 - 生成项目时再重命名为
.gitignore
3. 条件文件生成
function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
// ...文件名处理...
// 如果选择了Storybook,跳过demo目录
if (filename.startsWith('demo') && options.needDocs) {
return ''; // 返回空字符串表示跳过
}
// 根据样式选择过滤文件
if (filename.endsWith('.less') && !options.styles.includes('less')) {
return '';
}
if (filename.includes('tailwind') && !options.styles.includes('tailwind')) {
return '';
}
// Card组件只在选择了tailwind时生成
if (filename.includes('components/Card') && !options.styles.includes('tailwind')) {
return '';
}
return path.join(targetDir, filename);
}
设计思想:
- 根据用户选择动态生成文件
- 避免生成无用文件
- 保持项目清爽
文件生成机制【核心】
1. 核心生成流程
// src/utils/generate.ts
export async function generateProject(
targetDir: string,
options: ProjectOptions
): Promise<void> {
// 1. 定位模板目录
const templateDir = path.resolve(
__dirname,
'../../templates',
options.projectType
);
// 2. 确保模板目录存在
if (!fs.existsSync(templateDir)) {
throw new Error(`找不到模板目录:${templateDir}`);
}
// 3. 读取所有模板文件
const templateFiles = await glob('**/*', {
cwd: templateDir,
dot: true,
ignore: ['**/node_modules/**', '**/.git/**']
});
// 4. 准备模板数据
const templateData: TemplateData = {
projectName: options.projectName,
needDocs: options.needDocs,
hasLess: options.styles.includes('less'),
hasTailwind: options.styles.includes('tailwind'),
hasCssModules: options.styles.includes('css-modules'),
year: new Date().getFullYear(),
packageManager: options.packageManager
};
// 5. 遍历并处理每个文件
for (const file of templateFiles) {
const sourcePath = path.join(templateDir, file);
const targetPath = getTargetPath(file, targetDir, options);
// 跳过不需要的文件
if (targetPath === '') continue;
// 处理目录
if (fs.statSync(sourcePath).isDirectory()) {
await fs.ensureDir(targetPath);
continue;
}
// 读取文件内容
const content = await fs.readFile(sourcePath, 'utf8');
// 6. EJS 模板渲染
if (file.endsWith('.ejs')) {
const renderedContent = ejs.render(content, templateData);
// 去掉 .ejs 后缀
await fs.outputFile(
targetPath.replace(/\.ejs$/, ''),
renderedContent
);
} else {
// 非模板文件直接复制
await fs.outputFile(targetPath, content);
}
}
// 7. 生成 package.json
await generatePackageJson(targetDir, options);
}
2. package.json 动态生成
async function generatePackageJson(
targetDir: string,
options: ProjectOptions
): Promise<void> {
// 基础配置
const packageJson: any = {
name: options.projectName,
version: '0.1.0',
private: false,
scripts: {
dev: 'vite',
build: 'vite build && tsc --emitDeclarationOnly',
lint: 'eslint src --ext .ts,.tsx',
test: 'jest'
},
files: ['dist'],
devDependencies: {
"husky": "^9.0.7",
"typescript": "^5.2.2",
"eslint": "^8.52.0",
"terser": "^5.24.0"
}
};
// 根据项目类型定制
switch (options.projectType) {
case 'component-lib':
// 组件库配置
packageJson.type = 'module';
packageJson.main = './dist/index.js';
packageJson.module = './dist/index.js';
packageJson.types = './dist/index.d.ts';
packageJson.exports = {
'.': {
import: './dist/index.js',
require: './dist/index.cjs'
}
};
packageJson.peerDependencies = {
react: "^18.0.0",
"react-dom": "^18.0.0"
};
packageJson.devDependencies = {
...packageJson.devDependencies,
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.2.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31"
};
// Storybook 支持
if (options.needDocs) {
packageJson.scripts.dev = 'storybook dev -p 6006';
packageJson.scripts.storybook = 'storybook dev -p 6006';
packageJson.scripts['build-storybook'] = 'storybook build';
packageJson.devDependencies = {
...packageJson.devDependencies,
"@storybook/addon-essentials": "^7.5.3",
"@storybook/react": "^7.5.3",
"@storybook/react-vite": "^7.5.3",
"storybook": "^7.5.3"
};
} else {
// 使用 demo 作为开发环境
packageJson.scripts.dev = 'vite demo --open';
}
// 样式依赖
if (options.styles.includes('less')) {
packageJson.devDependencies.less = "^4.2.0";
}
if (options.styles.includes('tailwind')) {
packageJson.devDependencies.tailwindcss = "^3.3.5";
}
break;
case 'tool-lib':
// 工具库配置
packageJson.type = 'module';
packageJson.main = './dist/index.js';
packageJson.module = './dist/index.js';
packageJson.types = './dist/index.d.ts';
packageJson.scripts.dev = 'vite build --mode watch';
packageJson.scripts.docs = 'typedoc --out docs src/index.ts';
packageJson.devDependencies = {
...packageJson.devDependencies,
"vite": "^5.0.0",
"typedoc": "^0.25.3"
};
break;
case 'types-lib':
// 类型库配置
packageJson.scripts.build = 'tsc --emitDeclarationOnly';
packageJson.scripts.dev = 'tsc --emitDeclarationOnly --watch';
packageJson.scripts['test:types'] = 'tsc --noEmit';
packageJson.main = './dist/index.d.ts';
packageJson.types = './dist/index.d.ts';
packageJson.devDependencies = {
"husky": "^9.0.7",
"typescript": "^5.2.2",
"eslint": "^8.52.0",
"tsd": "^0.30.0",
"@types/node": "^20.8.10"
};
break;
}
// 写入文件
await fs.writeFile(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
}
亮点:
- 动态配置:根据项目类型和用户选择生成不同的依赖和脚本
- 双模式支持:同时支持 ESM 和 CommonJS
- 按需加载:只添加用户选择的功能的依赖
打包与发布
1. Rollup 打包配置
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
export default {
// 入口文件
input: 'src/index.ts',
// 输出配置
output: [{
dir: 'lib', // 输出目录
format: 'esm', // ES Module 格式
sourcemap: true, // 生成 source map
preserveModules: true, // 保留模块结构
entryFileNames: '[name].js'
}],
// 外部依赖(不打包)
external: [
'fs', 'path', 'os', 'util', 'child_process', // Node.js 内置模块
'commander', 'chalk', 'ora', // CLI 依赖
'@inquirer/prompts', 'fs-extra', 'ejs', // 工具库
'fast-glob', 'execa', 'semver'
],
// 插件
plugins: [
resolve({
extensions: ['.ts', '.js']
}),
commonjs(),
json(),
typescript({
tsconfig: './tsconfig.json',
outputToFilesystem: true
}),
terser() // 压缩代码
]
};
为什么使用 Rollup?
- Tree Shaking:更好的死代码消除
-
模块保留:
preserveModules: true
保持源码结构 - 更小的包体积:相比 Webpack 更适合库的打包
2. TypeScript 配置
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext", // Node.js ESM 支持
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"declaration": true, // 生成 .d.ts
"resolveJsonModule": true,
"skipLibCheck": true,
"outDir": "lib", // 输出到 lib 目录
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules", "src/templates/**/*"]
}
3. npm 发布配置
package.json 关键字段
{
"name": "objectx-cli",
"version": "0.1.0",
"type": "module",
"bin": {
"objectx-cli": "./bin/index.js"
},
"files": [
"bin",
"lib",
"templates"
],
"scripts": {
"build": "rollup -c",
"release": "bumpp && npm publish"
},
"keywords": [
"cli", "react", "typescript", "vite",
"component-library", "tooling"
],
"engines": {
"node": ">=16.0.0"
}
}
关键点解析:
-
files
字段:- 只发布
bin
、lib
、templates
目录 - 不发布源码
src/
,减小包体积
- 只发布
-
engines
字段:- 限制 Node.js 版本 >= 16
- 确保 ESM 特性可用
-
keywords
字段:- 提升 npm 搜索排名
发布流程
# 1. 构建
pnpm build
# 2. 版本管理(使用 bumpp)
pnpm release
# bumpp 会自动:
# - 提示选择版本号(patch/minor/major)
# - 更新 package.json
# - 创建 git tag
# - 推送到远程
# - 发布到 npm
后续
好了,这就是一个完整的企业级脚手架搭建流程,后续我还会介绍webpack、vite的实现原理,eslint插件开发,babel实现原理,babel插件开发,感兴趣的可以关注下
【react18原理探究实践】React Effect List 构建与 Commit 阶段详解
【react18原理探究实践】更新阶段 Render 与 Diff 算法详解
切记、切记、切记,diff对比的是ReactElement和old fiber,生成新的fiber节点,不是两个fiber对比,Render 阶段的最终结果不是直接的 DOM,而是一条 effect list,它告诉 commit 阶段要做哪些 DOM 操作。
🧑💻 前言
之前的文章中,我们从 首次挂载流程 出发,理解了 React 如何一步步“生造”出一棵 Fiber 树,并创建 DOM 节点。
更新阶段则不同:React 不再从零创建 Fiber,而是基于现有 Fiber 树,通过 Diff ReactElement 与旧 Fiber 来生成新的 Fiber 节点,并决定哪些 DOM 需要更新、移动或删除。
本篇文章,我们将结合源码,逐步拆解 更新阶段的 Diff 流程,理解 React 如何高效复用 Fiber 节点,同时生成新 Fiber 链表。
一、更新阶段与首次挂载的区别
1.1 核心差异对比
维度 | 首次挂载 | 更新阶段 |
---|---|---|
current 树 | 为 null(除 HostRootFiber) | 存在完整的树 |
alternate 指针 | null | 互相指向 |
beginWork 逻辑 | 直接创建子 Fiber | 对比 props,可能复用或更新 |
reconcileChildren | mountChildFibers(不标记) | reconcileChildFibers(执行 diff) |
副作用标记 | 只标记根节点 Placement | 标记 Update、Placement、Deletion |
DOM 操作 | 创建所有 DOM | 复用、更新或删除 DOM |
1.2 双缓冲树在更新时的状态
// 更新前:
FiberRoot
↓ current
current 树(屏幕显示)
HostRootFiber
↓
AppFiber {
memoizedProps: {count: 0},
memoizedState: {count: 0},
child: divFiber
}
↓
divFiber {
type: 'div',
children: [...]
}
// 更新开始:触发 setState({count: 1})
FiberRoot
↓ current
current 树 workInProgress 树
HostRootFiber ←→ HostRootFiber'
↓ ↓
AppFiber ←→ AppFiber'
{count: 0} {count: 1} (新state)
↓
divFiber
// 通过 alternate 互相指向
// workInProgress 树复用 current 树的节点
// 更新完成:
FiberRoot
↓ current (切换!)
workInProgress 树变成新的 current 树
1.3 更新的三种情况
// 1. props 没变,state 没变
// 结果:bailout 优化,跳过整个子树
// 2. props 变了,或 state 变了
// 结果:重新 render,执行 diff
// 3. context 变了
// 结果:强制更新
二、更新的触发方式
2.1 类组件的 setState
class App extends React.Component {
state = { count: 0 };
handleClick = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return <div onClick={this.handleClick}>{this.state.count}</div>;
}
}
// setState 的完整流程:
1. 调用 this.setState({count: 1})
↓
2. 创建 update 对象
update = {
payload: {count: 1},
next: null,
}
↓
3. 加入 updateQueue
fiber.updateQueue.shared.pending = update
↓
4. 调度更新
scheduleUpdateOnFiber(fiber, lane)
↓
5. 进入 render 阶段
workLoopSync() 或 workLoopConcurrent()
↓
6. beginWork 处理 AppFiber
↓
7. 计算新 state
processUpdateQueue() → newState = {count: 1}
↓
8. 调用 render()
nextChildren = instance.render()
↓
9. 执行 diff
reconcileChildFibers(current.child, nextChildren)
2.2 函数组件的 useState
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
return <div onClick={handleClick}>{count}</div>;
}
// useState 更新流程:
1. 调用 setCount(1)
↓
2. dispatchSetState(fiber, queue, 1)
↓
3. 创建 update 对象
update = {
action: 1,
next: null,
}
↓
4. 加入 hook.queue.pending
queue.pending = update
↓
5. 调度更新
scheduleUpdateOnFiber(fiber, lane)
↓
6. 进入 render 阶段
↓
7. renderWithHooks
- 设置 HooksDispatcherOnUpdate
- 调用 App()
↓
8. 执行 useState(0)
实际调用 updateState()
↓
9. 计算新 state
遍历 queue.pending,计算最终值 = 1
↓
10. 返回 [1, setCount]
↓
11. render() 返回新的 children
↓
12. 执行 diff
2.3 forceUpdate 强制更新
class App extends React.Component {
handleClick = () => {
this.forceUpdate(); // 跳过 shouldComponentUpdate
};
render() {
return <div onClick={this.handleClick}>Force Update</div>;
}
}
// forceUpdate 的特点:
// 1. 不检查 props 和 state
// 2. 强制执行 render
// 3. 子组件仍然会进行 diff
三、beginWork 更新路径
3.1 beginWork 的更新判断
// 📍 位置:ReactFiberBeginWork.new.js
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
if (current !== null) {
// ========== 更新路径 ==========
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// 步骤1:检查 props 是否变化
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
(__DEV__ ? workInProgress.type !== current.type : false)
) {
// Props 变了,标记需要更新
didReceiveUpdate = true;
} else {
// Props 没变,检查其他更新来源
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes
);
if (
!hasScheduledUpdateOrContext &&
(workInProgress.flags & DidCapture) === NoFlags
) {
// 🎯 完全不需要更新,执行 bailout
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes
);
}
if ((current.flags & ForceUpdateForLegacySuspense) !== NoFlags) {
didReceiveUpdate = true;
} else {
didReceiveUpdate = false;
}
}
} else {
// 首次挂载路径
didReceiveUpdate = false;
}
// 清空 lanes
workInProgress.lanes = NoLanes;
// 根据 tag 分发
switch (workInProgress.tag) {
case ClassComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateClassComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes
);
}
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
const resolvedProps =
workInProgress.elementType === Component
? unresolvedProps
: resolveDefaultProps(Component, unresolvedProps);
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes
);
}
// 其他类型...
}
}
3.2 bailout 优化机制
function attemptEarlyBailoutIfNoScheduledUpdate(
current: Fiber,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
/* 💡 bailout 的条件:
* 1. props 没变(oldProps === newProps)
* 2. 没有 state 更新(没有 setState)
* 3. context 没变
* 4. 没有 forceUpdate
*/
workInProgress.lanes = NoLanes;
switch (workInProgress.tag) {
case HostRoot:
// 根节点特殊处理
pushHostRootContext(workInProgress);
resetHydrationState();
break;
case ClassComponent: {
const Component = workInProgress.type;
if (isLegacyContextProvider(Component)) {
pushLegacyContextProvider(workInProgress);
}
break;
}
// 其他类型...
}
// 🔥 关键:直接克隆子节点,不执行 render
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
function bailoutOnAlreadyFinishedWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
if (current !== null) {
// 复用 current 的依赖
workInProgress.dependencies = current.dependencies;
}
// 检查子节点是否有更新
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 🎯 子树也没有更新,跳过整个子树!
return null;
}
// 子树有更新,克隆子节点
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
bailout 示例:
class Parent extends React.Component {
state = { count: 0 };
render() {
return (
<div>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
{this.state.count}
</button>
<Child name="React" /> {/* props 没变 */}
</div>
);
}
}
class Child extends React.Component {
render() {
console.log("Child render"); // 不会执行!
return <div>{this.props.name}</div>;
}
}
// 点击 button 触发更新:
// 1. Parent 重新 render
// 2. Child 的 props 没变(name="React")
// 3. Child bailout,不执行 render
// 4. 直接复用 Child 的子树
四、Diff 算法核心原理
4.1 传统 Diff 的问题
传统的树 diff 算法:
- 时间复杂度:O(n³)
- 对于 1000 个节点,需要 10 亿次比较
- 完全不可用
为什么是 O(n³)?
1. 遍历树1的每个节点:O(n)
2. 遍历树2的每个节点:O(n)
3. 计算最小编辑距离:O(n)
总共:O(n³)
4.2 React Diff 的三个策略
React 通过三个假设,将复杂度降到 O(n):
// 策略1:Tree Diff - 只比较同层级
/* 💡 假设:
* DOM 节点跨层级移动的情况非常少见
*
* 结果:
* - 只比较同一层级的节点
* - 不同层级的节点不比较
* - 如果节点跨层级移动,会删除重建
*/
旧树: 新树:
A A
↙ ↘ ↙ ↘
B C D C
↙ ↙
D B
// React 的处理:
// 1. A 层:A 保留
// 2. B、C 层:
// - B 删除
// - C 保留
// - D 创建(在 C 下)
// 3. D 层:
// - D 下的 B 创建(新的 B)
// 不会识别 B 移动到了 D 下!
// 策略2:Component Diff - 同类型组件对比
/* 💡 假设:
* - 同类型组件生成相似的树结构
* - 不同类型组件生成不同的树结构
*
* 结果:
* - 同类型组件:继续对比子节点(Virtual DOM Diff)
* - 不同类型组件:直接删除重建
*/
// 例子1:同类型组件
<div className="old"> → <div className="new">
<p>text</p> <p>text</p>
</div> </div>
// 处理:
// 1. div 类型相同,复用 Fiber
// 2. 更新 className: old → new
// 3. 继续对比子节点 p
// 例子2:不同类型组件
<div> → <span>
<p>text</p> <p>text</p>
</div> </span>
// 处理:
// 1. div → span,类型不同
// 2. 删除整个 div 子树(包括 p)
// 3. 创建整个 span 子树(包括 p)
// 4. 不会复用 p!
// 策略3:Element Diff - 同层级节点对比
/* 💡 假设:
* - 通过 key 可以准确识别节点
* - 有 key 的节点可以跨位置复用
*
* 结果:
* - 有 key:通过 key 匹配,可以移动
* - 无 key:通过 index 匹配,按顺序对比
*/
// 例子:列表更新
旧:[A, B, C, D]
新:[D, A, B, C]
// 有 key 的情况:
// 1. 通过 key 识别 D 移动到了最前面
// 2. A、B、C 位置变化,但复用 Fiber
// 3. 只需要移动 DOM 节点
// 无 key 的情况:
// 1. 按 index 对比:
// - 0: A → D (更新)
// - 1: B → A (更新)
// - 2: C → B (更新)
// - 3: D → C (更新)
// 2. 所有节点都需要更新!
4.3 reconcileChildren 的分发逻辑
// 📍 位置:ReactChildFiber.new.js
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) {
// 首次挂载
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
// 🔥 更新:执行 diff
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}
// reconcileChildFibers 的内部实现
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
// 判断 newChild 的类型,分发到不同的处理函数
// 1. 单个元素
if (typeof newChild === "object" && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 🔥 单节点 diff
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes
)
);
case REACT_PORTAL_TYPE:
return placeSingleChild(
reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes)
);
}
// 2. 数组(多个子节点)
if (isArray(newChild)) {
// 🔥 多节点 diff
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes
);
}
}
// 3. 文本节点
if (typeof newChild === "string" || typeof newChild === "number") {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
"" + newChild,
lanes
)
);
}
// 4. Fragment
if (typeof newChild === "object" && newChild !== null) {
if (newChild.$$typeof === REACT_LAZY_TYPE) {
// lazy 组件
}
}
// 5. 删除剩余节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
五、单节点 Diff
5.1 单节点 Diff 的流程
// 📍 位置:ReactChildFiber.new.js
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes
): Fiber {
const key = element.key;
let child = currentFirstChild;
// ========== 第一步:遍历所有旧子节点,尝试复用 ==========
while (child !== null) {
// 1.1 比较 key
if (child.key === key) {
// key 相同,继续比较 type
const elementType = element.type;
// 1.2 比较 type
if (child.elementType === elementType ||
/* 其他类型判断 */) {
// 🎯 key 和 type 都相同,可以复用!
// 删除剩余的兄弟节点
deleteRemainingChildren(returnFiber, child.sibling);
// 复用 current Fiber,创建 workInProgress Fiber
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
} else {
// key 相同但 type 不同,删除所有旧节点
deleteRemainingChildren(returnFiber, child);
break;
}
} else {
// key 不同,删除这个节点,继续找
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// ========== 第二步:没有找到可复用的,创建新节点 ==========
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
5.2 单节点 Diff 的四种情况
// 情况1:key 和 type 都相同 - 复用
// 旧:<div key="a">old</div>
// 新:<div key="a">new</div>
reconcileSingleElement() {
// child.key === "a",element.key === "a" ✓
// child.type === "div",element.type === "div" ✓
// 结果:复用 Fiber,更新 props
}
Fiber 变化:
div Fiber {
key: "a",
type: "div",
memoizedProps: {children: "old"}, // 旧 props
pendingProps: {children: "new"}, // 新 props
stateNode: <div>old</div>, // 复用 DOM
flags: Update, // 标记更新
}
// 情况2:key 相同但 type 不同 - 删除旧的,创建新的
// 旧:<div key="a">text</div>
// 新:<span key="a">text</span>
reconcileSingleElement() {
// child.key === "a",element.key === "a" ✓
// child.type === "div",element.type === "span" ✗
// 结果:删除 div Fiber,创建 span Fiber
}
操作:
1. divFiber.flags |= Deletion // 标记删除
2. 创建 spanFiber
3. spanFiber.flags |= Placement // 标记插入
// 情况3:key 不同 - 继续找或创建新的
// 旧:<div key="a">text</div><p key="b">text</p>
// 新:<div key="b">text</div>
reconcileSingleElement() {
// 第一次循环:
// child.key === "a",element.key === "b" ✗
// deleteChild(divFiber)
// 第二次循环:
// child.key === "b",element.key === "b" ✓
// child.type === "p",element.type === "div" ✗
// deleteRemainingChildren(pFiber)
// 创建新的 divFiber
}
操作:
1. divFiber(key="a").flags |= Deletion
2. pFiber(key="b").flags |= Deletion
3. 创建新的 divFiber(key="b")
4. 新 divFiber.flags |= Placement
// 情况4:旧节点为 null - 直接创建
// 旧:null
// 新:<div key="a">text</div>
reconcileSingleElement() {
// child === null
// 跳过 while 循环
// 创建新 Fiber
}
5.3 useFiber - 复用 Fiber 的关键
function useFiber(fiber: Fiber, pendingProps: mixed): Fiber {
// 复用 current Fiber,创建 workInProgress Fiber
const clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 首次更新,创建 alternate
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode
);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode; // 🔥 复用 DOM!
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 再次更新,复用 alternate
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
// 重置副作用
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
}
// 复用其他属性
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
// ... 复用更多属性
return workInProgress;
}
六、多节点 Diff
6.1 多节点 Diff 的挑战
// 多节点 diff 需要处理的情况:
// 1. 节点更新
旧:[<div key="a">old</div>, <div key="b">old</div>]
新:[<div key="a">new</div>, <div key="b">new</div>]
// props 变了,需要更新
// 2. 节点增删
旧:[<div key="a" />, <div key="b" />]
新:[<div key="a" />, <div key="b" />, <div key="c" />]
// 新增了 c
// 3. 节点移动
旧:[<div key="a" />, <div key="b" />, <div key="c" />]
新:[<div key="c" />, <div key="a" />, <div key="b" />]
// c 移动到了最前面
// 4. 混合情况
旧:[<div key="a" />, <div key="b" />, <div key="c" />]
新:[<div key="d" />, <div key="a" />, <div key="e" />]
// b 删除,c 删除,d 新增,e 新增,a 移动
6.2 多节点 Diff 的完整算法
// 📍 位置:ReactChildFiber.new.js
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<any>,
lanes: Lanes
): Fiber | null {
/* 💡 Diff 算法的两轮遍历:
*
* 第一轮遍历:
* - 处理节点更新的情况
* - 按位置一一对比
* - 遇到不能复用的节点就跳出
*
* 第二轮遍历:
* - 处理节点新增、删除、移动
* - 使用 Map 优化查找
* - 判断是否需要移动
*/
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0; // 最后一个可复用节点的位置
let newIdx = 0;
let nextOldFiber = null;
// ========== 第一轮遍历:处理更新 ==========
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 1. 检查位置是否匹配
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 2. 尝试更新节点
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes
);
/* 💡 updateSlot 的逻辑:
* - 比较 key
* - key 相同:尝试复用(比较 type)
* - key 不同:返回 null(跳出第一轮)
*/
if (newFiber === null) {
// key 不同,无法复用,跳出第一轮
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 3. 处理不能复用的情况
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// 新 Fiber 没有复用 old Fiber,需要删除 old
deleteChild(returnFiber, oldFiber);
}
}
// 4. 记录位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 5. 构建新的 Fiber 链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// ========== 第一轮遍历的三种结束情况 ==========
// 情况1:新节点遍历完,删除剩余旧节点
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// 情况2:旧节点遍历完,新增剩余新节点
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 情况3:新旧节点都没遍历完,进入第二轮
// ========== 第二轮遍历:处理移动 ==========
// 1. 将剩余旧节点放入 Map(key → Fiber)
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
/* 💡 Map 结构:
* Map {
* "a" => aFiber,
* "b" => bFiber,
* "c" => cFiber,
* }
*/
// 2. 遍历剩余新节点
for (; newIdx < newChildren.length; newIdx++) {
// 2.1 尝试从 Map 中找到可复用的节点
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes
);
/* 💡 updateFromMap 的逻辑:
* - 通过 key 或 index 在 Map 中查找
* - 找到:尝试复用
* - 找不到:创建新节点
*/
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 从 Map 中删除已复用的节点
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key
);
}
}
// 2.2 判断是否需要移动
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 2.3 构建链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
// 3. 删除 Map 中剩余的节点(没被复用的)
if (shouldTrackSideEffects) {
existingChildren.forEach((child) => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
6.3 placeChild - 判断是否需要移动
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number
): number {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// 首次渲染,不需要标记
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
// 🔥 节点复用的情况
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// 🔥 需要移动!
/* 💡 为什么?
* lastPlacedIndex 记录的是"最后一个不需要移动的节点"的位置
*
* 如果 oldIndex < lastPlacedIndex,说明:
* - 这个节点在旧列表中的位置,比"最后一个不需要移动的节点"还靠前
* - 但现在它在新列表中的位置,比"最后一个不需要移动的节点"还靠后
* - 所以需要向右移动
*/
newFiber.flags |= Placement;
return lastPlacedIndex;
} else {
// 不需要移动
return oldIndex;
}
} else {
// 新节点
newFiber.flags |= Placement;
return lastPlacedIndex;
}
}
6.4 多节点 Diff 完整示例
// 示例:理解 lastPlacedIndex
旧列表:A(0) - B(1) - C(2) - D(3)
新列表:D - A - B - C
// 第一轮遍历:
newIdx=0, 新节点D, 旧节点A
- D.key !== A.key
- 跳出第一轮
// 第二轮遍历:
// 构建 Map:{A: AFiber, B: BFiber, C: CFiber, D: DFiber}
// lastPlacedIndex = 0
newIdx=0, 新节点D:
- 从Map找到DFiber, oldIndex=3
- oldIndex(3) >= lastPlacedIndex(0) ✓
- 不需要移动
- lastPlacedIndex = 3
- 标记:DFiber.flags = 0
newIdx=1, 新节点A:
- 从Map找到AFiber, oldIndex=0
- oldIndex(0) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:AFiber.flags |= Placement
newIdx=2, 新节点B:
- 从Map找到BFiber, oldIndex=1
- oldIndex(1) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:BFiber.flags |= Placement
newIdx=3, 新节点C:
- 从Map找到CFiber, oldIndex=2
- oldIndex(2) < lastPlacedIndex(3) ✗
- 🔥 需要移动!
- lastPlacedIndex = 3 (不变)
- 标记:CFiber.flags |= Placement
结果:
- D 不动(作为参考点)
- A、B、C 都标记 Placement(移动到 D 后面)
实际 DOM 操作:
parent.insertBefore(A, D.nextSibling)
parent.insertBefore(B, A.nextSibling)
parent.insertBefore(C, B.nextSibling)
优化的例子:
// 反例:最差情况
旧列表:A(0) - B(1) - C(2) - D(3)
新列表:D - C - B - A
// Diff 结果:
// lastPlacedIndex 会被 D(3) 设置为 3
// A、B、C 的 oldIndex 都 < 3
// 所以 A、B、C 都需要移动
// 更好的方案:只移动 D
// 但 React 的算法不会识别这种情况
// 因为算法是从左到右遍历的
// 最佳实践:把不变的节点放在前面
旧列表:A(0) - B(1) - C(2) - D(3)
新列表:A - B - C - D - E
// Diff 结果:
// A、B、C、D 都不需要移动
// 只有 E 需要插入
七、Key 的作用机制
7.1 为什么需要 key?
// 场景:列表头部插入
// 没有 key:
旧:[<li>1</li>, <li>2</li>, <li>3</li>]
新:[<li>0</li>, <li>1</li>, <li>2</li>, <li>3</li>]
// Diff 过程(按 index 对比):
index=0: <li>1</li> → <li>0</li> // 更新文本
index=1: <li>2</li> → <li>1</li> // 更新文本
index=2: <li>3</li> → <li>2</li> // 更新文本
index=3: null → <li>3</li> // 插入
// 结果:4次操作(3次更新 + 1次插入)
// 有 key:
旧:[<li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>]
新:[<li key="0">0</li>, <li key="1">1</li>, <li key="2">2</li>, <li key="3">3</li>]
// Diff 过程(通过 key 对比):
key="0": 新节点,插入
key="1": 找到,不需要移动
key="2": 找到,不需要移动
key="3": 找到,不需要移动
// 结果:1次操作(1次插入)
7.2 key 的匹配逻辑
// 在 updateSlot 中使用 key
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes
): Fiber | null {
const key = oldFiber !== null ? oldFiber.key : null;
if (typeof newChild === "object" && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// 🔥 比较 key
if (newChild.key === key) {
// key 相同,尝试复用
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// key 不同,返回 null
return null;
}
}
}
}
return null;
}
// 在 updateFromMap 中使用 key
function updateFromMap(
existingChildren: Map<string | number, Fiber>,
returnFiber: Fiber,
newIdx: number,
newChild: any,
lanes: Lanes
): Fiber | null {
if (typeof newChild === "object" && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
// 🔥 通过 key 或 index 查找
const matchedFiber =
existingChildren.get(newChild.key === null ? newIdx : newChild.key) ||
null;
return updateElement(returnFiber, matchedFiber, newChild, lanes);
}
}
}
return null;
}
7.3 key 的最佳实践
// ❌ 错误1:使用 index 作为 key
{
list.map((item, index) => <Item key={index} data={item} />);
}
// 问题:
// 当列表顺序变化时,index 不会变
// 但对应的 item 变了
// 导致错误的复用
// ❌ 错误2:使用随机数作为 key
{
list.map((item) => <Item key={Math.random()} data={item} />);
}
// 问题:
// 每次 render,key 都会变
// 永远无法复用
// 等于没有 key
// ❌ 错误3:key 不稳定
{
list.map((item) => <Item key={item.id + Math.random()} data={item} />);
}
// 问题:
// key 在不同 render 之间会变化
// 无法复用
// ✅ 正确:使用稳定的唯一标识
{
list.map((item) => <Item key={item.id} data={item} />);
}
// 要求:
// 1. 唯一:在兄弟节点中唯一
// 2. 稳定:不会在 render 之间变化
// 3. 可预测:同一个 item 总是有同一个 key
// ✅ 如果没有 id,可以使用其他唯一标识
{
list.map((item) => <Item key={item.name + item.type} data={item} />);
}
// ✅ 只有在列表是静态的(不会重排)时,才能用 index
const STATIC_LIST = ["Home", "About", "Contact"];
{
STATIC_LIST.map((item, index) => (
<Link key={index} to={item}>
{item}
</Link>
));
}
7.4 key 的底层存储
// key 在 Fiber 节点中的存储
FiberNode {
key: string | null, // 🔥 key 值
// 通过 key 可以快速匹配节点
}
// 在 Map 中使用 key
const map = new Map();
// 有 key 的节点
map.set(fiber.key, fiber); // "a" → aFiber
// 没有 key 的节点
map.set(fiber.index, fiber); // 0 → fiber
// 查找节点
const key = element.key;
const matchedFiber = map.get(key !== null ? key : index);
八、完整更新流程实例
8.1 示例代码
class App extends React.Component {
state = {
list: [
{ id: "a", text: "A" },
{ id: "b", text: "B" },
{ id: "c", text: "C" },
],
};
handleClick = () => {
// 更新:移动 + 删除 + 新增
this.setState({
list: [
{ id: "c", text: "C-updated" }, // 移动到最前面,且文本变化
{ id: "a", text: "A" }, // 移动
{ id: "d", text: "D" }, // 新增
],
});
};
render() {
return (
<div>
<button onClick={this.handleClick}>Update</button>
<ul>
{this.state.list.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
</div>
);
}
}
8.2 完整的 Diff 执行过程
=== 更新前的 Fiber 树 ===
AppFiber {
memoizedState: {
list: [
{id: 'a', text: 'A'},
{id: 'b', text: 'B'},
{id: 'c', text: 'C'},
]
}
}
↓
divFiber
↓
ulFiber
↓ child
liFiber(key="a") → liFiber(key="b") → liFiber(key="c")
↓ ↓ sibling ↓ sibling
textFiber("A") textFiber("B") textFiber("C")
=== 点击按钮,触发 setState ===
1. 创建 update 对象
2. 放入 updateQueue
3. 调度更新
4. 进入 Render 阶段
=== beginWork(AppFiber) ===
1. current !== null,进入更新路径
2. oldProps === newProps,但有 state 更新
3. didReceiveUpdate = true
4. processUpdateQueue()
- 计算新 state:
{
list: [
{id: 'c', text: 'C-updated'},
{id: 'a', text: 'A'},
{id: 'd', text: 'D'},
]
}
5. instance.render()
- 返回新的 children
=== reconcileChildren(ulFiber, newChildren) ===
旧子节点:
currentFirstChild = liFiber(key="a")
↓ sibling
liFiber(key="b")
↓ sibling
liFiber(key="c")
新子节点(数组):
newChildren = [
<li key="c">C-updated</li>,
<li key="a">A</li>,
<li key="d">D</li>,
]
进入 reconcileChildrenArray()
--- 第一轮遍历 ---
newIdx=0:
- 新节点:<li key="c">
- 旧节点:liFiber(key="a")
- updateSlot():
- newChild.key="c", oldFiber.key="a"
- key 不同,返回 null
- 跳出第一轮遍历
--- 第二轮遍历 ---
// 构建 Map
existingChildren = Map {
"a" => liFiber(key="a", index=0),
"b" => liFiber(key="b", index=1),
"c" => liFiber(key="c", index=2),
}
lastPlacedIndex = 0
newIdx=0, 新节点<li key="c">C-updated</li>:
- updateFromMap():
- 从 Map 中找到 liFiber(key="c")
- oldIndex = 2
- 比较 type:"li" === "li" ✓
- 复用 Fiber,更新 props
- placeChild():
- oldIndex(2) >= lastPlacedIndex(0) ✓
- 不需要移动
- lastPlacedIndex = 2
- 从 Map 删除:
existingChildren.delete("c")
- 结果:
newCFiber = useFiber(oldCFiber, {children: "C-updated"})
newCFiber.flags = Update // props 变化
newIdx=1, 新节点<li key="a">A</li>:
- updateFromMap():
- 从 Map 中找到 liFiber(key="a")
- oldIndex = 0
- 复用 Fiber
- placeChild():
- oldIndex(0) < lastPlacedIndex(2) ✗
- 🔥 需要移动!
- lastPlacedIndex = 2 (不变)
- 从 Map 删除:
existingChildren.delete("a")
- 结果:
newAFiber = useFiber(oldAFiber, {children: "A"})
newAFiber.flags = Placement // 移动
newIdx=2, 新节点<li key="d">D</li>:
- updateFromMap():
- 从 Map 中找不到 key="d"
- 创建新 Fiber
- placeChild():
- current === null
- 新节点
- lastPlacedIndex = 2 (不变)
- 结果:
newDFiber = createFiber({children: "D"})
newDFiber.flags = Placement // 插入
遍历结束,Map 中剩余:
existingChildren = Map {
"b" => liFiber(key="b"),
}
删除剩余节点:
- liFiber(key="b").flags |= Deletion
--- 第二轮遍历结果 ---
新的 Fiber 链表:
ulFiber.child = newCFiber
↓ sibling
newAFiber
↓ sibling
newDFiber
副作用标记:
- newCFiber.flags = Update
- newAFiber.flags = Placement
- newDFiber.flags = Placement
- oldBFiber.flags = Deletion
deletions 数组:
ulFiber.deletions = [oldBFiber]
=== completeWork ===
// 从下往上完成
// DOM 在 Commit 阶段处理
=== Commit 阶段(简要)===
1. 删除阶段:
- ul.removeChild(oldBFiber.stateNode) // 删除 B
2. 更新阶段:
- newCFiber.stateNode.textContent = "C-updated" // 更新 C 的文本
3. 插入阶段:
- ul.insertBefore(newAFiber.stateNode, referenceNode) // 移动 A
- ul.appendChild(newDFiber.stateNode) // 插入 D
最终 DOM:
<ul>
<li>C-updated</li> <!-- 复用,更新 -->
<li>A</li> <!-- 复用,移动 -->
<li>D</li> <!-- 新建,插入 -->
</ul>
8.3 副作用的收集和执行
// Render 阶段收集的副作用:
ulFiber {
flags: NoFlags,
subtreeFlags: Update | Placement | Deletion,
deletions: [oldBFiber],
child: newCFiber,
}
newCFiber {
flags: Update, // 文本从 "C" → "C-updated"
sibling: newAFiber,
}
newAFiber {
flags: Placement, // 需要移动
sibling: newDFiber,
}
newDFiber {
flags: Placement, // 需要插入
sibling: null,
}
oldBFiber {
flags: Deletion, // 需要删除
}
// Commit 阶段的执行顺序:
// 1. Mutation 阶段(DOM 操作)
commitMutationEffects() {
// 1.1 删除
commitDeletionEffects(ulFiber.deletions)
- ul.removeChild(oldBFiber.stateNode)
// 1.2 更新
commitWork(newCFiber)
- updateDOMProperties(newCFiber.stateNode, oldProps, newProps)
- li.textContent = "C-updated"
// 1.3 插入
commitPlacement(newAFiber)
- const before = getHostSibling(newAFiber) // 找参考节点
- ul.insertBefore(newAFiber.stateNode, before)
commitPlacement(newDFiber)
- ul.appendChild(newDFiber.stateNode)
}
// 2. Layout 阶段(生命周期)
commitLayoutEffects() {
// 调用 componentDidUpdate、useLayoutEffect 等
}
九、常见问题深度解答
Q1: 为什么 React 不采用双端 Diff?
深度解答:
Vue 使用双端 Diff(从两端向中间遍历),而 React 使用单端 Diff(从左向右遍历)。
// Vue 的双端 Diff(伪代码)
let oldStartIdx = 0;
let oldEndIdx = oldChildren.length - 1;
let newStartIdx = 0;
let newEndIdx = newChildren.length - 1;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartIdx.key === newStartIdx.key) {
// 头头比较
} else if (oldEndIdx.key === newEndIdx.key) {
// 尾尾比较
} else if (oldStartIdx.key === newEndIdx.key) {
// 头尾比较
} else if (oldEndIdx.key === newStartIdx.key) {
// 尾头比较
} else {
// 使用 key 查找
}
}
// React 为什么不用?
// 1. React 的 Fiber 架构不适合双端遍历
// 2. Fiber 链表只有 child 和 sibling,没有反向指针
// 3. 添加反向指针会增加内存开销
// 4. React 的时间切片机制需要可随时中断,双端遍历不易实现
// 5. 实际场景中,从左到右遍历已经足够高效
Q2: 为什么多节点 Diff 需要两轮遍历?
深度解答:
// 原因1:处理最常见的情况 - 节点更新
// 大部分更新场景:只是 props 变化,顺序不变
旧:[A, B, C, D]
新:[A', B', C', D'] // 只是内容变化
// 第一轮遍历可以快速处理:
for (let i = 0; i < newChildren.length; i++) {
if (canReuse(oldChildren[i], newChildren[i])) {
update(oldChildren[i], newChildren[i]);
} else {
break; // 遇到不能复用的,跳出
}
}
// 如果第一轮就完成了,不需要第二轮
// 避免创建 Map 的开销
// 原因2:Map 的创建有成本
// 只有在需要查找时才创建 Map
// 第一轮遍历发现需要查找,才进入第二轮
const map = new Map(); // 创建 Map 有成本
for (let fiber of oldChildren) {
map.set(fiber.key, fiber);
}
Q3: lastPlacedIndex 的原理是什么?
深度解答:
// lastPlacedIndex 的含义:
// "最后一个不需要移动的节点"在旧列表中的位置
// 例子1:理解"不需要移动"
旧:A(0) - B(1) - C(2) - D(3)
新:A - B - D - C
// Diff 过程:
A: oldIndex=0, lastPlacedIndex=0, 0>=0 ✓, 不移动, lastPlacedIndex=0
B: oldIndex=1, lastPlacedIndex=0, 1>=0 ✓, 不移动, lastPlacedIndex=1
D: oldIndex=3, lastPlacedIndex=1, 3>=1 ✓, 不移动, lastPlacedIndex=3
C: oldIndex=2, lastPlacedIndex=3, 2<3 ✗, 移动!
// 为什么 C 需要移动?
// - C 在旧列表中的位置(2),比 D 在旧列表中的位置(3) 靠前
// - 但在新列表中,C 在 D 后面
// - 说明 C 相对于 D 向右移动了
// 例子2:理解算法的局限
旧:A(0) - B(1) - C(2)
新:C - A - B
// Diff 过程:
C: oldIndex=2, lastPlacedIndex=0, 2>=0 ✓, 不移动, lastPlacedIndex=2
A: oldIndex=0, lastPlacedIndex=2, 0<2 ✗, 移动!
B: oldIndex=1, lastPlacedIndex=2, 1<2 ✗, 移动!
// 实际上,只移动 C 到最前面更高效
// 但算法会移动 A 和 B
// 因为算法是从左到右的,C 先被处理,成为"参考点"
// 优化建议:
// 把不需要移动的节点放在前面
旧:A - B - C - D
新:A - B - D - E // C删除,E新增
// 这样只需要删除 C,插入 E
Q4: 什么时候会触发整个子树重建?
深度解答:
// 情况1:组件类型变化
// 旧:
<div>
<ComponentA />
</div>
// 新:
<div>
<ComponentB />
</div>
// 处理:
// 1. ComponentA 的整个子树被删除
// 2. ComponentB 的整个子树被重建
// 3. 不会尝试复用任何子节点
// 情况2:父节点类型变化
// 旧:
<div>
<span>A</span>
<span>B</span>
</div>
// 新:
<section>
<span>A</span>
<span>B</span>
</section>
// 处理:
// 1. div 及其所有子节点被删除(包括两个 span)
// 2. section 及其所有子节点被重建(包括两个 span)
// 3. 即使 span 的 type 和 key 都相同,也不会复用!
// 情况3:key 变化
// 旧:
<div>
<Item key="a" />
<Item key="b" />
</div>
// 新:
<div>
<Item key="c" />
<Item key="d" />
</div>
// 处理:
// 1. Item(key="a") 被删除
// 2. Item(key="b") 被删除
// 3. Item(key="c") 被创建
// 4. Item(key="d") 被创建
// 避免重建的方法:
// 1. 保持组件类型稳定
// 2. 保持 key 稳定
// 3. 避免条件渲染改变组件类型:
// ❌ 会导致重建
{condition ? <ComponentA /> : <ComponentB />}
// ✅ 在组件内部处理
<Component type={condition ? 'A' : 'B'} />
Q5: Diff 算法的时间复杂度真的是 O(n) 吗?
深度解答:
// 理论上的时间复杂度:
// 第一轮遍历:O(n)
// - 遍历新子节点数组:n 次
// - 每次对比:O(1)
// 总计:O(n)
// 第二轮遍历:
// - 创建 Map:O(m),m 是剩余旧节点数
// - 遍历剩余新节点:O(k),k 是剩余新节点数
// - 每次从 Map 查找:O(1)
// 总计:O(m + k)
// 总的时间复杂度:O(n + m + k)
// 由于 m + k <= n,所以是 O(n)
// 实际场景的分析:
// 最好情况:只有更新,没有移动
旧:[A, B, C, D]
新:[A', B', C', D']
// 第一轮遍历就完成了,O(n)
// 最坏情况:所有节点都移动
旧:[A, B, C, D]
新:[D, C, B, A]
// 需要两轮遍历,但总体还是 O(n)
// 极端最坏情况:列表非常长,且完全反序
旧:[1, 2, 3, ..., 100000]
新:[100000, ..., 3, 2, 1]
// 仍然是 O(n),但常数因子较大
// 因为每个节点都需要标记 Placement
// 优化:避免最坏情况
// ❌ 完全反序:性能差
list.reverse()
// ✅ 排序:使用稳定排序,尽量保持原有顺序
list.sort((a, b) => a.priority - b.priority)
十、总结
10.1 Diff 算法的核心要点
三个策略:
- Tree Diff:只比较同层级,跨层级移动会重建
- Component Diff:同类型继续比较,不同类型直接重建
- Element Diff:通过 key 识别节点,支持移动和复用
两轮遍历:
- 第一轮:处理更新,按位置对比,遇到不能复用就跳出
- 第二轮:处理移动、新增、删除,使用 Map 优化查找
关键机制:
- key:唯一标识节点,实现精确复用
- lastPlacedIndex:判断节点是否需要移动
- flags:标记副作用(Placement、Update、Deletion)
10.2 更新阶段的完整流程
触发更新(setState/useState/forceUpdate)
↓
创建 update 对象
↓
放入 updateQueue/hook.queue
↓
调度更新 scheduleUpdateOnFiber
↓
进入 Render 阶段
↓
beginWork(更新路径)
├─ current !== null
├─ 比较 props
├─ 检查 updateQueue
└─ 决定 render 或 bailout
↓
render 获取新 children
↓
reconcileChildren(执行 Diff)
├─ 单节点:reconcileSingleElement
└─ 多节点:reconcileChildrenArray
├─ 第一轮:处理更新
└─ 第二轮:处理移动/增删
↓
标记副作用 flags
↓
继续遍历子树
↓
completeWork
↓
收集副作用到父节点
↓
Render 阶段完成
↓
进入 Commit 阶段(执行 DOM 操作)
【react18原理探究实践】组件的 props 和 state 究竟是如何确定和存储的?
🧑💻 前言
在上一篇文章里,我们从 scheduleUpdateOnFiber
出发,分析了 render 阶段的完整流程:从 beginWork
递到 completeWork
归,最终构建 Fiber 树和 DOM。
那么,在这整个流程里,组件的 props 和 state 究竟是如何确定和存储的?
1. Props & State 在 Fiber 中的存储
先来看 Fiber 节点和 props/state 的关系:
type Fiber = {
pendingProps: any; // 新的 props
memoizedProps: any; // 已确认的 props
memoizedState: any; // 已确认的 state
updateQueue: any; // 更新队列
stateNode: any; // 类实例 or DOM 节点
}
要点:
-
props:从父组件传入,先放到
pendingProps
,在performUnitOfWork
结束时赋值给memoizedProps
。 -
state:来源不同:
- 类组件 →
constructor(this.state)
- 函数组件 →
useState
初始化 Hook 链表 - DOM 节点没有 state
- 类组件 →
2. Props 流程
来看 performUnitOfWork
的关键逻辑:
function performUnitOfWork(unitOfWork) {
const next = beginWork(unitOfWork.alternate, unitOfWork, renderLanes);
// 🔥 在这里 props 生效
unitOfWork.memoizedProps = unitOfWork.pendingProps;
return next;
}
- 每个 Fiber 在进入 beginWork 前,props 存在
pendingProps
。 - beginWork 执行完,
pendingProps
→memoizedProps
。 - 归阶段不再修改 props。
👉 所以:props 的确认时机在递阶段执行完毕时。
3. State 流程
3.1 类组件
function updateClassComponent(current, workInProgress, Component, nextProps) {
if (!workInProgress.stateNode) {
// 🔥 构造实例并初始化 state
const instance = new Component(nextProps);
workInProgress.stateNode = instance;
workInProgress.memoizedState = instance.state;
}
const nextChildren = workInProgress.stateNode.render();
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
特点:
-
new Component(props)
创建实例 → state 从constructor
里拿。 -
fiber.memoizedState = instance.state
。 - 每次更新时,
updateQueue
合并后的结果也会写到memoizedState
。
3.2 函数组件
function updateFunctionComponent(current, workInProgress, Component, nextProps) {
let nextChildren = renderWithHooks(current, workInProgress, Component, nextProps);
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
内部执行:
function mountState(initialState) {
const hook = mountWorkInProgressHook();
hook.memoizedState = initialState;
return [hook.memoizedState, dispatchSetState];
}
特点:
- 首次挂载时,
HooksDispatcherOnMount
生效。 -
useState(initial)
→ 创建 Hook 对象,挂到 Fiber.memoizedState 形成链表。 - 后续更新时,Hook 链表会被复用。
3.3 DOM 节点
function updateHostComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;
// HostComponent 没有 state
reconcileChildren(current, workInProgress, nextProps.children, renderLanes);
}
特点:
- 只有
props
,没有state
。 -
memoizedState
恒为null
。
4. Props & State 流程图
父组件传入 props → Fiber.pendingProps
↓
beginWork 执行完毕
↓
Fiber.memoizedProps = pendingProps // props 确认
↓
ClassComponent: memoizedState = instance.state
FunctionComponent: memoizedState = Hook 链表
HostComponent: 无 state
5. 小结对比
组件类型 | Props 确认位置 | State 初始化来源 | Fiber 存储点 |
---|---|---|---|
类组件 | performUnitOfWork |
constructor(this.state) |
memoizedProps & memoizedState
|
函数组件 | performUnitOfWork |
useState(initial) Hook |
memoizedProps & Hook 链表 |
DOM 组件 | performUnitOfWork |
无 |
memoizedProps ,无 state |
📌 总结
-
Props:统一流程,进入 beginWork 时在
pendingProps
,出 beginWork 后确认到memoizedProps
。 -
State:来源依组件类型不同:
- 类组件 → 构造函数初始化,存 Fiber.memoizedState
- 函数组件 → useState 初始化 Hook 链表,存 Fiber.memoizedState
- DOM 组件 → 无 state
-
最终落点:无论 props 还是 state,都会落到 Fiber 节点上。
一句话总结:
👉 首次挂载时,props 在递阶段确认,state 按组件类型初始化,统一写入 Fiber,供后续 render & commit 使用。
【react18原理探究实践】render阶段【首次挂载】
React Fiber 双缓冲树机制深度解析
前言
React Fiber 架构的核心创新之一是双缓冲树(Double Buffering Tree)机制,这种设计不仅提升了 React 应用的性能,还实现了可中断的渲染和平滑的用户体验。本文将深入分析 React Fiber 的双缓冲树机制,详细介绍首次挂载和更新过程中的处理流程。
一、Fiber 的起源与设计理念
1.1 为什么需要 Fiber?
在 React 16 之前,React 使用的是基于递归的 Stack Reconciler,存在以下问题:
- 不可中断性:一旦开始渲染,必须完成整个组件树的遍历
- 阻塞主线程:长时间的同步渲染会阻塞用户交互
- 缺乏优先级:无法区分不同更新的重要程度
1.2 Fiber 的设计目标
React Fiber 重新设计了协调算法,实现了以下核心能力:
- 可中断渲染:将渲染工作分解为可中断的小单元
- 优先级调度:根据更新的重要性分配不同的优先级
- 增量渲染:支持将渲染工作分散到多个帧中完成
- 并发特性:为未来的并发模式奠定基础
二、双缓冲树架构详解
2.1 什么是双缓冲?
双缓冲(Double Buffering)是一种常见的图形渲染技术,通过维护两个缓冲区来避免画面撕裂和闪烁:
- 前台缓冲区:当前显示给用户的内容
- 后台缓冲区:正在准备的下一帧内容
当后台缓冲区准备完成后,两个缓冲区角色互换,实现平滑的画面更新。图形编辑器中经常用一个未挂载的canvas来替换挂载的canvas
2.2 React Fiber 中的双缓冲树
React Fiber 将双缓冲概念应用到虚拟 DOM 树的管理中:
// Fiber 节点结构
function FiberNode() {
// 节点类型信息
this.tag = null;
this.type = null;
this.key = null;
// 树结构关系
this.child = null;
this.sibling = null;
this.return = null;
// 双缓冲核心:alternate 指针
this.alternate = null;
// 节点状态
this.memoizedProps = null;
this.memoizedState = null;
this.pendingProps = null;
// 副作用标记
this.flags = NoFlags;
this.subtreeFlags = NoFlags;
}
2.3 current 树 vs workInProgress 树
React 维护两棵 Fiber 树:
// FiberRoot 中的双树引用
function FiberRootNode() {
// current 树:当前屏幕显示内容对应的 Fiber 树
this.current = null;
// 正在构建的 workInProgress 树的根节点
this.finishedWork = null;
}
// 双树节点通过 alternate 相互引用
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
两棵树的作用:
- current 树:表示当前已经渲染到页面的 UI 结构
- workInProgress 树:表示正在内存中构建的新的 UI 结构
2.4 双缓冲的工作原理
// 创建 workInProgress 节点
function createWorkInProgress(current, pendingProps) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 首次渲染:创建新的 workInProgress 节点
workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
workInProgress.elementType = current.elementType;
workInProgress.type = current.type;
workInProgress.stateNode = current.stateNode;
// 建立双向引用
workInProgress.alternate = current;
current.alternate = workInProgress;
} else {
// 更新时复用已有节点
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
// 清除副作用标记
workInProgress.flags = NoFlags;
workInProgress.subtreeFlags = NoFlags;
}
// 复制状态信息
workInProgress.child = current.child;
workInProgress.memoizedProps = current.memoizedProps;
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
return workInProgress;
}
三、首次挂载流程详解
3.1 应用初始化
// 创建 FiberRoot 和 rootFiber
function createFiberRoot(containerInfo, tag) {
const root = new FiberRootNode(containerInfo, tag);
// 创建 rootFiber(HostRoot 类型)
const uninitializedFiber = createHostRootFiber(tag);
root.current = uninitializedFiber;
uninitializedFiber.stateNode = root;
// 初始化 updateQueue
initializeUpdateQueue(uninitializedFiber);
return root;
}
// 应用挂载入口
root.render(<App />, document.getElementById('root'));
3.2 首次挂载的双缓冲过程
阶段一:render 阶段
function renderRootSync(root, lanes) {
// 准备 workInProgress 树
prepareFreshStack(root, lanes);
// 从 rootFiber 开始构建 workInProgress 树
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
}
} while (true);
}
function prepareFreshStack(root, lanes) {
// 创建 workInProgress rootFiber
workInProgress = createWorkInProgress(root.current, null);
workInProgressRoot = root;
workInProgressRootRenderLanes = lanes;
}
首次挂载时的树结构变化:
初始状态:
FiberRoot
|
current ──→ rootFiber (HostRoot)
|
alternate: null
构建 workInProgress 树后:
FiberRoot
|
current ──→ rootFiber (HostRoot) ←──── alternate ────→ workInProgress rootFiber
| |
child: null child: App Fiber
|
child: div Fiber
|
child: "Hello World"
阶段二:深度优先遍历构建
function performUnitOfWork(unitOfWork) {
const current = unitOfWork.alternate;
// beginWork:向下遍历,创建子节点
let next = beginWork(current, unitOfWork, renderLanes);
if (next === null) {
// completeUnitOfWork:向上回溯,完成节点
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
// 首次挂载时,current 为 null
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
// 更新逻辑
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps !== newProps || hasLegacyContextChanged()) {
didReceiveUpdate = true;
} else {
// 性能优化:bailout
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
} else {
// 首次挂载逻辑
didReceiveUpdate = false;
}
// 根据节点类型处理
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress, Component, resolvedProps, renderLanes);
case ClassComponent:
return updateClassComponent(current, workInProgress, Component, resolvedProps, renderLanes);
case HostComponent:
return updateHostComponent(current, workInProgress, renderLanes);
// ...
}
}
阶段三:commit 阶段
function commitRoot(root) {
const finishedWork = root.finishedWork;
const lanes = root.finishedLanes;
// 清除完成的工作
root.finishedWork = null;
root.finishedLanes = NoLanes;
// 执行 DOM 操作
commitMutationEffects(root, finishedWork, lanes);
// 切换 current 指针:关键的双缓冲切换
root.current = finishedWork;
// 执行 layout 阶段
commitLayoutEffects(finishedWork, root, lanes);
}
首次挂载完成后的树结构:
FiberRoot
|
current ──→ workInProgress rootFiber (现在成为 current 树)
|
child: App Fiber
|
child: div Fiber ──→ stateNode: <div>
|
child: text Fiber ──→ stateNode: "Hello World"
旧的 rootFiber 通过 alternate 保持引用关系
3.3 首次挂载的关键特点
- current 树初始为空:只有一个 rootFiber 节点
- workInProgress 树从零构建:所有子节点都是新创建的
-
DOM 节点首次创建:调用
createElement
创建真实 DOM - 双树建立联系:通过 alternate 属性相互引用
四、更新流程详解
4.1 触发更新
// 函数组件中的状态更新
function App() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1); // 触发更新
};
return <div onClick={handleClick}>{count}</div>;
}
4.2 更新时的双缓冲过程
阶段一:调度更新
function dispatchAction(fiber, queue, action) {
// 创建 update 对象
const update = {
lane,
action,
eagerReducer: null,
eagerState: null,
next: null,
};
// 将 update 加入 updateQueue
enqueueUpdate(fiber, update);
// 调度更新
scheduleUpdateOnFiber(fiber, lane, eventTime);
}
function scheduleUpdateOnFiber(fiber, lane, eventTime) {
// 标记更新 lanes
markRootUpdated(root, lane, eventTime);
if (lane === SyncLane) {
// 同步更新
performSyncWorkOnRoot(root);
} else {
// 并发更新
ensureRootIsScheduled(root, eventTime);
}
}
阶段二:复用与重建
function beginWork(current, workInProgress, renderLanes) {
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (oldProps === newProps && !hasLegacyContextChanged()) {
// props 没有变化,检查是否可以复用
if (!includesSomeLane(renderLanes, workInProgress.lanes)) {
// 可以复用,执行 bailout 策略
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
}
// 需要更新
didReceiveUpdate = true;
}
// 清除 lanes,准备重新计算
workInProgress.lanes = NoLanes;
// 根据组件类型执行更新逻辑
switch (workInProgress.tag) {
case FunctionComponent: {
const Component = workInProgress.type;
const unresolvedProps = workInProgress.pendingProps;
return updateFunctionComponent(current, workInProgress, Component, unresolvedProps, renderLanes);
}
// ...
}
}
阶段三:Bailout 优化策略
function bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes) {
// 清除当前节点的 lanes
workInProgress.lanes = NoLanes;
// 检查子节点是否需要更新
if (!includesSomeLane(renderLanes, workInProgress.childLanes)) {
// 子树也不需要更新,直接返回 null
return null;
}
// 子树需要更新,克隆子节点
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
function cloneChildFibers(current, workInProgress) {
if (workInProgress.child === null) {
return;
}
let currentChild = workInProgress.child;
let newChild = createWorkInProgress(currentChild, currentChild.pendingProps);
workInProgress.child = newChild;
newChild.return = workInProgress;
while (currentChild.sibling !== null) {
currentChild = currentChild.sibling;
newChild = newChild.sibling = createWorkInProgress(currentChild, currentChild.pendingProps);
newChild.return = workInProgress;
}
newChild.sibling = null;
}
4.3 更新的三种情况
情况一:props 和 state 都没变化
// 组件完全复用,返回 null
function MyComponent({ name }) {
return <div>{name}</div>;
}
// 如果 name props 没有变化,整个组件子树都会被跳过
情况二:props 变化但结构相同
// 复用 Fiber 节点,但更新 props
function MyComponent({ count }) {
return <div>{count}</div>; // 只更新文本内容
}
情况三:组件结构发生变化
function MyComponent({ showDetails }) {
return (
<div>
{showDetails ? <Details /> : <Summary />}
</div>
);
}
// 需要删除旧节点,创建新节点
4.4 更新时的树对比
更新前:
current 树 workInProgress 树(构建中)
App App (复用)
| |
div (count: 0) --> div (count: 1) (更新)
| |
text "0" --> text "1" (更新)
更新后:
两棵树角色互换,workInProgress 成为新的 current 树
五、双缓冲树的核心优势
5.1 性能优化
- 节点复用:通过 alternate 引用避免重复创建 Fiber 节点
- Bailout 策略:跳过没有变化的子树
- 批量更新:收集多个更新,一次性处理
5.2 用户体验
- 平滑过渡:双树切换避免中间状态暴露给用户
- 一致性保证:确保 UI 状态的原子性更新
- 错误边界:渲染错误不会影响当前显示的 UI
5.3 开发体验
- 可预测性:清晰的树结构关系
- 调试友好:可以对比 current 和 workInProgress 树的差异
- 并发支持:为未来的并发特性提供基础
六、双缓冲树的内存管理
6.1 内存使用策略
// 节点池管理
const fiberPool = [];
function createFiber(tag, pendingProps, key, mode) {
// 从池中获取节点
let fiber = fiberPool.pop();
if (fiber) {
// 重置节点状态
resetFiber(fiber, tag, pendingProps, key, mode);
return fiber;
}
// 创建新节点
return new FiberNode(tag, pendingProps, key, mode);
}
function resetFiber(fiber, tag, pendingProps, key, mode) {
// 重置所有属性
fiber.tag = tag;
fiber.key = key;
fiber.elementType = null;
fiber.type = null;
fiber.stateNode = null;
// 清除引用
fiber.return = null;
fiber.child = null;
fiber.sibling = null;
fiber.index = 0;
// 重置状态
fiber.pendingProps = pendingProps;
fiber.memoizedProps = null;
fiber.updateQueue = null;
fiber.memoizedState = null;
// 清除副作用
fiber.flags = NoFlags;
fiber.subtreeFlags = NoFlags;
// 保持 alternate 引用
// fiber.alternate 不重置,用于双缓冲
}
6.2 垃圾回收优化
function detachFiber(fiber) {
// 断开父子关系
fiber.return = null;
fiber.child = null;
fiber.sibling = null;
// 但保留 alternate 引用用于后续复用
// fiber.alternate 保持不变
// 回收到节点池
if (fiberPool.length < MAX_POOL_SIZE) {
fiberPool.push(fiber);
}
}
七、实际应用中的最佳实践
7.1 优化组件设计
// 避免在 render 中创建新对象
function BadComponent({ items }) {
return (
<div>
{items.map(item => (
<Item
key={item.id}
data={{ ...item, extra: 'value' }} // 每次都创建新对象
/>
))}
</div>
);
}
// 优化版本
function GoodComponent({ items }) {
return (
<div>
{items.map(item => (
<Item
key={item.id}
id={item.id}
name={item.name}
extra="value" // 使用基本类型
/>
))}
</div>
);
}
7.2 合理使用 key
// key 的正确使用影响节点复用
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}> {/* 使用稳定的 id 作为 key */}
{todo.text}
</li>
))}
</ul>
);
}
7.3 理解更新边界
// 使用 React.memo 创建更新边界
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
// 复杂的渲染逻辑
return <div>{/* 复杂的 JSX */}</div>;
});
// 父组件更新时,如果 data 没有变化,ExpensiveComponent 会被跳过
八、总结
React Fiber 的双缓冲树机制是一个精妙的设计,它:
- 提升性能:通过节点复用和 bailout 策略减少不必要的工作
- 保证一致性:确保 UI 更新的原子性,避免中间状态暴露
- 支持并发:为可中断渲染和优先级调度提供基础
- 优化体验:实现平滑的用户界面更新
理解双缓冲树的工作原理,有助于我们:
- 编写更高效的 React 组件
- 避免常见的性能陷阱
- 更好地调试和优化应用性能
- 为未来的并发特性做好准备
双缓冲树不仅是 React Fiber 架构的核心,也是现代前端框架设计的典范,展示了如何通过巧妙的数据结构设计来解决复杂的工程问题。
参考资料
【react18原理探究实践】分层解析React Fiber 核心工作流程
【react18原理探究实践】调度器(Scheduler)原理深度解析
【图形编辑器架构】:编辑器的 Canvas 分层事件系统
发布日期:2025年10月3日 | 预计阅读时间:25 分钟
最近在重构编辑器 demo 的时候,我重新梳理了事件层的实现。 在节点层 → 渲染层之后,本篇重点切换到交互事件系统,也就是 Canvas 如何处理复杂交互,如何设计一个类似 Figma 的独立事件架构。
🧑💻 写在开头
点赞 + 收藏 = 支持原创 🤣🤣🤣
上一篇文章我们聊了 数据层与渲染层的绑定机制,今天继续推进 —— 把视角放到事件层。 你会看到一个清晰的五层架构设计,从 DOM 原生事件到业务逻辑处理器,再到渲染层通信,完整展示现代 Canvas 应用的事件流转机制。
本篇你将学到:
- 为什么 React 事件系统不适合高复杂度的 Canvas 应用
- Figma 式事件系统的五层架构
- 中间件、处理器、状态机的完整设计模式
- CanvasPanHandler(画布平移)的完整落地实现
- 性能优化策略:按需渲染、RAF 批处理、内存管理
🍎 系列背景 & 延续
这个系列文章主要记录了编辑器从 0 到 1 的实现细节:
- 节点树架构
- 渲染层对接 Reconciler + Canvas
- 数据层与渲染层绑定机制
- 事件系统(本文重点)
之前的文章:
- 01【图形编辑器架构】节点树篇 — 从零构建你的编辑器数据中枢
- 02【渲染层篇】React Reconciler 对接 Canvas 的完整实现
- 03【数据绑定篇】NodeTree → SkiaNode → CanvasElement 的绑定机制
今天我们聊第 4 篇:事件系统设计。
实现效果
🎯 一、引言:为什么需要独立的事件系统?
在构建复杂的 Canvas 应用(如 Figma、Sketch 等设计工具)时,传统的 React 事件或者原生的dom事件面临着严峻的挑战。随着应用复杂度的增加,我们需要处理更精细的用户交互、更复杂的状态管理,以及更高效的渲染性能。
比如mousedown事件,可能处理点击创建,铅笔绘制,拖拽事件,画布平移等,每种类型的事件可能会处理各种的业务逻辑,如何分发,如果处理不好,很容易就会混乱
传统方案的局限性
// 传统React事件处理的问题
const Canvas = () => {
const handleMouseDown = (e: React.MouseEvent) => {
// ❌ 问题1: 与React渲染周期耦合,性能受限
// ❌ 问题2: 事件对象被合成,丢失原生特性
// ❌ 问题3: 复杂交互状态管理困难
// ❌ 问题4: 缺乏优先级和中间件机制
};
return <canvas onMouseDown={handleMouseDown} />;
};
核心问题分析:
- 性能瓶颈:每次事件都要经过 React 的调度机制,增加不必要的开销
- 功能受限:合成事件丢失了原生事件的部分能力,如精确的时间戳、原生控制方法等
- 扩展性差:难以实现复杂的事件处理逻辑,如优先级、中间件、状态机等
- 耦合度高:事件处理与组件生命周期绑定,难以复用和测试
类Figma编辑器的常见方案
采用了完全独立于框架的事件系统,实现了,统一事件对象,方便内部逻辑统一处理:
- 高性能:直接处理原生 DOM 事件,绕过框架开销
- 灵活性:支持复杂的事件处理逻辑和自定义业务需求
- 可扩展:基于插件化架构,支持中间件和处理器扩展
- 可测试:完全解耦的设计,便于单元测试和集成测试
🏗️ 二、整体实现思路
事件系统的核心目标是将浏览器原生事件解耦、统一并高效分发,让 Canvas 交互逻辑清晰且可扩展。整个实现流程可分为五大层:
用户交互 → DOM 原生事件 → 事件工厂层 → 核心管理层 → 中间件层 → 处理器层 → 渲染通信 → Canvas 渲染
具体流程:
-
DOM 事件层
- 捕获鼠标、触摸、键盘事件
- 阻止默认浏览器行为(滚动、右键菜单等)
- 将事件传入事件系统
-
事件工厂层
- 将原生事件转换为统一的应用事件对象(BaseEvent)
- 增加时间戳、坐标、状态等元信息
- 保留对原生事件的控制能力(preventDefault、stopPropagation)
-
核心管理层(EventSystem)
- 单例模式管理全局事件
- 状态机管理交互状态(idle、hover、dragging、panning 等)
- 按优先级分发事件,支持责任链模式和短路机制
-
中间件层
- 洋葱模型处理事件
- 可插拔中间件支持日志、权限、缓存、性能监控等
- 可在前置或后置阶段处理事件
-
处理器层(EventHandler)
- 封装具体业务逻辑(平移、选择、绘制等)
- 根据当前工具和交互状态决定是否处理事件
- 返回处理结果:是否 handled、是否请求渲染、交互状态更新
-
渲染通信层
- 事件处理器通过 EventEmitter 发布渲染请求
- 渲染系统监听并响应,实现按需重绘
- 坐标系统状态同步,屏幕坐标 ↔ 世界坐标转换
🔄 三、完整事件流程示意
用户拖拽鼠标
↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
↓
EventFactory.createMouseEvent() → 标准化事件对象
↓
EventSystem.processEvent() → 中间件处理
↓
CanvasPanHandler.canHandle() → 检查是否可处理
↓
CanvasPanHandler.handleMouseDown() → 开始平移状态
↓
返回 { handled: true, newState: "panning" }
↓
EventSystem 更新交互状态
↓
用户移动鼠标...
↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
↓
计算位移增量 (deltaX, deltaY)
↓
coordinateSystemManager.updateViewPosition()
↓
viewManager.updateTranslation() → 更新变换矩阵
↓
返回 { handled: true, requestRender: true }
↓
eventEmitter.emit("render:request")
↓
SkiaLikeRenderer.performRender() → Canvas重绘
↓
视觉反馈完成 ✨
🏗️ 四、核心架构:五层分离设计
这个事件系统采用了清晰的分层架构,每一层都有明确的职责边界:
┌─────────────────┐
│ 处理器层 │ ← 具体业务逻辑(CanvasPanHandler, SelectionHandler等)
├─────────────────┤
│ 中间件层 │ ← 横切关注点(日志、权限、缓存等)
├─────────────────┤
│ 核心管理层 │ ← 事件分发调度(EventSystem)
├─────────────────┤
│ 事件工厂层 │ ← 标准化转换(EventFactory)
├─────────────────┤
│ DOM 事件层 │ ← 原生事件监听
└─────────────────┘
1. 事件工厂层 - 标准化转换
定义统一的事件对象
// 基础事件
export interface BaseEvent {
type: string;
timestamp: number;
preventDefault: () => void;
stopPropagation: () => void;
canceled: boolean;
propagationStopped: boolean;
}
// 鼠标事件
export interface MouseEvent extends BaseEvent {
type: "mouse.down" | "mouse.move" | "mouse.up" | "mouse.wheel";
mousePoint: { x: number; y: number };
}
// 键盘事件
export interface KeyboardEvent extends BaseEvent {
type: "key.down" | "key.up";
key: string;
code: string;
}
转换事件对象
设计目标:将原生 DOM 事件转换为应用层统一的事件对象
class EventFactory {
static createMouseEvent(nativeEvent: MouseEvent): CustomMouseEvent {
const point = {
x: nativeEvent.clientX,
y: nativeEvent.clientY,
};
return {
type: this.getMouseEventType(nativeEvent.type),
timestamp: Date.now(), // 精确时间戳
mousePoint: point,
canceled: false,
propagationStopped: false,
// 🎯 保留原生事件的控制能力
preventDefault: () => nativeEvent.preventDefault(),
stopPropagation: () => nativeEvent.stopPropagation(),
};
}
}
关键特性:
- 统一接口:消除浏览器和事件差异,提供一致的事件对象
- 增强信息:添加时间戳、坐标等应用层需要的元数据
- 保留控制:维持对原生事件的控制能力
绑定DOM事件到Canvas
在前面我们讨论了事件系统的统一和指针抽象,但所有的交互最终都来自 浏览器原生事件。因此,需要一个 DOM → 事件系统的桥梁,将 Canvas 上的鼠标、触摸、键盘事件统一接入我们设计的事件体系。
/**
* 绑定DOM事件到Canvas
*/
private bindDOMEvents(canvas: HTMLCanvasElement): void {
const listeners = new Map<string, EventListener>();
// 1️⃣ 鼠标事件
const mouseEvents = ["mousedown", "mousemove", "mouseup", "wheel"];
mouseEvents.forEach((eventType) => {
const listener = (e: Event) => this.handleDOMEvent(e as MouseEvent);
canvas.addEventListener(eventType, listener, { passive: false });
listeners.set(eventType, listener);
});
// 2️⃣ 阻止右键菜单
const contextMenuListener = (e: Event) => {
e.preventDefault();
};
canvas.addEventListener("contextmenu", contextMenuListener);
listeners.set("contextmenu", contextMenuListener);
// 3️⃣ 键盘事件(绑定到 window)
const keyboardEvents = ["keydown", "keyup"];
keyboardEvents.forEach((eventType) => {
const listener = (e: Event) => this.handleDOMEvent(e as KeyboardEvent);
window.addEventListener(eventType, listener);
// 使用特殊前缀标记这些是 window 事件
listeners.set(`window:${eventType}`, listener);
});
this.eventListeners.set(canvas, listeners);
}
/**
* 处理DOM事件
*/
private async handleDOMEvent(
nativeEvent: MouseEvent | KeyboardEvent
): Promise<void> {
if (!this.context || !this.isActive) return;
let event: BaseEvent;
// 转换为标准化事件
if (nativeEvent instanceof MouseEvent) {
event = EventFactory.createMouseEvent(nativeEvent);
} else {
event = EventFactory.createKeyboardEvent(nativeEvent);
}
// 处理事件
await this.processEvent(event);
}
-
统一存储监听器
使用Map<string, EventListener>
记录每个 Canvas 的监听器,方便后续解绑或热更新。 -
事件统一处理
所有原生事件都会传给handleDOMEvent
,在这里完成指针抽象或状态机分发,例如把mousedown
转成pointerDown
。 -
防止默认行为
对wheel
、contextmenu
等事件进行preventDefault()
,确保画布交互不受浏览器默认操作干扰。 -
键盘事件绑定到 window
键盘事件与画布尺寸无关,需要全局捕获,因此绑定到window
,同时使用前缀标记,便于管理。
这一步实际上是 事件系统落地的关键环节:从浏览器原生事件进入我们统一的事件体系,为 Canvas 的交互(如平移、缩放、拖拽)提供可靠输入源。
2. 核心管理层 - 事件分发调度
设计目标:提供统一的事件管理和分发机制
export class EventSystem {
private static instance: EventSystem | null = null;
private handlers: EventHandler[] = [];
private middlewares: EventMiddleware[] = [];
private interactionState: InteractionState = "idle";
// 🔄 责任链模式:按优先级处理事件
private async processCoreEvent(event: BaseEvent): Promise<EventResult> {
const availableHandlers = this.handlers
.filter(handler => handler.canHandle(event, this.interactionState))
.sort((a, b) => b.priority - a.priority); // 优先级排序
for (const handler of availableHandlers) {
const result = await handler.handle(event, this.context);
if (result.handled) return result; // 短路机制
}
return { handled: false };
}
}
核心设计模式:
- 单例模式:确保全局事件管理的一致性
- 责任链模式:支持多处理器按优先级处理,提供短路机制
- 状态机模式:维护应用交互状态,支持状态感知的事件处理
3. 中间件层 - 可插拔处理
设计目标:提供横切关注点的处理能力
interface EventMiddleware {
name: string;
process(
event: BaseEvent,
context: EventContext,
next: () => Promise<EventResult> // 类似Express的next函数
): Promise<EventResult>;
}
// 洋葱模型的中间件处理
private async processMiddlewares(event: BaseEvent, index: number): Promise<EventResult> {
if (index >= this.middlewares.length) {
return this.processCoreEvent(event); // 执行核心逻辑
}
const middleware = this.middlewares[index];
const next = () => this.processMiddlewares(event, index + 1);
return middleware.process(event, this.context!, next);
}
设计优势:
- 洋葱模型:类似 Koa/Express 的中间件机制,支持前置和后置处理
- 可插拔:支持日志、权限验证、性能监控等横切关注点
- 组合能力:多个中间件可以组合使用,实现复杂的处理逻辑
4. 处理器层 - 业务逻辑
设计目标:封装具体的业务处理逻辑
interface EventHandler {
name: string; // 处理器标识
priority: number; // 处理优先级
canHandle(event: BaseEvent, state: InteractionState): boolean; // 过滤条件
handle(event: BaseEvent, context: EventContext): Promise<EventResult>; // 处理逻辑
}
interface EventResult {
handled: boolean; // 是否处理成功
newState?: InteractionState; // 新的交互状态
requestRender?: boolean; // 是否需要重新渲染
data?: Record<string, unknown>; // 附加数据
}
5. 渲染通信层 - 事件驱动渲染
设计目标:实现事件系统与渲染系统的解耦通信
// 事件系统发布渲染请求
this.eventEmitter.emit("render:request");
// 渲染系统监听并响应
eventSystem.getEventEmitter().on("render:request", renderCallback);
🔄 五、事件系统与渲染层通信机制
通信架构图
┌─────────────┐ render:request ┌─────────────┐ ViewInfo ┌─────────────┐
│ EventSystem│ ──────────────────→ │CoordinateM │ ────────────→ │SkiaRenderer │
│ │ │anager │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
event:processed updateViewPosition Canvas API
│ │ │
↓ ↓ ↓
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│UI Components│ │ ViewManager │ │ Canvas │
└─────────────┘ └─────────────┘ └─────────────┘
1. 事件驱动的渲染请求
private async processEvent(event: BaseEvent): Promise<void> {
try {
const result = await this.processMiddlewares(event, 0);
// 🎯 更新交互状态
if (result.newState && result.newState !== this.interactionState) {
this.setInteractionState(result.newState);
}
// 🚀 关键:通过EventEmitter解耦通信
if (result.requestRender) {
this.eventEmitter.emit("render:request");
}
// 发布事件处理结果,供其他模块使用
this.eventEmitter.emit("event:processed", {
event,
result,
state: this.interactionState,
});
} catch (error) {
console.error("❌ 事件处理失败:", error);
}
}
2. 渲染层监听和响应
// CanvasContainer组件监听渲染请求
useEffect(() => {
const eventSystem = eventSystemInitializer.getEventSystem();
// 🔧 监听渲染请求事件
eventSystem.getEventEmitter().on("render:request", renderSkiaLikeUI);
return () => {
eventSystem.getEventEmitter().off("render:request", renderSkiaLikeUI);
};
}, []);
const renderSkiaLikeUI = useCallback(() => {
if (rendererRef.current) {
// 触发Skia风格渲染
rendererRef.current.render(
<>
<canvas-grid></canvas-grid>
<canvas-ruler></canvas-ruler>
<canvas-page></canvas-page>
</>
);
}
}, []);
3. 坐标系统状态同步
export class CoordinateSystemManager {
// 🎯 事件处理器更新视图状态
updateViewPosition(deltaX: number, deltaY: number): void {
const currentView = this.getViewState();
const updatedView = viewManager.updateTranslation(currentView, deltaX, deltaY);
this.setViewState(updatedView);
}
// 🔧 提供坐标转换能力
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const view = this.getViewState();
const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
const point = vec2.fromValues(screenX, screenY);
vec2.transformMat3(point, point, inverseMatrix);
return { x: point[0], y: point[1] };
}
}
📋 六、实战案例:画布移动事件的完整实现
本节我们实现一下画布平移功能的完整实现来理解整个系统的工作原理。
主要是根据鼠标计算出移动的距离,然后更新视图矩阵,然后重新渲染,因为记录了视图的偏移,我们实际每次绘制的只有屏幕范围内的图像,效果就好像一个可以无限移动的画布
🏗️ 工作流
用户交互 → CanvasPanHandler → CoordinateSystemManager → ViewManager → SkiaLikeRenderer
↓ ↓ ↓ ↓ ↓
鼠标/键盘事件 事件处理逻辑 坐标变换管理 视图状态更新 Canvas渲染
📝 核心实现:CanvasPanHandler
1. 处理器定义和优先级
export class CanvasPanHandler implements EventHandler {
name = "canvas-pan";
priority = 110; // 🎯 比选择工具优先级高,确保平移优先处理
private isPanning = false;
private lastPanPoint: { x: number; y: number } | null = null;
private isTemporaryPanMode = false; // 空格键临时模式
canHandle(event: BaseEvent, state: InteractionState): boolean {
// 🎯 只有手动工具激活时才处理平移事件
if (toolStore.getCurrentTool() !== "hand") {
return false;
}
return true;
}
}
2. 鼠标事件处理
// 鼠标按下 - 开始平移
private handleMouseDown(event: MouseEvent, context: EventContext): EventResult {
this.isPanning = true;
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning", // 🔄 切换到平移状态
requestRender: false, // 仅状态改变,无需重绘
};
}
// 鼠标移动 - 计算偏移并更新视图
private handleMouseMove(event: MouseEvent, context: EventContext): EventResult {
if (!this.isPanning || !this.lastPanPoint) {
return { handled: true, requestRender: false, newState: "idle" };
}
// 🎯 计算鼠标移动距离
const deltaX = event.mousePoint.x - this.lastPanPoint.x;
const deltaY = event.mousePoint.y - this.lastPanPoint.y;
// 🔧 应用平移偏移量到坐标系统
coordinateSystemManager.updateViewPosition(deltaX, deltaY);
// 🎯 更新记录点,为下次计算做准备
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: true, // 🚀 关键:请求重新渲染
};
}
// 鼠标释放 - 结束平移
private handleMouseUp(event: MouseEvent, context: EventContext): EventResult {
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, newState: "idle", requestRender: false };
}
3. 键盘事件处理:空格键临时平移
// 空格键按下 - 进入临时平移模式
private handleKeyDown(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
this.isTemporaryPanMode = true;
keyEvent.preventDefault(); // 阻止默认滚动行为
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
// 空格键释放 - 退出临时平移模式
private handleKeyUp(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (this.isTemporaryPanMode) {
this.isTemporaryPanMode = false;
this.isPanning = false;
this.lastPanPoint = null;
return { handled: true, requestRender: false };
}
}
return { handled: false };
}
🌐 坐标系统管理
坐标变换的数学实现
export class CoordinateSystemManager {
// 🎯 更新视图位置(平移变换)
updateViewPosition(deltaX: number, deltaY: number): void {
const currentView = this.getViewState();
// 🔧 通过ViewManager应用变换
const updatedView = viewManager.updateTranslation(
currentView,
deltaX,
deltaY
);
this.setViewState(updatedView); // 同步状态更新
}
// 🎯 坐标转换:屏幕坐标 ↔ 世界坐标
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
const view = this.getViewState();
const inverseMatrix = mat3.invert(mat3.create(), view.matrix);
const point = vec2.fromValues(screenX, screenY);
vec2.transformMat3(point, point, inverseMatrix);
return { x: point[0], y: point[1] };
}
}
export class ViewManager {
// 🎯 增量平移变换
updateTranslation(view: ViewInfo, deltaX: number, deltaY: number): ViewInfo {
const newMatrix = mat3.clone(view.matrix);
// 🔧 应用平移变换(矩阵乘法)
mat3.translate(newMatrix, newMatrix, [deltaX, deltaY]);
return { matrix: newMatrix };
}
}
🎨 渲染系统响应
export class SkiaLikeRenderer {
performRender(): void {
// 🎯 获取最新的视图变换矩阵
const viewState = coordinateSystemManager.getViewState();
// 🔧 应用变换到Canvas上下文
this.renderApi.setTransform(
viewState.matrix[0] * this.pixelRatio, // scaleX * pixelRatio
viewState.matrix[1] * this.pixelRatio, // skewY * pixelRatio
viewState.matrix[3] * this.pixelRatio, // skewX * pixelRatio
viewState.matrix[4] * this.pixelRatio, // scaleY * pixelRatio
viewState.matrix[6] * this.pixelRatio, // translateX * pixelRatio
viewState.matrix[7] * this.pixelRatio // translateY * pixelRatio
);
// 🎨 重新绘制所有元素
this.rootContainer.render(renderContext);
}
}
📊 完整的事件流程
用户拖拽鼠标
↓
DOM mousedown事件 → EventSystem.handleDOMEvent()
↓
EventFactory.createMouseEvent() → 标准化事件对象
↓
EventSystem.processEvent() → 中间件处理
↓
CanvasPanHandler.canHandle() → 检查是否可处理
↓
CanvasPanHandler.handleMouseDown() → 开始平移状态
↓
返回 { handled: true, newState: "panning" }
↓
EventSystem 更新交互状态
↓
用户移动鼠标...
↓
DOM mousemove事件 → CanvasPanHandler.handleMouseMove()
↓
计算位移增量 (deltaX, deltaY)
↓
coordinateSystemManager.updateViewPosition()
↓
viewManager.updateTranslation() → 更新变换矩阵
↓
返回 { handled: true, requestRender: true }
↓
eventEmitter.emit("render:request")
↓
SkiaLikeRenderer.performRender() → Canvas重绘
↓
视觉反馈完成 ✨
完整代码
import {
EventHandler,
EventResult,
EventContext,
BaseEvent,
MouseEvent,
KeyboardEvent,
InteractionState,
} from "../types";
import { toolStore } from "../../store/ToolStore";
import { coordinateSystemManager } from "../../manage/CoordinateSystemManager";
/**
* 画布移动处理器
* 处理手动工具的画布拖拽移动功能
*/
export class CanvasPanHandler implements EventHandler {
name = "canvas-pan";
priority = 110; // 比选择工具优先级高
private isPanning = false;
private lastPanPoint: { x: number; y: number } | null = null;
// 临时平移模式(按住空格键时启用)
private isTemporaryPanMode = false;
canHandle(event: BaseEvent, state: InteractionState): boolean {
if (toolStore.getCurrentTool() !== "hand") {
return false;
}
return true;
}
async handle(event: BaseEvent, context: EventContext): Promise<EventResult> {
const mouseEvent = event as MouseEvent;
switch (event.type) {
case "mouse.down":
return this.handleMouseDown(mouseEvent, context);
case "mouse.move":
return this.handleMouseMove(mouseEvent, context);
case "mouse.up":
return this.handleMouseUp(mouseEvent, context);
case "key.down":
return this.handleKeyDown(event, context);
case "key.up":
return this.handleKeyUp(event, context);
default:
return { handled: false };
}
}
private handleMouseDown(
event: MouseEvent,
context: EventContext
): EventResult {
this.isPanning = true;
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: false,
};
}
private handleMouseMove(
event: MouseEvent,
context: EventContext
): EventResult {
if (!this.isPanning || !this.lastPanPoint) {
return {
handled: true,
requestRender: false,
newState: "idle", // 明确设置为idle状态
};
}
// 计算移动距离
const deltaX = event.mousePoint.x - this.lastPanPoint.x;
const deltaY = event.mousePoint.y - this.lastPanPoint.y;
// 应用平移偏移量到坐标系统
coordinateSystemManager.updateViewPosition(deltaX, deltaY);
// 更新最后的平移点
this.lastPanPoint = { ...event.mousePoint };
return {
handled: true,
newState: "panning",
requestRender: true,
};
}
private handleMouseUp(event: MouseEvent, context: EventContext): EventResult {
this.isPanning = false;
this.lastPanPoint = null;
return {
handled: true,
newState: "idle",
requestRender: false,
};
}
private handleKeyDown(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
// 空格键启用临时平移模式
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (!this.isTemporaryPanMode && toolStore.getCurrentTool() !== "hand") {
this.isTemporaryPanMode = true;
keyEvent.preventDefault();
return {
handled: true,
requestRender: false,
};
}
}
return { handled: false };
}
private handleKeyUp(event: BaseEvent, context: EventContext): EventResult {
const keyEvent = event as unknown as KeyboardEvent;
console.log(
"⌨️ CanvasPanHandler - 处理按键释放:",
keyEvent.key,
keyEvent.code
);
// 释放空格键,退出临时平移模式
if (keyEvent.key === " " || keyEvent.code === "Space") {
if (this.isTemporaryPanMode) {
this.isTemporaryPanMode = false;
this.isPanning = false;
this.lastPanPoint = null;
return {
handled: true,
requestRender: false,
};
}
}
return { handled: false };
}
}
🎯 总结与展望
下一篇我会整理下标尺和网格的绘制逻辑