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 是驼峰
// ↑ 传函数引用
}

⚠️ 不要写 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)} />
// ↑ e 是 SyntheticEvent(合成事件)
// ↑ e.target 是触发事件的元素(input)
// ↑ input 的当前值

常见事件对象属性

事件关键属性
onChange (input/textarea)e.target.value
onClick (鼠标)e.target、鼠标位置
onKeyDown (键盘)e.keye.shiftKeye.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
// ChatItem.tsx
<div onClick={() => onSelect(chat.id)}> {/* 父的点击:选中 */}
<span onClick={(e) => {
e.stopPropagation(); {/* 阻止冒泡 */}
onDelete(chat.id); {/* 删除按钮的点击 */}
}}>
<Trash2 size={12} />
</span>
</div>

如果不阻止冒泡,点删除按钮会先触发外层的选中,体验很差。

1.6 项目里所有事件类型

事件项目里出现的位置
onClick几乎所有按钮
onChangeinput/textarea
onKeyDowntextarea(Enter 发送)
onSubmit(没用到)

项目里几乎只用 onClickonChangeonKeyDown 三种。

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
// InputArea.tsx
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) {
// ↑ Enter 键 ↑ 没按住 Shift
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
// FilePreview.tsx
function FilePreview() {
const file = useChatStore(s => s.selectedFile);
if (!file) return null; // 没有文件就不渲染

return <div>...</div>;
}

// ReferenceList.tsx
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
// ChatItem.tsx
<button onClick={() => sendMessage()}>
{isSending ? <StopCircle size={16} /> : <Send size={16} />}
// ↑ true 时显示停止 ↑ false 时显示发送
</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 时不显示(因为 0 是 falsy)
// 即使逻辑上「count 为 0」也是要显示的情况

{count > 0 && <p>有 count</p>} // ✅ 明确条件

项目里

1
2
3
4
5
6
7
8
9
// Message.tsx
{message.timeline && message.timeline.length > 0 && (
<Timeline items={message.timeline} ... />
)}
// ↑ 三段:message.timeline 存在 && 长度大于 0 && 渲染

{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
// MessageList.tsx
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
// ✅ 最好用稳定唯一 ID
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}

// ❌ 不推荐用 index
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
// 如果列表会重排,index 变化会导致 bug

项目里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Sidebar.tsx
{chatList.map((c) => (
<ChatItem
key={c.id} {/* chat.id唯一稳定) */}
chat={c}
active={c.id === currentChatId}
onSelect={selectChat}
onDelete={deleteChat}
/>
))}

// MessageList.tsx
{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>;
}

模式总结

  1. 父组件 map 列表
  2. 每个子项传一个 key
  3. 子项数据作为 prop 传
  4. 选中状态 / 处理函数也作为 prop 传

4. 受控 vs 非受控(项目里的 input 处理)

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」集中讲解

5.1 Sidebar — 列表渲染 + 条件 + 折叠

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 等状态条件渲染

5.3 InputArea — onClick + onKeyDown

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
我会拿项目里的一个真实组件逐行拆解,把前面学的所有概念串起来。