三十六、Hooks原理


面试题:Hook是如何保存函数组件状态的?为什么不能在循环,条件或嵌套函数中调用 Hook ?

Hook 内部介绍

在 React 中,针对 Hook 有三种策略,或者说三种类型的 dispatcher:

  • HooksDispatcherOnMount:负责初始化工作,让函数组件的一些初始化信息挂载到 Fiber 上面
/* 函数组件初始化用的 hooks */
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  ...
    useCallback: mountCallback,
  useEffect: mountEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  ...
    };
  • HooksDispatcherOnUpdate:函数组件进行更新的时候,会执行该对象所对应的方法。此时 Fiber 上面已经存储了函数组件的相关信息,这些 Hook 需要做的就是去获取或者更新维护这些 FIber 的信息
/* 函数组件更新用的 hooks */
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  ...
    useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  ...
    };
  • ContextOnlyDispatcher:这个是和报错相关,防止开发者在函数组件外部调用 Hook
/* 当hooks不是函数组件内部调用的时候,调用这个hooks对象下的hooks,所以报错。 */
export const ContextOnlyDispatcher: Dispatcher = {
  readContext,
  ...
    useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  ...
    };

总结一下:

  • mount 阶段:函数组件是进行初始化,那么此时调用的就是 mountXXX 对应的函数
  • update 阶段:函数组件进行状态的更新,调用的就是 updateXXX 对应的函数
  • 其他场景下(报错):此时调用的就是 throwInvalidHookError

当 FC 进入到 render 流程的时候,首先会判断是初次渲染还是更新:

if(current !== null && current.memoizedState !== null) {
  // 说明是 update
  ReactCurrentDispatcher.current = HooksDispatcherOnUpdate;
} else {
  // 说明是 mount
  ReactCurrentDispatcher.current = HooksDispatcherOnMount;
}

判断了是mount还是update之后,会给 ReactCurrentDispatcher.current 赋值对应的 dispatcher,因为赋值了不同的上下文对象,因此就可以根据不同上下文对象调用不同的方法。

假设有嵌套的 hook:

useEffect(()=>{
  useState(0);
})

那么此时的上下文对象指向 ContextOnlyDispatcher,最终执行的就是 throwInvalidHookError,抛出错误。

接下来我们来看一下 hook 的一个数据结构

const hook = {
  memoizedState: null, // 存储该Hook的当前状态值
  baseState: null,
  baseQueue: null,
  queue: null, // 存储该Hook的更新队列
  next: null // 指向下一个hook
}

这里需要注意 memoizedState 字段,因为在 FiberNode 上面也有这么一个字段,与 Hook 对象上面的

需要理解一个东西就是react中一个组件是一个fiberNode,组件里用了div或者span等,那么div和span也都有自己对应的fiberNode。

memoizedState 存储的东西是不一样的:

  • FiberNode.memoizedState:保存的是 Hook 链表里面的第一个链表,因为组件是一个fiberNode,所以组件里的hook也是在这个fiberNode里存储的,然后hook是以链表的形式存储的,所以FiberNode.memoizedState保存的是 Hooks链表里最新的节点,因为是链表所以知道第一个节点就能遍历整个链表,链表的每一个节点是一个hook对象组件更新的时候,react会遍历hooks链表来更新每个hook的状态。
  • hook.memoizedState:某个 hook 自身的数据,每一个hook是一个对象

不同类型的 hook,hook.memoizedState 所存储的内容也是不同的

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值
  • useReducer:对于 const [state, dispatch] = useReducer(reducer, { } ),memoizedState 保存的是 state 的值
  • useEffect:对于 useEffect( callback, […deps] ),memoizedState 保存的是 callback、[…deps] 等数据
  • useRef:对于 useRef(initialValue),memoizedState 保存的是 { current: initialValue}
  • useMemo:对于 useMemo( callback, […deps] ),memoizedState 保存的是 [callback( )、[…deps]] 数据
  • useCallback:对于 useCallback( callback, […deps] ),memoizedState 保存的是 [callback、[…deps]] 数据

有些 Hook 不需要 memoizedState 保存自身数据,比如 useContext。

在React的Fiber架构中,FiberNode.memoizedState属性用于存储与该FiberNode相关的状态信息。具体存储的内容取决于FiberNode所代表的组件类型和使用的特性。以下是一些常见的使用场景和存储内容:

1. 对于函数式组件

在函数式组件中,memoizedState主要用于存储Hooks的状态。

  • useState:
  • 当使用useState Hook时,memoizedState会存储当前的状态值。
  • 例如,const [count, setCount] = useState(0)memoizedState会存储count的当前值。
  • useEffect:
  • 当使用useEffect Hook时,memoizedState会存储副作用的相关信息,如依赖数组和副作用函数。
  • 其他Hooks:
  • 其他Hooks(如useContextuseReducer等)也会将相关状态存储在memoizedState中。

