陌上人如玉
公子世无双

11.2 手写 useEffect

11.2 手写 useEffect

一句话:手写 useEffect 的核心是「用 Object.is 逐个比较依赖数组」——不同则「先执行上次的清理函数,再执行本次的 effect」;所有 effect 都在「组件渲染完成后统一执行」。

一、目标与难点

要实现的三件事:
① 【依赖比较】——只有依赖变化时才重新执行
② 【清理时机】——依赖变化时「先清理上次,再执行本次」;卸载时执行清理
③ 【执行时机】——所有 effect 在「组件函数执行完(渲染完成)之后」统一执行
   (不能在组件函数执行过程中执行,否则「渲染没结束就做副作用」)

难点:
· 依赖数组的比较需要「记住上一次的依赖」
· 清理函数需要「被记住」,在下一次执行前调用
· 多个 effect 要「按声明顺序」在渲染后依次执行

二、最简实现(无依赖数组)

// 用与 useState 相同的「槽位」机制
let hookStates = [];
let hookIndex = 0;
let pendingEffects = [];        // ⭐ 收集本次渲染产生的 effect,渲染后统一执行

function useEffect(effect, deps) {
  const index = hookIndex++;
  const prev = hookStates[index];          // { deps, cleanup }

  // ① 判断是否需要执行
  const hasChanged = prev === undefined || !depsEqual(prev.deps, deps);

  if (hasChanged) {
    pendingEffects.push({
      index,
      effect,
      deps,
      cleanup: prev?.cleanup,              // ⭐ 上次的清理函数
    });
  }
}

function depsEqual(a, b) {
  // 不传依赖数组 → 每次都执行(返回 false 表示「不相等」)
  if (a === undefined || b === undefined) return false;
  if (a.length !== b.length) return false;
  return a.every((item, i) => Object.is(item, b[i]));    // ⭐ 逐项 Object.is
}
// ② 在「组件函数执行完」之后统一 flush(这是关键)
function scheduleEffects() {
  queueMicrotask(() => {
    pendingEffects.forEach(({ index, effect, deps, cleanup }) => {
      cleanup?.();                          // ⭐ 先执行上次的清理

      const result = effect();               // 再执行本次 effect
      hookStates[index] = {
        deps,
        cleanup: typeof result === 'function' ? result : undefined,   // 记住清理函数
      };
    });
    pendingEffects = [];
  });
}

三、完整实现(含卸载清理)

// ===== 运行时状态 =====
let hooks = [];                 // 每个槽位:{ deps, cleanup }
let cursor = 0;
let effectQueue = [];           // 本次渲染要执行/调度的 effect
let unmountQueue = [];          // 组件卸载时要执行的清理
let isMounted = false;

// ===== 渲染流程 =====
function render(Component, container) {
  cursor = 0;                   // ⭐ 游标归零
  effectQueue = [];

  const vnode = Component();    // 1. 执行组件函数(收集 effect)
  commitToDOM(vnode, container); // 2. 更新 DOM
  flushEffects();               // 3. ⭐ 渲染后统一执行 effect
}

// ===== useEffect 的「调度阶段」 =====
function useEffect(effect, deps) {
  const index = cursor++;
  const prev = hooks[index];

  const depsChanged =
    prev === undefined ||                 // 首次渲染
    deps === undefined ||                 // 不传依赖 → 每次都执行
    !depsEqual(prev.deps, deps);          // 依赖变化

  if (depsChanged) {
    effectQueue.push({ index, effect, deps, prev });
  }
}

// ===== useEffect 的「执行阶段」(渲染后) =====
function flushEffects() {
  const queue = effectQueue;
  effectQueue = [];

  queue.forEach(({ index, effect, deps, prev }) => {
    // ① 先执行「上一次的清理函数」
    if (typeof prev?.cleanup === 'function') {
      prev.cleanup();
    }

    // ② 再执行本次 effect
    const cleanup = effect();

    // ③ 记住「依赖」与「清理函数」,供下次比较/调用
    hooks[index] = {
      deps,
      cleanup: typeof cleanup === 'function' ? cleanup : undefined,
    };
  });
}

// ===== 卸载:执行所有清理 =====
function unmount() {
  hooks.forEach((hook) => {
    if (typeof hook?.cleanup === 'function') hook.cleanup();
  });
  hooks = [];
}
⭐ 三个关键设计:
① `effectQueue` + `flushEffects()` ——「收集」与「执行」分离
   → 保证「所有 effect 都在渲染完成后执行」(而不是在执行组件函数的过程中)
