React Hooks 原理与规则
Hooks 的本质是 React 在 Fiber 节点上维护的一套链表,靠调用顺序而非变量名来定位每个 Hook 的状态。理解这一点,就能解释「为什么必须顶层调用」和「为什么不能脱离组件使用」这两条规则。
一、Hooks 解决了什么
Hooks 是 React 16.8 引入的函数组件状态机制,主要解决 Class 组件的三个痛点:
| 痛点 | Class 的问题 | Hooks 的方案 |
|---|---|---|
| 逻辑复用 | mixin / HOC / render props 导致嵌套地狱、props 冲突 | 自定义 Hook 抽离逻辑 |
| 生命周期割裂 | 一个逻辑被拆到 mount / update / unmount 三处 | useEffect 聚合同一逻辑 |
| this 指向 | 需要手动 bind(this) | 完全函数式,无 this |
function Counter() {
const [count, setCount] = React.useState(0)
React.useEffect(() => {
document.title = String(count)
}, [count])
return <button onClick={() => setCount(count + 1)}>{count}</button>
}二、本质:链表挂在 Fiber 上
React 把所有 Hooks 存到当前组件对应 Fiber 节点的 memoizedState 上,组成一条单向链表:
// fiber.memoizedState = hook1 -> hook2 -> hook3
interface Hook {
memoizedState: any // 该 Hook 保存的状态
queue: UpdateQueue | null // 更新队列
next: Hook | null // 指向下一个 Hook
}三、useState 原理
首次渲染(mount):创建 Hook 对象,挂到链表,返回状态和 dispatch。
function mountState(initial: any) {
const hook: Hook = {
memoizedState: initial,
queue: { pending: null },
next: null,
}
// 挂到 fiber.memoizedState 链表
return [hook.memoizedState, dispatchSetState]
}调用 setState:本质是往 hook.queue 塞一个更新任务,然后触发调度。
function dispatchSetState(queue: any, action: any) {
queue.pending = action // 更新塞进队列
scheduleUpdateOnFiber() // 触发 Fiber 重新渲染
}四、为什么靠顺序定位 state
重新渲染时,React 不通过变量名找状态,而是按 Hook 的调用顺序依次从链表取节点:
function App() {
const [a] = React.useState(0) // 取 hook1
React.useEffect(() => {}) // 取 hook2
const b = React.useMemo(() => 1, []) // 取 hook3
}每次渲染,第 1 次 Hook 调用对应 hook1,第 2 次对应 hook2……一一对应。所以调用顺序必须每次渲染都一致。
五、为什么不能写在 if / 循环里
如果把 Hook 放进条件分支,顺序会在不同渲染间错乱:
function App({ flag }: { flag: boolean }) {
// ❌ 条件渲染导致 Hook 顺序变化
if (flag) {
React.useState(0)
}
React.useEffect(() => {})
}flag 为 true 时链表是 useState -> useEffect,为 false 时变成只有 useEffect。React 按顺序取节点就会对不上,导致状态错乱。
function App() {
// ✅ 顶层调用,顺序稳定
const [count, setCount] = React.useState(0)
React.useEffect(() => {
console.log(count)
}, [count])
return <button onClick={() => setCount(count + 1)}>{count}</button>
}六、为什么不能在组件外调用
Hooks 内部靠一个全局指针 currentlyRenderingFiber 找到当前正在渲染的 Fiber,这个指针只在渲染函数组件的那一刻才被赋值:
function renderWithHooks(current, workInProgress, Component, props) {
currentlyRenderingFiber = workInProgress // 渲染前赋值
const children = Component(props) // 执行组件,此时 Hook 能拿到 fiber
currentlyRenderingFiber = null // 渲染后清空
return children
}脱离渲染过程,这个上下文不存在,于是有三处依赖全部失效:
| 依赖 | 说明 | 缺失后果 |
|---|---|---|
| Fiber 存状态 | Hook 链表挂在 fiber.memoizedState | 状态无处存放 |
| Dispatcher 分发 | ReactCurrentDispatcher.current 区分 mount/update | 取到 null,直接抛错 |
| 触发重渲染 | setState 要通过 Fiber 发起调度 | 状态变了也无法驱动 UI |
// ❌ 组件外:currentlyRenderingFiber 为 null,报错
const [x] = useState(0)
// Invalid hook call. Hooks can only be called inside the body of a function component.为什么自定义 Hook 能用? 因为它最终是被某个组件在渲染期间调用的,仍处在渲染上下文中,顺序也稳定:
function useUser() {
const [user, setUser] = React.useState(null)
return { user, setUser }
}
function App() {
const { user } = useUser() // 在 App 的渲染过程中执行,上下文存在
return <div>{user?.name}</div>
}七、useEffect 与闭包陷阱
useEffect 的副作用在 commit 阶段执行(不是 render 阶段),deps 用 Object.is 逐项浅比较决定是否重新执行。
由于函数组件每次渲染都是全新作用域,effect 捕获的是当前渲染的变量快照,空依赖时会形成闭包陷阱:
function Timer() {
const [count, setCount] = React.useState(0)
React.useEffect(() => {
// count 永远是首次渲染时的 0(闭包捕获了旧快照)
const id = setInterval(() => console.log(count), 1000)
return () => clearInterval(id)
}, [count]) // 空依赖
return <button onClick={() => setCount(count + 1)}>{count}</button>
}解决方式:函数式更新 setCount(c => c + 1)、用 useRef 存最新值,或补全依赖项。
八、useRef 原理
useRef 返回一个 { current } 对象,修改 current 不会触发重渲染,因为 React 不把它当作 state 变化。
function Stopwatch() {
const timerRef = React.useRef<number | null>(null)
const start = () => {
// 改 current 不引发渲染,适合存定时器 id、DOM 引用
timerRef.current = window.setInterval(() => {}, 1000)
}
return <button onClick={start}>开始</button>
}九、最佳实践
- 顶层调用:不在条件、循环、嵌套函数中调用 Hook。
- 抽离逻辑:用自定义 Hook(
useRequest、useUser)复用状态逻辑。 - 不滥用 useEffect:能用派生值就别用 effect。
function Name({ first, last }: { first: string; last: string }) {
// ❌ 不要用 effect 同步派生值
// useEffect(() => setFullName(first + last), [first, last])
// ✅ 直接计算
const fullName = first + last
return <span>{fullName}</span>
}- 慎用 useMemo / useCallback:它们有比较和内存成本,不是越多越好。
- 依赖项完整:配合
eslint-plugin-react-hooks自动检查。
核心三句话:Hooks 是挂在 Fiber 上的链表,React 靠调用顺序匹配每个 Hook 的状态。必须顶层调用,否则顺序错乱导致状态对不上;不能脱离组件,因为 Hooks 依赖渲染时才设置的
currentlyRenderingFiber全局上下文。