从0实现React18系列六-dispatch update流程
本系列是讲述从0开始实现一个react18的基本版本。由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
上一节中我们讲解了update
的过程中,begionWork
和completeWork
、commitWork
的具体执行流程。本节主要是讲解
hooks
是如何存放数据的,以及一些hooks
的规则。- 一次
dispatch
触发的更新整体流程,双缓存树的运用。
我们有如下代码,在初始化的时候执行useState和调用setNum
的时候,是如何更新的。
function App() {
const [num, setNum] = useState(100);
window.setNum = setNum;
return <div>{num}</div>;
}
hooks原理
基于useState
我们来讲讲hook在初始化和更新阶段的区别。以及react是如何做到hook不能在条件语句和函数组件外部使用的。
在react
中,对于同一个hook,在不同的环境都是有不同的集合区分,这样就可以做到基于不同的执行环境的不同判断。
首先有几个名词:
currentlyRenderingFiber
: 记录当前正在执行的函数组件的fiberNode
workInProgressHook
: 当前正在执行的hook
currentHook
:更新的时候的数据来源
memoizedState
: 对于fiberNode.memoizedState
是存放hooks的指向。对于hook.memoizedState
就是存放数据的地方。
hook的结构如下图:
useState
初始化(mount)
我们知道当beginWork
阶段的时候,对于函数组件,会执行renderWithHooks
去生成当前对应的子fiberNode
。 我们首先来看看renderWithHooks
的逻辑部分。
export function renderWithHooks(wip: FiberNode) {
// 赋值操作
currentlyRenderingFiber = wip;
// 重置
wip.memoizedState = null;
const current = wip.alternate;
if (current !== null) {
// update
currentDispatcher.current = HooksDispatcherOnUpdate;
} else {
// mount
currentDispatcher.current = HooksDispatcherOnMount;
}
const Component = wip.type;
const props = wip.pendingProps;
const children = Component(props);
// 重置操作
currentlyRenderingFiber = null;
workInProgressHook = null;
currentHook = null;
return children;
}
首先会将currentlyRenderingFiber
赋值给当前的FC的fiberNode,然后重置掉memoizedState
, 因为初始化的时候会生成,更新的时候会根据初始化的时候生成。
可以看到对于mount
阶段,主要是执行HooksDispatcherOnMount
, 他实际上是一个hook集合。我们主要看看mountState
的逻辑处理。
const HooksDispatcherOnMount: Dispatcher = {
useState: mountState,
};
mountState
对于第一次执行useState
, 我们根据结果来推算这个函数的主要功能。useState
需要返回2个值,第一个是state
,第二个是可以引发更新的setState
。所以mountState
的主要功能:
- 根据传入的
initialState
生成新的state - 返回dispatch,便于之后调用更新state
基于hook的结构图,我们知道每一个hook有三个属性, 所以我们首先要有一个函数去生成对应的hook的结构。
interface Hook {
memoizedState: any;
updateQueue: unknown;
next: Hook | null;
}
mountWorkInProgressHook
mountWorkInProgressHook
这个函数主要是构建hook的数据。分为2种情况,第一种是第一个hook, 第二种是不是第一个hook就需要通过next
属性,将hook串联起来。
在这个函数中,我们就可以判断当前执行的hook
,是否是在函数中执行的。如果是在函数中执行的话,在执行函数组件的时候,我们将currentlyRenderingFiber
赋值给了wip
, 如果是直接调用的话,currentlyRenderingFiber
则为null,我们就可以抛出错误。
/**
* mount获取当前hook对应的数据
*/
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
updateQueue: null,
next: null,
};
if (workInProgressHook === null) {
// mount时,第一个hook
if (currentlyRenderingFiber === null) {
throw new Error("请在函数组件内调用hook");
} else {
workInProgressHook = hook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// mount时,后续的hook
workInProgressHook.next = hook;
workInProgressHook = hook;
}
return workInProgressHook;
}
当第一次执行的时候,workInProgressHook
的值为null, 说明是第一个hook执行。所以我们将赋值workInProgressHook
正在执行的hook, 同时将FC fiberNode
的memoizedState
指向第一个hook。此时就生成了如下图的结构:
处理hook数据
通过mountWorkInProgressHook
我们得到当前的hook结构后,需要处理memoizedState
以及updateQueue
的值。
function mountState<State>(
initialState: (() => State) | State
): [State, Dispatch<State>] {
// 找到当前useState对应的hook数据
const hook = mountWorkInProgressHook();
let memoizedState;
if (initialState instanceof Function) {
memoizedState = initialState();
} else {
memoizedState = initialState;
}
// useState是可以触发更新的
const queue = createUpdateQueue<State>();
hook.updateQueue = queue;
hook.memoizedState = memoizedState;
//@ts-ignore
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [memoizedState, dispatch];
}
从上面的代码中,我们可以看出memoizedState
的处理很简单,就是通过传入的参数,进行赋值处理,重点在于如何生成dispatch
生成dispatch
因为触发dispatch
的时候,react
是要触发更新的,所以必然会和调度
有关。
由于要触发更新,我们就需要创建触发更新的队列
- 执行
createUpdateQueue()
生成更新队列。 - 将更新队列赋值给当前
hook
保存起来,方便之后update使用。 - 将生成的
dispatch
保存起来,方便之后update
使用。
// useState是可以触发更新的
const queue = createUpdateQueue<State>();
hook.updateQueue = queue;
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
主要是看如何生成dispatch的逻辑,通过调用dispatchSetState
它接受三个参数,因为我们需要知道是从哪一个fiberNode开始调度的,所以当前的fiberNode是肯定看需要的。更新队列queue
也是需要的,用于执行dispatch
的时候触发更新。
function dispatchSetState<State>(
fiber: FiberNode,
updateQueue: UpdateQueue<State>,
action: Action<State>
) {
const update = createUpdate(action); // 1. 创建update
enqueueUpdate(updateQueue, update); // 2. 将更新放入队列中
scheduleUpdateOnFiber(fiber); // 3. 开始调度
}
所以我们每次执行setState
的时候,等同于执行上面函数,但是我们只需要传递action
就可以,前2个参数,已经通过bind
绑定。
执行dispatch
后,开始新一轮的调度,调和。
更新的总结
从上面的代码,我们可以看出我们首先是执行了createUpdateQueue
, 然后执行了createUpdate
, 然后enqueueUpdate
。这里总结一下这些函数调用。
createUpdateQueue
本质上就创建了一个对象,用于保存值return { shared: { pending: null, }, dispatch: null, }
createUpdate
就是也是返回一个对象。return { action, };
enqueueUpdate
就是将createUpdateQueue
的pending 赋值。{ updateQueue.shared.pending = update; };
最后我们生成的单个hook结构如下图:
useState
触发更新(dispatch)
当我们执行setNum(3)
的时候,我们之前讲过相当于是执行了下面函数, 将传递3为action
的值。
function dispatchSetState<State>(
fiber: FiberNode,
updateQueue: UpdateQueue<State>,
action: Action<State>
) {
const update = createUpdate(action);
enqueueUpdate(updateQueue, update);
scheduleUpdateOnFiber(fiber); // 3. 开始调度
}
当再次执行到函数组件App
的时候,会执行renderWithHooks
如下的逻辑。将 currentDispatcher.current
赋值给HooksDispatcherOnUpdate
。
// 赋值操作
currentlyRenderingFiber = wip;
// 重置
wip.memoizedState = null;
const current = wip.alternate;
if (current !== null) {
// update
currentDispatcher.current = HooksDispatcherOnUpdate;
} else {
// mount
currentDispatcher.current = HooksDispatcherOnMount;
}
然后执行App
函数,重新会调用useState
const [num, setNum] = useState(100);
updateState
在HooksDispatcherOnUpdate
中,useState
对应的是updateState
。对比于mountState
的话,updateState
主要是:
- hook的数据从哪里来
- 会有2种情况执行,交互阶段触发,render的时候触发
本节主要是分析交互阶段的触发的逻辑。
hook数据从哪里来
对比mountState
中,我们可以通过新建hook
数据结构。这个时候双缓存树的结构就可以解决,还记得我们之前的章节讲的react将正在渲染的和正在进行的分2个树,通过alternate
进行链接。整体结构如下图:
还记得我们mount
的时候说过,fiberNode.memoizedState
的指向保存着hook的数据。
所以我们可以通过currentlyRenderingFiber?.alternate
中的memoizedState
去查找对应的hook数据。
updateWorkInProgressHook
更新阶段hook
的数据获取是通过updateWorkInProgressHook
执行的。
function updateWorkInProgressHook(): Hook {
// TODO render阶段触发的更新
let nextCurrentHook: Hook | null;
// FC update时的第一个hook
if (currentHook === null) {
const current = currentlyRenderingFiber?.alternate;
if (current !== null) {
nextCurrentHook = current?.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
// FC update时候,后续的hook
nextCurrentHook = currentHook.next;
}
if (nextCurrentHook === null) {
// mount / update u1 u2 u3
// update u1 u2 u3 u4
throw new Error(
`组件${currentlyRenderingFiber?.type}本次执行时的Hook比上次执行的多`
);
}
currentHook = nextCurrentHook as Hook;
const newHook: Hook = {
memoizedState: currentHook.memoizedState,
updateQueue: currentHook.updateQueue,
next: null,
};
if (workInProgressHook === null) {
// update时,第一个hook
if (currentlyRenderingFiber === null) {
throw new Error("请在函数组件内调用hook");
} else {
workInProgressHook = newHook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// update时,后续的hook
workInProgressHook.next = newHook;
workInProgressHook = newHook;
}
return workInProgressHook;
}
主要逻辑总结如下:
- 刚开始
currentHook
为null, 通过alternate
指向memoizedState
获取到正在渲染中的hook数据,赋值给nextCurrentHook
- 将
currentHook
赋值为nextCurrentHook
, 记录更新的数据来源,方便之后的hook,通过next
连接起来。 - 赋值
workInProgressHook
标记正在执行的hook
这里有一个难点,就是nextCurrentHook === null
的时候,我们可以抛出错误。
hook在条件语句中报错
我们晓得hook是不能在条件语句中执行的。那是如何做到报错的呢?接下来我们根据上面的updateWorkProgressHook
源码分析。假如,伪代码如下所示: 在mount
阶段的时候,是3个hook,在执行setNum(100)
,update
阶段4个。
const [num, setNum] = useState(99);
const [num2, setNum] = useState(101);
const [num3, setNum] = useState(102);
if(num === 100) {
const [num4, setNum] = useState(103);
}
这里我们就会执行四次updateWorkProgressHook
,我们来分析一下。
nextCurrentHook
=currentHook
=m-hook1
,第一次后currentHook
不为nullnextCurrentHook
等于m-hook2
nextCurrentHook
等于m-hook3
- 第四次的时候
nextCurrentHook
=m-hook3.next
= null, 所以就会走到报错的逻辑。
useState
计算
上一部分我们已经知道了update的时候,hook的数据来源,我们现在得到数据了,那如何通过之前的数据,计算出新的数据呢?
- 在执行
setNum(action)
后,我们知道action
存放在queue.shared.pending
中 - 而
queue
是存放在对应hook
的updateQueue
中。所以我们可以拿到action
- 第三步就是去消费
action
,即执行processUpdateQueue
, 传入上一次的state
, 以及我们这次接受的action
,计算最新的值。
function updateState<State>(): [State, Dispatch<State>] {
// 找到当前useState对应的hook数据
const hook = updateWorkInProgressHook();
// 计算新的state逻辑
const queue = hook.updateQueue as UpdateQueue<State>;
const pending = queue.shared.pending;
if (pending !== null) {
const { memoizedState } = processUpdateQueue(hook.memoizedState, pending);
hook.memoizedState = memoizedState;
}
return [hook.memoizedState, queue.dispatch as Dispatch<State>];
}
这样,我们就在渲染的时候拿到了最新的值,以及重新返回的dispatch
。
双缓存树
在第一次更新的时候,我们的双缓存树还没有建立起来,在第一次更新之后,双缓存树就建立完成。
之后每一次调和生成子fiberNode
的时候,都会利用alternate
指针去重复利用相同type和相同key的节点。
例如初始化的时候num
的值为3, 通过setNum(4)
调用第一次更新后。首先会创建一个wip tree
在执行完commitWork
后,屏幕上渲染为4
后,root.current
的指向会被修改 为wip tree
。
当我们再setNum(5)
的时候,第二次更新后,双缓存树已经建立。会利用之前右边的4
的fiberNode tree
,进行下一轮渲染。
总结
此节我们主要是讲了hook是如何存放数据的,以及mount
阶段和update
阶段不同的存放,也讲解了通过dispatch
调用后,react是如何更新的。以及双缓存树在第一次更新后是如何建立的。
下一节我们讲解React的事件机制。
转载自:https://juejin.cn/post/7187976209844666426