从源码剖析react hook组件的生命周期

262 阅读13分钟

我正在参加「掘金·启航计划」

前言

React版本:16.8以上

注:本文目的不是带大家阅读晦涩难懂的源码,而是熟悉react hook组件的执行流程,从而帮助大家更好地阅读源码和排查问题~

大家好,相信点进来看的同学都是react的老手了,请问大家在编写react组件时,是否了解react组件从创建->更新->销毁的过程中,框架层面都做了什么事吗?

按照惯例,我们先上一道开胃菜⬇️

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
    console.log(count)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son count={count} />
    </div>
  );
}

问题1:Father组件从创建出来到渲染到页面上,在框架底层做了什么事情?

问题2:在点击触发setCount后,组件会有什么变化?框架层面又做了什么事?

问题3:在Father组件销毁后,框架层面做了什么事?

如果不知道也没关系,让我们一起从源码层面剥开react的外衣~

整体流程

文章只介绍大致流程,具体细节大家感兴趣可以看看过往文章或者私下研究~

生命周期整体可以分为三个阶段:初始化更新销毁。⬇️

image.png

看到这不少同学应该都懵了,这里面明明每个字都认识,但一组合在一起就变得很陌生~

image.png

不用担心,接下来让我们剥茧抽丝,逐步分析~

知识点

在阅读之前,我们先来了解几个概念,以便于我们更好地理解~

Fiber结构

Fiber是React16中引入的一种新的协调机制,它是一种以更细粒度的方式处理组件更新的算法。替代了原有的虚拟dom。

首先我们先看一下Fiber的结构⬇️

export type Fiber = {
  // Fiber 类型信息
  type: any,
  // ...
  // 用来标记fiber节点当前状态,例如是否需要被添加到dom树等
	flgs: Flags // react18版本前叫effectFlags
  // 链表结构
  // 指向父节点,或者render该节点的组件
  return: Fiber | null,
  // 指向第一个子节点
  child: Fiber | null,
  // 指向下一个兄弟节点
  sibling: Fiber | null,
}

image.png

小问题:在fiber之前react采用的是什么架构呢?为什么要换成fiber?

hook链表结构

每一个hook语句执行后,都会在对应的fiber中创建出一个hook节点,然后连接成hook链表

const hook: Hook = {
  // 保存当前Hook的最新状态值(state或者副作用)。在组件更新之后,如果Hook的状态值有变化,这个属性会被更新成最新值。
  memoizedState: null,
  // 保存Hook的初始状态值,不会随着组件渲染而改变。
  baseState: null,
  // 保存Hook的初始更新队列,不会随着组件渲染而改变。
  baseQueue: null,
  // 保存Hook的更新队列,用于记录组件更新时Hook状态的变化。它是一个单项环形链表结构,每个节点表示一个更新操作,包含了该操作对应的状态值和更新源等信息。
  queue: null,
  // 保存下一个Hook的指针,用于在组件渲染时遍历所有的Hook。如果当前Hook是最后一个Hook,那么这个属性的值为null。
  next: null,
};

function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 这是初始化第一个hook节点时
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 不是第一个节点直接放到节点后面
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

image.png

Fiber上有一个记录组件当前状态的对象叫做memoizedState,有了memoizedState,我们就能在每次渲染时获取当前组件里的相关数据(state或者副作用函数信息)。hook是一个单项链表的结构。如果workInProgressHook为空,表示这是链表中的第一个hook,将当前hook对象设置为组件的memoizedState和workInProgressHook。否则,将当前hook对象添加到链表的末尾,并将workInProgressHook指向当前hook对象。最后返回当前hook对象。

workInProgressHook:当前运行到的hook,如上图一所示,组件内部可能会存在多个hook。

currentlyRenderingFiber:当前运行到的fiber。

初始化

依旧是最开始提到的栗子⬇️

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    console.log(count)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son count={count} />
    </div>
  );
}

创建fiber

调用Father函数组件,创建Fiber节点,Fiber的创建流程大概如下:

  1. 创建一个Fiber对象,设置其类型为组件类型。
  2. 设置Fiber的初始状态为“New”,也就是把fiber对象的flags设置为NoFlags,表示此Fiber节点是未处理的。
  3. 将组件的props、state等信息设置为Fiber的属性。
  4. 如果组件中存在子节点,则递归创建子节点的Fiber节点,将其与父Fiber节点关联。
  5. 当所有子Fiber节点都创建完毕后,将其按照一定顺序添加到父Fiber节点的子节点列表中。

Fiber节点的创建是非常重要的一步,它负责将组件树转换为Fiber树,并在组件的props、state等发生变化时,更新Fiber树并进行DOM的渲染。

选择dispatcher

react在不同阶段引用的hook不是同一个函数,我们不妨想一想,阶段是怎么划分和判断的?又是怎么在确认阶段后进行hook选择的呢?首先我们先看一下react源码在不同阶段的处理函数⬇️

// ReactFiberHooks.js

// 初始化阶段选择的dispatcher
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

// 组件更新时选择的dispatcher
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

