12 — React 代码完整阅读训练

这一章是最重要的一章
我会拿项目里的真实组件逐行拆解,把前面学的所有概念串起来。
读完后,你应该能独立读懂 90% 的项目代码。


训练方式

我会拿几个典型的、有代表性的组件来拆解:

  1. InputArea.tsx — 简单但完整(表单、事件、ref)
  2. ChatItem.tsx — 中等(条件渲染、Props、事件回调)
  3. MessageList.tsx — 较复杂(map、条件、Hooks 综合)
  4. Message.tsx — 最复杂(多种条件组合、嵌套子组件)

阅读方法:先看拆解的「摘要」,再看「逐行注释」,最后试着自己读一遍。


训练 1:InputArea.tsx(简单)

1.1 完整代码(项目原始)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import { useRef } from 'react';
import { Paperclip, Send, StopCircle, FileText } from 'lucide-react';
import { useChatStore } from '@/store/chatStore';
import { useTextareaAutosize } from '@/hooks/useTextareaAutosize';
import { AgentSelector } from './AgentSelector';
import { FilePreview } from './FilePreview';
import styles from './InputArea.module.css';

export function InputArea() {
const inputMessage = useChatStore((s) => s.inputMessage);
const setInput = useChatStore((s) => s.setInput);
const selectedAgent = useChatStore((s) => s.selectedAgent);
const selectedFile = useChatStore((s) => s.selectedFile);
const isUploading = useChatStore((s) => s.isUploading);
const isSending = useChatStore((s) => s.isSending);
const sendMessage = useChatStore((s) => s.sendMessage);
const stopMessage = useChatStore((s) => s.stopMessage);
const handleFileSelect = useChatStore((s) => s.handleFileSelect);

const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useTextareaAutosize<HTMLTextAreaElement>(inputMessage);

const canSend = !isSending && !isUploading && (inputMessage.trim().length > 0 || selectedFile);
const showFileBtn = (selectedAgent === 'file' || selectedAgent === 'skills') && !selectedFile;

const placeholder =
selectedAgent === 'file' && selectedFile
? '文件问答模式... (删除文件可切换回对话助手)'
: '输入消息... (支持 Markdown,Shift+Enter 换行)';

const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (canSend) sendMessage();
}
};

const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) handleFileSelect(f);
e.target.value = '';
};

return (
<div className={styles.area}>
<AgentSelector />
<FilePreview />
<div className={styles.inputRow}>
{showFileBtn && (
<button
className={styles.fileBtn}
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
type="button"
title="上传文件(限1个)"
>
<Paperclip size={16} />
</button>
)}
<input
ref={fileInputRef}
type="file"
onChange={onPickFile}
style={{ display: 'none' }}
/>
{selectedFile && !isUploading && (
<span className={styles.inputFileIcon} title="文件问答模式">
<FileText size={14} />
</span>
)}
<textarea
ref={textareaRef}
value={inputMessage}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={1}
/>
<button
className={`${styles.sendBtn} ${isSending ? styles.stop : ''} ${!isSending && !canSend ? styles.disabled : ''}`}
disabled={isSending ? false : !canSend}
onClick={() => (isSending ? stopMessage() : sendMessage())}
type="button"
>
{isSending ? <StopCircle size={16} /> : <Send size={16} />}
</button>
</div>
</div>
);
}

1.2 整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
函数组件 InputArea
├─ 1. 从 Store 读 9 个状态/方法
├─ 2. 创建 1 个 ref
├─ 3. 计算 3 个本地变量
├─ 4. 定义 2 个事件处理函数
└─ 5. 返回 JSX
├─ <AgentSelector /> (用 props 传给子组件)
├─ <FilePreview />
└─ 输入行
├─ 文件上传按钮
├─ 隐藏的 file input
├─ 文件图标
├─ textarea(受控)
└─ 发送/停止按钮

1.3 逐行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// ── 1. 导入 ──
import { useRef } from 'react';
// ↑ React 提供的 Hook,用于拿 DOM 引用
import { Paperclip, Send, StopCircle, FileText } from 'lucide-react';
// ↑ 图标组件库(Paperclip = 回形针/上传)
import { useChatStore } from '@/store/chatStore';
// ↑ 全局状态(Zustand),用 @ 别名指向 src/store/chatStore
import { useTextareaAutosize } from '@/hooks/useTextareaAutosize';
// ↑ 自定义 Hook:根据内容自动调整 textarea 高度
import { AgentSelector } from './AgentSelector';
import { FilePreview } from './FilePreview';
// ↑ 同目录的子组件(./ 表示当前文件所在目录)
import styles from './InputArea.module.css';
// ↑ CSS Module,styles.area 等

