likes
comments
collection
share

为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

作者站长头像
站长
· 阅读数 22

Hooks 让我们能够在函数组件中使用状态和其他 React 特性,而不需要使用类组件。在 Hooks 的背后,有一个关键的数据结构,那就是 Hooks 链表。Hooks 链表是 React 内部用于管理函数组件中多个 Hooks 状态的数据结构。本文将深入探究 Hooks 链表的原理和用途,带你逐步了解 Hooks 的实现细节和优势。

Fiber 架构

在前面的内容中我们学习了整个 Fiber 的实现原理,讲解到 Fiber 是如何被创建并构造 Fiber 树的。

React 中,更新一个组件是分为两个主要阶段: render 渲染阶段和 commit 提交阶段。这两个阶段分别负责不同的任务,以确保组件的 UI 与状态同步,并将更改更新到真实的 DOM

render 阶段

在这个阶段,React 会根据组件的转改和 Props 计算出新的虚拟 DOM 树,并与之前的虚拟 DOM 树进行比较,找出需要更新的部分。

在这个阶段中,以下 hooks 会被执行: useStateuseReduceruseContextuseMemouseCallbackuseRef

以下是 Reactrender 阶段会做的事情。

生成 Fiber 树

React 首先通过组件树构建一个 Fiber 树。构建的过程是通过一个 的过程。具体流程请看下面的例子:

function App() {
  return (
    <div>
      我 是<span>靓仔</span>
    </div>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));

对应的 Fiber 树结构如下图所示: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

render 阶段,会一次执行:

  1. rootFiber beginWork
  2. App Fiber beginWork
  3. div Fiber beginWork
  4. 我 是 Fiber beginWork
  5. 我 是 Fiber completeWork
  6. span Fiber beginWork
  7. span Fiber completeWork
  8. div Fiber completeWork
  9. App Fiber completeWork
  10. rootFiber completeWork

协调

React 使用 Fiber 树来进行协调过程,这包括对组件等更新、比较和查找差异。React 使用 Fiber 树上的节点来跟踪组件的状态和变化,以便在需要时中断、恢复和重新启动渲染过程。

生成新的虚拟 DOM 树

在进行协调的过程中,React 会计算组件的 UI 更新,并生成一个新的虚拟 DOM 树,这个虚拟 DOM 树反映了组件的最新状态。

在这个过程中,React 会为每个组件生成一个 effectTag,用于标记该组件需要在完成工作之后执行的操作,例如:

// DOM需要插入到页面中
export const Placement = /*                */ 0b00000000000010;
// DOM需要更新
export const Update = /*                   */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /*       */ 0b00000000000110;
// DOM需要删除
export const Deletion = /*                 */ 0b00000000001000;

到这里,beginWork 也已经结束了,紧接着进入 completeWork 阶段来完成对组件更新的工作。

对比更新

这个时候已经进入 completeWork 阶段了,它会通过比较新旧虚拟 DOM 树,React 能够确定哪些部分的 UI 需要进行更新。这些差异可以分为插入、更新和删除等操作。

作为 DOM 操作的依据,commit 阶段需要找到所有有 effectTagFiber 节点并依次执行 effectTag 对应操作。为了避免在 commit 阶段需要再遍历一次 Fiber 树,它会在 completeWork 的上层函数 completeUnitOfWork中,每个执行完 completeWork 且存在 effectTagFiber 节点会被保存在一条被称为 effectList 的单向链表中。

为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

effectList 中第一个 Fiber 节点保存在 fiber.firstEffect,最后一个元素保存在 fiber.lastEffect

commit 阶段

React 中,commit 阶段是在 render 阶段之后的一个重要阶段。在 render 阶段,React 已经完成了对组件的协调和虚拟 DOM 树的构建,而在 commit 阶段,React 将根据 render 阶段计算出的更新队列,将更改应用到真实的 DOM,从而更新组件的 UI 并反映最新的状态。

rootFiber.firstEffect 上保存了一条需要执行副作用的 Fiber 节点的单向链表 effectList,这些 Fiber 节点的 updateQueue 中保存了变化的 props

这些副作用对应的 DOM 操作在 commit 阶段执行。初次之外,一些生命周期钩子、hooks 需要在 commit 阶段执行

commit 阶段主要的工作分为三部分:

  • before mutation 阶段,执行 DOM 操作前;
  • mutation 阶段,执行 DOM 操作;
  • layout 阶段,执行 DOM 操作后;

为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

layout 阶段在操作 DOM 之后,所以这个阶段是能拿到 DOM 的,Ref 更新是在这个阶段,useLayoutEffect 回调函数的执行也是在这个阶段。

