本文以
moonpeak-ai-web项目为学习素材,带你从零开始理解现代 React 开发。我们不讲抽象概念,而是直接通过真实代码,由浅入深地掌握 JSX、组件、状态、副作用、网络请求、流式交互等核心技能。
前言:我们要做一个什么项目
moonpeak-ai-web 是一个为 MoonPeak AI 知识库系统编写的前端界面。它的核心功能很简单:
- 聊天问答:用户输入问题,AI 基于知识库返回答案。
- 流式输出:AI 像打字一样,一个字一个字地实时显示答案。
- 文档上传:用户可以将 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 参数,类型是 MessageItemProps。props 是 React 组件的输入,父组件通过 props 向子组件传递数据。
这种设计让组件变得非常纯粹:给定相同的 message,MessageItem 总是渲染出相同的 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 返回一个数组,包含两个元素:
- 当前状态值(如
selectedFile) - 更新状态的函数(如
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('上传过程中发生未知错误'); } }}这段代码展示了完整的状态流转:
- 前置校验:没有文件时给出提示。
- 开始上传:设置状态为
UPLOADING,禁用按钮。 - 成功处理:重置状态,清空输入框,调用父组件的回调。
- 错误处理:捕获异常,显示错误信息。
这是一个非常典型的表单提交逻辑模式。
第五章:副作用与网络请求 —— 从 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 接收两个参数:
- 副作用函数:包含要执行的副作用逻辑。
- 依赖数组:一个数组,告诉 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 发送'}/>这是一个受控组件。textarea 的 value 完全由 React 状态 inputValue 控制,用户的每一次输入都会触发 onChange,更新状态,进而触发重新渲染。
受控组件的好处是:
- 可以精确控制输入内容(比如限制长度、过滤敏感词)
- 可以方便地清空输入框(直接
setInputValue('')) - 可以将输入值与其他 UI 元素联动
第七章:引用与高级状态管理 —— useRef 与 useCallback
ChatBox 组件中使用了三个相对高级的 Hook:useRef、useCallback,以及函数式状态更新。
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(); };}关键点:
encodeURIComponent:对问题进行 URL 编码,防止中文、空格、特殊符号破坏 URL。EventSource:浏览器原生 API,一行代码就能建立 SSE 连接。onmessage:每次服务器推送数据时触发。event.data就是推送的文本。[DONE]标记:后端约定用[DONE]表示流式输出结束。- 返回 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;}流程非常清晰:
- 先插入一条空的 AI 消息,并标记
isStreaming: true(显示闪烁光标)。 - 建立 SSE 连接,每次收到新片段就追加到最后一条消息。
- 连接结束或出错时,移除
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、
createRoot、StrictMode - TypeScript 类型:接口、联合类型、
as const - 组件设计:Props、纯组件、条件渲染
- 状态管理:
useState、受控组件、函数式状态更新 - 副作用处理:
useEffect、网络请求封装 - DOM 操作:
useRef、文件上传 - 列表渲染:
map、key、空状态 - 高级 Hook:
useCallback、不可变更新 - 实时交互:SSE 流式输出
- 工程化:CSS 变量、Vite 代理
这个项目的代码量不大,但覆盖了 React 日常开发中绝大多数核心场景。建议你跟着代码自己敲一遍,尤其是 ChatBox 中的流式交互逻辑,亲手实现一次,理解会比单纯阅读深刻得多。
Happy coding!