12 — React 代码完整阅读训练
这一章是最重要的一章。
我会拿项目里的真实组件逐行拆解,把前面学的所有概念串起来。
读完后,你应该能独立读懂 90% 的项目代码。
训练方式
我会拿几个典型的、有代表性的组件来拆解:
InputArea.tsx — 简单但完整(表单、事件、ref)ChatItem.tsx — 中等(条件渲染、Props、事件回调)MessageList.tsx — 较复杂(map、条件、Hooks 综合)Message.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
| 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}> {/* ↑ 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) |
| ref | useRef<HTMLInputElement>(null) |
| 自定义 Hook | useTextareaAutosize(inputMessage) |
| 受控组件 | <textarea value={...} onChange={...} /> |
| 条件渲染 | {showFileBtn && <button />} |
| 事件处理 | onClick、onChange、onKeyDown |
| 阻止默认 | e.preventDefault() |
| 可选链 | fileInputRef.current?.click() |
| 三元表达式 | isSending ? <Stop /> : <Send /> |
| CSS Module | className={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
| 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 : ''}`} // ↑ 基础样式 ↑ 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) |
| 必填函数 Props | onSelect: (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
| 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} /> {/* ↑ 传 onQuickPrompt 函数作为 prop */} </div> </div> ); }
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 ?? [] |
| 自定义 Hook | useAutoScroll(...) |
| 提前 return | 多种情况用 if + return |
| 列表 map | messages.map((m, i) => ...) |
| key | key={m.id} |
| 计算后的 prop | isLast={i === messages.length - 1} |
| 函数作为 prop | onQuickPrompt={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
| 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}`}> // ↑ 基础消息样式 ↑ 用户样式 / 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} // ↑ !! 转为 boolean(undefined → false) 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={...} /> |
| 多种角色 prop | message.role === 'user' |
我把 Sidebar.tsx 留给你作为练习。
任务:不看注释,自己读一遍代码,回答:
- 组件从 Store 读了哪些数据?
- 用到哪些 Hook?
- 折叠状态怎么存的?是否持久化?
- 列表怎么渲染?key 是什么?
- 移动端有特殊处理吗?用了什么 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 { } } 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. 一段话总结
读完这一章,你应该能:
- 读懂任何 React 组件:从 import 到 return
- 理解数据流:Store → 组件 → 子组件 (Props)
- 预测行为:看到条件渲染就知道何时显示
- 快速定位代码:遇到功能就在 Store 找对应 action,组件里找对应事件
到这里,React 核心就讲完了。
接下来讲项目里的「周边技术」:CSS Modules、Zustand、SSE、API 封装。