年后一直忙于工作,导致一直没有去继续阅读React的更新原理。今天我们接着往下阅读吧!
说到更新原理就离不开setState了,React是什么时候触发组件的更新的呢?就是通过自身触发setState改变组件自身的state,或者是传入的props改变的时候触发更新组件的。之前我们都有听说过React有一个很牛逼的虚拟DOM树,能通过比对虚拟DOM树的变化去进行最小化更新组件,从而提高整个DOM渲染的性能。这也是React的一大卖点之一。但是我们并不知道React是怎么知道更新了,以及怎么知道传入的props变化的,然后diff算法是如何快速判断到底哪个组件更新,哪个组件没有更新的,我们就带着这些问题去阅读吧!
可以先阅读setState机制,会更好理解之后的内容。
Lam:小前端读源码 - React16.7.0(深入了解setState)
本文基于以下DEMO进行阅读:
代码语言:javascript复制class App extends React.Component {
constructor() {
super();
this.state = {
text1: 1,
text2: 1
}
}
render() {
return (
<div id='a1'>
<p>curr Data1: {this.state.text1}</p>
<p>curr Data2: {this.state.text2}</p>
<button onClick={() => {
this.setState({text1: 2})
}}>setState</button>
</div>
)
}
}
接下来我们快速简单的过一下setState的大概流程:
- 触发setState函数,将触发setState的this和setState的参数传入enqueueSetState函数中。
- enqueueSetState函数,提出当前触发setState的Fiber节点并将传入的setState的参数创建一个update对象,update对象中的payload就是传入的state对象。
- enqueueUpdate函数,将当前Fiber的state和需要修改的state创建一个对象传入当前Fiber节点的updateQueue对象中。updateQueue对象有几个关键值,baseState(当前Fiber节点的state)、firstUpdate(首个更新任务)、lastUpdate(最后一个更新任务,防止多次重复setState)。
- scheduleWork函数,更新子组件的时间戳。
- requestWork函数调用addRootToSchedule,并判断当前是否在渲染中,和是否批量更新。
- addRootToSchedule,将root赋值到全局的firstScheduledRoot,lastScheduledRoot函数中。
经过上面的setState调用栈,最终我们得出的整个Fiber树中,已经包含了本次更新的任务在App的Fiber节点的updateQueue对象中了。因为我们现在是通过合成事件触发setState的,所以并不会立即触发performWorkOnRoot函数。然后会一层一层回到interactiveUpdates$1函数的调用栈中,最终执行performWork函数。
如果对这一块有疑问可以看看以下文章:
Lam:小前端读源码 - React16.7.0(合成事件)
performWork
在performWork函数中,会先通过findHighestPriorityRoot函数,将之前lastScheduledRoot变量赋值到nextFlushedRoot变量中(就是root)。通过将nextFlushedRoot传入到performWorkOnRoot函数中进行渲染。
performWorkOnRoot
在进入performWorkOnRoot函数时,会判断一个全局变量isRendering是否为true,如果为true代表当前正在执行performWorkOnRoot中,将会跳出本次渲染,等待下次,如果当前没有进行渲染,那么就会将全局的isRendering改为true。
最后就将当前的root对象传入renderRoot函数中进行render阶段。
详细的render阶段的介绍可以通过查看以下文章会有说到:
Lam:小前端读源码 - React16.7.0(三)Lam:小前端读源码 - React16.7.0(渲染总结篇)
之前的文章主要是说首次渲染的render和commit阶段,这次我们修改了state触发的render阶段会有一些不一样的情况。
现在的demo中,我们是改变state触发render的,所以在updateClassInstance函数中会有这么一段逻辑。
代码语言:javascript复制var oldState = workInProgress.memoizedState;
var newState = instance.state = oldState;
var updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
processUpdateQueue(workInProgress, updateQueue, newProps, instance, renderExpirationTime);
newState = workInProgress.memoizedState;
}
- workInProgress.memoizedState是当前组件的state
- newState是新的state值
还记得在setState的时候,将新的state作为一个任务存到updateQueue对象中。然后传入processUpdateQueue函数中。在processUpdateQueue函数中最终通过getStateFromUpdate函数返回新的state值。
在getStateFromUpdate中,会获取updateQueue中的firstUpdate的payload(setState传入的对象),如果本次触发render阶段的有传入state,那么将会和旧的state进行浅合并,否则返回旧的state。
代码语言:javascript复制if (typeof _payload2 === 'function') {
// Updater function
{
if (debugRenderPhaseSideEffects || debugRenderPhaseSideEffectsForStrictMode && workInProgress.mode & StrictMode) {
_payload2.call(instance, prevState, nextProps);
}
}
partialState = _payload2.call(instance, prevState, nextProps);
} else {
// Partial state object
partialState = _payload2;
}
if (partialState === null || partialState === undefined) {
// Null and undefined are treated as no-ops.
return prevState;
}
// Merge the partial state and the previous state.
return _assign({}, prevState, partialState);
最终updateQueue本来的baseState会被新的state替换,并将Fiber中的memoizedState替换为新的state。
这个时候就有一个问题了,现在的Fiber节点中已经不存在旧的state,怎么进行比对是否有变化呢?答案是在执行processUpdateQueue函数前,updateClassInstance函数内已经将旧的state保存在old_state变量中。
如果newProps === oldProps && newState === oldState的话,将会return false;
代码语言:javascript复制 if (oldProps === newProps && oldState === newState && !hasContextChanged() && !checkHasForceUpdateAfterProcessing()) {
// If an update was already in progress, we should schedule an Update
// effect even though we're bailing out, so that cWU/cDU are called.
if (typeof instance.componentDidUpdate === 'function') {
if (oldProps !== current.memoizedProps || oldState !== current.memoizedState) {
workInProgress.effectTag |= Update;
}
}
if (typeof instance.getSnapshotBeforeUpdate === 'function') {
if (oldProps !== current.memoizedProps || oldState !== current.memoizedState) {
workInProgress.effectTag |= Snapshot;
}
}
return false;
}
从这里其实可以知道为什么我们state有时候不会触发更新,例如text1是一个对象,我们修改他里面的值,因为最终我们修改的只是对象内部的属性,state.text1是没有改变内存地址,导致两个state对比是没有变化的。
当前我们触发了setState并且将test1的值从1改为2,所以state将不相等,所以将会跳入后面的代码。
之后会代用checkShouldComponentUpdate函数,改函数就是检测当前的Fiber节点中,是否有注册shouldComponentUpdate函数,如果有,就会调用shouldComponentUpdate函数将shouldComponentUpdate函数的返回结果return到updateClassInstance函数中。否则返回true。
updateClassInstance
代码语言:javascript复制function checkShouldComponentUpdate(workInProgress, ctor, oldProps, newProps, oldState, newState, nextContext) {
var instance = workInProgress.stateNode;
if (typeof instance.shouldComponentUpdate === 'function') {
startPhaseTimer(workInProgress, 'shouldComponentUpdate');
var shouldUpdate = instance.shouldComponentUpdate(newProps, newState, nextContext);
stopPhaseTimer();
{
!(shouldUpdate !== undefined) ? warningWithoutStack$1(false, '%s.shouldComponentUpdate(): Returned undefined instead of a ' 'boolean value. Make sure to return true or false.', getComponentName(ctor) || 'Component') : void 0;
}
return shouldUpdate;
}
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
return !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState);
}
return true;
}
因为当前demo中没有注册shouldComponentUpdate函数,所以会直接return true。
最终将新的props和state赋值到Fiber中的stateNode属性的props和state中,stateNode就是不同类型组件的实体类型,如果是一个class的Fiber,那么stateNode就是class本身,如果是html组件,那么stateNode就是实际的dom节点。
最终updateClassInstance函数返回shouldUpdate到updateClassInstance函数中。
finishClassComponent
代码语言:javascript复制finishClassComponent(current$$1, workInProgress, Component, shouldUpdate, hasContext, renderExpirationTime)
在finishClassComponent函数中,无论是否更新,都会更新refs的值(触发refs的回调函数)。
如果传入的shouldUpdate为false的话,会执行bailoutOnAlreadyFinishedWork函数。当前我们的shouldUpdate是为true的,所以继续往下看。
之后会触发当前Fiber的stateNode的render方式将class实例化出一个reactElement出来。这个时候reactElement因为外层的class的state变化,已经有所不同。之前是1,现在是2。
之后通过调用reconcileChildren函数,将实例化后的reactElement转换为Fiber节点保存到当前Fiber节点的child属性中。
详情可以看看以下文章:
Lam:小前端读源码 - React16.7.0(三)
因为现在在render阶段,那么workLoop会一直递归查找整个Fiber树的每一个Fiber节点的变化。回到我们的demo里面,这次的setState影响的是其中一个p标签的值。所以我们直接跳到workLoop对受影响的p标签有什么操作。
可以从断点中发现,原生html标签的Fiber节点描述标签内的任何东西是通过props来描述的。
在p标签中显示test1变量在Fiber节点中就是p标签的Fiber节点的props是1,下一个test的值是2。
- memoizedProps -> 当前props
- pendingProps -> 下一个props
在调用performUnitOfWork函数时,将pendingProps赋值到memoizedProps中,然后继续workLoop去render新的Fiber树。
直到完成workLoop,返回出renderRoot函数中的时候,更新state的render阶段就已经结束了。
接下来我们图解一下整个阶段发生了什么事情。
在render阶段,react还需要知道它需要更新的是什么,其中有几个关键的变量。
- effectTag -> 决定如何赋值firstEffect、lastEffect和nextEffect
- firstEffect -> 首次更改效果
- lastEffect -> 最后一次更改效果
- nextEffect -> 下一次更爱效果
在第一次渲染的时候,在completeWork函数,如果是text类型的或者标签类型的组件,当前Fiber树是第一次渲染的时候,那么effectTag都为0。而class类型的effectTag在第一次渲染的时候为。
那么在第一次渲染的时候,就决定了将App的Class赋值到Root的firstEffect和lastEffect为App的Fiber节点了。
那么在触发setState的时候,最终DEMO中改变的p标签的内容1变成2,那么在completeWork函数中1这个Text组件的时候,判断到不一样,那么就会为它的Fiber节点标记上4。
Fiber树其实有两颗
在每一次的renderRoot阶段,都会建立nextUnitOfWork变量。而这个变量是通过createWorkInProgress函数创建的(传入root)。
createWorkInProgress函数中会判断当前的RootFIber节点是否已经存在alternate节点(备用节点)。如果有则将RootFIber中的一些值更新到备用节点上,如果没有就新建一个备用节点。
在renderRoot函数中会将当前的Fiber节点传入createWorkInProgress函数中,最终返回备用节点,并赋值到nextUnitOfWork。然后整个workLoop的工作都将会在备用节点完成,最终形成一个备用树。包括上文说道的一切操作,到是在当前节点的备用节点上进行的。并不会改动当前节点的任何信息。
可能这么说会比较难懂,可以配合下图进行理解:
这是说明每个节点中的备用节点和当前节点的关系。
下图表示在经过render阶段后的两个树的状态:
从上图我们就很容易发现备用树和当前树的alternate是刚好相反的。而在setState后,备用树的所有需要改变的值都已经更新了。
commit阶段
经过render阶段对state和props的更新判断后,已经建立好了两个不一样的Fiber树了。接下来就去到commit阶段了。
代码语言:javascript复制 onComplete(root, rootWorkInProgress, expirationTime);
其中root是当前的root节点对象,rootWorkInProgress备用树!
commit阶段可以参考以下文章:
Lam:小前端读源码 - React16.7.0(渲染总结篇)
因为是通过setState触发了更新,最终生成的备用树中,受影响的节点只有一个p标签的一个内容,那么在进入到commitRoot函数中的时候,获取到的firstEffect就是Text组件的Fiber,因为触发state修改了p标签内的Text组件。
在commitRoot函数中,会根据当前firstEffect类型执行不同的更新方式,现在我们是属于一个Text组件,那么最终触发的是commitAllHostEffects函数。在commitAllHostEffects函数中会根据effectTag的标识来决定更新方式,那么当前是4,将会执行update的方式进行更新,进入commitWork函数。
commitWork函数会根据当前的组件类型选择不同更新方式,现在是一个Text组件,所以会执行commitTextUpdate函数进行更新。
代码语言:javascript复制function commitTextUpdate(textInstance, oldText, newText) {
textInstance.nodeValue = newText;
}
到此,基本上整个更新的流程已经跑过一遍了,但是这个只是最简单的更新。如果稍微复杂一点呢。下面举两个例子:
- 如果更新的组件会涉及多个会如何更新?
- 如果更新后组件不是改变文字内容,而是渲染不同的组件呢?
如果更新的组件会涉及多个会如何更新
我们把DEMO修改一下,改为一次渲染导致两个p标签的内容需要更新。
代码语言:javascript复制class App extends React.Component {
constructor() {
super();
this.state = {
text1: 1,
text2: 1
}
}
render() {
return (
<div id='a1'>
<p>{this.state.text1}</p>
<p>{this.state.text1}</p>
<button onClick={() => {
this.setState({text1: 2})
}}>setState</button>
</div>
)
}
}
在触发setState的时候,在render阶段,两个p标签因为内容需要更新,所以两个p标签的Fiber节点的effectTag都为4。
那么在completeUnitOfWork函数决定更新的循序就有变化了。
第一个的p标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:
第二个的p标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:
第三个button标签Fiber节点执行完completeUnitOfWork后,父级div的Fiber节点的更新顺序如下:
很奇怪为什么button都会更新呢,他又没有任何的改变!
那是因为在diff对比中,因为button中存在onClick属性,所以diff算法会对它特殊处理,会判断需要重新渲染。
最终在commitWork函数中,会循环根Fiber节点,因为这次是修改多个属性,所以渲染完firstEffect的Fiber后,会找firstEffect的Fiber节点是否存在nextEffect,如果存在则继续递归完成所有渲染!
更新state渲染不同的组件
再次修改DEMO。
代码语言:javascript复制class App extends React.Component {
constructor() {
super();
this.state = {
text1: 1,
text2: 1
}
}
render() {
return (
<div id='a1'>
{this.state.text1 == 1 ? <p>curr text 1</p> : <p>curr text 2</p>}
<button onClick={() => {
this.setState({text1: 2})
}}>setState</button>
</div>
)
}
}
在render阶段的时候,当建立div的Fiber节点的时候,需要循环divFiber节点的children属性。这个时候children是两个reactElement,分别为p和button。
在updateElement函数中,有这么一段逻辑,如果当前传入的reactElement的类型和当前对应节点的类型是同样的话,会复用Fiber节点,只需要修改当前节点的备用节点(alternate)。否则会新建一个Fiber节点。并且将当前父级节点的firstEffect和lastEffect设置为旧的Fiber节点,并设置父级Fiber节点的effectTag = 8。
代码语言:javascript复制function deleteChild(returnFiber, childToDelete) {
if (!shouldTrackSideEffects) {
// Noop.
return;
}
// Deletions are added in reversed order so we add it to the front.
// At this point, the return fiber's effect list is empty except for
// deletions, so we can just append the deletion to the list. The remaining
// effects aren't added until the complete phase. Once we implement
// resuming, this may not be true.
var last = returnFiber.lastEffect;
if (last !== null) {
last.nextEffect = childToDelete;
returnFiber.lastEffect = childToDelete;
} else {
returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
}
childToDelete.nextEffect = null;
childToDelete.effectTag = Deletion;
}
那么这里就有一个优化的点了,就是如果对不同state进行判断渲染不同的组件的时候,应该尽量使用相同的HTML标签,减少react卸载元素和重新创建Fiber节点的操作。使用同样的HTML标签能让react对需要改变的标签替换内容即可。
Diff
整个更新流程下来了,其实决定如何更新的是通过firstEffect、lastEffect、nextEffect和effectTag。那么Diff在那里呢?
diffProperties函数就是diff算法的函数。什么时候调用呢?下面是调用顺序:
在render的最后阶段,会对比新旧Fiber节点的不一样,去决定是否更新Fiber节点。
diff算法网上有很多教学,这里就不一一细说。有兴趣可以去看看源码,大概就是新旧的props会根据不同的参数例如style、children、dangerouslySetInnerHTML等等不同的参数会有不同的对比方式。最终返回更新内容的一个数组,然后为对应Fiber节点的effectTag打上标记,然后在commit阶段就知道应该如何更新组件了。
阅读源码的文章基本上就是到此结束了。开始阅读系列的时候才16.7,读完后变16.8.4了。救命。有机会再写一下关于React Hook的一些文章吧。
喜欢就点个赞,关注一下我的专栏吧!