React Hooks 难,不是因为 API 多,而是因为你以为是「调用函数」,实际是「注册回调」。这篇讲清这个心智模型,大多数 Hooks 的困惑会自然消解。
一、函数组件不是「调用」,是「描述」
写普通函数时,你调用它,它执行一次返回结果。但函数组件不是这样运行的:
function Counter() {
const [n, setN] = useState(0) // 不是「设初始值」,是「注册一个状态」
console.log('render', n) // 每次渲染都会打印
return <button onClick={() => setN(n + 1)}>{n}</button>
}关键点:这个函数会被反复调用(每次状态变化都调用一次)。所以:
- 函数体里所有代码在每次渲染时都会重跑(不只是你关心的那几行)
useState(0)里的只在首次渲染时用一次,后续渲染被忽略- 所以
useState(expensive())每次都会执行expensive()(结果被丢弃)——这是性能坑
二、useState:值 + 设置器,不是「属性」
很多人初期的心智模型是「n 是个会变的变量」。其实每次渲染,n 都是一个固定的常量,来自那次渲染的快照。
// 经典陷阱:快速点三次,以为加了 3,实际只加 1
function Bad() {
const [n, setN] = useState(0)
return (
<button onClick={() => {
setN(n + 1) // 这里的 n 是「本次渲染的快照」
setN(n + 1) // 还是同一个 n,所以第二次覆盖第一次
setN(n + 1)
}}>{n}</button>
)
}
// 正确:用函数式更新,拿到「最新值」
function Good() {
const [n, setN] = useState(0)
return (
<button onClick={() => {
setN(v => v + 1)
setN(v => v + 1)
setN(v => v + 1) // 会正确累加 3
}}>{n}</button>
)
}为什么函数式更新能正确累加:React 把三次
setN排进队列,依次用「上一个的最新值」调用回调。v => v + 1每次拿到的都是队列里前一步的结果,而n + 1用的是闭包里的旧快照。
三、useEffect:不是「生命周期」,是「依赖变化后执行」
最常见的错误心智模型是把 useEffect 当成 componentDidMount。它的真实语义是:
useEffect(() => {
// 这段在「依赖数组里的值发生变化」时执行
// 首次渲染也算一次「从无到有」的变化
}, [dep1, dep2])
// 三种依赖形态
useEffect(() => {...}, []) // 只在首次执行(最常见用法)
useEffect(() => {...}, [a]) // a 变了就执行
useEffect(() => {...}) // 不写依赖 = 每次渲染都执行(几乎总是错的)为什么「不写依赖 = 每次执行」是错的:因为每次渲染都是新的一次,Effect 无条件重跑,里面如果 setState 就会触发下一次渲染,形成死循环。
清理函数的真实作用
// 清理函数在「下次执行前」和「组件卸载时」都会被调用
useEffect(() => {
const id = setInterval(() => tick(), 1000)
return () => clearInterval(id) // 每次重跑前先清上一次的定时器
}, [delay])
// 若不清,delay 变了会累积多个定时器 —— 典型的泄漏四、闭包陷阱:为什么拿到的是旧值
这是 Hooks 最反直觉的地方:Effect 或回调捕获的是定义那次渲染的变量,即使之后变量变了,闭包里的还是旧值。
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // 永远是 0!因为捕获了首次渲染的 count
}, 1000)
return () => clearInterval(id)
}, []) // 空依赖 = 只跑一次,闭包永远是最初的
}
// 解法 1:把 count 放进依赖(但会重建定时器)
useEffect(() => {
const id = setInterval(() => console.log(count), 1000)
return () => clearInterval(id)
}, [count])
// 解法 2:用函数式更新 / ref 拿最新值
const countRef = useRef(count)
countRef.current = count // 每次渲染同步
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000)
return () => clearInterval(id)
}, []) // 不重建定时器,但读到最新值五、useMemo 与 useCallback:只解决「引用稳定」
这两个常被当成「性能优化万能药」。它们的真实作用只有一个:让依赖比较时的引用保持不变。
// 没有 useCallback:每次渲染都是新函数,子组件的 memo 失效
function Parent() {
const handler = () => doSomething() // 每次渲染新引用
return <Child onClick={handler} /> // Child 每次都会重渲染
}
// 有 useCallback:引用稳定,Child 的 memo 才生效
const handler = useCallback(() => doSomething(), [])
return <Child onClick={handler} />| 用法 | 解决什么 | 不解决什么 |
|---|---|---|
| useMemo | 昂贵计算重复执行 | 函数引用稳定性(那是 useCallback) |
| useCallback | 函数引用每次变化导致子组件重渲染 | 计算开销(那是 useMemo) |
| 两个都不用 | 大多数场景没问题 | 别为了「优化」而加 |
React 官方建议:先用最简单的写法,测出性能问题再加 memo。滥用 useMemo/useCallback 会让代码难读,收益常常为零——因为 memo 本身也有比较开销。
六、useRef:不触发渲染的可变容器
// 三种常见用途
// 1. DOM 引用
const inputRef = useRef(null)
<input ref={inputRef} />
// inputRef.current.focus()
// 2. 保存「不需要触发渲染」的值
const timerRef = useRef(null)
timerRef.current = setTimeout(...)
// 3. 跨渲染共享最新值(解决闭包陷阱)
const latestRef = useRef(value)
latestRef.current = valueref.current变了不会触发重渲染——这是它和 state 的核心区别- 不要在渲染期间读写 ref 作为状态(会破坏可预测性)
useRef初始值只在首次生效,后续传入被忽略
七、一张表:什么时候用哪个
| 需求 | 用哪个 | 注意 |
|---|---|---|
| 需要一个会触发重渲染的值 | useState | 依赖旧值时用函数式更新 |
| 需要副作用(订阅/请求/定时器) | useEffect | 清理函数必须写;依赖要完整 |
| 需要稳定的函数引用 | useCallback | 只在传给 memo 组件时才有意义 |
| 需要缓存昂贵计算 | useMemo | 先测性能再加 |
| 需要不触发渲染的可变值 | useRef | 渲染期间别读写 |
| 需要跨组件共享状态 | Context / 状态库 | Context 变化会导致所有消费者重渲染 |
八、总结:三条心智转变
- 函数组件是「反复执行的描述」,不是「执行一次的函数」——所以函数体的每行都会重跑
- 每次渲染的变量都是快照——这是闭包陷阱的根源,用函数式更新或 ref 解决
- Effect 是「依赖变了就执行」,不是生命周期钩子——依赖数组才是它的核心参数
- useMemo/useCallback 只解决引用稳定,不解决性能问题本身,滥用有害