前言
Hooks刚出的时候,大家都觉得是“黑魔法”:一个函数组件,居然能记住自己的状态?还能模拟生命周期?很多人用了很久,却不知道原理。导致遇到奇怪的问题(比如无限循环、状态不更新)时,只能靠试。
今天我们不背源码,用最简单的代码模拟React Hooks的核心机制。学完你不仅能解释“为什么不能在条件语句里调用Hook”,还能自己手写一个迷你useState。
一、函数组件为啥需要Hooks?
类组件有this.state和生命周期,但函数组件每次渲染都会重新执行,里面的变量都会重新创建。那怎么保存状态?React用了闭包 + 链表。
每个函数组件对应一个“虚拟节点”(Fiber节点),它上面有个memoizedState属性,用来保存该组件的Hooks链表。
二、模拟React Hooks:手写迷你useState
我们先不管React的实现细节,用纯JS模拟一个最简单的useState。
let hooks = null; // 当前组件的hooks链表
let currentHook = 0; // 当前正在执行的hook索引
function useState(initialValue) {
// 如果是第一次渲染,初始化这个hook的值
if (!hooks[currentHook]) {
hooks[currentHook] = { state: initialValue };
}
const hook = hooks[currentHook];
const setState = (newValue) => {
hook.state = newValue;
scheduleRender(); // 触发重新渲染(伪代码)
};
currentHook++;
return [hook.state, setState];
}
function render(component) {
hooks = []; // 重置hooks链表
currentHook = 0; // 重置索引
const vdom = component(); // 执行组件,收集hooks
// 渲染vdom...
}
关键点:
hooks数组按调用顺序存储每个useState的状态。- 每次渲染,
currentHook重置为0,依次取出对应的状态。 - 所以必须保证每次渲染时,Hook的调用顺序和数量完全一致。这就是不能在
if或循环里调用的根本原因。
三、为什么顺序必须不变?
假设第一次渲染时,你在if里调用了useState,第二次渲染时条件不成立,那个Hook被跳过了。那么后续Hook的对应关系就会错位:本来应该取第二个状态,结果取了第三个的。React就会报错。
// 错误示例
function MyComponent({ flag }) {
if (flag) {
const [a, setA] = useState(1); // 第一次有,第二次没有
}
const [b, setB] = useState(2); // 第一次是第二个hook,第二次变成了第一个
}
React团队之所以这样设计,是为了在保证性能的同时简化实现。用数组/链表存储,比用Map key查找快得多。
四、多个Hook是怎么串联的?
React实际用的是单向链表,每个Hook节点有next指针指向下一个。这样即使组件不渲染,链表也保留在Fiber节点上。
// 简化的链表结构
const hook = {
memoizedState: null, // 当前状态
next: null, // 下一个hook
// 还有queue等用于更新的字段
};
每次渲染,React根据上次的链表和本次调用的顺序,把新状态赋给对应的Hook。
五、useEffect的原理:等渲染完再执行
useEffect的回调不会阻塞浏览器绘制,它是在渲染提交到屏幕之后异步执行的。它的存储也类似,但多了清除函数的管理。
function useEffect(callback, deps) {
const hook = hooks[currentHook];
const prevDeps = hook?.deps;
const hasChanged = !prevDeps || deps.some((dep, i) => dep !== prevDeps[i]);
if (hasChanged) {
// 将callback放到待执行队列,等渲染完成后执行
scheduleEffect(callback);
}
hook.deps = deps;
currentHook++;
}
六、为什么不能在循环里调用Hook?
跟if同理:循环次数变了,Hook的顺序就变了。即使你保证循环次数不变,也没法阻止以后的维护者改代码。所以React直接禁止这种写法。
七、useCallback和useMemo本质是缓存
它们也存储在Hook链表里,只是memoizedState里存的是缓存的值和依赖。
function useMemo(factory, deps) {
const hook = hooks[currentHook];
const prevDeps = hook?.deps;
const hasChanged = !prevDeps || deps.some((d, i) => d !== prevDeps[i]);
if (hasChanged) {
hook.value = factory();
hook.deps = deps;
}
currentHook++;
return hook.value;
}
八、自定义Hook为什么没有特殊待遇?
自定义Hook只是调用了内置Hook的普通函数,它不会新增链表节点,只是把调用的内置Hook顺序归入组件的链表。所以自定义Hook的规则也遵循“只在顶层调用”。
九、总结:Hooks的“交通规则”
- Hooks用链表存储,顺序就是调用顺序。
- 每次渲染必须保持完全相同的调用顺序和数量。
- 所以禁止在条件、循环、嵌套函数里调用Hook。
useState返回的setter之所以能拿到最新值,是因为闭包引用了Hook对象,而Hook对象上的状态会被更新。
理解了这个原理,你再也不会害怕Hooks的诡异报错。下次同事问“为什么不能if里写useState”,你可以拍拍他肩膀:“因为React用数组存状态,你跳过一个,后面的全对不上了。”