📚 全栈开发学习系列 · 阶段三(前端深化)
17 React 入门:组件化思想与 JSX 语法
18 React 进阶:状态管理与副作用钩子(当前篇)
19 TypeScript 基础:类型系统与类型安全
20 Next.js 入门:SSR 与路由系统
21 Tailwind CSS:原子化样式与响应式设计
22 前端工程化:Vite 构建与前端测试
23 综合实战:React + TypeScript 重写待办事项
React 进阶:状态管理与副作用钩子
难度:基础 | React 19.2.8 · Hooks · useState/useEffect/useRef
读完本篇你将能:用 useState 管理组件内部状态并掌握函数式更新模式,用 useEffect 处理数据获取与定时器等副作用并正确编写清理函数,理解依赖数组的三种形式及其触发时机,用 useRef 跨渲染保存持久引用而不触发重渲染,编写自定义 Hook 实现逻辑复用——让你的 React 组件从静态展示进化为动态交互。
📑 本文目录
01useState:让组件拥有记忆
02函数式更新与批量合并
03useEffect:处理副作用
04依赖数组与清理函数
05useRef:跨渲染的持久引用
06自定义 Hook:逻辑复用
01 useState:让组件拥有记忆
上一篇你学会了用 Props 向组件传数据,也看了 useState 的计数器示例。Props 是只读的,组件内部无法修改它——但真实应用需要交互:用户点击按钮后计数器要增加,输入文字后搜索框要更新。这就需要组件自己管理"状态"。
useState 是最基础的 React Hook,它让函数组件拥有了"记忆"——在多次渲染之间保存数据。调用它时传入一个初始值,返回一个数组:当前状态值和一个更新函数。用解构语法取出这两个值:
Counter.jsx — useState 基本用法
|
1
2
3
4
5
6
7
8
9
10
11
|
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>当前计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
|
逐行拆解:useState(0) 初始化状态为 0。返回的数组 [count, setCount] 中,count 是当前状态值(首次渲染时为 0),setCount 是更新函数。点击按钮时调用 setCount(count + 1),React 收到新值后重新渲染组件,count 变成 1,页面自动更新。
多个状态值
一个组件可以有任意多个 useState,每个管理一个独立状态:
Form.jsx — 多个 useState
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [remember, setRemember] = useState(false);
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password}
onChange={(e) => setPassword(e.target.value)} />
<label>
<input type="checkbox" checked={remember}
onChange={(e) => setRemember(e.target.checked)} />
记住我
</label>
</form>
);
|
💡 小贴士:状态命名约定
React 社区约定:状态变量用名词(email、count),更新函数用 set 前缀 + 首字母大写(setEmail、setCount)。这不是语法要求,但一致的命名让代码可读性大幅提升,也是 ESLint 的 react-hooks 插件能正确分析依赖的前提。
02 函数式更新与批量合并
你已经能用 setCount(count + 1) 更新状态了。但在某些场景下,这种写法会出问题——尤其是连续多次调用更新时。React 19 默认开启批量合并(Automatic Batching),同一事件中的多次状态更新会合并成一次重渲染。如果在一个事件处理函数中连续调用三次 setCount(count + 1),结果不是 +3 而是 +1,因为三次调用读到的 count 都是同一个旧值。
解决方案是函数式更新——传一个函数而不是值给 setState,函数接收上一个最新状态作为参数:
Counter.jsx — 函数式更新对比
|
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// ❌ 直接更新:连续调用三次只 +1
const handleAddThree = () => {
setCount(count + 1); // count=0 → 1
setCount(count + 1); // count 仍是 0 → 1
setCount(count + 1); // count 仍是 0 → 1
};
// ✓ 函数式更新:连续调用三次正确 +3
const handleAddThreeV2 = () => {
setCount((prev) => prev + 1); // 0→1
setCount((prev) => prev + 1); // 1→2
setCount((prev) => prev + 1); // 2→3
};
|
⚠️ 常见错误
setCount(count + 1); setCount(count + 1); — 以为会 +2,实际只 +1。因为批量合并期间 count 不变,两次都读到旧值
✓ 正确:setCount(prev => prev + 1) — 每次基于上一个结果递增
状态更新对象
当状态是对象时,useState 不会自动合并属性——你必须用展开运算符 ... 手动合并旧属性再覆盖新值:
Profile.jsx — 对象状态更新
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
const [user, setUser] = useState({ name: '张三', age: 25 });
// ❌ 错误:丢失 age 属性
setUser({ name: '李四' });
// user 变成 { name: '李四' },age 没了!
// ✓ 正确:展开旧属性再覆盖
setUser((prev) => ({ ...prev, name: '李四' }));
// user 变成 { name: '李四', age: 25 }
// 更新多个字段
setUser((prev) => ({
...prev,
name: '王五',
age: 26
}));
|
03 useEffect:处理副作用
掌握了 useState 后,你的组件已经有状态了。但还有一类需求 useState 解决不了:组件渲染后需要"做点别的事"——从服务器获取数据、设置定时器、添加事件监听。这些操作叫做副作用(Side Effect),因为它们会影响组件外部的世界,而不是纯粹根据 props 和 state 计算 UI。
useEffect 让你在函数组件中执行副作用。它接收两个参数:一个函数(副作用逻辑)和一个依赖数组(决定何时重新执行)。
DataFetch.jsx — useEffect 基本用法
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(err => console.error(err));
}, [userId]); // 依赖数组
if (!user) return <p>加载中…</p>;
return <div>{user.name}({user.age}岁)</div>;
}
|
这段代码的逻辑:组件首次渲染时,useEffect 执行 fetch 请求获取用户数据。依赖数组 [userId] 表示:当 userId 变化时重新执行这个 effect。如果父组件传入新的 userId,组件会重新获取对应用户的数据。
💡 小贴士:React 官方对 useEffect 的定位
React 官方文档强调:useEffect 是"与外部系统同步"的手段,不是数据获取的银弹。React 19 引入了 use() API 和 Server Components 来更好地处理数据获取。但在纯客户端 SPA 中,useEffect + fetch 仍然是最常见的数据获取方式。判断标准:如果副作用涉及与 React 外部系统通信(API、定时器、订阅),用 useEffect;如果只是根据状态计算派生数据,不需要 useEffect。
04 依赖数组与清理函数
useEffect 的核心难点在于依赖数组——它决定了 effect 何时重新执行。理解三种形式是掌握 useEffect 的关键:
| 依赖数组形式 |
触发时机 |
典型场景 |
[] 空数组 |
仅首次渲染后执行一次 |
初始化数据获取、注册全局事件 |
[dep1, dep2] |
首次渲染后 + 依赖值变化时 |
依赖变化时重新获取数据 |
| 不传(无第二参数) |
每次渲染后都执行 |
几乎不应使用,会导致性能问题 |
清理函数
副作用函数可以返回一个清理函数,在组件卸载或依赖变化重新执行 effect 之前调用。这是防止内存泄漏的关键——定时器要清除,事件监听要移除,WebSocket 连接要断开。
Timer.jsx — 清理函数
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setSeconds((prev) => prev + 1);
}, 1000);
// 清理函数:组件卸载时清除定时器
return () => clearInterval(timer);
}, []); // 空数组 = 仅挂载时执行
return <p>已运行 {seconds} 秒</p>;
}
|
如果没有清理函数,组件卸载后定时器仍在运行,不断调用 setSeconds 更新一个已卸载组件的状态——React 19 会在开发模式下警告 "Can't perform a React state update on an unmounted component"。加上 return () => clearInterval(timer) 后,组件卸载时自动清除定时器。
⚠️ 常见错误:依赖数组遗漏
useEffect(() => { fetch(`/api/${userId}`) }, []) — 依赖数组为空但 effect 内使用了 userId,userId 变化时不会重新获取数据
✓ 正确:useEffect(() => { fetch(`/api/${userId}`) }, [userId]) — 诚实列出所有依赖
💡 小贴士:ESLint exhaustive-deps 规则
React 官方提供了 eslint-plugin-react-hooks 插件,其中 exhaustive-deps 规则会自动检查依赖数组是否遗漏了 effect 中使用的变量。强烈建议在项目中启用此规则,它能帮你发现 90% 以上的 useEffect 依赖问题。Vite 创建的 React 项目默认已包含此 ESLint 配置。
05 useRef:跨渲染的持久引用
useState 和 useEffect 已能实现大部分交互。但有一类需求它们处理不了:需要跨渲染保存一个值,但这个值变化时不应触发重渲染。比如:访问 DOM 元素、保存定时器 ID、记录上一次的某个值。这就是 useRef 的用武之地。
useRef 返回一个对象 { current: initialValue },这个对象在组件的整个生命周期中保持同一个引用。修改 ref.current 不会触发重渲染——这正是它和 useState 的本质区别。
FocusInput.jsx — useRef 访问 DOM
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import { useRef } from 'react';
function FocusInput() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current.focus(); // 直接操作 DOM
};
return (
<div>
<input ref={inputRef} placeholder="点击下方按钮聚焦" />
<button onClick={handleFocus}>聚焦输入框</button>
</div>
);
|
这里 ref={inputRef} 将 DOM 元素引用存入 inputRef.current。React 19 中 ref 已成为普通 prop,可以直接传给子组件而无需 forwardRef(旧版 React 需要用 forwardRef 包裹子组件才能接收 ref)。
| 对比维度 |
useState |
useRef |
| 值变化触发重渲染 |
是 |
否 |
| 更新方式 |
调用 set 函数 |
直接修改 .current |
| 适用场景 |
UI 需要响应的数据 |
DOM 引用、定时器 ID 等 |
06 自定义 Hook:逻辑复用
当你发现多个组件中有相同的状态和副作用逻辑时,可以把这些逻辑提取成一个自定义 Hook——一个以 use 开头的函数。自定义 Hook 不是 React 的特殊 API,它只是一个普通函数,内部可以调用其他 Hook。
比如多个组件都需要"窗口宽度"信息,可以封装一个 useWindowSize Hook:
useWindowSize.js — 自定义 Hook
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import { useState, useEffect } from 'react';
// 自定义 Hook:返回当前窗口宽高
export function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const handleResize = () => setSize({
width: window.innerWidth,
height: window.innerHeight
});
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size; // 返回 { width, height }
}
|
使用这个 Hook 时,像调用普通函数一样:
ResponsiveLayout.jsx — 使用自定义 Hook
import { useWindowSize } from './useWindowSize';
function ResponsiveLayout() {
const { width } = useWindowSize();
return (
<p>当前屏幕宽度:{width}px,{width < 768 ? '移动端' : '桌面端'}</p>
);
}
💡 小贴士:自定义 Hook 的命名与规则
自定义 Hook 必须以 use 开头(如 useWindowSize、useFetch、useLocalStorage)。这个命名约定不只是风格——ESLint 的 rules-of-hooks 规则靠它识别哪些函数内部可以调用 Hook。两条铁律:只在顶层调用(不在循环/条件中),只在函数组件或自定义 Hook 中调用。
📖 知识回顾
useState 状态管理
函数式更新
批量合并
useEffect 副作用
依赖数组
清理函数
useRef 持久引用
自定义 Hook
✏️ 动手练习
🟢 基础验证
用 useState 实现一个倒计时组件:初始值为 10,点击"开始"按钮后每秒减 1,减到 0 时停止。要求:用 useEffect 设置定时器,用清理函数在组件卸载时清除定时器。
参考解法:useState 管理 count 和 isRunning 两个状态,useEffect 依赖 [isRunning],isRunning 为 true 时 setInterval 每秒 setCount(prev => prev - 1),return 清理函数 clearInterval。count 为 0 时 setRunning(false)。
🟡 组合应用
编写一个自定义 Hook useFetch(url),接收一个 URL 参数,返回 { data, loading, error } 三个状态。要求:在 useEffect 中发起 fetch 请求,正确处理加载中、成功和错误三种状态,url 变化时重新请求。
提示:三个 useState 分别管理 data/loading/error,useEffect 依赖 [url],fetch 链式 .then/.catch/.finally 更新状态。注意 useEffect 中可以用 async/await 风格:定义一个 async 函数再调用。
🔴 开放挑战
实现一个带搜索过滤和防抖功能的用户列表组件。要求:用 useState 管理搜索关键词和用户列表,用 useRef 保存防抖定时器 ID(不触发重渲染),用 useEffect 在关键词变化时触发防抖搜索。搜索函数用 setTimeout 延迟 300ms 执行,关键词连续变化时取消上一次的定时器。可从 JSONPlaceholder API(搜索 "JSONPlaceholder users")获取测试数据。
参考方向:useEffect 依赖 [searchTerm],内部用 ref.current 保存 timer ID,每次 effect 执行前先 clearTimeout(ref.current),然后 setTimeout 300ms 后执行实际搜索。清理函数中也要 clearTimeout。
下篇预告
19 TypeScript 基础:类型系统与类型安全
将学习 TypeScript 的类型系统,掌握接口、泛型、联合类型和类型守卫,让 React 组件拥有编译时类型检查——告别运行时 undefined 报错