作为一名用了几年 React 的前端开发者,我曾经很长一段时间都处于"会用 Hooks"的状态------useState 管状态、useEffect 处理副作用、useMemo 做性能优化,一套流程下来感觉没什么问题。
但如果追问自己,"为什么 Hooks 不能写在条件语句里?",我才意识到自己对 Hooks 的理解停留在 API 层面,对它背后的设计逻辑知之甚少。
这篇文章是我重新梳理 React Hooks 的学习笔记。目标不是"教你用",而是试图回答:Hooks 为什么是这个样子的,它背后在解决什么问题,又体现了什么设计思想。
一、Hooks 诞生的背景:Class 组件的三个痛点
在 React 16.8 引入 Hooks 之前,Class 组件是编写有状态组件的唯一方式。Class 组件本身没什么大问题,但随着应用规模变大,三个痛点变得越来越明显。
痛点一:逻辑复用困难
假设你有一段"监听窗口尺寸"的逻辑,需要在多个组件里复用。Class 时代的方案通常是两种:
高阶组件(HOC) :
javascript
// 环境:React(Class 组件时代)
// 场景:通过 HOC 复用窗口尺寸逻辑
function withWindowSize(WrappedComponent) {
return class extends React.Component {
state = { width: window.innerWidth, height: window.innerHeight };
handleResize = () => {
this.setState({ width: window.innerWidth, height: window.innerHeight });
};
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
render() {
return <WrappedComponent windowSize={this.state} {...this.props} />;
}
};
}
或者 Render Props:
javascript
// 场景:通过 Render Props 复用逻辑
class WindowSize extends React.Component {
state = { width: window.innerWidth, height: window.innerHeight };
// ... 同上的监听逻辑
render() {
return this.props.children(this.state);
}
}
// 使用时:
<WindowSize>
{({ width, height }) => <div>{width} x {height}</div>}
</WindowSize>
两种方案各有问题:HOC 会产生"包装地狱",多个 HOC 嵌套后 props 来源变得不清晰;Render Props 则让 JSX 结构变得冗余。而用 Hooks,这个逻辑只需要:
javascript
// 环境:React 16.8+
// 场景:自定义 Hook 复用窗口尺寸逻辑
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;
}
// 任意组件中使用,逻辑来源清晰
function MyComponent() {
const { width, height } = useWindowSize();
return <div>{width} x {height}</div>;
}
代码量差不多,但清晰度和可组合性完全不同。
痛点二:生命周期割裂相关逻辑
Class 组件的生命周期是按"时机 "组织的,而不是按"逻辑 "组织的。这导致同一段业务逻辑往往被拆散到不同的 生命周期方法 里:
javascript
// 一个 Class 组件中,订阅和清理逻辑被迫分离
componentDidMount() {
// 初始化:订阅数据源 A
DataSourceA.subscribe(this.handleChange);
// 初始化:订阅数据源 B(完全不相关的逻辑混在一起)
DataSourceB.subscribe(this.handleOtherChange);
}
componentWillUnmount() {
// 清理逻辑分散在这里,需要和上面对应着看
DataSourceA.unsubscribe(this.handleChange);
DataSourceB.unsubscribe(this.handleOtherChange);
}
而 useEffect 把"建立"和"清理"放在同一个地方,相关逻辑内聚在一起:
javascript
// 每段逻辑自成一体,不需要跨方法对应
useEffect(() => {
DataSourceA.subscribe(handleChange);
return () => DataSourceA.unsubscribe(handleChange); // 清理在同一处
}, []);
useEffect(() => {
DataSourceB.subscribe(handleOtherChange);
return () => DataSourceB.unsubscribe(handleOtherChange);
}, []);
痛点三:this 的心智负担
Class 组件中 this 的指向问题是很多 React 初学者的噩梦。事件处理函数需要手动绑定、或者使用箭头函数属性,这是语言层面的摩擦,和 UI 逻辑本身无关。
Hooks 把这些摩擦从根源上消除了------函数组件里没有 this。
二、Hooks 能工作的秘密:链表与调用顺序
理解了"为什么需要 Hooks"之后,一个自然的问题是:函数组件每次渲染都会重新执行,React 怎么知道哪个 useState 对应哪个状态?
React 用链表记住 Hooks 的顺序
每个 React 组件对应一个 Fiber 节点。Fiber 节点上有一个 memoizedState 字段,它指向一条链表,每个节点存储着一个 Hook 的状态。
css
Fiber 节点
└── memoizedState
└── Hook[0]: { state: count, next → }
└── Hook[1]: { state: name, next → }
└── Hook[2]: { effect: ..., next → null }
关键点在于:React 靠调用顺序来对应每个 Hook。 第一次调用 useState 对应链表第一个节点,第二次对应第二个......以此类推。
这就是为什么 Hooks 必须在顶层调用,不能写在条件语句、循环或嵌套函数里:
javascript
// ❌ 错误:条件语句打乱了 Hook 的调用顺序
function BadComponent({ showName }) {
const [count, setCount] = useState(0); // Hook[0]
if (showName) {
const [name, setName] = useState(''); // 有时是 Hook[1],有时不存在
}
const [age, setAge] = useState(0); // 有时是 Hook[1],有时是 Hook[2]
// React 拿错链表节点,状态全乱了
}
结论很简单:
Hooks 的调用顺序就是它的"地址",顺序变了,React 就找错门了。
三、五个核心 Hook 的深度拆解
3.1 useState:状态更新的真相
useState 看起来最简单,但它有几个细节值得深究。
传值 vs 传函数
setState 接受两种形式:直接传新值,或传一个接收旧值的函数。
javascript
// 环境:React
// 场景:理解 setState 传函数的必要性
function Counter() {
const [count, setCount] = useState(0);
// ❌ 在某些场景下有问题
const handleClickBad = () => {
setCount(count + 1); // 闭包捕获的 count 可能是旧值
setCount(count + 1); // 两次调用,实际只加了 1
};
// ✅ 传函数,基于最新状态计算
const handleClickGood = () => {
setCount(prev => prev + 1); // 基于最新值 +1
setCount(prev => prev + 1); // 再基于最新值 +1,共加了 2
};
return <button onClick={handleClickGood}>{count}</button>;
}
原因在于:React 在处理事件时会批量更新(batching),多次 setState 不会立即触发重渲染。如果传的是值,多次调用都在引用同一个闭包里的旧 count;如果传的是函数,React 会把函数排队,依次传入最新状态执行。
React 18 的批量更新
在 React 18 之前,批量更新只在 React 合成事件中生效;在 setTimeout、Promise 回调里的 setState 是同步触发渲染的。React 18 引入了"自动批量更新"(Automatic Batching),这些场景也会被批量处理。
javascript
// 环境:React 18
// 场景:展示 Automatic Batching 的效果
function Example() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React 18:只触发一次重渲染
// React 17 及之前:触发两次重渲染
}, 100);
};
return <button onClick={handleClick}>Click</button>;
}
初始化惰性求值
如果初始状态需要复杂计算,可以传入函数,React 只在首次渲染时调用它:
javascript
// ✅ 惰性初始化:computeExpensiveValue 只执行一次
const [state, setState] = useState(() => computeExpensiveValue(props));
// ❌ 每次渲染都会执行
const [state, setState] = useState(computeExpensiveValue(props));
3.2 useEffect:副作用不等于生命周期
useEffect 是 Hooks 里最容易被误用的一个。很多人把它当作 componentDidMount + componentDidUpdate + componentWillUnmount 的合体,这个理解不完全准确。
依赖数组比较的是什么?
React 用 Object.is 对依赖数组里的每一项做浅比较。这意味着:
javascript
// 环境:React
// 场景:理解依赖比较的陷阱
function Component({ options }) {
useEffect(() => {
// 问题:每次渲染 options 都是新对象引用,即使内容没变
fetchData(options);
}, [options]); // 父组件每次渲染都传入 { page: 1 },但引用不同,Effect 会不停触发
}
// 父组件
function Parent() {
return <Component options={{ page: 1 }} />; // 每次渲染都是新对象
}
对于对象和数组类型的依赖,需要特别注意:要么用 useMemo 稳定引用,要么把对象解构成基本类型后放入依赖。
和 componentDidMount 的微妙差异
useEffect 的执行时机是渲染提交到 DOM 之后、浏览器完成绘制之后 ,是异步执行的。而 componentDidMount 在 DOM 更新后同步执行。如果需要同步读取 DOM 布局(比如测量元素尺寸后立即更新 UI),应该用 useLayoutEffect:
javascript
// useLayoutEffect:DOM 更新后同步执行,防止闪烁
useLayoutEffect(() => {
const height = ref.current.getBoundingClientRect().height;
setHeight(height); // 这次状态更新会和 DOM 操作合并,用户不会看到中间状态
}, []);
异步请求的内存泄漏问题
这是实际开发中很常见的问题:
javascript
// 环境:React
// 场景:组件卸载后异步请求仍在执行,尝试更新已卸载的组件
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // 用一个标志位标记是否已取消
fetchUser(userId).then(data => {
if (!cancelled) {
setUser(data); // 只有未取消时才更新状态
}
});
return () => {
cancelled = true; // 清理时标记为已取消
};
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
更现代的方案是使用 AbortController:
javascript
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort(); // 组件卸载时中止请求
}, [userId]);
3.3 useMemo & useCallback:缓存的代价
这两个 Hook 经常被一起提,但它们的本质略有不同。
本质区别
javascript
// useMemo:缓存计算结果(一个值)
const sortedList = useMemo(() => {
return items.sort((a, b) => a.value - b.value);
}, [items]);
// useCallback:缓存函数引用(函数也是一种值,但强调的是引用稳定性)
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
// useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
useCallback 的核心价值在于稳定函数引用,主要用于两个场景:
- 函数作为 props 传给用
React.memo包裹的子组件 - 函数被放入
useEffect的依赖数组
缓存本身有成本
这是很多人忽视的一点。useMemo 和 useCallback 本身并不是免费的------React 需要:
- 存储依赖数组的前一个值
- 每次渲染时对比新旧依赖
- 在内存中保留缓存的值
对于轻量的计算,这个开销可能比重新计算更大。一个粗略的判断原则:
javascript
// ❌ 没必要:计算本身很轻量
const doubled = useMemo(() => count * 2, [count]);
// ✅ 有价值:耗时的计算,或需要稳定引用传给子组件
const processedData = useMemo(() => {
return largeDataSet.filter(...).map(...).reduce(...);
}, [largeDataSet]);
React 官方的建议是:先写正确的代码,再根据实际性能问题决定是否优化 ,而不是预防性地给所有函数和值套上 useMemo / useCallback。
缓存失效的时机
依赖数组中任何一项(通过 Object.is 比较)发生变化时,缓存就会失效。另外,React 在某些情况下(如开发模式的 Strict Mode、内存压力)可能主动丢弃缓存,因此 useMemo 的缓存只能用于性能优化,不能作为语义保证。
3.4 useRef:被低估的 Hook
useRef 在很多教程里被简单介绍为"获取 DOM 节点的方式",但它的能力远不止于此。
Ref 的本质:一个稳定的可变容器
javascript
// useRef 返回一个在组件整个生命周期内保持同一引用的对象
const ref = useRef(initialValue);
// ref 始终是 { current: ... } 这个对象
// 修改 ref.current 不会触发重渲染
这个特性让 useRef 成了一个"逃生舱"------当你需要在不触发渲染的情况下存储某个值时,就用它。
保存计时器 ID
javascript
// 环境:React
// 场景:在多次渲染间保持 timer 引用,用于清除
function Stopwatch() {
const [time, setTime] = useState(0);
const timerRef = useRef(null); // 不需要触发渲染,不用 useState
const start = () => {
timerRef.current = setInterval(() => {
setTime(t => t + 1);
}, 1000);
};
const stop = () => {
clearInterval(timerRef.current);
};
return (
<div>
<span>{time}s</span>
<button onClick={start}>开始</button>
<button onClick={stop}>停止</button>
</div>
);
}
解决闭包陷阱:保存最新值
这是 useRef 最重要的一个高级用法,和下一节的"闭包陷阱"紧密相关:
javascript
// 环境:React
// 场景:在定时器回调中始终读到最新的 state 值
function LiveCounter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 每次 count 更新时,同步更新 ref
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
// countRef.current 始终是最新值,不受闭包影响
console.log('current count:', countRef.current);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组为空,effect 只执行一次
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
四、最容易掉进去的坑:闭包陷阱(Stale Closure)
闭包陷阱可能是使用 Hooks 过程中最隐蔽的 Bug 来源。
什么是闭包陷阱?
先看一个最小复现:
javascript
// 环境:React
// 场景:经典的闭包陷阱示例
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 这里的 count 永远是 0
// 因为 effect 只在挂载时执行一次,count 被"封印"在了那一刻的闭包里
console.log('count is:', count); // 始终打印 0
setCount(count + 1); // 所以始终是 0 + 1 = 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组:effect 不会随 count 更新而重新执行
return <div>{count}</div>;
}
问题的根源 :useEffect 的回调函数在执行时,捕获的是创建那一刻 的变量快照。空依赖数组意味着 effect 只在挂载时创建一次,之后 count 每次更新,effect 里的闭包引用的还是最初那个 0。
为什么 Hooks 特别容易产生这个问题?
Class 组件不容易出现这个问题,因为 this.state.count 是通过 this 动态查找的,总是指向最新值。而函数组件里,每次渲染产生一个新的函数作用域,变量的值是那次渲染的快照------这是"渲染即快照"的设计哲学,通常是优点,但在异步场景下会成为陷阱。
三种解法
方案一:把依赖项补全(最常见)
javascript
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 告诉 React:count 变了就重新创建 effect
// 副作用:每次 count 变化都会重新创建 setInterval,可接受
方案二:用函数式更新避免读取旧值
javascript
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 不需要读 count,只需要在旧值上 +1
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖数组可以为空,因为我们不依赖外部的 count 值
这是这个场景下最优雅的解法:利用函数式更新的特性,完全绕开了闭包捕获的问题。
方案三:用 useRef 保存最新值
javascript
// 适合需要在回调里读取多个最新状态,或状态逻辑更复杂的场景
function useLatest(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
});
return ref;
}
function Counter() {
const [count, setCount] = useState(0);
const countRef = useLatest(count); // 始终指向最新值的 ref
useEffect(() => {
const timer = setInterval(() => {
setCount(countRef.current + 1); // 安全地读取最新值
}, 1000);
return () => clearInterval(timer);
}, []);
}
如何从根源上避免?
一个有效的心智模型是:写 useEffect 时,先不填依赖数组,把所有回调里用到的外部变量都写进去。如果依赖数组里的东西太多或变化太频繁,再考虑用函数式更新或 useRef 来精简。
React 官方的 ESLint 插件 eslint-plugin-react-hooks 的 exhaustive-deps 规则可以自动检测遗漏的依赖,强烈建议开启。
五、设计思想的延伸:Hooks 与函数式编程
当我试着从更高的视角看 Hooks,发现它和函数式编程领域的一些概念有着深刻的共鸣。
"渲染即快照":拥抱不可变性
函数式编程的核心思想之一是不可变数据 :不修改现有的值,而是创建新的值。React 的每次渲染,本质上是用当前的 props 和 state 生成一个 UI 的快照。useState 的更新不会"修改"旧状态,而是产生一个新状态,触发新一轮渲染。
这种设计让每一次渲染都是一个纯函数式的映射:UI = f(state)。
副作用管理:Effect 作为"声明"
函数式编程里,纯函数不产生副作用。但真实应用里不可能没有副作用(网络请求、DOM 操作、定时器......)。
useEffect 的设计哲学是:把副作用从渲染逻辑里分离出来,以声明的方式描述"这个 effect 依赖哪些状态,应该在什么时候运行" ,而不是命令式地说"在第 3 步运行这段代码"。
这和函数式编程里用 Monad 把副作用"隔离"到类型系统边界的思想,有异曲同工之妙------当然,React 的实现要工程化得多。
代数效应:一个更底层的视角
这是一个稍微抽象的概念,但理解它能让你对 Hooks 的设计有更深的感受。
代数效应(Algebraic Effects) 是函数式编程里的一种错误处理和副作用管理机制(目前在一些学术语言如 Koka、Eff 中实现,主流语言尚未支持)。它的核心想法是:
函数可以"发出"一个效应(effect),调用方决定如何处理这个效应,函数本身不需要知道处理细节。
用一个伪代码来理解:
javascript
// 伪代码,非真实语法
// 场景:理解代数效应的思想
function getName() {
// 发出一个"读取用户名"的效应,不关心怎么读
const name = perform ReadUserName;
return `Hello, ${name}`;
}
// 调用方决定如何处理这个效应
handle getName() {
on ReadUserName -> resume('Alice'); // 用 'Alice' 处理,继续执行
}
Hooks 的 useState 可以被理解为一种类似的机制:函数组件"调用" useState 相当于发出一个"我需要管理这个状态"的效应,React 运行时(调用方)处理这个效应,并提供读写状态的能力------函数组件本身不需要知道状态存在哪里、怎么触发重渲染。
Dan Abramov(React 核心成员)在博客中明确提到,Hooks 的设计受到了代数效应思想的启发。当然,这是一种"精神上的借鉴",而非严格的学术实现。
自定义 Hook:组合优于继承
面向对象编程通过继承来复用逻辑,函数式编程通过函数组合。自定义 Hook 就是 React 版本的函数组合:
javascript
// 环境:React
// 场景:通过组合自定义 Hook 构建更复杂的能力
// 原子级 Hook
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key);
return saved !== null ? JSON.parse(saved) : initialValue;
});
const setStoredValue = useCallback((newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}, [key]);
return [value, setStoredValue];
}
// 组合出更具体的能力
function useTheme() {
return useLocalStorage('theme', 'light'); // 组合,而非继承
}
function useLanguage() {
return useLocalStorage('language', 'zh-CN');
}
每个 Hook 是一个纯粹的函数,可以独立测试、自由组合。这是函数式编程中"组合优于继承"原则的直接体现。
一个判断原则:先问结构,再问优化
最后分享一个我觉得很有价值的思考角度。当你发现需要用 useMemo 或 useCallback 来解决性能问题时,先停下来问一个问题:
"这个问题能不能通过调整组件结构来解决?"
javascript
// ❌ 用 useMemo 解决昂贵计算的"常见做法"
function Parent({ data }) {
const processed = useMemo(() => expensiveProcess(data), [data]);
return (
<div>
<ExpensiveChild data={processed} />
<SimpleChild /> {/* 这个组件因为 Parent 重渲染而跟着渲染 */}
</div>
);
}
// ✅ 先考虑:能不能把昂贵的部分单独提取成子组件?
function ProcessedChild({ data }) {
const processed = useMemo(() => expensiveProcess(data), [data]);
return <ExpensiveChild data={processed} />;
}
function Parent({ data }) {
return (
<div>
<ProcessedChild data={data} />
<SimpleChild /> {/* 现在 SimpleChild 不会受影响 */}
</div>
);
}
组件结构的调整往往比性能 API 的使用更根本,也更容易维护。
小结
回头看这些问题,React Hooks 表面上是一套 API,但深入进去,会发现它在工程层面做了很多取舍:
- 用调用顺序换来了简洁的 API,代价是"不能在条件语句里调用 Hooks"的规则
- 用渲染即快照的模型换来了可预测性,代价是需要小心处理闭包陷阱
- 用声明式副作用换来了逻辑内聚,代价是依赖数组需要仔细维护
这种取舍本身就是软件设计的本质。
还有一些问题我还在探索中:
useTransition和useDeferredValue是如何在 Hooks 模型下实现并发渲染的?- React Server Components 的出现对 Hooks 的使用边界有什么影响?
- 代数效应如果真的进入主流 JavaScript,会怎样改变前端状态管理的方式?
如果你对某个部分有不同的理解,或者有什么补充,欢迎交流。
参考资料
- React 官方文档 - Hooks 介绍 - 官方 API 参考,是最准确的一手资料
- Dan Abramov - Making Sense of React Hooks - Hooks 设计动机的第一手解释
- Dan Abramov - Algebraic Effects for the Rest of Us - 代数效应的通俗解释,推荐阅读
- Dan Abramov - A Complete Guide to useEffect - 目前见过对 useEffect 最深入的解析
- React Beta Docs - You Might Not Need an Effect - 官方对 useEffect 误用场景的梳理