Hooks
让我们能够在函数组件中使用状态和其他 React
特性,而不需要使用类组件。在 Hooks
的背后,有一个关键的数据结构,那就是 Hooks
链表。Hooks
链表是 React
内部用于管理函数组件中多个 Hooks
状态的数据结构。本文将深入探究 Hooks
链表的原理和用途,带你逐步了解 Hooks
的实现细节和优势。
Fiber 架构
在前面的内容中我们学习了整个 Fiber
的实现原理,讲解到 Fiber
是如何被创建并构造 Fiber
树的。
在 React
中,更新一个组件是分为两个主要阶段: render
渲染阶段和 commit
提交阶段。这两个阶段分别负责不同的任务,以确保组件的 UI
与状态同步,并将更改更新到真实的 DOM
。
render 阶段
在这个阶段,React
会根据组件的转改和 Props
计算出新的虚拟 DOM
树,并与之前的虚拟 DOM
树进行比较,找出需要更新的部分。
在这个阶段中,以下 hooks 会被执行: useState
、useReducer
、useContext
、useMemo
、useCallback
、useRef
。
以下是 React
在 render
阶段会做的事情。
生成 Fiber 树
React
首先通过组件树构建一个 Fiber
树。构建的过程是通过一个 递
和 归
的过程。具体流程请看下面的例子:
jsx
function App() {
return (
<div>
我 是<span>靓仔</span>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
对应的 Fiber
树结构如下图所示:
render
阶段,会一次执行:
rootFiber
beginWorkApp
Fiber beginWorkdiv
Fiber beginWork我 是
Fiber beginWork我 是
Fiber completeWorkspan
Fiber beginWorkspan
Fiber completeWorkdiv
Fiber completeWorkApp
Fiber completeWorkrootFiber
completeWork
协调
React
使用 Fiber
树来进行协调过程,这包括对组件等更新、比较和查找差异。React
使用 Fiber
树上的节点来跟踪组件的状态和变化,以便在需要时中断、恢复和重新启动渲染过程。
生成新的虚拟 DOM 树
在进行协调的过程中,React
会计算组件的 UI
更新,并生成一个新的虚拟 DOM
树,这个虚拟 DOM
树反映了组件的最新状态。
在这个过程中,React
会为每个组件生成一个 effectTag
,用于标记该组件需要在完成工作之后执行的操作,例如:
js
// 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
阶段需要找到所有有 effectTag
的 Fiber
节点并依次执行 effectTag
对应操作。为了避免在 commit
阶段需要再遍历一次 Fiber
树,它会在 completeWork
的上层函数 completeUnitOfWork
中,每个执行完 completeWork
且存在 effectTag
的 Fiber
节点会被保存在一条被称为 effectList
的单向链表中。
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
操作后;
layout
阶段在操作 DOM
之后,所以这个阶段是能拿到 DOM
的,Ref
更新是在这个阶段,useLayoutEffect
回调函数的执行也是在这个阶段。
而 useEffect
的执行时机是在组件完成更新并将变化应用到真实 DOM
之后。也就是说,在布局 layout
阶段之后、绘制 paint
阶段之前,useEffect
的回调函数会被调用。
小结
React
的 Hooks
在组件的生命周期中主要分为两个阶段: render
阶段和 commit
阶段。不同的 Hooks
在这两个阶段执行的时机和目的略有不同。
其中在 render
阶段中,以下 hooks
会被执行: useState
、useReducer
、useContext
、useMemo
、useCallback
、useRef
。
在 commit
阶段中,以下 hooks
会被执行: useEffect
、useLayoutEffect
、useImperativeHandle
。
理清了 react
的渲染流程 render
+ commit
之后,我们来进入今天的主要内容 hooks
实现原理部分内容了。
HooKs 链表
当函数组件进入 render
阶段时,会被 renderWithHooks
函数处理。函数组件作为一个函数,它的渲染其实就是函数调用,而函数组件又会调用 React
提供的 hooks
函数。
初始挂载和更新时,所用的 hooks
函数是不同的,比如初次挂载时调用的 useEffect
,和后续更新时调用的 useEffect
,虽然都是同一个 hook
,但是因为在两个不同的渲染过程中调用它们,所以本质上他们两个是不一样的。这种不一样来源于函数组件要维护一个 hooks
的链表,初次挂载时要创建链表,后续更新的时候要更新链表。
分属于两个过程的 hook
函数会在各自的过程中被赋值到 ReactCurrentDispatcher
的 current
属性上。所以在调用函数组件当务之急是根据当前所处的阶段来决定 ReactCurrentDispatcher
的 current
,这样才可以在正确的阶段调用到正确的 hook
函数。 根据 renderWithHooks
中的代码看出,第一次渲染 ReactCurrentDispatcher.current
是对象 HooksDispatcherOnMount
,其它时候是 HooksDispatcherOnUpdate
。
两者它们内部的 hooks 函数是不同的实现,区别之一在于不同阶段对于 hooks 链表的处理不同:
确认完成 ReactCurrentDispatcher.current
之后,紧接着调用 Component
函数,并传入 props
和 secondArg
两个参数:
最终返回当前的 children
,也就是我们所说的 jsx
。
认识 hooks 链表
无论是初次挂载函数更新,每调用一次 hooks 函数,都会产生一个 hook 对象与之对应,以下是 hook
对象的结构:
json
{
baseQueue: null,
baseState: 'hook1',
memoizedState: null,
queue: null,
next: {
baseQueue: null,
baseState: null,
memoizedState: 'hook2',
next: null
queue: null
}
}
那就让我们来看看首次渲染时的 hooks 链表是如何形成的吧:
产生的 hook 对象依次排列,形成链表在到函数组件 fiber.memoizedState
上,也 ius 在调用 Component
时产生的:
组件挂载
初次挂载时,组件上没有任何 hooks 的信息,所以,这个过程主要是在 fiber 上创建 hooks 链表。挂载调用的是 mountWorkInProgressHook
函数,也就是我们前面的代码截图中,它会创建 hook 并将它们连接成链表,同时更新 workInProgressHook
,最终返回创建的 hooks 也就是 hook 链表。
我们在组件中调用 hook
函数,就可以获取到 hook
对象,例如 useState
,它会调用 mountState
函数:
还是用到我们之前的例子:
jsx
const App = () => {
const [state, setState] = useState(1);
const [a, setA] = useState(2);
return <div>1</div>;
};
-
执行第一个
useState
命令,进入mountWorkInProgressHook
函数,创建一个初始值为1
的hook
节点,并将currentlyRenderingFiber.memoizedState
指向它; -
初始值被记录在
currentlyRenderingFiber
上,此时memoizedState
由null
变为1
。此时 hooks 链表如下所示: -
执行第二个
useState
命令,生成一个初始值为2
的hook
节点,然后将上一个hook
节点指向它,重复以上步骤,形成一个链表结构;
hook
链表就是这样被创建出来的,那么我们怎么去更新它呢?那当然是 dispatch
函数啦。
组件更新
数组件每次更新,每一次 react-hooks
函数执行,都需要有一个函数去做上面的操作,这个函数就是 updateWorkInProgressHook
。
updateWorkInProgressHook
我们接下来一起看这个 updateWorkInProgressHook
:
ts
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
接下来我们来看看一次组件的更新中,都干了些啥?
在 updateState
中调用了 updateReduce
函数,也就是说,useState
是 useReducer
的低配版。
这个函数不会详细讲,这在下一篇文章详细展开来讲。
updateReducer
的作用是将待更新的队列 pendingQueue
合并到 baseQueue
上,之后进行循环更新,最后进行一次合成更新,也就是批量更新,统一更换节点。 这种行为解释了 useState
在更新的过程中为何传入相同的值,不进行更新,同时多次操作,只会执行最后一次更新的原因了。
这里以 useState
的运行流程来简单回顾一下:
为什么不能在 if 里面写 hooks
根据前面的内容,我们来看以下的例子:
jsx
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
判断进不去,会发生下面的情况:
一旦在条件语句中声明 hooks
,函数组件更新时,hooks
链表结构被破坏,currentFiber
树 的 memoizedState
缓存 hooks
链表 的信息,和 workInProgress
不一致,如果涉及到读取 state
等操作,就会发生异常。因此不能在条件、循环语句中使用 hooks
。
参考文章
总结
Hooks
链表是 React
内部用于在函数组件中管理多个 Hooks
状态的数据结构。它是在 Render
阶段创建的,用于记录函数组件中所有使用的 Hooks
及其对应的状态信息。通过 Hooks
链表,React
能够准确地跟踪每个 Hook
的调用顺序和状态,从而实现组件的状态管理和更新。从而实现更高效、可预测的组件渲染和更新。
Hooks
链表使得 React
能够在函数组件的多次渲染之间复用 Hooks
对象,从而实现状态的保持和更新。
每个函数组件都有自己的 Hooks
链表,Hooks
链表存储在对应的 Fiber
节点中,保证了每个组件的 Hooks
是独立且安全的。
最后分享两个我的两个开源项目,它们分别是:
这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