useEffect 的执行时机是在组件完成更新并将变化应用到真实 DOM 之后。也就是说,在布局 layout 阶段之后、绘制 paint 阶段之前,useEffect 的回调函数会被调用。

小结

ReactHooks 在组件的生命周期中主要分为两个阶段: render 阶段和 commit 阶段。不同的 Hooks 在这两个阶段执行的时机和目的略有不同。

其中在 render 阶段中,以下 hooks 会被执行: useStateuseReduceruseContextuseMemouseCallbackuseRef

commit 阶段中,以下 hooks 会被执行: useEffectuseLayoutEffectuseImperativeHandle

理清了 react 的渲染流程 render + commit之后,我们来进入今天的主要内容 hooks 实现原理部分内容了。

HooKs 链表

当函数组件进入 render 阶段时,会被 renderWithHooks 函数处理。函数组件作为一个函数,它的渲染其实就是函数调用,而函数组件又会调用 React 提供的 hooks 函数。

初始挂载和更新时,所用的 hooks 函数是不同的,比如初次挂载时调用的 useEffect,和后续更新时调用的 useEffect,虽然都是同一个 hook,但是因为在两个不同的渲染过程中调用它们,所以本质上他们两个是不一样的。这种不一样来源于函数组件要维护一个 hooks 的链表,初次挂载时要创建链表,后续更新的时候要更新链表。

分属于两个过程的 hook 函数会在各自的过程中被赋值到 ReactCurrentDispatchercurrent 属性上。所以在调用函数组件当务之急是根据当前所处的阶段来决定 ReactCurrentDispatchercurrent,这样才可以在正确的阶段调用到正确的 hook 函数。 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣 根据 renderWithHooks 中的代码看出,第一次渲染 ReactCurrentDispatcher.current 是对象 HooksDispatcherOnMount,其它时候是 HooksDispatcherOnUpdate

两者它们内部的 hooks 函数是不同的实现,区别之一在于不同阶段对于 hooks 链表的处理不同:

为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

确认完成 ReactCurrentDispatcher.current 之后,紧接着调用 Component 函数,并传入 propssecondArg 两个参数: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

最终返回当前的 children,也就是我们所说的 jsx

认识 hooks 链表

无论是初次挂载函数更新,每调用一次 hooks 函数,都会产生一个 hook 对象与之对应,以下是 hook 对象的结构:

{
    baseQueue: null,
    baseState: 'hook1',
    memoizedState: null,
    queue: null,
    next: {
        baseQueue: null,
        baseState: null,
        memoizedState: 'hook2',
        next: null
        queue: null
    }
}

那就让我们来看看首次渲染时的 hooks 链表是如何形成的吧: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

产生的 hook 对象依次排列,形成链表在到函数组件 fiber.memoizedState 上,也 ius 在调用 Component 时产生的: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

组件挂载

初次挂载时,组件上没有任何 hooks 的信息,所以,这个过程主要是在 fiber 上创建 hooks 链表。挂载调用的是 mountWorkInProgressHook 函数,也就是我们前面的代码截图中,它会创建 hook 并将它们连接成链表,同时更新 workInProgressHook,最终返回创建的 hooks 也就是 hook 链表。

我们在组件中调用 hook 函数,就可以获取到 hook 对象,例如 useState,它会调用 mountState 函数: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

还是用到我们之前的例子:

const App = () => {
  const [state, setState] = useState(1);

  const [a, setA] = useState(2);

  return <div>1</div>;
};
  1. 执行第一个 useState 命令,进入 mountWorkInProgressHook 函数,创建一个初始值为 1hook 节点,并将 currentlyRenderingFiber.memoizedState 指向它;

  2. 初始值被记录在 currentlyRenderingFiber 上,此时 memoizedStatenull 变为 1。此时 hooks 链表如下所示:

    为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

  3. 执行第二个 useState 命令,生成一个初始值为 2hook 节点,然后将上一个 hook 节点指向它,重复以上步骤,形成一个链表结构;

hook 链表就是这样被创建出来的,那么我们怎么去更新它呢?那当然是 dispatch 函数啦。

组件更新

数组件每次更新,每一次 react-hooks 函数执行,都需要有一个函数去做上面的操作,这个函数就是 updateWorkInProgressHook

updateWorkInProgressHook

我们接下来一起看这个 updateWorkInProgressHook:

