返回文章列表

由浅入深学 React:以 MoonPeak AI Web 项目为例

m
moonpeak
| | 35 分钟

本文以 moonpeak-ai-web 项目为学习素材,带你从零开始理解现代 React 开发。我们不讲抽象概念,而是直接通过真实代码,由浅入深地掌握 JSX、组件、状态、副作用、网络请求、流式交互等核心技能。

前言:我们要做一个什么项目

moonpeak-ai-web 是一个为 MoonPeak AI 知识库系统编写的前端界面。它的核心功能很简单:

  1. 聊天问答:用户输入问题,AI 基于知识库返回答案。
  2. 流式输出:AI 像打字一样,一个字一个字地实时显示答案。
  3. 文档上传:用户可以将 PDF、Word 等文档上传到知识库。

技术栈选择了 React 18 + TypeScript + Vite。整个项目代码量不大,但覆盖了 React 日常开发中绝大多数常见场景,非常适合拿来学习。

第一章:React 18 入门 —— 从 main.tsx 开始

打开 src/main.tsx,这是整个应用的入口文件。虽然它只有十几行,但蕴含了 React 18 最重要的变化。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
const rootElement = document.getElementById('root')!
const root = createRoot(rootElement)
root.render(
<StrictMode>
<App />
</StrictMode>,
)

1.1 JSX 是什么

注意代码中的这一段:

<StrictMode>
<App />
</StrictMode>

这看起来像是 HTML,但它其实是 JSX(JavaScript XML)。JSX 是 React 提供的一种语法扩展,允许我们在 JavaScript 代码中直接写类似 HTML 的结构。

在编译时,JSX 会被转换成普通的 JavaScript 函数调用。例如 <App /> 会被转换成 React.createElement(App)。这种写法让我们可以用 JavaScript 的思维来描述用户界面

1.2 createRoot:React 18 的新特性

在 React 18 之前,人们使用 ReactDOM.render 来挂载应用。React 18 引入了 createRoot,这是为了支持并发特性(Concurrent Features)

简单理解:createRoot 让 React 拥有了更智能的调度能力。比如当用户在输入框中快速打字时,React 可以优先保证输入的响应,而暂时延后一些不重要的后台渲染工作。

const root = createRoot(rootElement)
root.render(...)

1.3 StrictMode 严格模式

StrictMode 是一个开发辅助组件,它不会渲染任何可见的 UI,但会在开发环境下主动帮你发现潜在问题。比如:

  • 某些函数会被故意调用两次,以检测副作用。
  • 检查是否使用了即将废弃的 API。

生产构建时,StrictMode 会被自动移除,不会影响性能。

第二章:TypeScript 类型系统 —— 从 types/index.ts 说起

现代 React 项目几乎都会搭配 TypeScript 使用。TypeScript 最大的价值在于:在代码运行之前,就能发现很多类型错误

2.1 接口(Interface)

src/types/index.ts 中对消息的定义:

export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
}

interface 用来描述一个对象应该长什么样。Message 接口告诉我们:一条消息必须有 id(字符串)、role(只能是 'user''assistant')、content(字符串),以及可选的 isStreaming(布尔值,问号表示可选)。

2.2 字面量联合类型

注意 role: 'user' | 'assistant' 这种写法。它叫做字面量联合类型,意思是 role 字段只能取这两个字符串中的一个,写错任何一个字母,TypeScript 都会报错。

这比单纯写 role: string 要严格得多,能避免很多手误。

2.3 as const:比 enum 更安全的常量定义

项目中没有使用 TypeScript 的 enum,而是用了 as const

export const HealthStatus = {
HEALTHY: 'HEALTHY',
UNHEALTHY: 'UNHEALTHY',
CHECKING: 'CHECKING'
} as const;
export type HealthStatusType = typeof HealthStatus[keyof typeof HealthStatus];

as const 的作用是让 TypeScript 把对象里的每个值都当作不可变的字面量类型。这样 HealthStatus.HEALTHY 的类型就是精确的 'HEALTHY' 字符串,而不是宽泛的 string

HealthStatusType 会推导为 'HEALTHY' | 'UNHEALTHY' | 'CHECKING',和 enum 的效果几乎一样,但不会在编译后生成额外的 JavaScript 代码,更加轻量。

第三章:组件与 Props —— 从 MessageItem 学习纯组件

