Engineering

理解 React Hooks:从状态快照到外部资源生命周期

Hooks 不只是 API 清单。真正稳定的理解路径,是先分清 state 快照、更新队列、render/commit/effect 边界,再判断一个 Hook 应该管理状态、缓存、引用还是外部资源。

React Hooks 最容易被学成一串 API 名称:useStateuseEffectuseRefuseMemouseCallback。这样记当然能应付简单代码,但一遇到连续更新、过期闭包、依赖数组、资源清理和自定义 Hook 设计,问题就会重新出现。

更稳定的入口不是问“这个 API 怎么用”,而是问:

  1. 组件这次遇到的是记住值、连接外部世界,还是保存一个不触发渲染的可变对象?
  2. 当前代码发生在 render、commit 还是 effect 阶段?
  3. 这个值是 React 管理的 state,还是浏览器、网络、音频、定时器这类外部资源?

把这三件事分清,Hooks 会从“记忆负担”变成“组件能力的边界工具”。

const + useState 没有违反 JavaScript

先看最常见的问题:

function Counter() {
  const [count, setCount] = useState(0)

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  )
}

既然 countconst,为什么点击后它还能从 0 变成 1

答案是:变的不是同一个 count 变量。

const 限制的是“当前这一次函数执行里的变量绑定不能被重新赋值”。React 函数组件不是一个长期存在的对象实例,而是一个会被 React 反复调用的函数。每次 render,React 都重新调用组件函数,给你一份新的 state 快照。

可以这样理解:

第一次 render:const count = 0
第二次 render:const count = 1
第三次 render:const count = 2

所以 setCount 并不是在做:

count = count + 1

它做的是:

给 React 提交一条更新记录:
下一次渲染这个组件时,count 应该变成新的值

React 官方文档把 state 描述成一次渲染里的快照。这个说法很关键:调用 setter 不会改变你当前已经拿到的那个 state 变量,而是让 React 安排下一次 render。

render 不是浏览器绘制

React 里的 render 也容易被误解。

在浏览器语境里,“渲染”很容易让人想到像素被画到屏幕上。但在 React 语境里,render 更准确地说是:

React 调用组件函数,计算新的 UI 描述

这句 JSX:

<button>{count}</button>

不是直接创建真实 DOM。它会变成 React Element,也就是一份 UI 描述。React 根据这份描述和上一次结果做协调,找出需要变化的地方。真正修改浏览器 DOM 发生在 commit 阶段,由 React DOM 这个 renderer 把变化落到真实 DOM 上。

一次点击更新可以粗略串成这样:

用户点击 button

执行 setCount(count + 1)

React 创建 state update

update 进入当前 Hook 的 Update Queue

React 调度一次更新

render 阶段:重新执行组件函数

useState 处理队列,得到新的 count

组件返回新的 React Element

reconcile 阶段:对比新旧结果

commit 阶段:React DOM 修改真实 DOM

浏览器绘制最新页面

这里有几个边界要分清:

JSX 不是 DOM,而是 UI 描述
state 不是普通局部变量,而是 React 保存的组件记忆
setState 不是直接改变量,而是提交更新记录
render 阶段负责计算,commit 阶段负责落地
React DOM 不是检测变化的人,而是把变化应用到浏览器 DOM 的 renderer

Fiber 可以先理解为 React 内部的工作单元树。它记录组件、props、state、hooks、子节点、更新标记和调度信息。对初学者来说,不需要马上背 Fiber 字段,先抓住它的作用:React 需要一个内部结构来保存组件记忆、挂载更新队列、组织渲染工作,并把计算出的变化提交给不同平台的 renderer。

为什么连续三次 setCount(count + 1) 只加一

理解 state 快照以后,就能解释一个更实用的问题。

假设有一个简单的自定义 Hook:

function useCounter() {
  const [count, setCount] = useState(0)

  function addCount() {
    setCount(count + 1)
  }

  return { count, addCount }
}

如果用户点一次按钮,它通常能正常工作:

0 -> 1

但如果在同一次事件里连续调用三次:

function handleClick() {
  addCount()
  addCount()
  addCount()
}

很多人的直觉是最终得到 3。实际结果常常是 1

原因不是闭包本身错了,而是这句代码使用了当前 render 的旧快照:

setCount(count + 1)

