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 执行】
                     ↑ 同步,阻塞绘制                        ↑ 异步,不阻塞

对比:

特性useEffectuseLayoutEffect
执行时机浏览器绘制之后浏览器绘制之前
是否阻塞渲染
适用场景数据请求、订阅、日志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)