2. 对于Host组件(如DOM元素)

对于Host组件(如divspan等),memoizedState通常为null,因为这些组件不持有状态。但某些情况下,它也可能存储一些与DOM相关的信息。

示例说明

函数式组件示例

function MyComponent() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState("Hello");

  useEffect(() => {
    console.log("Effect runs");
  }, [count]);

  return (
    <div onClick={() => setCount(count + 1)}>
      {count} - {text}
    </div>
  );
}

在这个示例中,MyComponent的FiberNode的memoizedState会存储以下信息:

  • count的状态值。
  • text的状态值。
  • useEffect的副作用信息和依赖数组。

总结

  • 对于函数式组件,memoizedState主要存储Hooks的状态和副作用信息。
  • 对于Host组件,memoizedState通常为null,但在某些情况下也可能存储与DOM相关的信息。

Hook 的一个执行流程

当 FC 进入到 render 阶段时,会被 renderWithHooks 函数处理执行:

export function renderWithHooks(current, workInProgress, Component, props, secondArg, nextRenderLanes) {
  renderLanes = nextRenderLanes;
  currentlyRenderingFiber = workInProgress;

  // 每一次执行函数组件之前,先清空状态 (用于存放hooks列表)
  // 注意组件首次挂载和卸载才会执行这个
  // 具体看代码块下面的介绍
  workInProgress.memoizedState = null;
  // 清空状态(用于存放effect list)
  workInProgress.updateQueue = null;
  // ...

  // 判断组件是初始化流程还是更新流程
  // 如果初始化用 HooksDispatcherOnMount 对象
  // 如果更新用 HooksDispatcherOnUpdate 对象
  // 初始化对应的上下文对象,不同的上下文对象对应了一组不同的方法
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
    ? HooksDispatcherOnMount
    : HooksDispatcherOnUpdate;

  // 执行我们真正函数组件,所有的 hooks 将依次执行。
  let children = Component(props, secondArg);

  // ...

  // 判断环境
  finishRenderingHooks(current, workInProgress);
  return children;
}

function finishRenderingHooks(current, workInProgress) {
  // 防止 hooks 在函数组件外部调用,如果调用直接报错
  ReactCurrentDispatcher.current = ContextOnlyDispatcher;
  // ...
}

在React的源码中,workInProgress.memoizedState = null; 这行代码确实存在,但它并不是在更新过程中直接丢弃Hooks链表,而是用于重置和初始化某些状态。让我们详细解释这行代码的作用和上下文。

语境和目的

workInProgress 是React Fiber架构中的一个概念,它表示当前正在处理的Fiber节点。workInProgress.memoizedState 用于存储该Fiber节点的状态,包括Hooks链表。

在特定的阶段,React会重置workInProgress.memoizedStatenull,这通常发生在以下几种情况:

  1. 组件首次挂载:
  • 在组件首次挂载时,workInProgress.memoizedState会被初始化为null,然后在Hooks的初始化过程中逐步构建Hooks链表。
  1. 组件卸载:
  • 在组件卸载时,React会清理相关状态,包括将workInProgress.memoizedState重置为null
  1. 某些特殊情况的错误处理:
  • 在某些错误处理或异常情况下,React可能会重置workInProgress.memoizedState以避免状态污染。

具体代码分析

以下是一个简化的示例,展示了在组件首次挂载时,workInProgress.memoizedState是如何被重置和初始化的:

function mountIndeterminateComponent(current, workInProgress, Component, renderExpirationTime) {
  // 初始化阶段,重置memoizedState
  workInProgress.memoizedState = null;

  // 其他初始化逻辑...
  // 例如,调用组件函数,构建Hooks链表等

  return someValue; // 返回某些值以继续处理
}

在这个示例中,workInProgress.memoizedState = null; 用于确保在组件首次挂载时,状态是从一个干净的状态开始构建的。

更新过程中的Hooks链表

在组件的更新过程中,React并不会丢弃现有的Hooks链表。更新过程大致如下:

  1. 复制现有状态:
  • 在开始更新时,React会从当前Fiber节点(current)复制状态到workInProgress节点。
  1. 遍历和更新Hooks链表:
  • React会遍历现有的Hooks链表,根据更新队列来更新每个Hook的状态。
  1. 构建新的Fiber树:
  • 在遍历和更新过程中,React会构建新的Fiber树,但并不会丢弃现有的Hooks链表。

示例说明

假设我们有以下组件:

function MyComponent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

