React Hooks 实现原理
一句话原理:Hooks 把状态存在「按调用顺序排列的链表」里,所以调用的顺序必须每次都一样。 理解这一点,
不能写在条件里、闭包陷阱、setState 后拿不到新值就都能自己推出来。
一、为什么需要 Hooks
类组件有三个痛点:
| 问题 | 说明 |
|---|---|
| 逻辑复用难 | 复用状态逻辑只能靠 HOC / render props,嵌套地狱 |
| 生命周期割裂 | 一个逻辑被拆到 componentDidMount / componentDidUpdate / componentWillUnmount |
this 心智负担 | 绑定、指向、闭包 |
Hooks 的解法:把「状态 + 副作用」按逻辑组织成函数,而不是按生命周期切。
二、核心模型:按顺序存储
// 极简模型:一个数组 + 一个游标
let hookStates = []
let hookIndex = 0
function render() {
hookIndex = 0 // 每次渲染,游标归零
// 执行组件函数,Hooks 依次落位
}// 极简模型:一个数组 + 一个游标
let hookStates = []
let hookIndex = 0
function render() {
hookIndex = 0 // 每次渲染,游标归零
// 执行组件函数,Hooks 依次落位
}每次渲染都从头按同样顺序取一遍状态。所以:
Hooks 的调用顺序必须在每次渲染中都完全一致 —— 这是「不能写在条件/循环里」的唯一原因,没有第二个原因。
三、useState 的最小实现
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]
}两个关键设计:
- 闭包捕获
currentIndex:每个setState都绑定自己的槽位,所以多个setState不会串; hookIndex++在返回时才自增:保证返回值与槽位对应。
四、真实结构:Fiber 上的 Hook 链表
React 实际用的是链表而不是数组,挂在 Fiber 节点的 memoizedState 上:
// 每个 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) → nullFiber
└── memoizedState → Hook1(useState) → Hook2(useEffect) → Hook3(useMemo) → null为什么是链表:Hook 数量不固定,链表天然支持「按需追加」,并且能和 Fiber 的双缓存(current / workInProgress)配合 —— 复用节点、避免每次重建。
为什么条件调用会崩:
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:时机与清理
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
| useEffect | useLayoutEffect | |
|---|---|---|
| 执行时机 | 浏览器绘制之后(异步、不阻塞) | DOM 更新后、绘制之前(同步) |
| 会不会闪 | 可能闪一下(先绘制再改) | 不会闪(改完才绘制) |
| 适用 | 数据请求、事件订阅、打点 | 需要同步读布局/改样式的场景(getBoundingClientRect) |
| 服务端 | SSR 中不执行 | SSR 中会有警告 |
顺序:render → 提交 DOM → useLayoutEffect(同步) → 浏览器绘制 → useEffect(异步)。
六、useMemo / useCallback
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 为什么不会触发渲染
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
// 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 的优势:更新逻辑集中、可测试、适合「下一个状态依赖多个字段」的场景。
九、闭包陷阱(高频面试点)
现象
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 | 定时器 / 事件回调里读最新值 |
// 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 之后为什么拿不到新值
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 会把同一次事件里的多次更新批量处理。
// 连续两次想加 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) // ✅ 结果 2React 18 的自动批处理
| 场景 | React 17 | React 18 |
|---|---|---|
| 事件处理函数内 | 批量 | 批量 |
setTimeout / Promise / 原生事件 | 不批量(各触发一次渲染) | 批量 |
十一、并发特性(React 18+)
| API | 作用 |
|---|---|
useTransition | 把「非紧急更新」标记为可中断(如搜索列表过滤) |
useDeferredValue | 让某个值「延后更新」,先渲染紧急部分 |
startTransition | 函数式版本的 transition |
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) |
Cao Note