② `hooks[index] = { deps, cleanup }` —— 同时记住「依赖」与「清理函数」
   → 依赖用于「是否需要重跑」,清理函数用于「重跑前撤销」
③ 清理的执行顺序是「**先清理上一次,再执行本次**」
   → 与 React 一致(这也是 useEffect 比 componentDidUpdate 更安全的原因)
// ✅ 验证:执行顺序
function Demo({ id }) {
  useEffect(() => {
    console.log('effect', id);
    return () => console.log('cleanup', id);
  }, [id]);

  useEffect(() => {
    console.log('effect2', id);
    return () => console.log('cleanup2', id);
  }, [id]);

  return { /* jsx */ };
}

// 首次挂载:effect 1 → effect2 1
// id 变为 2:cleanup 1 → effect 2 → cleanup2 1 → effect2 2

四、多个 effect 的执行顺序

⭐ 真实 React 的顺序规则(手写实现也应尽量一致):

① 同一个组件内:【按声明顺序】执行
   (因为 effectQueue 是按 cursor 递增 push 的)

② 不同组件之间:子组件的 effect 【先于】父组件
   (因为 commit 阶段是「由下往上」收集 effect 的)

③ 清理函数的顺序:与「执行顺序」相反(后进先出)
   ⚠️ 这一条在手写实现里通常简化处理

④ useLayoutEffect 与 useEffect:
   · layout effect 在「DOM 更新后、绘制前」同步执行
   · passive effect(useEffect)在「绘制后」异步执行
   → 两者在同一个组件内,layout 先于 passive
// ✅ 若想模拟「清理顺序相反」,可以倒序遍历清理(简化版)
function flushEffects() {
  const queue = effectQueue;
  effectQueue = [];

  // ① 先「倒序」执行所有清理(后进先出)
  for (let i = queue.length - 1; i >= 0; i--) {
    const { prev } = queue[i];
    if (typeof prev?.cleanup === 'function') prev.cleanup();
  }

  // ② 再「顺序」执行所有 effect
  queue.forEach(({ index, effect, deps }) => {
    const cleanup = effect();
    hooks[index] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
  });
}

五、useLayoutEffect 的差异(同步执行)

// ✅ useLayoutEffect:不在「微任务」里执行,而是「同步」在 DOM 更新后立即执行
function useLayoutEffect(effect, deps) {
  const index = cursor++;
  const prev = hooks[index];

  const depsChanged = prev === undefined || deps === undefined || !depsEqual(prev.deps, deps);
  if (!depsChanged) return;

  // ⭐ 同步执行(不推入 effectQueue)
  if (typeof prev?.cleanup === 'function') prev.cleanup();

  const cleanup = effect();
  hooks[index] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
}
// ⚠️ 注意:真实实现是「收集起来在 commit 的 layout 阶段统一执行」,
//    这里为了简单直接同步执行(效果类似:都在「绘制前」完成)

六、常见坑

① 在「组件函数执行过程中」直接执行 effect
   → 副作用发生在「渲染未完成」时(可能被中断/丢弃,见 4.3)
   ✅ 必须「收集 → 渲染后统一执行」

② 忘记「保存清理函数」→ 下次执行前无法撤销上一次的订阅/定时器
③ 忘记「保存依赖」→ 无法判断「是否需要重跑」
④ 用 `===` 比较依赖(数组与对象永远不相等,且 NaN/-0 判断不对)
   ✅ 逐项 `Object.is`

⑤ 依赖数组长度变化时不处理(真实 React 会警告)
⑥ 把「依赖为 undefined(不传)」与「依赖为 [] 」混为一谈
   · 不传 deps → 每次渲染都执行
   · deps = [] → 只在挂载时执行一次

⑦ 忘记「卸载时执行清理」(内存泄漏)
⑧ 认为「清理只会在卸载时执行」(依赖变化时也会执行)
⑨ 在清理里访问「已被置 null 的 DOM ref」
⑩ 认为「手写实现与真实 React 等价」
   → 真实实现还有:passive effect 的调度(绘制后异步)、
     effect 链表、优先级、StrictMode 的双执行、
     以及「渲染被丢弃时丢掉已收集的 effect」
// ✅ 关于「渲染被丢弃」的处理(真实 React 会做,手写版可简化)
function render(Component, container) {
  cursor = 0;
  effectQueue = [];

  try {
    const vnode = Component();
    commitToDOM(vnode, container);
    flushEffects();
  } catch (err) {
    effectQueue = [];        // ⭐ 渲染失败 → 丢弃本次收集的 effect
    throw err;
  }
}

