Skip to content

React Hooks 实现原理 ​

一句话原理:Hooks 把状态存在「按调用顺序排列的链表」里,所以调用的顺序必须每次都一样。 理解这一点,不能写在条件里、闭包陷阱、setState 后拿不到新值 就都能自己推出来。

一、为什么需要 Hooks ​

类组件有三个痛点:

问题说明
逻辑复用难复用状态逻辑只能靠 HOC / render props,嵌套地狱
生命周期割裂一个逻辑被拆到 componentDidMount / componentDidUpdate / componentWillUnmount
this 心智负担绑定、指向、闭包

Hooks 的解法:把「状态 + 副作用」按逻辑组织成函数,而不是按生命周期切。

二、核心模型:按顺序存储 ​

js
// 极简模型:一个数组 + 一个游标
let hookStates = []
let hookIndex = 0

function render() {
  hookIndex = 0          // 每次渲染,游标归零
  // 执行组件函数,Hooks 依次落位
}
// 极简模型:一个数组 + 一个游标
let hookStates = []
let hookIndex = 0

function render() {
  hookIndex = 0          // 每次渲染,游标归零
  // 执行组件函数,Hooks 依次落位
}

每次渲染都从头按同样顺序取一遍状态。所以:

Hooks 的调用顺序必须在每次渲染中都完全一致 —— 这是「不能写在条件/循环里」的唯一原因,没有第二个原因。

三、useState 的最小实现 ​

js
let hookStates = []
let hookIndex = 0

function useState(initialState) {
  const currentIndex = hookIndex          // 闭包记住自己的槽位

  if (hookStates[currentIndex] === undefined) {
    hookStates[currentIndex] = typeof initialState === 'function'
      ? initialState()                    // 惰性初始化
      : initialState
  }

  const setState = (next) => {
    // 支持函数式更新:setCount(c => c + 1)
    const nextValue = typeof next === 'function'
      ? next(hookStates[currentIndex])
      : next
    hookStates[currentIndex] = nextValue
    scheduleRender()                      // 触发重新渲染(批量)
  }

  return [hookStates[hookIndex++], setState]
}
let hookStates = []
let hookIndex = 0

function useState(initialState) {
  const currentIndex = hookIndex          // 闭包记住自己的槽位

  if (hookStates[currentIndex] === undefined) {
    hookStates[currentIndex] = typeof initialState === 'function'
      ? initialState()                    // 惰性初始化
      : initialState
  }

  const setState = (next) => {
    // 支持函数式更新:setCount(c => c + 1)
    const nextValue = typeof next === 'function'
      ? next(hookStates[currentIndex])
      : next
    hookStates[currentIndex] = nextValue
    scheduleRender()                      // 触发重新渲染(批量)
  }

  return [hookStates[hookIndex++], setState]
}

两个关键设计:

  1. 闭包捕获 currentIndex:每个 setState 都绑定自己的槽位,所以多个 setState 不会串;
  2. hookIndex++ 在返回时才自增:保证返回值与槽位对应。

四、真实结构:Fiber 上的 Hook 链表 ​

React 实际用的是链表而不是数组,挂在 Fiber 节点的 memoizedState 上:

js
// 每个 Hook 节点
{
  memoizedState: any,      // 当前状态值
  baseState: any,          // 基础状态(用于处理中断/优先级)
  queue: UpdateQueue,      // 待处理的更新队列
  next: Hook | null,       // 指向下一个 Hook
}
// 每个 Hook 节点
{
  memoizedState: any,      // 当前状态值
  baseState: any,          // 基础状态(用于处理中断/优先级)
  queue: UpdateQueue,      // 待处理的更新队列
  next: Hook | null,       // 指向下一个 Hook
}
Fiber
 └── memoizedState → Hook1(useState) → Hook2(useEffect) → Hook3(useMemo) → null
Fiber
 └── memoizedState → Hook1(useState) → Hook2(useEffect) → Hook3(useMemo) → null

为什么是链表:Hook 数量不固定,链表天然支持「按需追加」,并且能和 Fiber 的双缓存(current / workInProgress)配合 —— 复用节点、避免每次重建。

为什么条件调用会崩:

js
function Bad({ show }) {
  const [a, setA] = useState(0)      // 第 1 个 Hook
  if (show) {
    const [b, setB] = useState(0)    // 有时是第 2 个,有时不存在
  }
  const [c, setC] = useState(0)      // show 为 false 时,它变成第 2 个
  // 下一次渲染 show 变 true,c 就会读到 b 的槽位 —— 数据错位
}
function Bad({ show }) {
  const [a, setA] = useState(0)      // 第 1 个 Hook
  if (show) {
    const [b, setB] = useState(0)    // 有时是第 2 个,有时不存在
  }
  const [c, setC] = useState(0)      // show 为 false 时,它变成第 2 个
  // 下一次渲染 show 变 true,c 就会读到 b 的槽位 —— 数据错位
}

