11 — 事件处理与条件/列表渲染
这一章讲 JSX 里最常用的几种写法:事件、列表渲染、条件渲染。
掌握这些,你就能读懂项目里所有「动态 UI」的部分。
1. 事件处理
1.1 基础模式
1 2 3 4 5 6 7 8 9
| function MyButton() { const handleClick = () => { console.log('点击了'); };
return <button onClick={handleClick}>点击</button>; }
|
⚠️ 不要写 onClick={handleClick()}(会立即执行),要写 onClick={handleClick}(点击时执行)。
1.2 内联箭头函数
1 2
| <button onClick={() => console.log('hi')}>点击</button>
|
适合需要传参数或逻辑简单的情况。
1.3 接收事件对象
1 2 3 4
| <input onChange={(e) => console.log(e.target.value)} />
|
常见事件对象属性:
| 事件 | 关键属性 |
|---|
onChange (input/textarea) | e.target.value |
onClick (鼠标) | e.target、鼠标位置 |
onKeyDown (键盘) | e.key、 e.shiftKey、 e.ctrlKey |
onSubmit (表单) | e.preventDefault() 阻止提交 |
onScroll (滚动) | e.target.scrollTop |
1.4 阻止默认行为
1 2 3 4 5 6 7 8 9 10 11
| <form onSubmit={(e) => { e.preventDefault(); }}>
<input onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); // 阻止默认行为 } }}>
|
1.5 阻止冒泡
1 2 3 4 5 6 7 8
| <div onClick={() => console.log('外层')}> <button onClick={(e) => { e.stopPropagation(); // 阻止冒泡,外层 onClick 不触发 console.log('内层'); }}> 点击 </button> </div>
|
项目例子:
1 2 3 4 5 6 7 8 9
| <div onClick={() => onSelect(chat.id)}> {} <span onClick={(e) => { e.stopPropagation(); {} onDelete(chat.id); {} }}> <Trash2 size={12} /> </span> </div>
|
如果不阻止冒泡,点删除按钮会先触发外层的选中,体验很差。
1.6 项目里所有事件类型
| 事件 | 项目里出现的位置 |
|---|
onClick | 几乎所有按钮 |
onChange | input/textarea |
onKeyDown | textarea(Enter 发送) |
onSubmit | (没用到) |
项目里几乎只用 onClick、onChange、onKeyDown 三种。
1.7 完整例子:项目里的 textarea 事件
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
| function InputArea() { const inputMessage = useChatStore(s => s.inputMessage); const setInput = useChatStore(s => s.setInput); const sendMessage = useChatStore(s => s.sendMessage); const isSending = useChatStore(s => s.isSending);
const canSend = !isSending && (inputMessage.trim().length > 0 || selectedFile);
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (canSend) sendMessage(); } };
return ( <textarea value={inputMessage} onChange={(e) => setInput(e.target.value)} onKeyDown={onKeyDown} placeholder="输入消息..." /> ); }
|
2. 条件渲染
2.1 写法 1:if 提前返回
1 2 3 4 5 6
| function Message({ message }) { if (!message) return null; if (message.hidden) return null;
return <div>{message.content}</div>; }
|
项目里:
1 2 3 4 5 6 7 8 9 10 11 12 13
| function FilePreview() { const file = useChatStore(s => s.selectedFile); if (!file) return null;
return <div>...</div>; }
function ReferenceList({ items }) { if (!items || items.length === 0) return null; return <div>...</div>; }
|
2.2 写法 2:三元运算符
1 2 3
| <div className={`${styles.item} ${active ? styles.active : ''}`}> {isUser ? <UserIcon /> : <BotIcon />} </div>
|
项目里:
1 2 3 4 5
| <button onClick={() => sendMessage()}> {isSending ? <StopCircle size={16} /> : <Send size={16} />} </button>
|
2.3 写法 3:逻辑与 &&
1 2
| {showModal && <Modal />} {user && <UserInfo user={user} />}
|
短路逻辑:
- 左边是
true → 返回右边(渲染) - 左边是
false → 返回左边(不渲染)
⚠️ 陷阱:数字 0:
1 2 3 4 5
| {count && <p>有 count</p>}
{count > 0 && <p>有 count</p>}
|
项目里:
1 2 3 4 5 6 7 8 9
| {message.timeline && message.timeline.length > 0 && ( <Timeline items={message.timeline} ... /> )}
{message.reference && message.reference.length > 0 && ( <ReferenceList ... /> )}
|
2.4 写法 4:复杂条件用函数
1 2 3 4 5 6 7 8
| function renderContent() { if (loading) return <Spinner />; if (error) return <ErrorMessage error={error} />; if (!data) return <Empty />; return <Content data={data} />; }
return <div>{renderContent()}</div>;
|
项目里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| if (!chat) { return <div className={styles.container} ref={ref} />; } if (messages.length === 0) { return ( <div className={styles.container} ref={ref}> <EmptyState onQuickPrompt={quickPrompt} /> </div> ); } return ( <div className={styles.container} ref={ref}> {messages.map(...)} </div> );
|
2.5 总结:什么时候用哪种
| 场景 | 推荐 |
|---|
| 「没有就完全不显示」 | 提前 return null |
| 简单 true/false 二选一 | 三元 condition ? A : B |
| 满足条件才显示 | && |
| 多种状态 | 函数返回不同 JSX |
3. 列表渲染
3.1 基础:.map()
1 2 3 4 5 6 7 8 9 10
| function List() { const items = ['苹果', '香蕉', '橘子']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }
|
3.2 ⚠️ 关键:必须传 key
1 2 3 4 5
| {items.map(item => <li>{item}</li>)}
{items.map(item => <li key={item}>{item}</li>)}
|
key 是什么? React 用来识别「哪个元素是哪个」,高效更新列表。
3.3 key 的选择原则
1 2 3 4 5 6 7 8 9 10
| {items.map(item => ( <li key={item.id}>{item.name}</li> ))}
{items.map((item, index) => ( <li key={index}>{item.name}</li> ))}
|
项目里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| {chatList.map((c) => ( <ChatItem key={c.id} {/* 用 chat.id(唯一稳定) */} chat={c} active={c.id === currentChatId} onSelect={selectChat} onDelete={deleteChat} /> ))}
{messages.map((m, i) => ( <Message key={m.id} {/* 用 message.id */} message={m} isLast={i === messages.length - 1} {/* 用 index 计算 isLast */} isSending={isSending} /> ))}
|
3.4 列表 + 条件渲染
1 2 3 4 5 6 7
| {items.length === 0 ? ( <EmptyState /> ) : ( <ul> {items.map(item => <li key={item.id}>{item.name}</li>)} </ul> )}
|
3.5 嵌套列表
1 2 3 4 5 6 7
| {users.map(user => ( <UserCard key={user.id} user={user}> {user.posts.map(post => ( <PostItem key={post.id} post={post} /> ))} </UserCard> ))}
|
3.6 列表项传 Props 的完整模式(项目典型)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| {chatList.map((c) => ( <ChatItem key={c.id} chat={c} active={c.id === currentChatId} onSelect={selectChat} onDelete={deleteChat} /> ))}
function ChatItem({ chat, active, onSelect, onDelete }: Props) { return <div>...</div>; }
|
模式总结:
- 父组件 map 列表
- 每个子项传一个
key - 子项数据作为 prop 传
- 选中状态 / 处理函数也作为 prop 传
4.1 受控组件(本项目用)
1 2 3 4 5 6 7 8 9 10
| function Input() { const [value, setValue] = useState('');
return ( <input value={value} // ← value 来自 state onChange={(e) => setValue(e.target.value)} // ← 变化时更新 state /> ); }
|
特点:React 完全控制 input 的值(单一数据源)。
4.2 非受控组件(项目不用)
1 2 3 4 5 6 7 8 9 10
| function Input() { const ref = useRef<HTMLInputElement>(null);
return ( <> <input ref={ref} defaultValue="初始" /> <button onClick={() => console.log(ref.current?.value)}>读取</button> </> ); }
|
特点:DOM 自己管值,React 通过 ref 读取。
4.3 项目里全是受控组件
| 元素 | value 来源 | onChange |
|---|
<textarea> | inputMessage (Store) | setInput(e.target.value) |
<input type="file"> | 无(非受控) | handleFileSelect(f) |
5. 实战:项目里所有「动态 UI」集中讲解
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
| function Sidebar() { const [collapsed, setCollapsed] = useState(false); const isMobile = useMediaQuery('(max-width: 767px)');
return ( <aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}> <button onClick={toggleCollapsed}>折叠</button>
<div className={styles.list}> {chatList.length === 0 ? ( <p>暂无会话</p> ) : ( chatList.map(c => ( <ChatItem key={c.id} chat={c} active={c.id === currentChatId} // 条件:当前选中 onSelect={selectChat} onDelete={deleteChat} /> )) )} </div> </aside> ); }
|
涉及概念:
useState 管理 collapsed- 三元判断 collapsed 应用样式
- 条件渲染:空列表 vs 列表
map 列表渲染key 用 chat.id- props 传值
5.2 Message — 多种条件组合
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
| function Message({ message, isLast, isSending }) { const isUser = message.role === 'user';
return ( <div className={styles.message}> <div className={styles.avatar}> {isUser ? <User /> : <Bot />} {/* 条件:用户/AI 不同图标 */} </div>
<div className={styles.content}> {isUser ? ( // 用户消息 <div className={styles.userBubble}> {message.file && <FileAttachment />} {/* 条件:是否有文件 */} <div>{message.content}</div> </div> ) : ( // AI 消息 <div> {message.timeline && message.timeline.length > 0 && ( <Timeline items={message.timeline} /> {/* 条件:有思考过程 */} )}
<Markdown content={message.content} />
{isSending && isLast && message.content === '' && ( <LoadingDots /> {/* 条件:正在生成且无内容 */} )}
{message.reference && message.reference.length > 0 && ( <ReferenceList items={message.reference} /> {/* 条件:有参考 */} )}
{isLast && message.recommend && message.recommend.length > 0 && ( <RecommendQuestions questions={message.recommend} /> )} </div> )} </div> </div> ); }
|
涉及概念:
- 多层条件渲染(三元 +
&& 组合) - 用户/AI 消息两种完全不同的 UI
- 根据数据存在与否渲染组件
- 根据 isLast、isSending 等状态条件渲染
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
| function InputArea() { const fileInputRef = useRef<HTMLInputElement>(null);
const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0]; if (f) handleFileSelect(f); e.target.value = ''; };
return ( <div> <button onClick={() => fileInputRef.current?.click()}> 上传 </button> <input ref={fileInputRef} type="file" onChange={onPickFile} style={{ display: 'none' }} />
<textarea value={inputMessage} onChange={(e) => setInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (canSend) sendMessage(); } }} />
<button onClick={() => isSending ? stopMessage() : sendMessage()}> {isSending ? <Stop /> : <Send />} </button> </div> ); }
|
涉及概念:
- ref 控制 input 点击
- 隐藏 input 接收文件
- onChange + e.target.files 获取文件
- onKeyDown 监听 Enter
- e.preventDefault() 阻止默认行为
- 三元判断 isSending 显示不同按钮
6. 一段话总结
事件处理:用驼峰属性(onClick)传函数,可以接事件对象 e。
条件渲染:4 种写法(提前返回 / 三元 / && / 函数),按场景选。
列表渲染:用 .map(),每项必须有 key(用唯一稳定的 ID)。
项目特点:几乎所有动态 UI 都靠这三种组合实现,业务逻辑则在 Store。
接下来
到这里你已经掌握了 React 所有核心概念。
下一章是重点:12-React代码完整阅读训练.md。
我会拿项目里的一个真实组件逐行拆解,把前面学的所有概念串起来。