在使用 React Hooks 时,你是否被 ESLint 警告过:"React Hook must be called in the exact same order in every component render"?
为什么不能在 if 语句或循环中调用 Hooks?今天,我们从 单向链表 和 索引偏移 的底层视角,深度剖析 React Hooks 的实现机制。
1. Hooks 的本质:链表上的节点
在 React Fiber 架构中,每个函数组件实例都对应一个 Fiber 节点。而该组件内调用的所有 Hooks,则通过一个单向链表串联起来。
1.1 Hook 数据结构
arduino
class Hook {
memoizedState: any; // 保存的状态值
baseState: any; // 基础状态
queue: UpdateQueue; // 更新队列
next: Hook | null; // 指向下一个 Hook
}
1.2 链表构建过程
假设组件代码如下:
scss
function App() {
const [a, setA] = useState(0); // Hook 1
const [b, setB] = useState(1); // Hook 2
useEffect(() => {}, []); // Hook 3
}
内存中的链表结构如下:
php
Fiber.memoizedState
→ Hook1 (useState: a)
→ Hook2 (useState: b)
→ Hook3 (useEffect)
→ null
2. 为什么必须按顺序调用?
2.1 索引偏移灾难
React 并不通过"名字"来识别 Hook,而是通过调用顺序(Index) 。
场景演示:
scss
// 第一次渲染
function App() {
const [name, setName] = useState('Lee'); // Index 0
if (someCondition) {
const [age, setAge] = useState(18); // Index 1
}
}
如果 someCondition 从 true 变为 false:
- React 依然会从链表的 Index 1 处取值。
- 但此时 Index 1 对应的可能是另一个完全不同的 Hook(或者
null)。 - 导致状态错位,甚至程序崩溃。
2.2 核心算法:指针移动
ini
function useState(initialState) {
// 1. 确定当前操作的 Hook
let hook;
if (isMount) {
hook = createNewHook(initialState);
appendToLinkedList(hook);
} else {
hook = currentHook;
currentHook = currentHook.next; // 关键:指针后移
}
return [hook.memoizedState, dispatchAction];
}
关键点 :每次调用 useState,内部的 workInProgressHook 指针都会向后移动一位。如果调用顺序变了,指针就会指错位置。
3. 状态更新机制:环形链表与批处理
3.1 更新队列(Update Queue)
当你调用 setState 时,React 并不会立即修改状态,而是创建一个 Update 对象并加入队列:
ini
const update = {
action: action, // 传入的值或函数
next: null
};
// 将 update 加入环形链表
if (queue.pending === null) {
update.next = update; // 自己指向自己
} else {
update.next = queue.pending.next;
queue.pending.next = update;
}
queue.pending = update;
3.2 批量计算新状态
在下一次渲染时,React 会遍历这个环形链表,依次执行所有的更新:
ini
let newState = hook.baseState;
let update = firstUpdate;
do {
const action = update.action;
newState = typeof action === 'function' ? action(newState) : action;
update = update.next;
} while (update !== firstUpdate);
hook.memoizedState = newState;
4. useEffect 的依赖对比
useEffect 的核心在于浅比较(Shallow Compare) :
ini
function useEffect(create, deps) {
const hook = getWorkInProgressHook();
if (deps) {
const prevDeps = hook.memoizedState?.deps;
// 逐项对比
if (prevDeps && deps.every((d, i) => Object.is(d, prevDeps[i]))) {
return; // 依赖没变,跳过执行
}
}
// 执行副作用
hook.memoizedState = { create, deps };
scheduleEffect(create);
}
5. 工业界实战:自定义 Hooks 封装
5.1 useLocalStorage
javascript
function useLocalStorage(key, initialValue) {
const [state, setState] = useState(() => {
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : initialValue;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
5.2 useAsync(异步状态管理)
php
function useAsync(asyncFunction) {
const [state, setState] = useState({ loading: false, data: null, error: null });
const execute = useCallback(() => {
setState({ loading: true, data: null, error: null });
asyncFunction()
.then(data => setState({ loading: false, data, error: null }))
.catch(error => setState({ loading: false, data: null, error }));
}, [asyncFunction]);
return { ...state, execute };
}
6. 面试考点
Q1: 为什么 Hooks 不能放在条件语句中?
A: React 依靠 Hooks 的调用顺序(Index)来匹配链表中的节点。如果顺序改变,会导致状态读取错位,引发严重的 Bug。
Q2: useState 的更新是同步还是异步的?
A: 在 React 18 之前,合成事件中的更新是异步批处理的。在 React 18 中,引入了自动批处理(Automatic Batching),即使是 Promise 或 setTimeout 中的更新也会合并渲染。
Q3: useRef 和 useState 有什么区别?
A:
useState更新会触发组件重新渲染,而useRef只是修改一个普通对象的.current属性,不会触发渲染。useRef常用于保存不需要反映在 UI 上的可变值。
7. 总结
React Hooks 的设计美学:
- 极简的 API:用函数式写法实现了类组件的所有功能
- 高效的链表:O(1) 复杂度的状态存取
- 严格的约束:通过顺序约束换取了极致的性能和简洁性
理解 Hooks 的链表本质,你就不再是"背规则",而是真正掌握了 React 的心跳。
如果你觉得这篇关于"React 底层原理"的文章对你有帮助,欢迎点赞收藏!🚀