React 在开发环境会抛 Rendered fewer hooks than expected 或 Invalid hook call 来保护你。

五、useEffect:时机与清理 ​

js
function useEffect(callback, deps) {
  const currentIndex = hookIndex

  if (hookStates[currentIndex] === undefined) {
    // 首次:渲染后异步执行
    schedulePassiveEffect(() => {
      hookStates[currentIndex] = { deps, destroy: callback() }
    })
  } else {
    const prev = hookStates[currentIndex]
    const same = areDepsEqual(prev.deps, deps)
    if (!same) {
      // 先清理上一次,再执行这一次
      schedulePassiveEffect(() => {
        prev.destroy?.()
        hookStates[currentIndex] = { deps, destroy: callback() }
      })
    }
  }
  hookIndex++
}
function useEffect(callback, deps) {
  const currentIndex = hookIndex

  if (hookStates[currentIndex] === undefined) {
    // 首次:渲染后异步执行
    schedulePassiveEffect(() => {
      hookStates[currentIndex] = { deps, destroy: callback() }
    })
  } else {
    const prev = hookStates[currentIndex]
    const same = areDepsEqual(prev.deps, deps)
    if (!same) {
      // 先清理上一次,再执行这一次
      schedulePassiveEffect(() => {
        prev.destroy?.()
        hookStates[currentIndex] = { deps, destroy: callback() }
      })
    }
  }
  hookIndex++
}

useEffect vs useLayoutEffect ​

useEffectuseLayoutEffect
执行时机浏览器绘制之后(异步、不阻塞)DOM 更新后、绘制之前(同步)
会不会闪可能闪一下(先绘制再改)不会闪(改完才绘制)
适用数据请求、事件订阅、打点需要同步读布局/改样式的场景(getBoundingClientRect)
服务端SSR 中不执行SSR 中会有警告

顺序:render → 提交 DOM → useLayoutEffect(同步) → 浏览器绘制 → useEffect(异步)。

六、useMemo / useCallback ​

js
function useMemo(factory, deps) {
  const currentIndex = hookIndex
  const prev = hookStates[currentIndex]

  if (!prev || !areDepsEqual(prev.deps, deps)) {
    hookStates[currentIndex] = { value: factory(), deps }
  }
  hookIndex++
  return hookStates[currentIndex].value     // 注意用 currentIndex,别用已自增的 hookIndex
}

function useCallback(fn, deps) {
  return useMemo(() => fn, deps)            // useCallback = useMemo 返回函数
}
function useMemo(factory, deps) {
  const currentIndex = hookIndex
  const prev = hookStates[currentIndex]

  if (!prev || !areDepsEqual(prev.deps, deps)) {
    hookStates[currentIndex] = { value: factory(), deps }
  }
  hookIndex++
  return hookStates[currentIndex].value     // 注意用 currentIndex,别用已自增的 hookIndex
}

function useCallback(fn, deps) {
  return useMemo(() => fn, deps)            // useCallback = useMemo 返回函数
}

注意:它们不是「性能银弹」。useMemo 本身也有比较依赖的开销;只有计算昂贵或需要稳定引用(传给 React.memo 子组件、作为其他 Hook 的依赖)时才值得用。

七、useRef 为什么不会触发渲染 ​

js
function useRef(initialValue) {
  const currentIndex = hookIndex
  if (!hookStates[currentIndex]) {
    hookStates[currentIndex] = { current: initialValue }
  }
  hookIndex++
  return hookStates[currentIndex]        // 始终返回同一个对象
}
function useRef(initialValue) {
  const currentIndex = hookIndex
  if (!hookStates[currentIndex]) {
    hookStates[currentIndex] = { current: initialValue }
  }
  hookIndex++
  return hookStates[currentIndex]        // 始终返回同一个对象
}

特点:

  • 返回的对象在组件生命周期内引用不变;
  • 修改 .current 不会触发渲染(它不在 React 的更新队列里);
  • 适合存「不影响视图的值」:定时器 id、上一次的值、DOM 引用。

八、useContext 与 useReducer ​

js
// useContext 本质是读取 Provider 传下来的值,并在值变化时触发重渲染
const theme = useContext(ThemeContext)