function updateWorkInProgressHook(): Hook {
  // 确定nextCurrentHook的指向
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    // currentHook在函数组件调用完成时会被设置为null,
    // 这说明组件是刚刚开始重新渲染,刚刚开始调用第一个hook函数。
    // hooks链表为空
    const current = currentlyRenderingFiber.alternate;

    if (current !== null) {
      // current节点存在,将nextCurrentHook指向current.memoizedState
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 这说明已经不是第一次调用hook函数了,
    // hooks链表已经有数据,nextCurrentHook指向当前的下一个hook
    nextCurrentHook = currentHook.next;
  }
  // 确定nextWorkInProgressHook的指向
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    // workInProgress.memoizedState在函数组件每次渲染时都会被设置成null,
    // workInProgressHook在函数组件调用完成时会被设置为null,
    // 所以当前的判断分支说明现在正调用第一个hook函数,hooks链表为空
    // 将nextWorkInProgressHook指向workInProgress.memoizedState,为null
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    // 走到这个分支说明hooks链表已经有元素了,将nextWorkInProgressHook指向
    // hooks链表的下一个元素
    nextWorkInProgressHook = workInProgressHook.next;
  }

  if (nextWorkInProgressHook !== null) {
    // 依据上面的推导,nextWorkInProgressHook不为空说明hooks链表不为空
    // 更新workInProgressHook、nextWorkInProgressHook、currentHook
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // 走到这个分支说明hooks链表为空
    // 刚刚调用第一个hook函数,基于currentHook新建一个hook对象,

    invariant(
      nextCurrentHook !== null,
      "Rendered more hooks than during the previous render."
    );
    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,

      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,

      next: null,
    };

    // 依据情况构建hooks链表,更新workInProgressHook指针
    if (workInProgressHook === null) {
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

这个函数主要做的流程是: 如果是首次执行 Hooks 函数,就会从已有的 current 树中取到对应的值,然后声明 nextWorkInProgressHook,经过一系列的操作,得到更新后的 Hooks 状态。 在这里要注意一点,大多数情况下,workInProgress 上的 memoizedState 会被置空,也就是 nextWorkInProgressHook 应该为 null。但执行多次函数组件时,就会出现循环执行函数组件的情况,此时 nextWorkInProgressHook 不为 null

workInProgressHook 指向当前正在处理的 Hook 对象,同时帮助构建 Hooks 的链表,以确保 Hooks 的处理顺序和状态管理的正确性。在函数组件的 Render 阶段,React 会根据 workInProgressHook 来正确执行 Hooks 并处理组件的状态和副作用。

通过这样的处理,React 能够在函数组件的多次渲染之间正确地管理和更新 Hooks 的状态。

updateState

接下来我们来看看一次组件的更新中,都干了些啥?

为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

updateState 中调用了 updateReduce 函数,也就是说,useStateuseReducer 的低配版。

这个函数不会详细讲,这在下一篇文章详细展开来讲。

updateReducer 的作用是将待更新的队列 pendingQueue 合并到 baseQueue 上,之后进行循环更新,最后进行一次合成更新,也就是批量更新,统一更换节点。 这种行为解释了 useState 在更新的过程中为何传入相同的值,不进行更新,同时多次操作,只会执行最后一次更新的原因了。

这里以 useState 的运行流程来简单回顾一下: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

为什么不能在 if 里面写 hooks

根据前面的内容,我们来看以下的例子:

const App = () => {
  const isFirstRender = true;
  const [state, setState] = useState(1);
  if (isFirstRender) {
    const [a, setA] = useState(2);
  }

  useEffect(() => {
    console.log(3);
  }, []);

  return <div onClick={() => setA(3)}>1</div>;
};

后续组件重新 render 是时,if 判断进不去,会发生下面的情况: 为什么不能在if语句里面写hooks,HooKs链表写得清清楚楚🤣🤣🤣

一旦在条件语句中声明 hooks,函数组件更新时,hooks 链表结构被破坏,currentFiber 树 的 memoizedState 缓存 hooks 链表 的信息,和 workInProgress 不一致,如果涉及到读取 state 等操作,就会发生异常。因此不能在条件、循环语句中使用 hooks

参考文章

总结

Hooks 链表是 React 内部用于在函数组件中管理多个 Hooks 状态的数据结构。它是在 Render 阶段创建的,用于记录函数组件中所有使用的 Hooks 及其对应的状态信息。通过 Hooks 链表,React 能够准确地跟踪每个 Hook 的调用顺序和状态,从而实现组件的状态管理和更新。从而实现更高效、可预测的组件渲染和更新。

Hooks 链表使得 React 能够在函数组件的多次渲染之间复用 Hooks 对象,从而实现状态的保持和更新。

每个函数组件都有自己的 Hooks 链表,Hooks 链表存储在对应的 Fiber 节点中,保证了每个组件的 Hooks 是独立且安全的。

最后分享两个我的两个开源项目,它们分别是:

这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