// 组件重新渲染时选择的dispatcher(后面会介绍与HooksDispatcherOnUpdate的区别)
const HooksDispatcherOnRerender: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: rerenderReducer,
  useRef: updateRef,
  useState: rerenderState,
  useDebugValue: updateDebugValue,
  useDeferredValue: rerenderDeferredValue,
  useTransition: rerenderTransition,
  useMutableSource: updateMutableSource,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

从上面可以看到总共有三个阶段的dispatcher,那react是怎么判断组件去调用哪一个dispatcher呢?

// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  //...省略无关代码
  // 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  //...省略无关代码
}

上述例子中,Father函数是首次渲染,所以对应fiber中的memoizedState为空,选择HooksDispatcherOnMount

创建Fiber中的hook链表

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    console.log(count)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son count={count} />
    </div>
  );
}

image.png

在执行Father函数组件时,里面依次执行了3个hook语句:

  1. const [count, setCount] = useState(0) ;创建出对应的hook1节点workInProgressHook当时为空,说明是fiber中第一个被创建的hook节点,将当前fiber中的memoizedState置为当前的hook1节点。
  2. const [isRender, setIsRender] = useState(false) ;创建出对应的hook2节点,workInProgressHook存在,所以将hook2节点接在hook1节点后面。
  3. 执行useEffect hook,创建出对应的hook3节点,重复步骤二。

收集更新

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount(count + 1)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son count={count} />
    </div>
  );
}

image.png

hook.queue:hook节点的更新队列,用来保存hook状态的更新操作,在上述例子中,第一次渲染中,在useEffect中执行了setCount(count + 1),所以count + 1这一个更新操作会被记录在对应的hook.queue中。

提问:如果直接写count = count + 1,这一个更新操作会使count从0变成1吗?

fiber.updateQueue:fiber节点的更新队列,保存组件状态的更新操作,在上述例子中,第一次渲染中,在useEffect执行了setCount(count + 1),所以setCount将会作为一个更新操作被记录在fiber.updateQueue中。

提问:如果count + 1就能够改变原state,那么setCount这一步的意义是什么呢?

答:setCount(count + 1)简单来说可以拆解成两步,第一步将count = count + 1,第二步进行组件重新render。不清楚的同学可以看一下yuque.antfin.com/wangdingwei…这篇文章。

更新阶段

选择dispatcher

上文提到,react会在不同阶段选择不同的dispatcher⬇️

// ReactFiberHooks.js

// 初始化阶段选择的dispatcher
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
};

// 组件更新时选择的dispatcher
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

// 组件重新渲染时选择的dispatcher(后面会介绍与HooksDispatcherOnUpdate的区别)
const HooksDispatcherOnRerender: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: rerenderReducer,
  useRef: updateRef,
  useState: rerenderState,
  useDebugValue: updateDebugValue,
  useDeferredValue: rerenderDeferredValue,
  useTransition: rerenderTransition,
  useMutableSource: updateMutableSource,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
};

前文提到当前组件fiber初始化以后,fiber.memoizedState会被置为当前fiber中的hook链表,所以判断当前fiber中的memoizedState是否为空来判断当前状态⬇️。

// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  //...省略无关代码
  // 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  //...省略无关代码
}

细心的同学可能会问到,这里只提到了两种dispatcher的情况,那HooksDispatcherOnRerender什么时候会被使用呢?让我们看看源码⬇️

// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  //...省略无关代码
  // 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
  ReactCurrentDispatcher.current =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  //...省略无关代码
  // 检查是否存在渲染阶段的更新(通常发生在组件渲染过程中又引起了某个子组件的渲染)
  if (didScheduleRenderPhaseUpdateDuringThisPass) {
    // 持续render直到组件稳定(没有组件被标记需要渲染)
    children = renderWithHooksAgain(
      workInProgress,
      Component,
      props,
      secondArg,
    );
  }
}

function renderWithHooksAgain<Props, SecondArg>(
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
): any {
  // 省略无关代码...
  do {
   	// 省略无关代码...
    ReactCurrentDispatcher.current = __DEV__
      ? HooksDispatcherOnRerenderInDEV
      : HooksDispatcherOnRerender;
    children = Component(props, secondArg);
    // 不断循环直至稳定
  } while (didScheduleRenderPhaseUpdateDuringThisPass);
  return children;
}

可以发现didScheduleRenderPhaseUpdateDuringThisPass被置为true(组件在渲染过程中引起的额外渲染)时候会调用这个dispatcher。

fiber更新消费

在函数组件重新渲染时,React会遍历该组件Fiber节点中的updateQueue,执行其中保存的状态更新操作。如果执行某个状态更新操作时需要获取或更新组件中的某个Hook,React会在当前组件的Fiber节点中的Hook链表中查找该Hook,并执行对应的操作。

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount(count + 1)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son count={count} />
    </div>
  );
}

在上述代码中,Father组件首次渲染,count + 1操作放入const [count, setCount] = useState(0)对应的hook.queue中,将setCount放入了fiber.updateQueue中。在第一次重新渲染时,遍历fiber.updateQueue,发现setCount的更新操作,然后找到对应的hook节点,进行对应处理。