// useReducer:把「更新逻辑」抽出来,适合复杂状态机
const [state, dispatch] = useReducer(reducer, initialState)
// useContext 本质是读取 Provider 传下来的值,并在值变化时触发重渲染
const theme = useContext(ThemeContext)

// useReducer:把「更新逻辑」抽出来,适合复杂状态机
const [state, dispatch] = useReducer(reducer, initialState)

useReducer 相对 useState 的优势:更新逻辑集中、可测试、适合「下一个状态依赖多个字段」的场景。

九、闭包陷阱(高频面试点) ​

现象 ​

jsx
function Counter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count)      // ❌ 永远打印 0
    }, 1000)
    return () => clearInterval(timer)
  }, [])                       // 空依赖:effect 只执行一次
}
function Counter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count)      // ❌ 永远打印 0
    }, 1000)
    return () => clearInterval(timer)
  }, [])                       // 空依赖:effect 只执行一次
}

原因:useEffect 只执行一次,它闭包捕获的是首次渲染时的 count(0),后续渲染产生的新闭包没人用。

三种解法 ​

解法写法适用
依赖写全}, [count])逻辑简单,但会反复重建定时器
函数式更新setCount(c => c + 1)更新值时不依赖外部旧值
useRef 存最新值const ref = useRef(count); ref.current = count定时器 / 事件回调里读最新值
jsx
// useRef 版(最通用)
const countRef = useRef(0)
countRef.current = count            // 每次渲染同步
useEffect(() => {
  const timer = setInterval(() => console.log(countRef.current), 1000)
  return () => clearInterval(timer)
}, [])
// useRef 版(最通用)
const countRef = useRef(0)
countRef.current = count            // 每次渲染同步
useEffect(() => {
  const timer = setInterval(() => console.log(countRef.current), 1000)
  return () => clearInterval(timer)
}, [])

十、setState 之后为什么拿不到新值 ​

jsx
const [n, setN] = useState(0)
function onClick() {
  setN(n + 1)
  console.log(n)      // 还是旧值
}
const [n, setN] = useState(0)
function onClick() {
  setN(n + 1)
  console.log(n)      // 还是旧值
}

原因:setState 只是把更新放进队列,组件函数要重新执行才会产生新的 n;而且 React 18 会把同一次事件里的多次更新批量处理。

jsx
// 连续两次想加 1,必须用函数式更新
setN(n + 1)
setN(n + 1)          // ❌ 两次都是「0+1」,结果 1
setN(c => c + 1)
setN(c => c + 1)     // ✅ 结果 2
// 连续两次想加 1,必须用函数式更新
setN(n + 1)
setN(n + 1)          // ❌ 两次都是「0+1」,结果 1
setN(c => c + 1)
setN(c => c + 1)     // ✅ 结果 2

React 18 的自动批处理 ​

场景React 17React 18
事件处理函数内批量批量
setTimeout / Promise / 原生事件不批量(各触发一次渲染)批量

十一、并发特性(React 18+) ​

API作用
useTransition把「非紧急更新」标记为可中断(如搜索列表过滤)
useDeferredValue让某个值「延后更新」,先渲染紧急部分
startTransition函数式版本的 transition
jsx
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')

function onChange(e) {
  setQuery(e.target.value)                  // 紧急:输入框必须立刻响应
  startTransition(() => {
    setResults(search(e.target.value))      // 非紧急:可被打断
  })
}
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')

function onChange(e) {
  setQuery(e.target.value)                  // 紧急:输入框必须立刻响应
  startTransition(() => {
    setResults(search(e.target.value))      // 非紧急:可被打断
  })
}

原理:并发渲染把一次渲染拆成可中断的小任务,高优先级更新(输入)可以插队。这也是 Hooks 链表要配 baseState / baseQueue 的原因 —— 中断后要能恢复。

十二、速查 ​

问题答案
Hooks 存在哪Fiber 节点的 memoizedState 单向链表上
为什么不能写在条件里按调用顺序匹配槽位,顺序变了数据就错位
setState 是同步更新吗不是,进队列 + 批量 + 重新执行组件函数
为什么连续 setN(n+1) 只加 1两次都基于同一个旧值,要用函数式更新
useEffect 和 useLayoutEffect 区别一个绘制后异步,一个绘制前同步
useRef 改值会渲染吗不会,它不在更新队列里
useCallback 和 useMemo 关系useCallback(fn, deps) ≡ useMemo(() => fn, deps)
闭包陷阱怎么解依赖写全 / 函数式更新 / useRef 存最新值
React 18 批处理范围所有场景(含 setTimeout、Promise)