// ── 2. 函数组件(命名导出) ──
export function InputArea() {
// 组件没有参数(所有数据从 Store 读,不通过 Props 传)

// ── 2.1 从 Store 读状态 ──
const inputMessage = useChatStore((s) => s.inputMessage);
// ↑ 输入框当前内容
const setInput = useChatStore((s) => s.setInput);
// ↑ 修改输入框内容的方法
const selectedAgent = useChatStore((s) => s.selectedAgent);
// ↑ 当前选中的 Agent(chat / file / ppt / ...)
const selectedFile = useChatStore((s) => s.selectedFile);
// ↑ 当前选择的文件对象
const isUploading = useChatStore((s) => s.isUploading);
// ↑ 是否正在上传文件
const isSending = useChatStore((s) => s.isSending);
// ↑ 是否正在发送/接收 AI 回复
const sendMessage = useChatStore((s) => s.sendMessage);
// ↑ 发送消息方法
const stopMessage = useChatStore((s) => s.stopMessage);
// ↑ 停止生成方法
const handleFileSelect = useChatStore((s) => s.handleFileSelect);
// ↑ 处理文件选择

// ── 2.2 创建 ref ──
const fileInputRef = useRef<HTMLInputElement>(null);
// ↑ 拿到隐藏的 file input DOM,初始 null
// ↑ 用 <input ref={fileInputRef} /> 关联

const textareaRef = useTextareaAutosize<HTMLTextAreaElement>(inputMessage);
// ↑ 自定义 Hook,自动调整 textarea 高度
// ↑ 参数:当前输入值(变化时触发高度调整)
// ↑ 返回:ref,挂到 textarea 上

// ── 2.3 计算本地变量(不存 State,每次渲染重新算) ──
const canSend =
!isSending && // 没在发送
!isUploading && // 没在上传
(inputMessage.trim().length > 0 || // 输入框有内容(去首尾空格后)
selectedFile); // 或有文件

const showFileBtn =
(selectedAgent === 'file' || // 当前是"文件问答"agent
selectedAgent === 'skills') && // 或"技能助手"agent
!selectedFile; // 且还没选文件

const placeholder =
selectedAgent === 'file' && selectedFile
? '文件问答模式... (删除文件可切换回对话助手)'
: '输入消息... (支持 Markdown,Shift+Enter 换行)';
// ↑ 根据状态计算提示文字

// ── 2.4 事件处理函数 ──
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// ↑ e: 键盘事件对象,TypeScript 类型
if (e.key === 'Enter' && !e.shiftKey) {
// ↑ 按了 Enter 键 ↑ 没按 Shift
e.preventDefault(); // 阻止默认行为(防止换行)
if (canSend) sendMessage(); // 发送消息
}
};

const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
// ↑ 文件 input 的 onChange 事件
const f = e.target.files?.[0];
// ↑ 拿到选中的第一个文件(FileList 可能为 null)
if (f) handleFileSelect(f); // 把文件交给 Store 处理
e.target.value = ''; // 清空,允许再次选同一文件
};

