创见博客
useEffect原理
七崽爱吃小饼干2026/01/06阅读 1专栏 React

一、先明确核心前提

React 中所有 Hook 的实现都依赖两个核心基础:

  1. Fiber 节点:每个组件对应一个 Fiber 节点,用来存储组件的状态、Hook 链表等信息;
  2. Hook 链表:每个组件的 Fiber 节点中,会维护一个 hooks 数组(链表),useEffect/useState 等 Hook 的数据都存在这里;
  3. 渲染阶段 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 的回调函数不是在渲染阶段执行,而是在:

  1. 组件渲染完成(DOM 更新)后;
  2. 异步执行(不会阻塞浏览器绘制); 这也是为什么 useEffect 中可以安全操作 DOM 的原因(DOM 已经更新完成)。

四、React 源码中的关键补充(简化)

实际 React 源码中,useEffect 的实现还包含:

  • 调度优先级:React 会根据优先级决定 effect 是立即执行还是延后执行;
  • 批量更新:多个 effect 会被批量收集,统一执行;
  • 销毁队列:组件卸载时,会遍历所有 effect 的清理函数并执行;
  • 并发模式:支持中断和恢复渲染,保证 effect 执行的正确性。

总结

  1. useEffect 的实现依赖Fiber 节点和Hook 链表:每个组件的 useEffect 都会被存储在对应 Fiber 节点的 hooks 数组中,通过 hookIndex 保证顺序;
  2. 核心逻辑是依赖对比:更新阶段会浅比较依赖数组,只有依赖变化时,才会重新执行副作用函数;
  3. 清理函数的执行时机:更新时先执行上一次的清理函数,再执行新的副作用;组件卸载时执行所有清理函数,避免内存泄漏。
评论
0/100