对比新老fiber进行更新

新老fiber的对比更新流程可以概括为以下4步:

  1. 执行函数组件代码,生成新的JSX对象。
  2. 将JSX对象转换为Fiber节点,并构建新的Fiber树。
  3. 对比新旧Fiber节点,并标记哪些节点需要进行更新。如果需要更新,则将对应的DOM节点添加到更新队列中。(此处涉及到diff算法,本次不深入探讨~)
  4. 遍历更新队列中的DOM节点,对需要更新的节点进行更新操作。具体的更新操作包括更新属性、事件、子节点等。

销毁阶段

标记删除

fiber的flags包含ChildDeletion标记,也就是标记组件被卸载的场景。被标记的组件会被放入react的删除队列中。下一轮render中,React会遍历删除队列中的Fiber节点,并执行真正的删除操作。

进行删除操作

function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
  deletedSubtreeRoot: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
  	// ...省略无关代码
    // 进行副作用清除的函数
    commitPassiveUnmountInsideDeletedTreeOnFiber(fiber, nearestMountedAncestor);

    const child = fiber.child;
    if (child !== null) {
      child.return = fiber;
      nextEffect = child;
    } else {
      // 完成副作用清除的收尾工作(例如释放副作用函数的引用,避免内存泄漏)
      commitPassiveUnmountEffectsInsideOfDeletedTree_complete(
        deletedSubtreeRoot,
      );
    }
  }
}

组件被标记卸载时,React Fiber会先执行commitDeletionEffects函数,清理一些DOM操作类的副作用,这个阶段是在主线程(tips:在该阶段中,React会处理一些用户发起的事件,例如dom的操作类等)执行的。

然后再执行commitPassiveUnmountEffectsInsideOfDeletedTree_begin函数,清理一些类似于useEffect中的卸载函数,这个阶段是在passively(tips:在该阶段中,React通常清理一些类似于useEffect中的卸载函数),也就是空闲时间执行的。

最后执行commitPassiveUnmountEffectsInsideOfDeletedTree_complete函数,完成副作用清除的收尾工作。

fiber更新

对应节点被删除,重新更新fiber链表。

总结

在学习完以上知识点后,我们再回头来看看一开始抛出的问题,理一理从Father组件被创建到更新到销毁的全流程⬇️

function Son() {
  // ...
  console.log('render');
  // ...
}

function Father() {
  const [count, setCount] = useState(0);
  const [isRender, setIsRender] = useState(false);
  useEffect(() => {
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
    console.log(count)
    return () => console.log('destroy')
  }, [isRender]);
  return (
    <div>
      <div onClick={() => setCount(count + 1)}>点我</div>
      <Son />
    </div>
  );
}
  1. 组件初始化。
  • 创建fiber,并与各个fiber连接。
  • 选择dispatcher。Father组件还未有hook被创建,fiber中的memoizedState为null,所以认为是初始化阶段,所以选择HooksDispatcherOnMount。所以useState选择mountState方法,useEffect使用mountEffect方法。
  • 创建hook链表。代码执行到const [count, setCount] = useState(0),调用mountWorkInProgressHook方法去创建hook,发现当前fiber中并没有hook,所以作为当前hook链表的头节点。接着代码执行到const [isRender, setIsRender] = useState(false)和useEffect,调用mountWorkInProgressHook方法,发现当前fiber中已经存在hook,将新hook接在上一个hook后面,从而创建出hook链表。接着代码执行到useEffect,重复上述的步骤。
  • 收集更新。将各自hook的更新信息收集起来中,等待组件更新的时候来消费,并且会保存各个hook的状态。(例如这里的setCount执行后修改的count就会在组件更新->也就是下一次render前被消费,useEffect的副作用函数信息也被存储在了useEffect对应hook的memoizedState中)
  1. 组件更新。
  • 选择dispatcher。当我们点击按钮触发setCount时,组件进入了更新阶段进行render,当前fiber中已经存在了hook链表(memoizedState = hook链表),认为是更新阶段,所以选择HooksDispatcherOnUpdate。所以useState选择updateState方法,useEffect使用updateEffect方法。
  • fiber更新消费。遍历fiber.updateQueue,发现setCount的更新操作,然后找到对应的hook节点,进行对应处理。
  • 对比新老Fiber进行更新。利用diff算法进行新老Fiber比较,对应进行更新或删除操作。
  1. 组件销毁
  • 标记删除。给Father组件的fiber.flags中的ChildDeletion标记为true,代表需要被删除。并加入react的删除队列。
  • 进行删除操作。执行commitDeletionEffects函数,进行dom类的清理,删除Father函数组件里的dom(在主线程执行) 。再执行commitPassiveUnmountEffectsInsideOfDeletedTree_begin函数清理useEffect中的副作用,也就是例子中的console.log('destroy')(在passive阶段执行),最后再执行commitPassiveUnmountEffectsInsideOfDeletedTree_complete函数,进行收尾工作。

现在大家回头再去看看自己写的react代码,会不会又有一番新的感悟呢~