初始挂载

  1. 初始化workInProgress.memoizedState
  • 在首次挂载时,workInProgress.memoizedState被重置为null
  1. 构建Hooks链表:
  • 调用useState(0)时,创建第一个Hook实例,并将其添加到Hooks链表中。
  1. 存储Hooks链表:
  • Hooks链表的头节点被存储在workInProgress.memoizedState中。

更新过程

  1. 复制状态:
  • 在更新时,React会从当前Fiber节点复制状态到workInProgress节点。
  1. 遍历Hooks链表:
  • React遍历Hooks链表,根据更新队列更新每个Hook的状态。
  1. 更新workInProgress.memoizedState
  • 更新后的Hooks链表仍然存储在workInProgress.memoizedState中。

总结

  • workInProgress.memoizedState = null; 用于初始化或重置状态,通常在组件首次挂载或卸载时使用。
  • 在更新过程中,React不会丢弃现有的Hooks链表,而是会遍历和更新它。
  • 更新后的Hooks链表仍然存储在workInProgress.memoizedState中,确保状态的一致性和连续性。

renderWithHooks 会在每次函数组件触发时(mount、update),该方法就会清空 workInProgress 的 memoizedState 以及 updateQueue,接下来判断该组件究竟是初始化还是更新,为 ReactCurrentDispatcher.current 赋值不同的上下文对象,之后调用Component 方法来执行函数组件,组件里面所书写的 hook 就会依次执行。

接下来我们来以 useState 为例看一下整个 hook 的执行流程:

function App(){
  const [count, setCount] = useState(0);
  return <div onClick={()=>setCount(count+1)}>{count}</div>
}

接下来就会根据你是 mount 还是 update 调用不同上下文里面所对应的方法。

mount 阶段调用的是 mountState,相关代码如下:

function mountState(initialState) {
  // 1. 拿到 hook 对象
  const hook = mountWorkInProgressHook();
  if (typeof initialState === "function") {
    initialState = initialState();
  }

  // 2. 初始化hook的属性
  // 2.1 设置 hook.memoizedState/hook.baseState
  hook.memoizedState = hook.baseState = initialState;
  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  // 2.2 设置 hook.queue
  hook.queue = queue;

  // 2.3 设置 hook.dispatch
  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));

  // 3. 返回[当前状态, dispatch函数]
  return [hook.memoizedState, dispatch];
}

上面在执行 mountState 的时候,首先调用了 mountWorkInProgressHook,该方法的作用就是创建一个 hook 对象,相关代码如下:

function mountWorkInProgressHook() {
  const hook = {
    memoizedState: null, // Hook 自身维护的状态

    baseState: null,
    baseQueue: null,
    queue: null, // Hook 自身维护的更新队列

    next: null, // next 指向下一个 Hook
  };

  // 最终 hook 对象是要以链表形式串联起来,因此需要判断当前的 hook 是否是链表的第一个
  if (workInProgressHook === null) {
    // 如果当前组件的 Hook 链表为空,那么就将刚刚新建的 Hook 作为 Hook 链表的第一个节点(头结点) 
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 如果当前组件的 Hook 链表不为空,那么就将刚刚新建的 Hook 添加到 Hook 链表的末尾(作为尾结点)
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

假设现在我们有如下的一个组件:

function App() {
  const [number, setNumber] = React.useState(0); // 第一个hook
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个hook
    console.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
      <div onClick={() => setNumber(number + 1)}> {number} </div>
      <div onClick={() => setNum(num + 1)}> {num}</div>
    </div>
  );
}

当上面的函数组件第一次进行初始化后,就会形成一个 hook 的链表:


接下来我们来看一下更新,更新的时候会执行 updateXXX 对应的方法,相关的代码如下:

function updateWorkInProgressHook() {
  let nextCurrentHook;
  if (currentHook === null) {
    // 从 当前渲染的fiber身上的alternate 上获取到 fiber 对象
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      // 拿到第一个 hook 对象
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    // 拿到下一个 hook
    nextCurrentHook = currentHook.next;
  }

  // 更新 workInProgressHook 的指向
  // 让 workInProgressHook 指向最新的 hook
  let nextWorkInProgressHook; // 下一个要进行工作的 hook
  if (workInProgressHook === null) {
    // 当前是第一个,直接从 fiber 上获取第一个 hook
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    // 取链表的下一个 hook
    nextWorkInProgressHook = workInProgressHook.next;
  }

  // nextWorkInProgressHook 指向的是当前要工作的 hook
  if (nextWorkInProgressHook !== null) {
    // There's already a work-in-progress. Reuse it.
    // 进行复用
    workInProgressHook = nextWorkInProgressHook; 
    nextWorkInProgressHook = workInProgressHook.next;

    currentHook = nextCurrentHook;
  } else {
    // Clone from the current hook.
    // 进行克隆
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        // This is the initial render. This branch is reached when the component
        // suspends, resumes, then renders an additional hook.
        const newHook = {
          memoizedState: null,

          baseState: null,
          baseQueue: null,
          queue: null,

          next: null,
        };
        nextCurrentHook = newHook;
      } else {
        // This is an update. We should always have a current hook.
        throw new Error("Rendered more hooks than during the previous render.");
      }
    }

    currentHook = nextCurrentHook;

    const newHook = {
      memoizedState: currentHook.memoizedState,

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

      next: null,
    };
    // 之后的操作和 mount 时候一样
    if (workInProgressHook === null) {
      // This is the first hook in the list.
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      // Append to the end of the list.
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

在上面的源码中,有一个非常关键的信息:

// ...
if (nextWorkInProgressHook !== null) {
  // There's already a work-in-progress. Reuse it.
  // 进行复用
  workInProgressHook = nextWorkInProgressHook; 
  nextWorkInProgressHook = workInProgressHook.next;

  currentHook = nextCurrentHook;
}
// ...

这里如果 nextWorkInProgressHook 不为 null,那么就会复用之前的 hook,这里其实也就解释了为什么 hook 不能放在条件或者循环语句里面

面试题:hook 为什么通常放在顶部,而且不能写在条件或者循环语句里面?

因为更新的过程中,如果通过 if 条件增加或者删除了 hook,那么在复用的时候,就会产生当前hook 的顺序和之前 hook 的顺序不一致的问题。

例如,我们将上面的代码进行修改:

function App({ showNumber }) {
  let number, setNumber
  showNumber && ([ number,setNumber ] = React.useState(0)) // 第一个hooks
  const [num, setNum] = React.useState(1); // 第二个hook
  const dom = React.useRef(null); // 第三个hook
  React.useEffect(() => {
    // 第四个hook
    console.log(dom.current);
  }, []);
  return (
    <div ref={dom}>
      <div onClick={() => setNumber(number + 1)}> {number} </div>
      <div onClick={() => setNum(num + 1)}> {num}</div>
    </div>
  );
}

假设第一次父组件传递过来的 showNumber 为 true,此时就会渲染第一个 hook,第二次渲染的时候,假设父组件传递过来的是 false,那么第一个 hook 就不会执行,那么逻辑就会变得如下表所示:

hook 链表顺序 第一次 第二次
第一个 hook useState useState
第二个 hook useState useRef

那么此时在进行复用的时候就会报错:


第二次复用的时候,发现 hook 的类型不同, useState !==useRef,那么就会直接报错。因此开发的时候一定要注意 hook 顺序的一致性。

真题解答

题目:Hook是如何保存函数组件状态的?为什么不能在循环,条件或嵌套函数中调用 Hook ?

首先 Hook 是一个对象,大致有如下的结构:

const hook = {
  memoizedState: null,
  baseState: null,
  baseQueue: null,
  queue: null,
  next: null
}

不同类型的 hook,hook 的 memoizedState 中保存了不同的值,例如:

  • useState:对于 const [state, updateState] = useState(initialState),memoizedState 保存的是 state 的值
  • useEffect:对于 useEffect( callback, […deps] ),memoizedState 保存的是 callback、[…deps] 等数据

一个组件中的 hook 会以链表的形式串起来,FiberNode 的 memoizedState 中保存了 Hooks 链表中的第一个 Hook

在更新时,会复用之前的 Hook,如果通过 if 条件语句,增加或者删除 hooks ,在复用 hooks 过程中,会产生复用 hooks 状态和当前 hooks 不一致的问题。

在React中,Hooks的复用流程涉及到了Fiber架构中的双缓冲机制。当一个组件需要更新时,React会创建一个新的Fiber节点(workInProgress节点),并尝试复用当前渲染树(current树)中的Fiber节点的状态。这个复用流程可以概括为以下几点:

1. 创建workInProgress节点

当React开始处理组件更新时,它会为每个需要更新的组件创建一个新的workInProgress节点。这个节点是当前正在处理的工作副本,用于构建新的Fiber树。

2. 复用current节点的状态

在创建workInProgress节点之后,React会尝试复用current节点的状态。对于函数式组件,这意味着复用current节点的Hooks链表。

3. 遍历Hooks链表

React会遍历current节点的Hooks链表,并将每个Hook的状态复制到workInProgress节点的对应Hook中。这样确保了状态的一致性。

4. 更新Hooks状态

在复用状态之后,React会根据新的props和state来更新workInProgress节点的Hooks状态。例如,如果有useState,React会更新memoizedState为反映新的状态值。

5. 完成更新

一旦所有Hooks的状态都更新完毕,workInProgress节点的构建就代表了组件的新状态。


文章作者: 吴俊杰
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 吴俊杰 !
  目录