// ── 2.5 返回 JSX ──
return (
// 整个区域容器
<div className={styles.area}>
{/* ↑ className 直接用 CSS Module 的 .area(glass-panel 类已删除)*/}

{/* 子组件 1:Agent 选择器(没传 Props,从 Store 读) */}
<AgentSelector />

{/* 子组件 2:文件预览(有文件时才显示) */}
<FilePreview />

{/* 输入行:文件按钮 + input + 文件图标 + textarea + 发送按钮 */}
<div className={styles.inputRow}>

{/* 文件按钮(仅在 file/skills agent 且无文件时显示) */}
{showFileBtn && (
<button
className={styles.fileBtn}
disabled={isUploading}
onClick={() => fileInputRef.current?.click()}
// ↑ 点击时调用 input 的 click() 方法
// ↑ fileInputRef.current 可能为 null
type="button"
title="上传文件(限1个)"
>
<Paperclip size={16} />
</button>
)}

{/* 隐藏的文件 input(display: none) */}
<input
ref={fileInputRef} // ← ref 关联到上面
type="file"
onChange={onPickFile} // 选中文件后触发
style={{ display: 'none' }}
/>

{/* 文件图标(有文件且没在上传时显示) */}
{selectedFile && !isUploading && (
<span className={styles.inputFileIcon} title="文件问答模式">
<FileText size={14} />
</span>
)}

{/* 文本输入框(受控组件) */}
<textarea
ref={textareaRef} // 自定义 Hook 返回的 ref
value={inputMessage} // ← value Store 控制受控
onChange={(e) => setInput(e.target.value)} // 变化时更新 Store
onKeyDown={onKeyDown} // Enter 键发送
placeholder={placeholder} // 根据状态显示不同提示
rows={1} // 默认 1 行
/>

{/* 发送 / 停止按钮(同一个按钮,根据 isSending 切换) */}
<button
className={`${styles.sendBtn} ${isSending ? styles.stop : ''} ${!isSending && !canSend ? styles.disabled : ''}`}
// ↑ 基础样式发送中样式不可点击样式
disabled={isSending ? false : !canSend}
// ↑ 发送中不 disabled可以停止) ↑ 否则根据 canSend
onClick={() => (isSending ? stopMessage() : sendMessage())}
// ↑ 三元:发送中→停止;否则→发送
type="button"
>
{isSending ? <StopCircle size={16} /> : <Send size={16} />}
{/* ↑ 发送中显示停止图标 ↑ 否则显示发送图标 */}
</button>
</div>
</div>
);
}

1.4 关键概念回顾

概念在这里的体现
函数组件export function InputArea() { ... }
从 Store 读数据useChatStore((s) => s.xxx)
refuseRef<HTMLInputElement>(null)
自定义 HookuseTextareaAutosize(inputMessage)
受控组件<textarea value={...} onChange={...} />
条件渲染{showFileBtn && <button />}
事件处理onClickonChangeonKeyDown
阻止默认e.preventDefault()
可选链fileInputRef.current?.click()
三元表达式isSending ? <Stop /> : <Send />
CSS ModuleclassName={styles.area}

这个组件用了几乎所有 React 核心概念,但代码量不大。理解它就读懂了一半项目。


训练 2:ChatItem.tsx(中等)

2.1 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { Trash2 } from 'lucide-react';
import type { Chat } from '@/store/chatStore';
import styles from './ChatItem.module.css';

interface Props {
chat: Chat;
active: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
}

export function ChatItem({ chat, active, onSelect, onDelete }: Props) {
return (
<div
className={`${styles.item} ${active ? styles.active : ''}`}
onClick={() => onSelect(chat.id)}
>
<span className={styles.title}>{chat.title}</span>
{!chat.isNew && (
<span
className={styles.deleteBtn}
onClick={(e) => {
e.stopPropagation();
onDelete(chat.id);
}}
role="button"
>
<Trash2 size={12} />
</span>
)}
</div>
);
}

2.2 逐行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// ── 1. 导入 ──
import { Trash2 } from 'lucide-react';
// ↑ 垃圾桶图标
import type { Chat } from '@/store/chatStore';
// ↑ type-only import(编译时删掉)
// ↑ Chat 类型在 chatStore 中定义
import styles from './ChatItem.module.css';

// ── 2. Props 类型定义 ──
interface Props {
chat: Chat; // 必填:Chat 对象
active: boolean; // 必填:是否选中
onSelect: (id: string) => void; // 必填:选中回调
onDelete: (id: string) => void; // 必填:删除回调
}

// ── 3. 函数组件 ──
export function ChatItem({ chat, active, onSelect, onDelete }: Props) {
// ↑ 解构 Props
return (
// 整个聊天项的容器
<div
className={`${styles.item} ${active ? styles.active : ''}`}
// ↑ 基础样式active=true 时附加 active 样式
onClick={() => onSelect(chat.id)}
// ↑ 点击时调用 onSelect,把 chat.id 传过去
>
{/* 标题 */}
<span className={styles.title}>{chat.title}</span>
{/* ↑ 显示会话标题 */}

{/* 删除按钮(仅对已存在的会话显示) */}
{!chat.isNew && ( // chat.isNew 为 false 才显示
<span
className={styles.deleteBtn}
onClick={(e) => {
e.stopPropagation(); // 阻止冒泡(不触发外层 onClick)
onDelete(chat.id); // 调用删除回调
}}
role="button" // 语义化:让屏幕阅读器知道这是按钮
>
<Trash2 size={12} />
</span>
)}
</div>
);
}