React 应用的本质就是组件的嵌套与组合MessageItem 是项目中最简单的组件之一,非常适合理解组件的基本概念。

import type { JSX } from 'react';
import type { Message } from '../types';
interface MessageItemProps {
message: Message;
}
export function MessageItem(props: MessageItemProps): JSX.Element {
const message = props.message;
const isUser = message.role === 'user';
return (
<div
style={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
marginBottom: '20px'
}}
>
<div
style={{
maxWidth: '92%',
padding: isUser ? '12px 16px' : '14px 18px',
borderRadius: isUser ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
backgroundColor: isUser ? 'var(--primary-muted)' : 'var(--bg-soft)',
color: 'var(--text-body)',
textAlign: 'left',
wordBreak: 'break-word',
lineHeight: '1.8',
fontSize: '14px',
letterSpacing: '0.2px'
}}
>
<span>{message.content}</span>
{message.isStreaming === true && (
<span
style={{
display: 'inline-block',
width: '2px',
height: '1.1em',
backgroundColor: 'var(--primary-soft)',
marginLeft: '4px',
verticalAlign: 'middle',
animation: 'blink 1.2s step-end infinite'
}}
/>
)}
</div>
</div>
);
}

3.1 Props:组件的输入

MessageItem 接收一个 props 参数,类型是 MessageItemPropsprops 是 React 组件的输入,父组件通过 props 向子组件传递数据。

这种设计让组件变得非常纯粹:给定相同的 messageMessageItem 总是渲染出相同的 DOM。这样的组件被称为纯组件展示组件,非常容易理解和测试。

3.2 条件渲染

注意这段代码:

{message.isStreaming === true && (
<span ... />
)}

这是 React 中常见的条件渲染技巧。在 JSX 的大括号 {} 中,我们可以写任意 JavaScript 表达式。&& 运算符的意思是:如果左边的条件为真,就返回右边的元素;否则返回 false,React 不会渲染任何东西。

3.3 内联样式与 CSS 变量

项目中大量使用了 style 属性来设置样式,并且配合了 CSS 变量(如 var(--primary-muted))。

内联样式的好处是直观、无需额外 CSS 文件、不会类名冲突。配合 CSS 变量,又能保证整个应用的色彩一致性。

第四章:状态与事件 —— 从 FileUploader 学习 useState

FileUploader 组件实现了文档上传功能。它比 MessageItem 复杂,因为它需要管理自己的状态

4.1 useState:让组件拥有记忆

React 组件默认是”无状态”的,每次渲染都是一次全新的函数执行。useState 这个 Hook 让组件能够”记住”某些数据。

const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [status, setStatus] = useState<UploadStatusType>(UploadStatus.IDLE);
const [errorMessage, setErrorMessage] = useState<string>('');

