组件的渲染本质上是纯粹的,即相同的 props
总是产生相同的 UI。为了引入动态行为和交互,需要使用 Hooks 来管理组件的"记忆"(State)和与外部世界的"同步"(Side Effects)。
useState
:组件的记忆
useState
用于在组件中声明一个可变的状态,当该状态更新时,将触发组件的重新渲染。
核心语法:
const [value, setValue] = useState(initialValue)
value
:当前渲染周期的状态快照(snapshot),只读。setValue
:用于更新状态的函数。调用它会向 React 请求一次重新渲染。initialValue
:初始值,仅在组件的首次渲染时生效。
实际使用示例:
import { useState } from 'react';
function TodoList() {
const [todos, setTodos] = useState<string[]>([]);
const [inputValue, setInputValue] = useState('');
const addTodo = () => {
if (inputValue.trim()) {
// 正确:创建新数组
setTodos(prevTodos => [...prevTodos, inputValue.trim()]);
setInputValue(''); // 清空输入框
}
};
const removeTodo = (index: number) => {
// 正确:创建新数组,过滤掉要删除的项目
setTodos(prevTodos => prevTodos.filter((_, i) => i !== index));
};
return (
<div className="p-4">
<div className="flex gap-2 mb-4">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
placeholder="添加新任务..."
className="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={addTodo}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
添加
</button>
</div>
<ul className="space-y-2">
{todos.map((todo, index) => (
<li key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
<span>{todo}</span>
<button
onClick={() => removeTodo(index)}
className="text-red-500 hover:text-red-700"
>
删除
</button>
</li>
))}
</ul>
</div>
);
}
关键原则与模式:
-
状态是不可变的 (Immutability is Key) 绝对不能直接修改状态变量。必须始终创建新的对象或数组。React 依赖于引用地址的变更 (
Object.is
比较) 来检测更新。// 对于数组 // 错误 ❌ list.push('new') setList(list) // 正确 ✅ setList([...list, 'new']) // or list.concat('new') // 对于对象 // 错误 ❌ user.age = 30 setUser(user) // 正确 ✅ setUser({ ...user, age: 30 })
-
状态更新可能是异步的
setState
函数调用不会立即改变当前组件内的state
值,它仅仅是"安排"了一次未来的更新。React 可能会为了性能而批量处理多次setState
调用。 -
使用函数式更新 当新的状态依赖于前一个状态时,务必使用函数式更新的形式。这可以确保你总是基于最新的状态进行计算,避免闭包陷阱。
// 安全的做法 ✅ setCount(prevCount => prevCount + 1) // 应用于对象 setUser(prevUser => ({ ...prevUser, age: prevUser.age + 1 }))
useEffect
:与外部世界同步
useEffect
用于处理所有与组件渲染无关的操作,即副作用(Side Effects),例如 API 请求、事件监听、定时器等。它将组件的内部状态与外部系统进行同步。
核心语法:
useEffect(
() => {
// 副作用逻辑
return () => {
// 清除逻辑 (cleanup)
}
},
[
/* 依赖项数组 */
]
)
依赖项数组的规则 (The Rule of Dependencies):
[]
(空数组): 副作用仅在组件挂载 (mount) 时执行一次。[dep1, dep2, ...]
(包含依赖项): 副作用在挂载时执行,并且每当数组中的任意一个依赖项发生变化时,都会再次执行。- 不提供数组 (省略): 副作用在每一次渲染后都会执行。应极力避免,这通常是 Bug 的来源。
清除函数 (Cleanup Function):
useEffect
中返回的函数是其"清除"机制。它会在组件卸载 (unmount) 时,或在副作用下一次执行之前被调用。必须用它来清理在副作用中创建的任何订阅、定时器或事件监听器,以防止内存泄漏。
import { useState, useEffect } from 'react';
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// 重置状态
setLoading(true);
setError(null);
setUser(null);
const controller = new AbortController();
const signal = controller.signal;
fetch(`/api/users/${userId}`, { signal })
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
return res.json();
})
.then(data => {
setUser(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => {
setLoading(false);
});
// 清除函数:取消请求
return () => {
controller.abort();
};
}, [userId]); // 当 userId 变化时重新执行
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error}</div>;
if (!user) return <div>未找到用户</div>;
return (
<div className="p-4 border rounded">
<h2 className="text-xl font-bold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
);
}
useEffect
的陷阱与解决方法
-
陷阱:遗漏依赖项 (The #1 Bug Source)
useEffect
最常见的错误就是忘记在依赖项数组中包含所有在 effect 内部用到的、来自组件作用域的变量(props, state, 函数等)。这会导致 effect 不在期望的时机重新运行,使用的是过时的数据。 最佳实践: 强烈推荐安装并配置官方的eslint-plugin-react-hooks
插件。它会自动检查并警告你遗漏的依赖项,帮你从根源上杜绝此类问题。 -
陷阱:在依赖项中包含函数或对象 在组件内部定义的函数或对象,在每次渲染时都会重新创建,导致它们的引用地址发生变化。如果将它们作为
useEffect
的依赖项,会导致 effect 在每次渲染后都不必要地重新执行。 最佳实践:- 方法一(推荐): 如果该函数只在
useEffect
内部使用,就直接把它定义在useEffect
内部。 - 方法二: 如果该函数需要被其他地方使用,使用
useCallback
Hook 来包裹它。我们将在下一章详细介绍useCallback
。
- 方法一(推荐): 如果该函数只在
-
陷阱:异步 Effect 与竞态条件 (Race Condition) 当一个依赖项快速变化(例如,搜索框输入),可能同时触发多个 API 请求。如果一个较早的请求比较晚的请求后返回,UI 就会显示出错误(过时)的数据。 最佳实践: 务必处理异步请求的清理。在
fetch
请求中,最佳方式是使用AbortController
。useEffect(() => { const controller = new AbortController() const signal = controller.signal fetch(`api/data?query=${query}`, { signal }) .then(res => res.json()) .catch(err => { // AbortError 是我们主动取消的,无需作为错误处理 if (err.name === 'AbortError') { console.log('Fetch aborted') } else { // ... handle other errors } }) // 清除函数:在组件卸载或 effect 重新运行时,取消上一次的请求 return () => { controller.abort() } }, [query]) // 依赖于 query
实用技巧与最佳实践
1. 状态设计原则
- 最小化状态:只存储真正需要触发重新渲染的数据
- 派生状态:通过计算得出的值,不要存储在状态中
- 状态提升:将共享状态提升到最近的共同父组件
// 好的状态设计
function UserForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
age: ''
});
// 派生状态:通过计算得出,不需要存储
const isFormValid = formData.name && formData.email && formData.age;
const isAdult = parseInt(formData.age) >= 18;
return (
<form>
{/* 表单内容 */}
<button disabled={!isFormValid}>
{isAdult ? '提交' : '需要成年'}
</button>
</form>
);
}
2. useEffect 的最佳实践
- 单一职责:每个 useEffect 只处理一个副作用
- 依赖项最小化:只包含真正需要的依赖项
- 清理函数:始终清理定时器、订阅、请求等
// 好的 useEffect 设计
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState([]);
// 处理消息订阅
useEffect(() => {
const subscription = subscribeToMessages(roomId, (message) => {
setMessages(prev => [...prev, message]);
});
return () => subscription.unsubscribe();
}, [roomId]);
// 处理在线状态
useEffect(() => {
const interval = setInterval(() => {
updateOnlineStatus(roomId);
}, 30000);
return () => clearInterval(interval);
}, [roomId]);
return <div>{/* 聊天界面 */}</div>;
}
本篇总结:
- 状态管理:使用
useState
管理组件内部状态,牢记不可变性原则 - 副作用处理:使用
useEffect
处理异步操作和外部系统同步 - 性能优化:合理使用依赖项数组和清理函数
- 最佳实践:遵循单一职责、最小化依赖、及时清理的原则
掌握这些核心概念,你就能构建出健壮、可维护的 React 组件!