前言
React Hook是16.8之后新增的新特性,在允许你在不编写class组件的情况下使用state和其他React特性,在16.8之前的版本中组件按照其定义方式可以分成class组件和函数组件,class组件可以使用内部状态state以及相关生命周期,函数组件数据来源只是外部prop。简单来说React Hook = 函数组件 + state/其他特性。关于为什么提供React Hook的相关介绍可以通过官网相关文章(Hook官网介绍)来了解,这里就不再赘述了。
本文是一系列Hook梳理的开篇,旨在了解React Hook相关内部实现、执行时机等来加深对其的理解。
React Hook处理简述
使用React Hook来编写组件,常用的hook有useState、useEffect、useLayoutEffect、useCallback、useContext、useRef等,这里会对每一个hook做梳理。在之前的Render处理文章中,知道了在React运行中会存在update对象,该对象的载荷就是组件的内容,而组件的执行逻辑是在performUnitOfWork下面的beginWork的处理逻辑中,相关组件处理逻辑如下:
switch (workInProgress.tag) {
case IndeterminateComponent:
...
case LazyComponent:
...
case FunctionComponent:
case ClassComponent:
case HostRoot:
case HostComponent:
case HostText:
case SuspenseComponent:
case HostPortal:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextProvider:
case ContextConsumer:
case MemoComponent:
case SimpleMemoComponent:
case SuspenseListComponent:
}
就是针对于不同组件做不同的处理,需要注意的是这里的组件类型是React内部的相关区分,其中IndeterminateComponent就比较特殊,这里以React Hook组件为例(实际上就是函数)看看这边的处理:
function Hello() {
const [date, setDate] = React.useState(Date.now())
return (
React.createElement(
'button',
{
onClick: () => {
setDate(111)
}
},
date,
)
)
}
实际上代码执行到beginWork,React使用Hook的函数组件的对应的类型就是IndeterminateComponent,会调用mountIndeterminateComponent来处理。
mountIndeterminateComponent
IndeterminateComponent类型的组件会调用该函数来处理相关逻辑,而该函数中主要的处理逻辑如下:
var value = renderWithHooks(...)
if (typeof value === 'object' && value !== null && typeof value.render === 'function' && value.$$typeof === undefined) {
...
workInProgress.tag = ClassComponent;
...
} else {
...
workInProgress.tag = FunctionComponent;
...
reconcileChildren(...)
...
}
renderWithHooks简单概述就是函数组件执行,value值就是函数返回的是React元素,如果函数组件中返回是一个带有render函数的对象并且类型不知道就会被判定是class组件,否则就是FunctionComponent。
带有hook的函数组件就是通过该函数来处理,其中renderWithHooks中处理函数执行外,还有一个非常重要的逻辑点就是设置ReactCurrentDispatcher.current的值,具体逻辑如下:
function renderWithHooks() {
...
{
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
} else if (hookTypesDev !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
} else {
ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
}
}
var children = Component(props, secondArg);
...
}
从上面的逻辑中可知,挂载阶段ReactCurrentDispatcher.current会被赋值为HooksDispatcherOnMountInDEV或HooksDispatcherOnMountWithHookTypesInDEV,其中后者是处理边缘情况的正常挂载阶段都是HooksDispatcherOnMountInDEV,当后面函数组件执行时,其获取到的dispatcher就是这个了。
useState
该hook提供内部状态state的功能,state更新就会触发组件渲染继而视图更新,在React源码中useState的直接逻辑非常简单:
function useState(initialState) {
// 获取ReactCurrentDispatcher.current值
var dispatcher = resolveDispatcher();
// 调用dispatcher的useState方法
return dispatcher.useState(initialState);
}
而dispatcher对应的useState方法的具体逻辑如下:
{
useState: function (initialState) {
...
var prevDispatcher = ReactCurrentDispatcher.current;
// 临时替换dispatcher
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnMountInDEV;
try {
return mountState(initialState);
} finally {
ReactCurrentDispatcher.current = prevDispatcher;
}
},
}
最核心的处理还是调用mountState函数,该函数的主要逻辑如下:
function mountState(initialState) {
var hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
var queue = hook.queue = {
pending: null,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState
};
var dispatch = queue.dispatch = dispatchAction.bind(null, currentlyRenderingFiber$1, queue);
return [hook.memoizedState, dispatch];
}
在该函数中会创建hook对象和queue对象,其中hook对象的属性有:
var hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null
};
需要注意的是这个hook对象会被添加到对应的Fiber对象上,通过memoizedState属性。useState值支持函数,初始化时initialState会被赋值给memoizedState、baseState属性,useState返回值实际上memoizedState属性的值,而其set方法是调用dispatchAction来实现的。
挂载阶段的过程React Hook的函数组件的处理并并没有什么大的区别,还是那一套可以看之前的文章详细了解
dispatchAction更新
该函数是useState返回的state更新的实际处理函数,该函数相关逻辑具体如下:
function dispatchAction(fiber, queue, action) {
...
}
首先是相关参数说明,当调用useState后使用bind实现函数绑定,fiber、queue是已经被传递了,action就是开发者需要更新的state数据。接下来看看具体的逻辑:
function dispatchAction(fiber, queue, action) {
var update = {
expirationTime: expirationTime,
suspenseConfig: suspenseConfig,
action: action,
eagerReducer: null,
eagerState: null,
next: null,
priority: getCurrentPriorityLevel()
};
var pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
var alternate = fiber.alternate;
if (fiber === currentlyRenderingFiber$1 || alternate !== null && alternate === currentlyRenderingFiber$1) {
...
// 渲染阶段更新相关处理,主要逻辑就是更新expirationTime属性值
} else {
...
scheduleWork(fiber, expirationTime)
}
}
实际上面主要逻辑可以总结下面几点:
- 创建update对象,涉及到相关执行时间、优先级等顺序
- update队列排序,通过next构成链式结构
- scheduleWork开始调度作业
在scheduleWork之前,会处理state相关逻辑,其中就包括:
- 支持更新state传递函数,即action是函数的情况,会向函数中传递旧的state值
- 通过Object.is对新旧state做对比,如果没有变化直接退出不会执行相关逻辑,是一种优化手段
对于useState hook来说,scheduleWork的处理流程理论上应该与class组件的setState逻辑相似,但是因为存在hook可能流程是有不同的处理,这里主要看下。
useState中scheduleWork相关处理
通过实例Debug实际上useState中scheduleWork的处理逻辑与class组件中setState的处理逻辑并没有什么大的区别,整体的处理还是:
scheduleWork -> scheduleUpdateOnFiber -> ensureRootIsScheduled -> scheduleSyncCallback -> flushSyncCallbackQueueImpl -> performSyncWorkOnRoot
注意flushSyncCallbackQueueImpl的触发时机并不是由setState来触发的,在performSyncWorkOnRoot函数中就会调用workLoopSync对所有组件进行更新渲染,在这个过程中class组件会再次调用render函数,纯函数组件会再次执行。那么存在React Hook的函数组件呢?也是会再次执行,那么问题来了这个过程中useState是如何处理的呢?接下来就看看更新阶段useState的处理过程。
更新渲染时useState处理流程
存在hook的函数组件在组件更新阶段会再次被调用,其中useState的逻辑是否存在区别呢?答案必然是存在的,主要是通过dispatcher的不同来控制的。而更新阶段的dispatcher的设置是在renderWithHooks中处理的,需要注意的是更新阶段此时带hook的函数组件就不是挂载阶段对应的IndeterminateComponent类型了,而是FunctionComponent类型。其对应的处理逻辑如下:
case FunctionComponent:
{
var _Component = workInProgress.type;
var unresolvedProps = workInProgress.pendingProps;
var resolvedProps = workInProgress.elementType === _Component ? unresolvedProps : resolveDefaultProps(_Component, unresolvedProps);
return updateFunctionComponent(current, workInProgress, _Component, resolvedProps, renderExpirationTime);
}
可以先不看这边的具体处理,主要关注current参数,此时这个参数是通过beginWork传递过来的,本质上是Fiber对象alternate的属性即FiberNode对象。所以在更新阶段renderWithHooks的current是有值的,具体逻辑如下:
function renderWithHooks() {
...
{
if (current !== null && current.memoizedState !== null) {
ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
}
}
var children = Component(props, secondArg); // Check if there was a render phase update
}
在更新阶段ReactCurrentDispatcher.current值是HooksDispatcherOnUpdateInDEV,后面函数执行时useState获取到的dispatcher就是HooksDispatcherOnUpdateInDEV,该对象useState方法的具体逻辑是:
HooksDispatcherOnUpdateInDEV = {
useState: function (initialState) {
currentHookNameInDev = 'useState';
updateHookTypesDev();
var prevDispatcher = ReactCurrentDispatcher.current;
ReactCurrentDispatcher.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
try {
return updateState(initialState);
} finally {
ReactCurrentDispatcher.current = prevDispatcher;
}
},
}
会调用updateState函数来具体处理相关逻辑,其主要逻辑如下:
function updateState(initialState) {
return updateReducer(basicStateReducer);
}
需要注意此时这个initialState函数挂载阶段时的初始值,而不是要更新的值。而且你会发现在再次调用useState传入的值竟然被丢弃了没有任何处理,如果此时传入的是一个新值,实际上是不生效的,useState对于新值得赋值必须是通过set方法来操作的。实际上有个场景就会存在问题:
function Child(props) {
const [date, setDate] = useState(props.date)
...
}
子组件使用父组件的props值作为useState的初始值,当props更新后此时useState内部是按照updateState来处理的,这个值就会被丢弃。如果要在子组件中依赖props维护自己的state,那么你只能调用set方法来处理了,此时可以借助useEffect或useMemo,这样会多一次渲染,但是有些场景可能导致其他问题,需要特别注意。
updateReducer函数的处理逻辑
更新阶段函数组件再次被调用最后会执行updateReducer函数,而该函数的主要处理逻辑如下:
function updateReducer(reducer, initialArg, init) {
var hook = updateWorkInProgressHook();
var queue = hook.queue;
...
// 相关处理,逻辑相对多些
queue.lastRenderedReducer = reducer;
// queue的pending存储的就是当前最新值
var pendingQueue = queue.pending;
if (pendingQueue !== null) {
...
baseQueue = pendingQueue;
queue.pending = null;
}
if (baseQueue !== null) {
...
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
...
var dispatch = queue.dispatch;
return [hook.memoizedState, dispatch];
}
首先是调用updateWorkInProgressHook获取到挂载阶段定义的hook对象即保留了旧值的,上面中间逻辑实际上就是使用新值替换旧值,即将hook对象的memoizedState、baseState替换成新值,之后返回对应的新值和set方法。通过hook的queue的pending拿到setState时创建的update对象,该对象中action就是最新的值,而旧的hook对象则是通过从Fiber对象上获取的,即Fiber对象上保存了旧的hook对象,实际上是FiberNode对象的memoizedState属性的值。
问题说明
使用useState定义多个同时调用其set方法会导致函数执行多次吗?
不会,以事件处理程序中多次调用setState为例,在整个处理过程中setState不会直接触发视图渲染,视图渲染是通过performSyncWorkOnRoot来处理的,而更新阶段中该函数是通过flushSyncCallbackQueueImpl来触发的,而flushSyncCallbackQueueImpl函数的触发有很多情况,React事件处理保证在执行完所有同步代码后才触发视图渲染,这样在事件处理程序中所有setState都会执行完毕然后才进行一次视图渲染即调用flushSyncCallbackQueueImpl,其他flushSyncCallbackQueueImpl函数的触发逻辑可能与事件机制类似,理论上都应该是一次渲染要尽可能处理完所有setState避免频繁的视图渲染造成的性能问题
setState不会触发flushSyncCallbackQueueImpl调用从而视图渲染,而是通过其他机制不同时机触发的,从而控制渲染次数
setState相同值多次调用是如何处理?
在class组件中setState背后会是合并操作,既将多个setState合并也将其值合并,而useState中setState中值是替换操作。由前面的梳理实际上关心的是多个相同值都会触发scheduleWork调度作业吗?答案会,但是scheduleWork的执行逻辑是不同的,最主要的一点是只有第一次setState将performSyncWorkOnRoot放入syncQueue中,而后面的setState就不会存在此逻辑
setState支持传递函数,那么是如何做到累积生效?
useState中set操作和class组件中的setState背后的执行逻辑是存在不同的,其中useState的set更新是替换,但是当是函数时跟class组件中setState是相同效果即累加。实际上是通过hook对象的queue中next来形成链式结构把每一次setState的action保留下来,在更新渲染阶段再次调用函数时useState内部就会处理这个链式结构,从而得到累加的效果。
总结
通过上面整体梳理,知道useState的整体处理的主要逻辑点以及更新阶段如何做到更新的,这里总结下:
- 初始化阶段,useState对应的dispacther核心逻辑是调用mountState,该函数会创建hook和queue对象,这两个对象是构建useState链式结构的核心
- useState支持传递函数,函数的行为和class组件中setState传递函数相同会存在累加即所谓的同步行为
- 更新阶段,通过set方法来更新实际上是通过更改dispatcher来实现的,更新阶段最后会调用updateState来实现更新
- useState返回的更新方法对应着内部的dispatchAction方法,该函数会创建update对象最后调用scheduleWork开始调度作业,触发视图渲染的两个核心update对象和scheduleWork
- useState的背后处理实际上和class组件的setState背后的处理逻辑相似,多个state更新的合并等特点也是一致的,需要注意的是setState本身不会触发视图渲染,而是通过其他机制在后续逻辑中触发的,所以从形式上表现出异步效果,但实际上setState本身是同步处理的,所谓的异步也不是调用异步API,实际上setState逻辑处理中也没有调用异步API,这种异步更准确的说是延迟,是由于相关机制导致的
- 如果useState依赖父组件的props,那么注意更新阶段是无法生效的,可以通过useEffect/useMemo + set来触发更新,这样会多一次渲染,但是有些场景可能会存在其他问题,可以不维护自己的state来完成一些场景的逻辑