useState 返回一个数组,包含两个元素:

  1. 当前状态值(如 selectedFile
  2. 更新状态的函数(如 setSelectedFile

当用户选择了文件,我们调用 setSelectedFile(files[0]),React 就会重新渲染这个组件,并且 selectedFile 在新一轮渲染中就有了值。

4.2 事件处理:onChange

function handleFileChange(event: ChangeEvent<HTMLInputElement>): void {
const files = event.target.files;
if (files && files.length > 0) {
setSelectedFile(files[0]);
setErrorMessage('');
setStatus(UploadStatus.IDLE);
}
}

这是标准的 DOM 事件处理。ChangeEvent<HTMLInputElement> 是 TypeScript 对文件输入框 change 事件的精确类型描述。event.target.files 是浏览器提供的 FileList 对象。

4.3 useRef:操作 DOM 节点

上传成功后,我们希望清空文件输入框,让用户可以再次选择同一个文件。但文件输入框的值不能通过 React 状态直接控制,这时就需要 useRef

const fileInputRef = useRef<HTMLInputElement>(null);
// 在 JSX 中绑定
<input ref={fileInputRef} type="file" ... />
// 需要清空时
if (fileInputRef.current !== null) {
fileInputRef.current.value = '';
}

useRef 返回一个对象 { current: ... },它的 current 属性指向真实的 DOM 节点。通过它,我们可以绕过 React 的虚拟 DOM,直接操作原生 DOM。

4.4 异步事件处理:上传文件

async function handleUpload(): Promise<void> {
if (selectedFile === null) {
setErrorMessage('请先选择一个文件');
return;
}
setStatus(UploadStatus.UPLOADING);
setErrorMessage('');
try {
await uploadDocument(selectedFile);
setStatus(UploadStatus.SUCCESS);
setSelectedFile(null);
if (fileInputRef.current !== null) {
fileInputRef.current.value = '';
}
if (props.onUploadSuccess) {
props.onUploadSuccess();
}
} catch (error) {
setStatus(UploadStatus.ERROR);
if (error instanceof Error) {
setErrorMessage(error.message);
} else {
setErrorMessage('上传过程中发生未知错误');
}
}
}

这段代码展示了完整的状态流转:

  1. 前置校验:没有文件时给出提示。
  2. 开始上传:设置状态为 UPLOADING,禁用按钮。
  3. 成功处理:重置状态,清空输入框,调用父组件的回调。
  4. 错误处理:捕获异常,显示错误信息。

这是一个非常典型的表单提交逻辑模式。

第五章:副作用与网络请求 —— 从 App.tsx 学习 useEffect

App.tsx 是应用的根组件,负责整体布局和组合各个子模块。其中最重要的一段代码是健康检查逻辑:

import { useState, useEffect } from 'react';
function App(): JSX.Element {
const [healthStatus, setHealthStatus] = useState<HealthStatusType>(HealthStatus.CHECKING);
useEffect(function(): void {
async function doCheck(): Promise<void> {
const isHealthy = await checkHealth();
setHealthStatus(isHealthy ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY);
}
doCheck();
}, []);
// ... 渲染逻辑
}

5.1 什么是副作用

在 React 中,渲染本身应该是纯的:相同的输入(props 和 state)应该产生相同的输出(JSX)。但真实应用中,我们经常需要做一些”不纯”的事情,比如:

  • 发送网络请求
  • 操作 DOM
  • 设置定时器
  • 订阅事件

这些操作被称为副作用(Side Effects)。React 提供了 useEffect 这个 Hook 来专门处理副作用。

5.2 useEffect 的依赖数组

useEffect(function(): void {
// 副作用逻辑
}, []);

useEffect 接收两个参数:

  1. 副作用函数:包含要执行的副作用逻辑。
  2. 依赖数组:一个数组,告诉 React 什么时候需要重新执行副作用函数。

传入空数组 [] 表示:只在组件首次挂载时执行一次。这非常适合初始化数据、发送首次请求等场景。

如果数组里有变量,比如 [userId],那么每当 userId 变化时,副作用函数就会重新执行。

5.3 API 的模块化封装

项目中所有的网络请求都集中在 src/api/ragApi.ts 中:

const BASE_URL = '/api/knowLedge';
export async function checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${BASE_URL}/health`);
if (response.ok === false) {
return false;
}
const text = await response.text();
return text === 'OK';
} catch (error) {
console.error('健康检查失败:', error);
return false;
}
}

把 API 请求从组件中抽离出来,有以下几个好处:

  • 单一职责:组件只关心”什么时候请求”和”怎么处理结果”,不关心”怎么构造请求”。
  • 复用性:同一个 API 可能在多个组件中使用。
  • 可维护性:后端接口地址或参数格式变化时,只需修改一处。

第六章:列表渲染与条件渲染 —— 从 ChatBox 学习 map

ChatBox 是项目中最复杂的组件,负责管理整个聊天会话。我们先看它的消息列表渲染部分:

{messages.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text-placeholder)', marginTop: '100px' }}>
<p style={{ fontSize: '15px', marginBottom: '8px', color: 'var(--text-muted)' }}>
有什么可以帮你的?
</p>
<p style={{ fontSize: '13px' }}>
在下方输入问题,开始与知识库对话
</p>
</div>
)}
{messages.map(function(message) {
return (
<MessageItem
key={message.id}
message={message}
/>
);
})}

6.1 列表渲染:map

在 React 中渲染列表,标准做法是用 JavaScript 数组的 map 方法:

messages.map(function(message) {
return <MessageItem key={message.id} message={message} />;
})

为什么必须传 key

key 是 React 用来识别列表中每个元素的唯一标识。当列表发生变化(增加、删除、重排)时,React 通过 key 来判断哪些元素是新的、哪些需要移动位置、哪些可以复用。如果没有 key,React 的性能会大幅下降,甚至可能出现奇怪的 UI bug。

6.2 空状态的条件渲染

{messages.length === 0 && (...)}

这是前面讲过的 && 条件渲染。当 messages 数组为空时,显示一个友好的引导提示。这种设计让空状态不再是一片空白,用户体验更好。

6.3 受控组件:textarea

<textarea
value={inputValue}
onChange={function(event): void {
setInputValue(event.target.value);
}}
onKeyDown={handleKeyDown}
disabled={isLoading}
placeholder={isLoading ? 'AI 正在思考中...' : '输入问题,按 Enter 发送'}
/>

这是一个受控组件textareavalue 完全由 React 状态 inputValue 控制,用户的每一次输入都会触发 onChange,更新状态,进而触发重新渲染。

受控组件的好处是:

  • 可以精确控制输入内容(比如限制长度、过滤敏感词)
  • 可以方便地清空输入框(直接 setInputValue('')
  • 可以将输入值与其他 UI 元素联动

第七章:引用与高级状态管理 —— useRef 与 useCallback

ChatBox 组件中使用了三个相对高级的 Hook:useRefuseCallback,以及函数式状态更新。

7.1 useRef 保存可变值

在流式问答中,我们需要建立 SSE(Server-Sent Events)连接,并且能够在必要时关闭它。useRef 非常适合保存这种”不需要触发重新渲染”的可变值:

const cleanupRef = useRef<(() => void) | null>(null);
// 建立连接时
const cleanup = streamAnswer(question, onMessage, onError, onComplete);
cleanupRef.current = cleanup;
// 需要关闭时
if (cleanupRef.current !== null) {
cleanupRef.current();
cleanupRef.current = null;
}

cleanupRef.current 的变化不会触发组件重新渲染,但它能在多次渲染之间保持稳定引用。

7.2 useCallback:缓存函数引用

const appendMessage = useCallback(function(newMessage: Message): void {
setMessages(function(prevMessages) {
const newArray = prevMessages.slice();
newArray.push(newMessage);
return newArray;
});
}, []);

useCallback 的作用是缓存函数,让它在依赖项不变的情况下,始终保持同一个引用。

ChatBox 中,appendMessage 被传递给 streamAnswer 作为回调。如果不使用 useCallback,每次 ChatBox 重新渲染时,appendMessage 都会是一个新的函数对象。虽然在这个简单场景下影响不大,但在更复杂的应用中(比如配合 React.memo 或作为 useEffect 的依赖),不稳定的函数引用会导致不必要的重渲染或副作用重复执行。

7.3 函数式状态更新

注意 setMessages 的用法:

setMessages(function(prevMessages) {
const newArray = prevMessages.slice();
newArray.push(newMessage);
return newArray;
});

这里传入了一个函数,而不是直接传新值。这叫做函数式状态更新

它的好处是:React 会保证传入的 prevMessages 一定是最新的状态。如果你在异步回调(如 SSE 的 onMessage)中更新状态,直接引用外部的 messages 变量可能已经过时了。函数式更新能彻底避免这种”闭包陷阱”。

同时,我们使用 slice() 复制了一个新数组,而不是直接 push 到原数组。这是因为 React 的状态应该是不可变的,直接修改原数组不会触发重新渲染,还可能导致不可预期的行为。

第八章:流式交互与 SSE —— 从 ragApi.ts 到 ChatBox

这是整个项目中最有趣的部分。我们来拆解”流式问答”的实现原理。

8.1 SSE 是什么

SSE(Server-Sent Events)是一种服务器向浏览器推送实时数据的技术。它基于 HTTP 协议,使用 EventSource 对象建立持久连接。与 WebSocket 不同,SSE 是单向的:只有服务器能向客户端推送数据,客户端不能通过同一个连接发送数据。

对于 AI 打字机效果来说,SSE 非常合适,因为只需要服务器不断推送文本片段。

8.2 建立 SSE 连接

ragApi.ts 中的 streamAnswer 函数:

export function streamAnswer(
question: string,
onMessage: (chunk: string) => void,
onError: (error: Error) => void,
onComplete: () => void
): () => void {
const encodedQuestion = encodeURIComponent(question);
const url = `${BASE_URL}/stream?question=${encodedQuestion}`;
const eventSource = new EventSource(url);
eventSource.onmessage = function(event: MessageEvent<string>): void {
const data = event.data;
if (data === '[DONE]') {
eventSource.close();
onComplete();
return;
}
onMessage(data);
};
eventSource.onerror = function(): void {
eventSource.close();
onError(new Error('SSE 连接异常或已断开'));
};
return function cleanup(): void {
eventSource.close();
};
}

关键点:

  1. encodeURIComponent:对问题进行 URL 编码,防止中文、空格、特殊符号破坏 URL。
  2. EventSource:浏览器原生 API,一行代码就能建立 SSE 连接。
  3. onmessage:每次服务器推送数据时触发。event.data 就是推送的文本。
  4. [DONE] 标记:后端约定用 [DONE] 表示流式输出结束。
  5. 返回 cleanup 函数:调用方可以在需要时主动关闭连接,避免内存泄漏。

8.3 在 ChatBox 中消费流式数据

if (isStreamMode === true) {
// 先添加一条空的 AI 消息
appendMessage({
id: generateId(),
role: 'assistant',
content: '',
isStreaming: true
});
const cleanup = streamAnswer(
question,
function(chunk: string): void {
appendToLastMessage(chunk);
},
function(error: Error): void {
appendToLastMessage('\n[连接异常,输出中断]');
finishLastMessage();
setIsLoading(false);
cleanupRef.current = null;
},
function(): void {
finishLastMessage();
setIsLoading(false);
cleanupRef.current = null;
}
);
cleanupRef.current = cleanup;
}

流程非常清晰:

  1. 先插入一条空的 AI 消息,并标记 isStreaming: true(显示闪烁光标)。
  2. 建立 SSE 连接,每次收到新片段就追加到最后一条消息。
  3. 连接结束或出错时,移除 isStreaming 标记,恢复输入框状态。

appendToLastMessage 的实现也值得注意:

const appendToLastMessage = useCallback(function(chunk: string): void {
setMessages(function(prevMessages) {
const newArray = prevMessages.slice();
const lastIndex = newArray.length - 1;
const lastMessage = newArray[lastIndex];
if (lastMessage && lastMessage.role === 'assistant') {
newArray[lastIndex] = {
...lastMessage,
content: lastMessage.content + chunk
};
}
return newArray;
});
}, []);

这里使用了展开运算符 ...lastMessage 来复制旧消息对象,然后只修改 content 字段。这保证了状态更新的不可变性。

第九章:样式设计与工程化 —— CSS 变量与 Vite 代理

9.1 CSS 变量:一套配色,明暗双模式

src/index.css 定义了一整套 CSS 自定义属性:

:root {
--primary: #5b6b7c;
--primary-soft: #7a8a9a;
--text-main: #2c2c2c;
--text-body: #4a4a4a;
--bg-body: #faf9f7;
--bg-card: #ffffff;
--border: #e8e6e3;
}

使用 CSS 变量的好处是:

  • 一致性:整个应用的颜色都引用同一个变量,不会出现”这个蓝和那个蓝不一样”的情况。
  • 可维护性:想换主题色时,只需修改一处。
  • 暗色模式:通过 @media (prefers-color-scheme: dark) 重新定义变量值,就能实现自动适配系统暗色模式。

9.2 Vite 代理:解决开发跨域

vite.config.ts 中配置了开发服务器代理:

export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
},
port: 5173
}
})

前端运行在 localhost:5173,后端运行在 localhost:8080。由于浏览器的同源策略,前端直接请求后端会遇到 CORS 跨域限制。

Vite 的代理配置解决了这个问题:当前端代码请求 /api/xxx 时,Vite 开发服务器会悄悄把这个请求转发到 http://localhost:8080/api/xxx,然后把响应再传回给前端。因为转发是在服务器端完成的,浏览器感知不到跨域。

结语

通过 moonpeak-ai-web 这个真实项目,我们由浅入深地学习了:

  • React 18 基础:JSX、createRootStrictMode
  • TypeScript 类型:接口、联合类型、as const
  • 组件设计:Props、纯组件、条件渲染
  • 状态管理useState、受控组件、函数式状态更新
  • 副作用处理useEffect、网络请求封装
  • DOM 操作useRef、文件上传
  • 列表渲染mapkey、空状态
  • 高级 HookuseCallback、不可变更新
  • 实时交互:SSE 流式输出
  • 工程化:CSS 变量、Vite 代理

这个项目的代码量不大,但覆盖了 React 日常开发中绝大多数核心场景。建议你跟着代码自己敲一遍,尤其是 ChatBox 中的流式交互逻辑,亲手实现一次,理解会比单纯阅读深刻得多。

Happy coding!