Hooks 我一开始也是当 API 记,后来写过一个依赖写错、effect 反复触发的 bug,才回头理顺状态为什么存在 Fiber 链表上、依赖为什么用 Object.is 比较。
Q1: useState 的原理是什么?为什么能在函数组件中保存状态?
1
函数组件的'无状态'困境
函数组件每次渲染都会重新执行整个函数体。按照 JavaScript 的常识,函数内部的局部变量在函数执行完毕后就会被销毁。那
useState 返回的值是怎么在多次渲染间保持不变的?2
答案: Fiber 节点上的 memoizedState 链表
每个函数组件对应一个 Fiber 节点,Fiber 节点有一个
memoizedState 属性,存储所有 Hook 的状态。多个 Hook 通过 next 指针串联成链表。3
按顺序读取的链表机制
React 在每次渲染时,按照 Hook 的调用顺序依次从链表中读取对应的节点。首次渲染时创建链表,后续渲染时按顺序遍历读取。
4
条件语句的致命陷阱
由于链表是按顺序读取的,如果在条件语句中使用 Hook,条件变化会导致链表顺序错乱,React 会读到错误的状态值。
5
dispatchAction 的批处理
调用
setState 时,React 并不会立即更新状态,而是将更新推入一个环形链表队列,通过 scheduleUpdateOnFiber 调度一次渲染,在 Render 阶段批量消费所有更新。useState 的状态不是存在函数里,而是存在组件对应的 Fiber 节点的
memoizedState 链表上。没有 Hooks 的函数组件
函数组件是”一次性”的,每次渲染都会重新创建所有局部变量。就像一个失忆的收银员,每来一个客人就忘记之前的账目。
有了 useState
状态被持久保存在 Fiber 节点上,函数组件可以”记住”之前的状态。就像收银员有了账本,每次都能翻到上次的记录继续算。
Q2: useEffect 和 useLayoutEffect 的执行时机是什么?
同步模型 vs 生命周期
Dan Abramov 在《A Complete Guide to useEffect》中反复强调一个核心心智模型:“Think of useEffect as synchronization between your component and some external system, not as a lifecycle method”[2]。不要把useEffect 当作 componentDidMount 的替代品——它是声明式地描述”当这些依赖变化时,需要与外部系统同步”。依赖数组告诉 React “哪些变化需要重新同步”,而不是”什么时候执行”。
两者的本质区别在于它们在 Commit 阶段的执行位置不同。
Hooks vs Class 生命周期
自定义 Hooks 最佳实践
自定义 Hooks 是以use 开头的函数,内部可以调用其他 Hooks。最佳实践包括:单一职责(一个 Hook 解决一个问题)、返回数组而非对象(便于解构重命名)、依赖数组尽量精简、清理函数必须返回。
Q3: useMemo、useCallback 和 React.memo 分别解决什么问题?
Hooks 原理:闭包 + 链表
Hooks 的底层原理是两个核心机制的结合:第一,状态存储在 Fiber 节点的memoizedState 链表上,跨渲染持久保存;第二,每次渲染时 Hooks 通过闭包捕获当前的 state 和 props,形成”快照”。Dan Abramov 在其经典文章中构建了关键的心智模型:“Each render has its own state and effects — React essentially takes a snapshot of state for each render, so closures capture the values from that specific render”[2]。这就是为什么 useEffect 中的回调能访问到”创建时”的值而不是”最新”的值——因为闭包捕获的是那次渲染的快照。Stale closures 不是 bug,而是 JavaScript 闭包机制的自然结果——每个渲染帧就像一张静止的照片,useEffect 和事件处理函数中的代码被”冻结”在那张照片的值里。
依赖比较的底层实现
所有 Hooks 的依赖比较都通过areHookInputsEqual 函数实现,内部使用 Object.is 逐个比较。Object.is 和 === 的区别在于:Object.is(NaN, NaN) 返回 true,Object.is(+0, -0) 返回 false。这意味着 NaN 作为依赖不会触发重新执行,而 +0 和 -0 会。
基础梳理
专家视角深度剖析
很多人只会说”用它们来优化性能”,但实际更在意的是:什么时候不该用?过度优化的代价是什么?React.memo 需要在每次渲染前遍历比对新旧 props(浅比较),useMemo 和 useCallback 在首次执行时不仅要计算,还要将结果分配给 Fiber 节点的 memoizedState 链表保存。如果计算本身很简单(如字符串拼接),或者组件渲染很快(如纯文本),强加缓存反而是负优化——缓存的开销超过了重新计算的开销。
Q4: useRef 和 useState 的核心区别是什么?
可变容器 vs 响应式状态
ref 的修改不触发 re-render,state 的修改会触发 re-render。 这是两者最根本的区别。理解这个区别的关键在于:useState 的 setter 会调用 dispatchAction 进入 React 的调度系统,而 useRef 的 current 属性是直接赋值,完全绕过调度器。
useRef 的底层实现
useRef 在底层就是一个 { current: initialValue } 的普通对象,存储在 Fiber 节点的 memoizedState 上。关键区别在于:useState 的 setter 会调用 dispatchAction 触发调度,而 useRef 的 current 属性是直接赋值,不经过调度器。
闭包陷阱的根因
闭包陷阱的本质是:Hooks 通过闭包捕获每次渲染的”快照”。如果一个异步回调(如 setTimeout、setInterval)在渲染时创建,它捕获的是那次渲染的 state 值,即使后续 state 已经更新,回调中的值仍然是旧的。解决方案是用useRef 保存最新值,每次渲染同步更新 ref.current。
Q5: Hooks 在 Fiber 节点上是如何存储的?
链表存储机制
Hooks 在 Fiber 节点上是怎么存储的?
在 React 源码中,每个 Hook 是一个链表节点,存储在 Fiber 节点的 memoizedState 上。详见 React 源码 packages/react-reconciler/src/ReactFiberHooks.js。
memoizedState 链表上。next 指针将所有 Hook 串联起来,React 通过 workInProgressHook 指针按顺序遍历。崔健(卡颂)在《React 技术揭秘》中详细剖析了这一机制:Fiber 节点的 memoizedState 字段指向一个单向链表,链表中的每个节点对应一个 Hook,Hook 的执行顺序决定了其在链表中的位置[4]。
更新队列的环形链表
useState 的更新队列是怎么实现的?
详见 React 源码 packages/react-reconciler/src/ReactFiberHooks.js 的 dispatchAction 函数:
setState 会被合并);第二,通过 scheduleUpdateOnFiber 调度一次渲染,在 Render 阶段批量消费所有更新。这就是为什么多次 setState 只触发一次渲染。
Q6: 及格回答和满分回答差在哪?
面试评分标准
考察 Hooks 时,区分”会用”和”懂原理”的关键在于:你是否能解释 Hooks 的链表机制、闭包快照模型、以及 useEffect 的同步心智模型。问题:“说说 React Hooks 的原理”
及格回答(60-70分):“Hooks 的状态存在 Fiber 节点的 memoizedState 上,用链表连接。不能在条件语句中使用是因为顺序会变。”问题出在:知道规则但不理解机制。 满分回答(90-100分):
Hooks 的核心机制是链表。每个函数组件对应一个 Fiber 节点,Fiber 节点的memoizedState属性存储 Hook 链表的头节点。每个 Hook 是一个包含memoizedState(当前值)、queue(更新队列)和next(下一个 Hook)的对象。React 在渲染时通过workInProgressHook指针按顺序遍历链表,这就是 Hooks 必须在函数顶层调用、不能放在条件语句中的根本原因。
useState 的更新通过dispatchAction实现。调用setState时,React 将更新推入一个环形链表队列(queue.pending),然后通过scheduleUpdateOnFiber调度一次渲染。在 Render 阶段,updateWorkInProgressHook从链表中取出对应 Hook,批量消费队列中的所有更新,得到最终状态。多次setState只触发一次渲染。
useEffect 和 useLayoutEffect 的区别在 Commit 阶段的执行位置。useLayoutEffect在 Layout 子阶段同步执行(DOM 已更新、浏览器未绘制),useEffect在 Commit 结束后通过MessageChannel异步调度执行(浏览器可能已绘制一帧)。底层都通过areHookInputsEqual(使用Object.is)比较依赖数组是否变化来决定是否重新执行。
常见追问
为什么 Hooks 不能在条件语句或循环中使用?
为什么 Hooks 不能在条件语句或循环中使用?
答题要点:
从链表顺序读取的机制解释。高分回答:
“因为 Hook 状态存在 Fiber 节点的
memoizedState 链表上,React 按调用顺序依次读取。首次渲染时链表是 [useState(0)] → [useState('')] → [useState([])]。如果第二次渲染在条件语句中跳过了第二个 Hook,代码执行 useState(0) 读到 Hook1 正确,但 useState([]) 读到的却是 Hook2(应该是 Hook3),状态就错乱了。React 的 ESLint 插件 eslint-plugin-react-hooks 会在编译期检查这个规则。”useEffect 的依赖数组用 Object.is 而不是 === 是为什么?
useEffect 的依赖数组用 Object.is 而不是 === 是为什么?
答题要点:
从边界值语义的角度解释。高分回答:
“
Object.is 和 === 在绝大多数情况下行为一致,但有两个关键差异:Object.is(NaN, NaN) 返回 true(=== 返回 false),Object.is(+0, -0) 返回 false(=== 返回 true)。React 选择 Object.is 是因为它更符合”值是否变化”的语义——NaN 作为依赖时不应该触发重新执行(NaN === NaN 在数学上是 false 但语义上是相同的),而 +0 和 -0 在某些场景下确实有区别(如 1/x 的结果)。源码中的比较函数是 areHookInputsEqual,遍历依赖数组逐个用 Object.is 比较。”useEffect 的 cleanup 函数什么时候执行?
useEffect 的 cleanup 函数什么时候执行?
答题要点:
必须分场景说明:依赖变化时和组件卸载时。高分回答:
“useEffect 的 cleanup 在两个时机执行:第一,依赖变化时,React 先执行上一次 effect 的 cleanup,再执行新 effect 的 create 函数。此时 DOM 已经更新到最新状态(可以访问新 DOM)。第二,组件卸载时,React 在 Commit 阶段的 Deletion 子阶段执行 cleanup。cleanup 的执行顺序是从子组件到父组件(深度优先的逆序),这与 componentDidMount 的执行顺序(父到子)相反。理解这个顺序对于正确管理订阅和定时器至关重要。”
如果 useEffect 的依赖数组写错了 (遗漏依赖) , 会怎样?
如果 useEffect 的依赖数组写错了 (遗漏依赖) , 会怎样?
答题要点:
从闭包陷阱和实际 bug 两个角度分析。高分回答:
“最常见的问题是闭包陷阱。例如
useEffect(() => { setInterval(() => console.log(count), 1000); }, []),依赖数组缺少 count,导致定时器中的 count 永远是初始值 0——因为闭包捕获的是创建时的值,而不是最新值。解决方案有两种:第一,补全依赖数组 [count],每次 count 变化都重建定时器(正确但可能频繁重建)。第二,用 useRef 保存最新值,countRef.current = count 每次渲染同步更新,定时器中读取 countRef.current(推荐,依赖数组保持空)。React 的 ESLint 插件 exhaustive-deps 规则会自动检测遗漏的依赖。”useReducer 和 useState 怎么选?
useReducer 和 useState 怎么选?
答题要点:
从状态复杂度和更新逻辑两个维度分析。高分回答:
“
useState 适合独立的、简单的状态值(如 count、isOpen)。useReducer 适合状态逻辑复杂、多个状态相互关联、或更新逻辑需要集中管理的场景。选择标准:如果 setState 的逻辑只有简单的赋值,用 useState;如果更新逻辑需要根据当前状态做分支判断(如 switch/case),或者多个 setState 总是一起调用,用 useReducer。在底层实现上,useState 本身就是用 useReducer 实现的——useState 的 reducer 只有一个 action:直接返回新值。”useId 解决了什么问题?
useId 解决了什么问题?
答题要点:
从 SSR 水合和无障碍访问两个角度解释。高分回答:
“
useId 解决了 SSR 场景下 ID 不一致的问题。在 React 18 之前,很多开发者用 Math.random() 或自增计数器生成 ID,导致服务端和客户端的 ID 不同,Hydration 时会报错。useId 通过 Fiber 树的结构生成确定性的 ID——同一个组件在服务端和客户端永远生成相同的 ID。它还支持 identifierPrefix 配置,避免多个 React 应用在同一页面的 ID 冲突。注意:useId 不是 key 的替代品,它用于 DOM 属性(如 htmlFor、aria-describedby)。”useSyncExternalStore 解决了什么问题?
useSyncExternalStore 解决了什么问题?
答题要点:
从并发模式下的 UI 撕裂角度解释。高分回答:
“
useSyncExternalStore 是 React 18 引入的 Hook,用于安全地订阅外部数据源(如 Redux、Zustand 的 store)。在并发模式下,如果直接在组件中 useEffect 订阅外部 store,可能会出现 UI 撕裂(Tearing)——不同组件读取到同一时刻的不同版本的外部状态。useSyncExternalStore 通过 getSnapshot 参数确保在并发渲染过程中,所有组件读取到的是同一时刻的快照。它是连接 React 并发模式和外部状态库的桥梁。”useEffect 中的异步操作为什么不能直接写 async?
useEffect 中的异步操作为什么不能直接写 async?
答题要点:
从 useEffect 的返回值语义解释。高分回答:
“
useEffect 的回调函数需要返回一个清理函数(或 undefined)。async 函数的返回值永远是 Promise,而不是清理函数。如果直接写 useEffect(async () => { ... }, []),React 会收到一个 Promise 作为清理函数,无法正确执行清理逻辑。正确做法是在回调内部定义 async 函数并调用:useEffect(() => { const fn = async () => { ... }; fn(); }, [])。或者用 IIFE:useEffect(() => { (async () => { ... })(); }, [])。“延伸阅读与引用参考
- React Hooks RFC (Sophie Alpert & Dan Abramov):Hooks 的官方设计提案,阐述了三大动机:状态逻辑复用、复杂组件简化、Class 困境。[1]
- Dan Abramov: A Complete Guide to useEffect:将 useEffect 定义为”同步机制”而非”生命周期”,深入解析依赖数组和闭包快照。[2]
- Dan Abramov: Making Sense of React Hooks:Hooks 的心智模型——“Don’t think about lifecycles, think about synchronization”。[3]
- 崔健(卡颂):React 技术揭秘:像素级剖析 Hooks 链表机制和 Fiber 节点的 memoizedState 结构。[4]
- React 官方文档:Hooks FAQ:官方对 Hooks 规则和最佳实践的权威解释。[5]
- React 源码 ReactFiberHooks.js:Hooks 的完整实现,包含 useState、useEffect、useMemo 的底层逻辑。[6]
- React Conf 2018: React Hooks 原理介绍:Dan Abramov 和 Sophie Alpert 现场讲解 Hooks 的设计动机和底层实现。[7]