假设当前 render 里的 count0,那么 addCount 闭包里记住的也是这次 render 的 count = 0。连续调用三次时,三次都会先在 JavaScript 事件阶段算出:

setCount(1)
setCount(1)
setCount(1)

React 下一次 render 处理 Update Queue 时,队列里是三个已经算好的值:

state = 0

第一个 update:state = 1
第二个 update:state = 1
第三个 update:state = 1

最终自然是 1

如果新状态依赖旧状态,更稳妥的写法是函数式更新:

function addCount() {
  setCount(c => c + 1)
}

这时传给 React 的不是计算结果,而是一条计算规则:

c => c + 1

连续调用三次时,Update Queue 里保存的是三个 updater function。React 在下一次 render 处理队列时,会把最新 state 依次传进去:

state = 0

第一个 update:state = 0 + 1 = 1
第二个 update:state = 1 + 1 = 2
第三个 update:state = 2 + 1 = 3

所以可以记成:

setCount(count + 1)
= 现在就用当前闭包里的 count 算出结果

setCount(c => c + 1)
= 把计算规则交给 React,等处理队列时再用最新 state 计算

函数式更新解决的是“新 state 依赖旧 state”的问题。它不是所有闭包问题的万能药。如果写成:

setCount(c => c + step)

这里的 c 是 React 传入的最新 state,但 step 仍然可能来自旧闭包。只要回调依赖外部变量,就仍然要面对闭包和依赖管理。

Hook 要按问题分类,而不是按名字背

有了上面的模型,再看常用 Hooks,会清楚很多。

useStateuseReducer 解决的是“组件如何记住会影响 UI 的值”。如果值变化需要让界面变化,通常应该进入 state;如果状态转移规则变复杂,或者多个事件会改变同一组状态,useReducer 会比散落的多个 useState 更容易维护。

useEffect 解决的是“组件如何连接外部世界”。请求、订阅、定时器、浏览器事件、第三方组件、非 React 系统同步,都属于这个范围。它的核心边界是:

render = 根据 props 和 state 计算 UI
effect = 与外部系统同步

如果只是从已有 state 推导另一个值,通常不应该先放进 useEffect,而应该直接计算,或者在确实有成本时使用 useMemo

useRef 解决的是“组件如何保存一个跨 render 稳定、但修改后不触发渲染的值”。DOM 节点、timer id、socket 实例、上一次的值、播放器对象,都可能适合放在 ref 里。

可以这样区分:

useState:值变化需要让 UI 变化
useRef:值变化只是组件内部需要记住

useMemo 缓存计算结果,useCallback 缓存函数引用。它们不是越多越好。真正需要它们的常见场景是:计算确实昂贵、对象引用需要稳定、回调要传给 memo 优化过的子组件,或者回调会作为其他 Hook 的依赖。

useTransitionuseDeferredValue 解决的是并发渲染下的优先级问题。前者标记一段更新为非阻塞更新,后者延迟一个值的低优先级使用。它们的共同目标不是让代码“更高级”,而是让输入、点击这类高优先级交互先响应,让大列表、搜索结果、预览区域稍后跟上。

外部资源不是 React state

自定义 Hook 最容易出问题的地方,是把外部资源当成普通值处理。

比如要把一个命令式音频播放器包装成 Hook:

export function useTtsPlayback() {
  const playerRef = useRef(new PcmAudioPlayer())

  function enqueue(pcmArrayBuffer: ArrayBuffer) {
    playerRef.current.enqueue(pcmArrayBuffer)
  }

  function interrupt() {
    playerRef.current.interrupt()
  }

  function play() {
    playerRef.current.drain()
  }

  return { enqueue, interrupt, play }
}

这段代码看起来问题不大:播放器实例放进 ref,组件通过 Hook 拿到几个动作函数。

但这里有一个细节:useRef(initialValue) 只是在第一次 render 时采用 initial value,后续 render 返回同一个 ref 对象;它不保证 initialValue 表达式只执行一次。

也就是说:

useRef(new PcmAudioPlayer())

在每次组件函数执行时,都会先执行:

new PcmAudioPlayer()

React 后续 render 会忽略新的 initial value,但构造函数已经执行了。普通对象可能只是浪费;如果构造函数里创建了 AudioContextWebSocket、定时器或订阅,就会变成真实副作用。

更稳妥的策略是懒创建:

const playerRef = useRef<PcmAudioPlayer | null>(null)