七、完整可运行版本

<!doctype html>
<html>
  <body>
    <div id="root"></div>
    <script>
      // ---------- 运行时 ----------
      let hooks = [];
      let cursor = 0;
      let effectQueue = [];
      let root = null;
      let Component = null;
      let scheduled = false;

      function h(type, props, ...children) {
        return { type, props: { ...props, children: children.length === 1 ? children[0] : children } };
      }

      function depsEqual(a, b) {
        if (a === undefined || b === undefined) return false;   // 不传 → 每次都执行
        if (a.length !== b.length) return false;
        return a.every((x, i) => Object.is(x, b[i]));
      }

      function useState(initial) {
        const i = cursor++;
        if (hooks[i]?.state === undefined && !hooks[i]?.initialized) {
          hooks[i] = { ...(hooks[i] ?? {}), state: typeof initial === 'function' ? initial() : initial, initialized: true };
        }
        const setState = (v) => {
          const prev = hooks[i].state;
          const next = typeof v === 'function' ? v(prev) : v;
          if (Object.is(prev, next)) return;
          hooks[i] = { ...hooks[i], state: next };
          schedule();
        };
        return [hooks[i].state, setState];
      }

      function useEffect(effect, deps) {
        const i = cursor++;
        const prev = hooks[i];
        const changed = prev === undefined || !depsEqual(prev?.deps, deps);
        if (changed) effectQueue.push({ i, effect, deps, prev });
      }

      function flushEffects() {
        const queue = effectQueue;
        effectQueue = [];
        queue.forEach(({ i, effect, deps, prev }) => {
          if (typeof prev?.cleanup === 'function') prev.cleanup();   // 先清理
          const cleanup = effect();                                   // 再执行
          hooks[i] = { ...(hooks[i] ?? {}), deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
        });
      }

      // ---------- 渲染 ----------
      function schedule() {
        if (scheduled) return;
        scheduled = true;
        queueMicrotask(() => { scheduled = false; render(Component, root); });
      }

      function render(C, container) {
        root = container; Component = C;
        cursor = 0;
        effectQueue = [];

        const vnode = C();                     // 1. 组件函数(收集 effect)
        container.innerHTML = '';              // 2. 提交 DOM
        container.appendChild(mount(vnode));
        flushEffects();                        // 3. ⭐ 渲染后统一执行 effect
      }

      function mount(vnode) {
        if (vnode == null || typeof vnode === 'boolean') return document.createTextNode('');
        if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));

        const el = document.createElement(vnode.type);
        const { children, ...props } = vnode.props ?? {};
        for (const [k, v] of Object.entries(props)) {
          if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
          else el.setAttribute(k, String(v));
        }
        (Array.isArray(children) ? children : [children]).forEach((c) => el.appendChild(mount(c)));
        return el;
      }

      // ---------- 用户代码 ----------
      function Timer() {
        const [count, setCount] = useState(0);
        const [running, setRunning] = useState(true);

        useEffect(() => {
          if (!running) return;
          const id = setInterval(() => setCount((c) => c + 1), 1000);
          console.log('[effect] 启动定时器');
          return () => { clearInterval(id); console.log('[cleanup] 清理定时器'); };   // ⭐ 清理
        }, [running]);

        useEffect(() => {
          document.title = `计数 ${count}`;
          return () => { document.title = '原来的标题'; };                            // ⭐ 恢复
        }, [count]);

        return h('div', null,
          h('p', null, `count = ${count}`),
          h('button', { onClick: () => setRunning((r) => !r) }, running ? '暂停' : '继续')
        );
      }

      render(Timer, document.getElementById('root'));
    </script>
  </body>
</html>
⭐ 跑起来观察:
① 点「暂停」时,控制台会打印「[cleanup] 清理定时器」→ 说明依赖变化时执行了清理
② 计数变化时,document.title 被更新,且「下一次变化前」会先恢复原标题