2.3 关键概念

概念在这里的体现
函数组件 + 命名导出export function ChatItem
Props 类型接口interface Props { ... }
解构 Props({ chat, active, onSelect, onDelete }: Props)
必填函数 PropsonSelect: (id: string) => void
模板字符串` ${styles.item} ${active ? ... : ''} `
三元条件类名${active ? styles.active : ''}
阻止事件冒泡e.stopPropagation()
条件渲染{!chat.isNew && <span>...</span>}

训练 3:MessageList.tsx(较复杂)

3.1 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { useMemo } from 'react';
import { useChatStore } from '@/store/chatStore';
import { useAutoScroll } from '@/hooks/useAutoScroll';
import { Message } from '../Message/Message';
import { EmptyState } from './EmptyState';
import styles from './MessageList.module.css';

export function MessageList() {
const chatList = useChatStore((s) => s.chatList);
const currentChatId = useChatStore((s) => s.currentChatId);
const isSending = useChatStore((s) => s.isSending);
const quickPrompt = useChatStore((s) => s.quickPrompt);

const chat = useMemo(
() => chatList.find((c) => c.id === currentChatId),
[chatList, currentChatId],
);

const messages = chat?.messages ?? [];
const ref = useAutoScroll<HTMLDivElement>([messages.length, isSending, chat?.id]);

if (!chat) {
return <div className={styles.container} ref={ref} />;
}

if (messages.length === 0) {
return (
<div className={styles.container} ref={ref}>
<div className={styles.inner}>
<EmptyState onQuickPrompt={quickPrompt} />
</div>
</div>
);
}

return (
<div className={styles.container} ref={ref}>
<div className={styles.inner}>
{messages.map((m, i) => (
<Message
key={m.id}
message={m}
isLast={i === messages.length - 1}
isSending={isSending}
/>
))}
</div>
</div>
);
}

3.2 逐行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// ── 1. 导入 ──
import { useMemo } from 'react';
// ↑ React Hook:缓存计算结果
import { useChatStore } from '@/store/chatStore';
import { useAutoScroll } from '@/hooks/useAutoScroll';
// ↑ 自定义 Hook:消息变化时自动滚动
import { Message } from '../Message/Message';
// ↑ 上一级目录的 Message 组件
// ↑ ../ 表示上一级目录
import { EmptyState } from './EmptyState';
import styles from './MessageList.module.css';

// ── 2. 函数组件(无 Props) ──
export function MessageList() {

// 从 Store 读
const chatList = useChatStore((s) => s.chatList);
// ↑ 所有会话
const currentChatId = useChatStore((s) => s.currentChatId);
// ↑ 当前选中的会话 ID
const isSending = useChatStore((s) => s.isSending);
// ↑ 是否正在接收 AI 回复
const quickPrompt = useChatStore((s) => s.quickPrompt);
// ↑ 快捷问题点击后的回调

// 用 useMemo 找当前会话(避免每次渲染都重新 find)
const chat = useMemo(
() => chatList.find((c) => c.id === currentChatId),
[chatList, currentChatId], // 依赖:chatList 或 currentChatId 变才重算
);
// ↑ 返回类型:Chat | undefined(find 找不到时)

// 取消息列表(chat 可能为 undefined,用 ?.)
const messages = chat?.messages ?? [];
// ↑ 等价于:chat ? chat.messages : []
// ↑ chat 为 undefined 时返回空数组

// 自定义 Hook:自动滚动到底部
const ref = useAutoScroll<HTMLDivElement>([messages.length, isSending, chat?.id]);
// ↑ 参数:依赖数组
// ↑ 当消息数量变化 / 发送状态变化 / 切换会话时,自动滚到底部
// ↑ 返回 ref,挂到滚动容器上

// ── 三种渲染分支 ──

// 分支 1:没有当前会话(显示空容器)
if (!chat) {
return <div className={styles.container} ref={ref} />;
}

// 分支 2:有会话但没消息(显示欢迎页)
if (messages.length === 0) {
return (
<div className={styles.container} ref={ref}>
<div className={styles.inner}>
<EmptyState onQuickPrompt={quickPrompt} />
{/* ↑ 传 onQuickPrompt 函数作为 prop */}
</div>
</div>
);
}

// 分支 3:有消息,渲染消息列表
return (
<div className={styles.container} ref={ref}>
<div className={styles.inner}>
{messages.map((m, i) => (
// ↑ map 列表渲染
<Message
key={m.id}
// ↑ 列表 key message.id
message={m}
// ↑ 整个消息对象作为 prop
isLast={i === messages.length - 1}
// ↑ 是不是最后一条用于特殊样式
isSending={isSending}
// ↑ 是否正在发送用于显示 loading
/>
))}
</div>
</div>
);
}