function getPlayer() {
  if (playerRef.current === null) {
    playerRef.current = new PcmAudioPlayer()
  }

  return playerRef.current
}

播放器这类资源还有另一个问题:组件卸载时不会因为 React 组件消失而自动释放。它可能持有:

  • AudioContext
  • 已经调度的音频节点
  • WebSocket 连接
  • 定时器
  • DOM 事件监听
  • 异步回调
  • 内部队列

只要 Hook 创建了外部资源,就要问四个问题:

  1. 它什么时候创建?
  2. 它什么时候停止?
  3. 组件卸载时谁释放它?
  4. 异步回调晚到时,会不会操作已经无效的资源?

useEffect 返回的函数就是 React 提供给外部资源的 cleanup 机制:

useEffect(() => {
  return () => {
    playerRef.current?.dispose()
    playerRef.current = null
  }
}, [])

空依赖数组意味着:组件挂载后 setup 一次,组件卸载时 cleanup 一次。如果 effect 有依赖,React 会在下一次 effect 重新执行前先执行上一轮 cleanup。

比如:

useEffect(() => {
  subscribe(userId)

  return () => {
    unsubscribe(userId)
  }
}, [userId])

userId1 变成 2 时,合理流程应该是:

unsubscribe(1)
subscribe(2)

这就是 effect cleanup 的意义:它不是“页面关闭时顺手清一下”,而是外部系统和 React 生命周期之间的析构边界。

自定义 Hook 的 API 也要稳定

自定义 Hook 不只是把几行代码包起来。它还要给调用方一个稳定的使用契约。

如果每次 render 都返回新的函数:

function play() {
  playerRef.current?.drain()
}

那么调用方把它传给子组件或放进依赖数组时,就可能触发不必要的渲染或 effect 重跑。

更好的做法是用 useCallback 稳定 action:

const play = useCallback(() => {
  getPlayer().drain()
}, [getPlayer])

一个更合理的 useTtsPlayback 版本大致是:

import { useCallback, useEffect, useRef } from 'react'

export function useTtsPlayback() {
  const playerRef = useRef<PcmAudioPlayer | null>(null)

  const getPlayer = useCallback(() => {
    if (playerRef.current === null) {
      playerRef.current = new PcmAudioPlayer()
    }

    return playerRef.current
  }, [])

  const enqueue = useCallback((pcmArrayBuffer: ArrayBuffer) => {
    getPlayer().enqueue(pcmArrayBuffer)
  }, [getPlayer])

  const interrupt = useCallback(() => {
    playerRef.current?.interrupt()
  }, [])

  const play = useCallback(() => {
    getPlayer().drain()
  }, [getPlayer])

  useEffect(() => {
    return () => {
      playerRef.current?.dispose()
      playerRef.current = null
    }
  }, [])

  return { enqueue, interrupt, play }
}

这个版本体现了几个原则:

  • 不在 useRef(...) 参数里创建重资源。
  • 播放器按需懒创建。
  • Hook 卸载时释放播放器。
  • 返回对象而不是隐式 tuple,调用方不容易弄错顺序。
  • 返回的 action 是稳定函数。
  • Hook 负责 React 生命周期,播放器类负责底层播放细节。

最后,把 Hooks 看成边界判断工具

学习 Hooks 的目标不是背出每个 API 的定义,而是在写组件时能快速判断当前问题属于哪一类。

可以用这张表做入口:

问题优先考虑
值变化需要更新 UIuseState / useReducer
连接外部系统useEffect
DOM 测量或绘制前同步修正useLayoutEffect
跨 render 保存可变值但不触发渲染useRef
缓存昂贵计算结果useMemo
稳定函数引用useCallback
跨层读取上下文useContext
低优先级更新不阻塞交互useTransition / useDeferredValue
订阅外部 storeuseSyncExternalStore

真正重要的是背后的边界:

state 是当前 render 的快照
setState 是提交更新记录,不是直接改变量
render 阶段计算 UI,commit 阶段修改 DOM
effect 是连接外部世界的地方
ref 可以保存可变对象,但不自动管理生命周期
自定义 Hook 要同时设计状态、资源、cleanup 和 API 稳定性

当这些边界稳定以后,Hooks 就不再是一组零散 API,而是一套把组件逻辑拆清楚的工程语言。

Back to Blog

Related Posts

View All Posts »