八、面试延伸

  1. 「手写一个 useEffect」
    核心三点:① 依赖比较——把「上次的依赖数组」存在 Hook 槽位里,用 Object.is 逐项比较(不传依赖数组表示「每次都执行」);② 收集与执行分离——在组件函数执行时只把需要执行的 effect 推进一个队列,等「组件函数执行完、DOM 提交后」统一执行(不能在渲染过程中执行,否则副作用发生在「渲染未完成」时);③ 清理函数——保存 effect 返回的函数,在下一次执行前与卸载时调用,且顺序是「先清理上次、再执行本次」。

  2. 「为什么 effect 要在渲染后统一执行,而不是遇到就执行?」
    因为「执行组件函数(render 阶段)必须是纯的」——它可能被中断、重放或丢弃(见 4.3)。如果在「渲染过程中」就执行副作用:① 副作用可能被执行多次(重放);② 已经执行的副作用无法撤销(丢弃时);③ 渲染还没结束就改了 DOM/外部状态,行为不可预测。所以 React 的做法是「收集起来,commit 之后再执行」——这也是 useEffect(绘制后)与 useLayoutEffect(绘制前)时机的来源。

  3. 「依赖数组怎么比较?」
    用 Object.is 逐项比较,而不是 ===(也不是 JSON.stringify)。原因:① === 对数组/对象总是 false(所以「数组比较」必须逐项);② Object.is 能正确处理 NaN(Object.is(NaN, NaN) === true)与 -0/+0(Object.is(-0, +0) === false),而 === 在这两个边界上会判错。另外要区分三种情况:不传依赖(每次都执行)、传 [](只挂载时执行)、传 [a, b](依赖变化时执行)。

  4. 「清理函数什么时候执行?」
    两个时机:① 依赖变化后、下一次 effect 执行前(用于「撤销上一次的同步」);② 组件卸载时(用于释放资源)。顺序上,React 是「先执行所有清理(倒序,后进先出),再执行所有 effect(顺序)」。关键理解是「清理不只发生在卸载」——它在每次「重新同步」前都会执行,这正是 useEffect 比 componentDidUpdate 更安全的原因(不需要手动比较前后值)。

  5. 「手写实现里怎么区分『不传依赖』和『传空数组』?」
    通过「deps === undefined」判断:不传时 deps 是 undefined → 判定为「每次都执行」(depsEqual 直接返回 false);传 [] 时 deps 是「空数组」→ 判定为「只在挂载时执行」(因为「空数组与空数组」是相等的,所以之后不再执行)。这个区分很关键——很多实现错误地把两者当成一回事。

  6. 「为什么要保存『上一次的清理函数』?」
    因为「清理」是「撤销上一次建立的资源」——如果每次执行 effect 都新建一个定时器/订阅而不清理旧的,就会「资源累积」(定时器越来越多、订阅重复触发)。所以要:① 在 effect 被收集时带上 prev(上一次的 deps 与 cleanup);② 在 flush 时先调用 prev.cleanup()、再执行本次 effect、并保存本次返回的 cleanup。这个「保存-调用」的循环就是清理机制的实现。

  7. 「useLayoutEffect 在手写实现里怎么处理?」
    区别只在「执行时机」:useEffect 把 effect 推进队列、在「微任务里(绘制后)」执行;useLayoutEffect 则「同步在 DOM 提交后立即执行」(绘制前)。手写时最简单的做法是「不推队列,直接同步执行」(同时执行上次的清理)。真实 React 更精细:它把 layout effect 收集到 draft queue,在 commit 的 layout 阶段统一同步执行;passive effect 则调度到绘制后。

  8. 「手写实现与真实 React 的差距在哪?」
    五处:① 调度精细化——真实的 passive effect 是「绘制后异步调度」(用 Scheduler),还支持「渲染被丢弃时丢弃已收集的 effect」;② effect 链表与优先级——真实实现把 effect 挂在 fiber 的更新队列上,支持优先级与中断;③ StrictMode 的双执行——真实的开发模式会「double invoke」(挂载 → 卸载 → 再挂载);④ useLayoutEffect/useInsertionEffect 的不同时机;⑤ 错误处理——effect 里的错误会被捕获并交给 Error Boundary(部分场景)。手写的目的是「理解依赖比较与清理机制」,不是复刻实现。

一句话速记

手写 useEffect 的核心是「把上次的 { deps, cleanup } 存在 Hook 槽位里」:用 Object.is 逐项比较依赖(不传依赖 = 每次都执行、[] = 只挂载一次,两者要区分);在组件函数执行时只『收集』effect,等渲染提交后『统一执行』(因为 render 必须纯、可能被中断/重放);执行时「先调用上次的清理、再执行本次 effect、并保存本次返回的 cleanup」,卸载时执行所有清理;useLayoutEffect 的差异只是「同步在绘制前执行」;手写版缺失的细节有「passive effect 的异步调度、effect 链表与优先级、StrictMode 双执行、渲染丢弃时丢弃 effect」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.2 手写 useEffect

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