3.3 关键概念

概念在这里的体现
无 Props 组件所有数据从 Store 读
useMemo缓存 find 结果
可选链 + 空值合并chat?.messages ?? []
自定义 HookuseAutoScroll(...)
提前 return多种情况用 if + return
列表 mapmessages.map((m, i) => ...)
keykey={m.id}
计算后的 propisLast={i === messages.length - 1}
函数作为 proponQuickPrompt={quickPrompt}

训练 4:Message.tsx(最复杂)

4.1 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { Paperclip, Copy, Check, User, Bot } from 'lucide-react';
import type { ChatMessage } from '@/store/chatStore';
import { useChatStore } from '@/store/chatStore';
import { Markdown } from './Markdown';
import { Timeline } from './Timeline';
import { ReferenceList } from './ReferenceList';
import { RecommendQuestions } from './RecommendQuestions';
import { PptDownload } from './PptDownload';
import styles from './Message.module.css';

interface Props {
message: ChatMessage;
isLast: boolean;
isSending: boolean;
}

export function Message({ message, isLast, isSending }: Props) {
const toggleTimeline = useChatStore((s) => s.toggleTimeline);
const toggleReference = useChatStore((s) => s.toggleReference);
const copyMessage = useChatStore((s) => s.copyMessage);
const sendRecommendQuestion = useChatStore((s) => s.sendRecommendQuestion);

const isUser = message.role === 'user';

return (
<div className={`${styles.message} ${isUser ? styles.user : styles.assistant}`}>
<div className={styles.avatar}>
{isUser ? <User size={16} /> : <Bot size={16} />}
</div>
<div className={styles.content}>
{isUser ? (
<div className={styles.userBubble}>
{message.file && (
<span className={styles.fileAttachment}>
<Paperclip size={12} />
{message.fileName}
</span>
)}
<div>{message.content}</div>
<button
className={`${styles.copyBtn} ${styles.copyBtnUser}`}
onClick={() => copyMessage(message.id)}
type="button"
title="复制"
>
{message.copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
) : (
<div className={styles.aiBlock}>
{message.timeline && message.timeline.length > 0 && (
<Timeline
items={message.timeline}
open={!!message.showTimeline}
onToggle={() => toggleTimeline(message.id)}
/>
)}

<Markdown content={message.content} />

{isSending && isLast && message.content === '' && (
<div className={styles.thinkingLoading}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
)}

{message.reference && message.reference.length > 0 && (
<ReferenceList
items={message.reference}
open={!!message.showReference}
onToggle={() => toggleReference(message.id)}
/>
)}

{isLast && message.recommend && message.recommend.length > 0 && (
<RecommendQuestions
questions={message.recommend}
onPick={sendRecommendQuestion}
/>
)}

{message.pptFile && <PptDownload url={message.pptFile} />}

<button
className={styles.copyBtn}
onClick={() => copyMessage(message.id)}
type="button"
title="复制"
>
{message.copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
)}
</div>
</div>
);
}

4.2 整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<Message>
├─ 头像:用户/AI 不同图标
└─ 内容区
├─ 如果是用户消息
│ ├─ 文件附件标识
│ ├─ 消息文字
│ └─ 复制按钮
└─ 如果是 AI 消息
├─ 思考时间线(折叠)
├─ Markdown 渲染的回复
├─ Loading(发送中且最后一条且无内容时)
├─ 参考来源(折叠)
├─ 推荐问题(点击自动发送)
├─ PPT 下载链接
└─ 复制按钮

4.3 逐行注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// ── 1. 导入 ──
import { Paperclip, Copy, Check, User, Bot } from 'lucide-react';
import type { ChatMessage } from '@/store/chatStore';
// ↑ ChatMessage 类型
import { useChatStore } from '@/store/chatStore';
// ↑ 运行时需要(用到里面的 hooks)
import { Markdown } from './Markdown';
import { Timeline } from './Timeline';
import { ReferenceList } from './ReferenceList';
import { RecommendQuestions } from './RecommendQuestions';
import { PptDownload } from './PptDownload';
import styles from './Message.module.css';

// ── 2. Props 类型 ──
interface Props {
message: ChatMessage; // 一条消息(复杂对象)
isLast: boolean; // 是否是最后一条
isSending: boolean; // 是否正在发送
}

// ── 3. 函数组件 ──
export function Message({ message, isLast, isSending }: Props) {

// 从 Store 取方法(用 4 个,组件本身不存 state)
const toggleTimeline = useChatStore((s) => s.toggleTimeline);
// ↑ 切换时间线展开/折叠
const toggleReference = useChatStore((s) => s.toggleReference);
// ↑ 切换参考来源展开/折叠
const copyMessage = useChatStore((s) => s.copyMessage);
// ↑ 复制消息内容
const sendRecommendQuestion = useChatStore((s) => s.sendRecommendQuestion);
// ↑ 点击推荐问题后自动发送

// 派生变量:判断是用户还是 AI
const isUser = message.role === 'user';

// ── 返回 JSX ──
return (
// 外层容器:根据角色应用不同样式
<div className={`${styles.message} ${isUser ? styles.user : styles.assistant}`}>
// ↑ 基础消息样式 ↑ 用户样式 / AI 样式

{/* 头像 */}
<div className={styles.avatar}>
{isUser ? <User size={16} /> : <Bot size={16} />}
// ↑ 条件:用户显示 User 图标,AI 显示 Bot 图标
</div>

{/* 内容区 */}
<div className={styles.content}>

{/* ─── 分支 A:用户消息 ─── */}
{isUser ? (
<div className={styles.userBubble}>
// ↑ 用户消息气泡样式

{/* 文件附件标识(如果消息带文件) */}
{message.file && (
<span className={styles.fileAttachment}>
<Paperclip size={12} />
{message.fileName}
// ↑ 显示文件名(注意 message.fileName 是 string | null,但前面 .file 为 true 说明有文件)
</span>
)}

{/* 消息文字内容 */}
<div>{message.content}</div>

{/* 复制按钮 */}
<button
className={`${styles.copyBtn} ${styles.copyBtnUser}`}
onClick={() => copyMessage(message.id)}
type="button"
title="复制"
>
{message.copied ? <Check size={12} /> : <Copy size={12} />}
// ↑ 已复制显示对勾 ↑ 未复制显示复制图标
</button>
</div>
) : (

/* ─── 分支 B:AI 消息 ─── */
<div className={styles.aiBlock}>

{/* 思考时间线(仅在有时间线时显示) */}
{message.timeline && message.timeline.length > 0 && (
<Timeline
items={message.timeline}
open={!!message.showTimeline}
// ↑ !! 转为 booleanundefinedfalse
onToggle={() => toggleTimeline(message.id)}
// ↑ 点击时切换折叠状态
/>
)}

{/* Markdown 渲染的 AI 回复 */}
<Markdown content={message.content} />
// ↑ 字符串 prop,Markdown 内部自己处理渲染

{/* 加载动画:发送中 + 是最后一条 + 还没内容时 */}
{isSending && isLast && message.content === '' && (
<div className={styles.thinkingLoading}>
<span className={styles.dot} />
<span className={styles.dot} />
<span className={styles.dot} />
</div>
)}

{/* 参考来源(仅在有参考时显示) */}
{message.reference && message.reference.length > 0 && (
<ReferenceList
items={message.reference}
open={!!message.showReference}
onToggle={() => toggleReference(message.id)}
/>
)}

{/* 推荐问题(仅最后一条 + 有推荐时显示) */}
{isLast && message.recommend && message.recommend.length > 0 && (
<RecommendQuestions
questions={message.recommend}
onPick={sendRecommendQuestion}
// ↑ 点击推荐问题时自动发送
/>
)}

{/* PPT 下载链接(有 pptFile 时显示) */}
{message.pptFile && <PptDownload url={message.pptFile} />}

{/* 复制按钮 */}
<button
className={styles.copyBtn}
onClick={() => copyMessage(message.id)}
type="button"
title="复制"
>
{message.copied ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
)}
</div>
</div>
);
}

4.4 关键概念

概念在这里的体现
嵌套三元{isUser ? (用户 UI) : (AI UI)}
复杂条件渲染isSending && isLast && message.content === ''
&& 组合条件message.timeline && message.timeline.length > 0
类型转换!!message.showTimeline
子组件 + Props<Timeline items={...} open={...} onToggle={...} />
多种角色 propmessage.role === 'user'

5. 自测:试着自己读 Sidebar.tsx

我把 Sidebar.tsx 留给你作为练习。

任务:不看注释,自己读一遍代码,回答:

  1. 组件从 Store 读了哪些数据?
  2. 用到哪些 Hook?
  3. 折叠状态怎么存的?是否持久化?
  4. 列表怎么渲染?key 是什么?
  5. 移动端有特殊处理吗?用了什么 Hook?

代码如下(你应该已经能读懂了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import { useEffect, useState } from 'react';
import { Plus, Link2, ChevronLeft, Menu } from 'lucide-react';
import { useChatStore } from '@/store/chatStore';
import { API_BASE } from '@/api/client';
import { ChatItem } from './ChatItem';
import styles from './Sidebar.module.css';

function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return matches;
}

export function Sidebar() {
const chatList = useChatStore((s) => s.chatList);
const currentChatId = useChatStore((s) => s.currentChatId);
const createNewChat = useChatStore((s) => s.createNewChat);
const selectChat = useChatStore((s) => s.selectChat);
const deleteChat = useChatStore((s) => s.deleteChat);

const STORAGE_KEY = 'dodo.sidebar.collapsed';
const isMobile = useMediaQuery('(max-width: 767px)');

const [collapsed, setCollapsed] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(STORAGE_KEY) === 'true';
} catch {
return false;
}
});

const toggleCollapsed = () => {
setCollapsed((prev) => {
const next = !prev;
if (!isMobile) {
try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { /* noop */ }
}
return next;
});
};

const isDrawerOpen = isMobile && !collapsed;

return (
<>
{isMobile && !collapsed && (
<div className={styles.backdrop} onClick={toggleCollapsed} />
)}

<aside
className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''} ${
isDrawerOpen ? styles.drawerOpen : ''
}`}
>
<button
className={styles.collapseBtn}
onClick={toggleCollapsed}
type="button"
aria-label="折叠侧边栏"
>
<ChevronLeft size={14} />
</button>
<div className={styles.header}>
<div className={styles.title}>
<span className={styles.logo}>🌱</span>
<span className={styles.titleText}>豆豆</span>
</div>
<button className={styles.newBtn} onClick={createNewChat} type="button">
<Plus size={14} />
<span>新对话</span>
</button>
</div>
<div className={styles.list}>
{chatList.map((c) => (
<ChatItem
key={c.id}
chat={c}
active={c.id === currentChatId}
onSelect={selectChat}
onDelete={deleteChat}
/>
))}
</div>
<div className={styles.footer}>
<div className={styles.backend}>
<Link2 size={12} />
<span>{API_BASE}</span>
</div>
</div>
</aside>

{collapsed && (
<button
className={styles.hamburger}
onClick={toggleCollapsed}
type="button"
aria-label="展开侧边栏"
>
<Menu size={18} />
</button>
)}
</>
);
}

6. 一段话总结

读完这一章,你应该能:

  1. 读懂任何 React 组件:从 import 到 return
  2. 理解数据流:Store → 组件 → 子组件 (Props)
  3. 预测行为:看到条件渲染就知道何时显示
  4. 快速定位代码:遇到功能就在 Store 找对应 action,组件里找对应事件

到这里,React 核心就讲完了
接下来讲项目里的「周边技术」:CSS Modules、Zustand、SSE、API 封装。