mini-react 第七天:实现useEffect

实现最简单的useEffect

调用时机是在 React 完成对 DOM 的渲染之后(commitWork(root.child)),并且浏览器完成绘制之前(wipRoot = null)。

首先写一个最简单的useEffect 调用:打印一个字符串

js 复制代码
function Foo() {
  console.log("re foo");
  const [count, setCount] = React.useState(10);
  const [bar, setBar] = React.useState("bar");
  function handleClick() {
    setCount((c) => c + 1);
    setBar(() => "bar");
  }
 
  React.useEffect(() => {
    console.log("init");
  }, []);
 
  return (
    <div>
      <h1>foo</h1>
      {count}
      <div>{bar}</div>
      <button onClick={handleClick}>click</button>
    </div>
  );
}

【思路】遍历fiber树,逐个查看是否有effect,有的话就执行:

  • 写useEffect函数,将callback和deps存入fiber。
  • 然后在commitWork(wipRoot.child)后从根节点开始按fiber链表结构调用每个fiber节点的effectHook。
    • 这里commitEffectHooks的节点遍历与commitWork中的稍有不同:不像commitWork需要append到parent,这里只是遍历。所以不需要查找和处理parent,只需要逐级遍历fiber节点本身, child和sibling。
js 复制代码
function useEffect(callback, deps) {
  const effectHook = {
    callback,
    deps,
  }

  wipFiber.effectHook = effectHook;
}

function commitRoot() {
  deletions.forEach(commitDeletion)
  commitWork(wipRoot.child);
  commitEffectHooks();
  wipRoot = null;
  deletions = [];
}

function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return
    fiber.effectHook?.callback()
    run(fiber.child)
    run(fiber.sibling)
  }
  run(wipRoot)
}

至此,已经实现了每一次渲染都执行effect。下一步就是根据依赖数组元素值是否变化来决定是否执行effect。

实现依赖数组

对于依赖数组的判断,分两种情况:

  1. 【初始化】如果fiber.alternate 没有值,那就是第一次执行
  2. 【更新】否则就是更新:如果老的effectHook中存储的依赖数组中有数据和新的有不同,就执行effect
js 复制代码
function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return

    if (!fiber.alternate) {
      fiber.effectHook?.callback()
    } else {
      const oldDeps = fiber.alternate.effectHook?.deps
      const newDeps = fiber.effectHook?.deps
      const hasChanged = newDeps.some((dep, i) => dep !== oldDeps[i])
      if (hasChanged) {
      fiber.effectHook?.callback()
      }
    }
    run(fiber.child)
    run(fiber.sibling)
  }
  run(wipRoot)
}

将count加入依赖数组,并且注释掉handleClick中对count的改变。

js 复制代码
function Foo() {
  const [count, setCount] = React.useState(10);
  const [bar, setBar] = React.useState("bar");
  function handleClick() {
    setCount((c) => c + 1);
    setBar(() => "bar");
  }

  React.useEffect(() => {
    console.log("update");
  }, [bar]);
 
  return (
    <div>
      <h1>foo</h1>
      {count}
      <div>{bar}</div>
      <button onClick={handleClick}>click</button>
    </div>
  );
}

发现在bar不变的时候,不会再执行effect了。effect只在第一次渲染时执行。

支持多个useEffect

js 复制代码
function Foo() {
  const [count, setCount] = React.useState(10);
  const [bar, setBar] = React.useState("bar");
  function handleClick() {
    setCount((c) => c + 1);
    setBar(() => "bar");
  }
 
  React.useEffect(() => {
    console.log("init");
  }, []);

  React.useEffect(() => {
    console.log("update");
  }, [bar, count]);
  ...

类似于前一天课程的useState, 我们也使用数组来存储和调用多个effect

js 复制代码
let effectHooks;
function useEffect(callback, deps) {
  const effectHook = {
    callback,
    deps,
  }
  effectHooks.push(effectHook);

  wipFiber.effectHooks = effectHooks;
}

function updateFunctionComponent(fiber) {
  stateHooks = [];
  stateHookIndex = 0;
  
  //对于每个fiber节点进行初始化
  effectHooks = [];
  wipFiber = fiber;
  const children = [fiber.type(fiber.props)]
  reconcileChildren(fiber, children)
}

原来的单个hook调用改为依次调用

js 复制代码
function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return

    if (!fiber.alternate) {
      fiber.effectHooks?.forEach(hook => hook?.callback());
    } else {
      fiber.effectHooks?.forEach((newHook, index) => {
        const oldDeps = fiber.alternate.effectHooks[index]?.deps
        const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
        if (hasChanged) {
          newHook.callback();
        }
      })
    }
    run(fiber.child)
    run(fiber.sibling)
  }
  run(wipRoot)
}

实现cleanup


  • useEffect 返回的一个函数。用来在执行本次effect之前,清理上一次effect。
  • deps 为空的时候不会调用返回的cleanUp

设计一下Foo组件,让它先后打印update和cleanup。

