2024年12月06日10 分钟
React Hooks 深度解析:useState / useEffect / useLayoutEffect
深入理解 React 核心 Hooks 的调用时机、内部机制和注意事项,搞清楚 React 拿到你的 Hook 后到底做了什么
Hooks 的本质
Hooks 不是魔法。它们是挂载在 Fiber 节点上的链表。每个组件实例对应一个 Fiber 节点,Fiber 上有一个 memoizedState 指针,指向第一个 Hook,每个 Hook 通过 next 指针串联。
Fiber 节点 (MyComponent)
│
memoizedState → Hook1(useState) → Hook2(useEffect) → Hook3(useState) → null
│ │ │
state: 0 effect: fn state: "hello"
queue: [...] deps: [a, b] queue: [...]
这就是为什么 Hooks 不能放在条件语句中——React 靠调用顺序来匹配 Hook 和存储的状态。
useState:状态管理
调用时机
useState 在组件每次渲染时都会被调用,但行为不同:
首次渲染(mount):
useState(0) → 创建 Hook 节点,state = 0,返回 [0, setter]
后续渲染(update):
useState(0) → 找到已有 Hook 节点,返回 [当前state, 同一个setter]
初始值 0 被完全忽略
React 内部做了什么
const [count, setCount] = useState(0)
Mount 阶段(首次渲染):
// React 内部(简化版)
function mountState(initialState) {
const hook = {
memoizedState: initialState, // 存储当前值:0
queue: [], // 更新队列
next: null, // 指向下一个 Hook
}
// 挂到 Fiber 的 Hook 链表上
fiber.memoizedState = hook
const setState = (newValue) => {
hook.queue.push(newValue) // 把更新推入队列
scheduleRerender(fiber) // 触发组件重新渲染
}
return [hook.memoizedState, setState]
}
Update 阶段(后续渲染):
function updateState() {
const hook = getCurrentHook() // 按顺序取出对应的 Hook
// 依次处理队列中的所有更新
for (const update of hook.queue) {
if (typeof update === 'function') {
hook.memoizedState = update(hook.memoizedState) // 函数式更新
} else {
hook.memoizedState = update // 直接赋值
}
}
hook.queue = [] // 清空队列
return [hook.memoizedState, setState]
}
批量更新(Batching)
function handleClick() {
setCount(1) // 不会立即渲染
setName('Alice') // 不会立即渲染
setAge(25) // 不会立即渲染
// 事件处理完毕后,React 统一触发一次渲染
}
// React 18 自动批量更新,即使在异步操作中:
async function handleClick() {
const data = await fetch('/api')
setCount(1) // React 18: 仍然会批量
setName('Alice') // 只触发一次渲染 ✅
}
函数式更新 vs 直接赋值
// ❌ 闭包陷阱
function Counter() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1) // count 是闭包中的旧值
setCount(count + 1) // 还是同一个旧值!
setCount(count + 1) // 结果:只 +1
}
}
// ✅ 函数式更新:总是基于最新值
function Counter() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(prev => prev + 1) // prev = 0, 返回 1
setCount(prev => prev + 1) // prev = 1, 返回 2
setCount(prev => prev + 1) // prev = 2, 返回 3
}
}
惰性初始化
// ❌ 每次渲染都执行昂贵计算
const [data, setData] = useState(expensiveCompute())
// ✅ 传入函数,只在首次渲染时执行
const [data, setData] = useState(() => expensiveCompute())
useEffect:副作用处理
调用时机
渲染流程:
setState 触发
│
┌────▼────┐
│ render │ 计算 Virtual DOM(同步)
└────┬────┘
│
┌────▼────┐
│ commit │ 更新真实 DOM(同步)
└────┬────┘
│
浏览器绘制(paint) ← 用户此时已经看到新画面
│
┌────▼─────────┐
│ useEffect │ ← 在绘制之后、异步执行
└──────────────┘
关键:useEffect 是异步的,不会阻塞浏览器绘制。
React 内部做了什么
useEffect(() => {
console.log('effect 执行')
return () => console.log('cleanup 执行')
}, [count])
Mount 阶段:
function mountEffect(create, deps) {
const hook = {
memoizedState: {
create: create, // effect 函数
destroy: undefined, // cleanup 函数(还没执行过)
deps: deps, // 依赖数组 [count]
},
next: null,
}
// 标记:commit 之后需要执行这个 effect
pushEffect(hook)
}
// commit 完成,浏览器绘制后 → 执行 create()
// create() 的返回值存为 destroy(cleanup 函数)
Update 阶段:
function updateEffect(create, deps) {
const hook = getCurrentHook()
const prevDeps = hook.memoizedState.deps
// 浅比较依赖数组
if (areHookInputsEqual(prevDeps, deps)) {
// 依赖没变 → 跳过这个 effect
return
}
// 依赖变了 → 标记需要执行
// 先执行上一次的 cleanup,再执行新的 create
hook.memoizedState = { create, destroy: hook.memoizedState.destroy, deps }
pushEffect(hook)
}
执行顺序:
第一次渲染:
render → commit → paint → effect1 执行
count 从 0 变成 1:
render → commit → paint → effect1 的 cleanup 执行 → effect2 执行
组件卸载:
最后一次 effect 的 cleanup 执行
依赖数组的三种形态
// 1️⃣ 无依赖数组:每次渲染后都执行
useEffect(() => {
console.log('每次渲染后都执行')
})
// 2️⃣ 空依赖数组:只在挂载和卸载时执行
useEffect(() => {
console.log('挂载')
return () => console.log('卸载')
}, [])
// 3️⃣ 有依赖:依赖变化时执行
useEffect(() => {
console.log(`count 变为 ${count}`)
return () => console.log(`清理旧的 count: ${count}`)
}, [count])
常见的依赖陷阱
// ❌ 遗漏依赖 → 闭包读到旧值
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1) // count 永远是初始值 0
}, 1000)
return () => clearInterval(timer)
}, []) // count 不在依赖中
// ✅ 方案1:添加依赖(但会频繁重建定时器)
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count])
// ✅ 方案2:函数式更新(推荐,无需依赖 count)
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [])
useLayoutEffect:同步副作用
与 useEffect 的区别
渲染流程:
render → commit → 【useLayoutEffect 执行】→ 浏览器绘制 → 【useEffect 执行】
↑ 同步,阻塞绘制 ↑ 异步,不阻塞
对比:
| 特性 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制之后 | 浏览器绘制之前 |
| 是否阻塞渲染 | 否 | 是 |
| 适用场景 | 数据请求、订阅、日志 | DOM 测量、防止闪烁 |
| 性能影响 | 无 | 如果执行耗时会导致卡顿 |
什么时候用 useLayoutEffect?
当你需要在用户看到画面之前修改 DOM 时:
// ❌ 使用 useEffect → 用户会先看到位置 0,然后跳到正确位置(闪烁)
function Tooltip({ targetRect }) {
const [position, setPosition] = useState({ top: 0, left: 0 })
useEffect(() => {
const { top, left } = calculatePosition(targetRect)
setPosition({ top, left }) // 更新位置 → 触发重渲染 → 闪烁!
}, [targetRect])
return <div style={position}>Tooltip</div>
}
// ✅ 使用 useLayoutEffect → 在绘制前就计算好位置,用户看不到闪烁
function Tooltip({ targetRect }) {
const [position, setPosition] = useState({ top: 0, left: 0 })
useLayoutEffect(() => {
const { top, left } = calculatePosition(targetRect)
setPosition({ top, left }) // 在绘制前更新,不闪烁
}, [targetRect])
return <div style={position}>Tooltip</div>
}
内部执行流程
useLayoutEffect 的完整时序:
1. React render 阶段 → 生成新的 Virtual DOM
2. React commit 阶段 → 更新真实 DOM
3. ⚡ 同步执行所有 useLayoutEffect 的 cleanup
4. ⚡ 同步执行所有 useLayoutEffect 的 setup
5. 浏览器绘制(用户看到画面)
6. 异步执行所有 useEffect 的 cleanup
7. 异步执行所有 useEffect 的 setup
完整的渲染时序图
setState() 触发更新
│
▼
┌─────────┐
│ Render │ 调用组件函数
│ Phase │ useState → 返回最新 state
│ (可中断) │ useMemo → 检查依赖,决定是否重新计算
│ │ useCallback → 检查依赖,决定是否返回新函数
│ │ useRef → 直接返回同一个 ref 对象
└────┬────┘
│ 生成新的 Fiber 树
▼
┌─────────┐
│ Commit │ 对比新旧 Fiber,更新真实 DOM
│ Phase │
│ (不可中断) │
└────┬────┘
│
▼
┌──────────────────┐
│ useLayoutEffect │ 同步执行(阻塞绘制)
│ 的 cleanup + setup │
└────┬─────────────┘
│
▼
┌──────────────┐
│ 浏览器绘制 │ 用户看到更新后的画面
│ (Paint) │
└────┬─────────┘
│
▼
┌──────────────────┐
│ useEffect │ 异步执行(不阻塞)
│ 的 cleanup + setup │
└──────────────────┘
总结:三个 Hook 的选择指南
需要管理数据? → useState
└─ 频繁更新? → 使用函数式更新 setCount(prev => prev + 1)
└─ 初始化昂贵? → 惰性初始化 useState(() => compute())
需要副作用?
├─ 会不会导致视觉闪烁?
│ ├─ 会(DOM 测量/定位)→ useLayoutEffect
│ └─ 不会 → useEffect
└─ 需要请求数据? → useEffect
└─ 需要订阅/监听? → useEffect(记得返回 cleanup)