一、先明确核心前提
React 中所有 Hook 的实现都依赖两个核心基础:
- Fiber 节点:每个组件对应一个 Fiber 节点,用来存储组件的状态、Hook 链表等信息;
- Hook 链表:每个组件的 Fiber 节点中,会维护一个
hooks数组(链表),useEffect/useState等 Hook 的数据都存在这里; - 渲染阶段 vs 执行阶段:React 分为「渲染阶段(计算 UI)」和「提交阶段(更新 DOM)」,
useEffect的回调函数是在提交阶段完成后异步执行的(这也是它和useLayoutEffect的核心区别)。
二、useEffect 核心实现逻辑(简化版)
我们先模拟 React 内部的核心变量和函数,再一步步实现 useEffect 的核心逻辑。
步骤1:定义核心存储结构
javascript
// 1. 模拟当前正在渲染的组件的Fiber节点
let currentFiber = null;
// 2. 模拟Hook的结构:存储effect的回调、依赖、清理函数
class EffectHook {
constructor(callback, deps) {
this.callback = callback; // useEffect的第一个参数(副作用函数)
this.deps = deps; // 依赖数组
this.cleanup = null; // 清理函数(副作用函数的返回值)
}
}
// 3. 模拟Hook链表的指针:每次调用Hook时,指针后移
let hookIndex = 0;
步骤2:模拟组件挂载/更新的核心流程
javascript
// 模拟React的组件渲染入口
function renderComponent(Component) {
// 重置Hook指针(每次组件渲染时,从第一个Hook开始遍历)
hookIndex = 0;
// 创建/获取组件的Fiber节点(模拟)
if (!currentFiber) {
currentFiber = {
hooks: [], // 存储当前组件的所有Hook
memoizedState: null // 存储组件状态(简化)
};
}
// 执行组件函数(触发Hook调用)
Component();
// 执行所有待执行的effect(核心:在渲染完成后执行)
runEffects();
}
// 模拟:在渲染完成后,执行所有effect
function runEffects() {
const { hooks } = currentFiber;
// 遍历所有EffectHook
hooks.forEach(hook => {
if (hook instanceof EffectHook) {
const { callback, deps, cleanup } = hook;
// 1. 先执行上一次的清理函数(如果有)
if (cleanup) {
cleanup();
}
// 2. 执行副作用函数,保存清理函数
hook.cleanup = callback();
}
});
}
步骤3:实现核心的 useEffect 函数
javascript
// 模拟实现useEffect
function useEffect(callback, deps) {
// 1. 获取当前Hook(根据指针从Fiber的hooks数组中取)
const hook = currentFiber.hooks[hookIndex];
// 2. 如果是首次渲染(没有该Hook),创建新的EffectHook
if (!hook) {
currentFiber.hooks.push(new EffectHook(callback, deps));
} else {
// 3. 如果是更新阶段,判断依赖是否变化
const oldDeps = hook.deps;
const newDeps = deps;
// 核心:判断依赖是否变化(浅比较)
const isDepsChanged = !oldDeps ||
oldDeps.length !== newDeps.length ||
newDeps.some((dep, index) => dep !== oldDeps[index]);
// 如果依赖变化,更新回调和依赖
if (isDepsChanged) {
hook.callback = callback;
hook.deps = newDeps;
}
}
// 4. Hook指针后移(保证Hook调用顺序不变)
hookIndex++;
}
步骤4:模拟组件卸载时的清理逻辑
javascript
// 模拟组件卸载:执行所有effect的清理函数
function unmountComponent() {
const { hooks } = currentFiber;
hooks.forEach(hook => {
if (hook instanceof EffectHook && hook.cleanup) {
hook.cleanup(); // 执行清理函数(比如清除定时器)
}
});
// 清空Fiber节点
currentFiber = null;
hookIndex = 0;
}
步骤5:测试我们的简化版 useEffect
javascript
// 模拟一个组件
function MyComponent() {
let count = 0; // 模拟useState的状态
// 调用我们的useEffect
useEffect(() => {
console.log('副作用执行:count =', count);
// 模拟清理函数
return () => {
console.log('清理函数执行');
};
}, [count]);
// 模拟更新count
count++;
}
// 首次渲染组件(挂载)
console.log('=== 首次渲染 ===');
renderComponent(MyComponent);
// 输出:
// 清理函数执行(首次无旧清理函数,实际React中首次不会执行)
// 副作用执行:count = 1
// 再次渲染组件(更新)
console.log('=== 再次渲染 ===');
renderComponent(MyComponent);
// 输出:
// 清理函数执行(上一次的清理)
// 副作用执行:count = 2
// 卸载组件
console.log('=== 卸载组件 ===');
unmountComponent();
// 输出:
// 清理函数执行
三、核心细节拆解
1. Hook 的调用顺序是关键
useEffect 能正确工作的核心前提是:每次组件渲染时,Hook 的调用顺序必须完全一致。
- 因为 React 是通过
hookIndex指针来定位每个 Hook 的(第一个useEffect对应hooks[0],第二个对应hooks[1]); - 如果在条件语句中调用
useEffect,会导致指针错位,Hook 数据对应错误(这也是 React 禁止在条件中使用 Hook 的原因)。
2. 依赖对比的逻辑
React 内部对依赖数组的对比是浅比较(===):
- 基本类型(数字、字符串):直接比较值;
- 引用类型(对象、数组):比较引用地址(所以如果每次渲染都创建新对象,会导致依赖永远变化);
javascript
// 错误示例:每次渲染都创建新数组,导致effect每次都执行 useEffect(() => {}, [{}]); // 正确做法:把引用类型状态抽离,或用useMemo缓存 const obj = useMemo(() => ({ a: 1 }), []); useEffect(() => {}, [obj]);
3. 执行时机的核心
useEffect 的回调函数不是在渲染阶段执行,而是在:
- 组件渲染完成(DOM 更新)后;
- 异步执行(不会阻塞浏览器绘制);
这也是为什么
useEffect中可以安全操作 DOM 的原因(DOM 已经更新完成)。
四、React 源码中的关键补充(简化)
实际 React 源码中,useEffect 的实现还包含:
- 调度优先级:React 会根据优先级决定
effect是立即执行还是延后执行; - 批量更新:多个
effect会被批量收集,统一执行; - 销毁队列:组件卸载时,会遍历所有
effect的清理函数并执行; - 并发模式:支持中断和恢复渲染,保证
effect执行的正确性。
总结
useEffect的实现依赖Fiber 节点和Hook 链表:每个组件的useEffect都会被存储在对应 Fiber 节点的hooks数组中,通过hookIndex保证顺序;- 核心逻辑是依赖对比:更新阶段会浅比较依赖数组,只有依赖变化时,才会重新执行副作用函数;
- 清理函数的执行时机:更新时先执行上一次的清理函数,再执行新的副作用;组件卸载时执行所有清理函数,避免内存泄漏。