js 复制代码
function Foo() {
  const [count, setCount] = React.useState(10)
  const [bar, setBar] = React.useState("bar")
  function handleClick() {
    setCount(c => c + 1)
    setBar(() => "bar")
  }
  React.useEffect(() => {
    console.log("init")
    return () => {
      console.log("cleanUp 0")
    }
  }, [])

  React.useEffect(() => {
    console.log("update", count)
    return () => {
      console.log("cleanUp 1")
    }
  }, [count])

  React.useEffect(() => {
    console.log("update", count)
    return () => {
      console.log("cleanUp 2")
    }
  }, [count])

  return (
    <div>
      <h1>Foo : {count}</h1>
      <div>{bar}</div>
      <button onClick={handleClick}>click</button>
    </div>
  )
}
  • 执行hook.callback 并且将返回的函数赋值给hook.cleanUp
  • 写一个runCleanup函数,在run之前执行
    • React选择"先整树清理,再整树执行回调"的设计,本质是出于副作用隔离闭包值稳定性 的考虑。
      • 若逐个节点交替执行cleanup和callback,可能导致不同节点的副作用相互干扰(例如前一节点的callback修改了全局状态,影响后续节点的cleanup逻辑)‌
      • 清理函数中访问的propsstate是上一次渲染时捕获的值。若清理函数与回调函数交替执行,可能导致新回调中使用的值与清理函数中的旧值产生冲突‌。
js 复制代码
function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return

    if (!fiber.alternate) {
      fiber.effectHooks?.forEach(hook => {
        hook.cleanUp = hook?.callback();  // 将返回的函数赋值
      });
    } else {
      fiber.effectHooks?.forEach((newHook, index) => {
        if (newHook.deps.length > 0) {
          const oldDeps = fiber.alternate.effectHooks[index]?.deps
          const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
          if (hasChanged) {
              newHook.cleanUp = newHook.callback(); // 将返回的函数赋值
          }
        }
      })
    }
    run(fiber.child)
    run(fiber.sibling)
  }
  function runCleanup(fiber) {
    if (!fiber) return

    fiber.alternate?.effectHooks?.forEach((hook) => {
      if (hook.deps.length > 0) {
        hook?.cleanUp && hook?.cleanUp()
      }
    })
    runCleanup(fiber.child)
    runCleanup(fiber.sibling)
  }
  runCleanup(wipRoot)
  run(wipRoot)
}

看看效果:

  1. 依赖数组为空的useEffect, 其cleanup函数不会被执行。因为这个effect并不会被再次执行
  2. 依赖数组元素变化的useEffect, 其cleanup会和fiber树上的其它cleanup一起首先被执行。然后effect才会被执行。

支持不传依赖数组

按照react的设计,当不传依赖数组参数时,应当每次都执行effect,也会在下次 effect 执行前或组件卸载时运行cleanup。

js 复制代码
  React.useEffect(() => {
    console.log("init every time")
    return () => {
      console.log("cleanUp every time")
    }
  }, undefined)

添加一些判断即可完成

js 复制代码
function commitEffectHooks() {
  function run(fiber) {
    if (!fiber) return

    if (!fiber.alternate) {
      fiber.effectHooks?.forEach(hook => {
        hook.cleanUp = hook?.callback();
      });
    } else {
      fiber.effectHooks?.forEach((newHook, index) => {
        if (newHook.deps?.length > 0) {
          const oldDeps = fiber.alternate.effectHooks[index]?.deps
          const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
          if (hasChanged) {
            newHook.cleanUp = newHook.callback();
          }
        } else if (newHook.deps === undefined) {
          // 依赖数组为空,则每次都执行effect
          newHook.cleanUp = newHook.callback();
        }
      })
    }
    run(fiber.child)
    run(fiber.sibling)
  }
  function runCleanup(fiber) {
    if (!fiber) return

    fiber.alternate?.effectHooks?.forEach((hook) => {
      // 依赖数组为空也执行cleanup
      if (hook.deps?.length > 0 || hook.deps === undefined) {  
        hook?.cleanUp && hook?.cleanUp()
      }
    })
    runCleanup(fiber.child)
    runCleanup(fiber.sibling)
  }
  runCleanup(wipRoot)
  run(wipRoot)
}

尾声

至此,我们的mini-react 已经完成了。接下来我们可以使用它来做一个小应用:todos。

相关推荐
疏狂难除7 小时前
基于SeaORM+MySQL+Tauri2+Vite+React等的CRUD交互项目
前端·react.js·前端框架
在广东捡破烂的吴彦祖8 小时前
React关闭缓存标签页刷新页面的hooks
react.js
FE_C_P小麦8 小时前
使用 Redux Toolkit封装一个模块化的 store
react.js·typescript·redux
随笔记8 小时前
用create-react-app脚手架创建react项目报错怎么办
前端·react.js·typescript
前端加油站11 小时前
Errorboundary详解
前端·react.js
小成C12 小时前
一文搞懂 React useState的内部机制:闭包状态持久化的奥秘
前端·javascript·react.js
敲代码的玉米C12 小时前
React中useReducer钩子的使用指南
react.js
IT、木易13 小时前
大白话解释 React 中高阶组件(HOC)的概念和应用场景,并实现一个简单的 HOC。
前端·javascript·react.js
束尘14 小时前
React前端开发中实现断点续传
前端·javascript·react.js