从源码剖析react hook组件的生命周期
前言
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的外衣~
整体流程
文章只介绍大致流程,具体细节大家感兴趣可以看看过往文章或者私下研究~
生命周期整体可以分为三个阶段:初始化、更新、销毁。⬇️
看到这不少同学应该都懵了,这里面明明每个字都认识,但一组合在一起就变得很陌生~
不用担心,接下来让我们剥茧抽丝,逐步分析~
知识点
在阅读之前,我们先来了解几个概念,以便于我们更好地理解~
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,
}
小问题:在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;
}
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的创建流程大概如下:
- 创建一个Fiber对象,设置其类型为组件类型。
- 设置Fiber的初始状态为“New”,也就是把fiber对象的flags设置为NoFlags,表示此Fiber节点是未处理的。
- 将组件的props、state等信息设置为Fiber的属性。
- 如果组件中存在子节点,则递归创建子节点的Fiber节点,将其与父Fiber节点关联。
- 当所有子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>
);
}
在执行Father函数组件时,里面依次执行了3个hook语句:
- const [count, setCount] = useState(0) ;创建出对应的hook1节点,workInProgressHook当时为空,说明是fiber中第一个被创建的hook节点,将当前fiber中的memoizedState置为当前的hook1节点。
- const [isRender, setIsRender] = useState(false) ;创建出对应的hook2节点,workInProgressHook存在,所以将hook2节点接在hook1节点后面。
- 执行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>
);
}
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步:
- 执行函数组件代码,生成新的JSX对象。
- 将JSX对象转换为Fiber节点,并构建新的Fiber树。
- 对比新旧Fiber节点,并标记哪些节点需要进行更新。如果需要更新,则将对应的DOM节点添加到更新队列中。(此处涉及到diff算法,本次不深入探讨~)
- 遍历更新队列中的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>
);
}
- 组件初始化。
- 创建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中)
- 组件更新。
- 选择dispatcher。当我们点击按钮触发setCount时,组件进入了更新阶段进行render,当前fiber中已经存在了hook链表(memoizedState = hook链表),认为是更新阶段,所以选择HooksDispatcherOnUpdate。所以useState选择updateState方法,useEffect使用updateEffect方法。
- fiber更新消费。遍历fiber.updateQueue,发现setCount的更新操作,然后找到对应的hook节点,进行对应处理。
- 对比新老Fiber进行更新。利用diff算法进行新老Fiber比较,对应进行更新或删除操作。
- 组件销毁
- 标记删除。给Father组件的fiber.flags中的ChildDeletion标记为true,代表需要被删除。并加入react的删除队列。
- 进行删除操作。执行commitDeletionEffects函数,进行dom类的清理,删除Father函数组件里的dom(在主线程执行) 。再执行commitPassiveUnmountEffectsInsideOfDeletedTree_begin函数清理useEffect中的副作用,也就是例子中的console.log('destroy')(在passive阶段执行),最后再执行commitPassiveUnmountEffectsInsideOfDeletedTree_complete函数,进行收尾工作。
现在大家回头再去看看自己写的react代码,会不会又有一番新的感悟呢~
转载自:https://juejin.cn/post/7242227037242294330