背景
最近在看《深入浅出react开发指南》,这是我接触react以来第二次看,第一次看的时候,基本上是不知所云,那因为最近在恶补 react原理,又捡起来了,才发现真是宝书,值得我去细细品味,所以呢,我将会按照我看的章节的顺序进行一系列的读书笔记总结,既是希望能把书中的知识点真的缝合在自己的头脑里,也希望分享出来和大家一起交流成长!
今天要看的是 第十章:React运行时原理探秘,为啥先看这章,虽然没有做过统计,但我相信这章应该是浏览量最高的一章,因为市面上讲的所谓react源码讲座都是这章要讲的内容,那我们就开始吧
看了这么多react源码讲解,越发觉得图解才是王道,所以我尽量插入我认为 能一目了然醍醐灌顶的图片,帮助加深理解
初始化渲染
首先我们看下 初始化渲染/更新时大致流程
左侧是第一次渲染的流程,我们需要关注一个点,就是 初始化渲染 走的是 非批量模式。 为什么这么做? 这里简要解释一下,后面章节应该会详细聊聊
官方词汇: React 对初始渲染特殊处理,通过unbatchedUpdates
直接同步创建整个 Fiber 树结构,确保关键路径最短化。后续更新才会进入常规的批处理调度流程。
我们开发过 react 应用的同学都知道,当你在连续调用两次 setState时,是要进入调度流程,异步批量更新,而初始化渲染时,恰好相反,同步执行渲染,为啥呢?显而易见,初始化渲染第一要务就是尽快 将视图渲染到网页上,所以是同步渲染
初始化渲染/更新流程 -- 殊途同归
直接看函数的调用关系
可以清楚地发现,无论是初始化,还是useState,setState 最后都是调用 scheduleUpdateOnFiber 方法(至于 scheduleUpdateOnFiber 前面的流程后面也会讲到不要担心哦)
更新入口 scheduleUpdateOnFiber
我们直接看图读一下首次渲染/更新渲染流程的共通之处(不同点先不考虑,后面会讲),不难发现: 一头一尾是一样的,都是先调用markUpdateLaneFromFiberToRoot,最终都调用 performSyncWorkOnRoot
这里面对于更新任务我们划分成 可控任务和 非可控任务,这里解释一下
官方解释: 可控任务:在前面讲到过,对于 React 事件系统中发生的任务,会标记成 EventContext,在 batchUpdate API 里面的更新任务,会标记成 BatchedContext,那么这些任务是 React 可以检测到的,所以executionContext!= =NoContext,不会执行 flushSyncCallbackQueue。
非可控任务:在延时器 (Timer)队列或微任务队列 (Microtask)中,React 是无法控制执行时机 的,所以说这种任务就是非可控的任务。比如 setTimeout 和 promise 里面的更新任务
其实我们直接上代码,你就看懂了,至于react对于他们处理的区别后面会讲到
js
// 可控任务示例(在React事件处理函数中)
handleClick = () => {
this.setState({ count: this.state.count + 1 }); // 可控更新
this.setState({ count: this.state.count + 1 }); // 会被批处理
// 非可控任务示例(在setTimeout中)
setTimeout(() => {
this.setState({ count: this.state.count + 1 }); // 非可控更新
this.setState({ count: this.state.count + 1 }); // 不会被批处理
}, 0);
}
markUpdateLaneFromFiberToRoot:更新准备工作:标记 ChildLanes
来看函数体
js
/* *
* @param {*} sourceFiber发生state变化的fiber,比如组件A触发了useState,那么组件A对应的fiber就是sourceFiber
* @param {*} lane 产生的更新优先级
*/
function markUpdateLaneFromFiberToRoot(sourceFiber,lane){
/*更新当前fiber上的lane */
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
/*更新缓存树上的lanes */ - 关注点1
let alternate = sourceFiber.alternate;
if (alternate ! = = null) alternate.lanes = mergeLanes(alternate.lanes, lane);
/*当前更新的fiber */
let node = sourceFiber;
/*找到并返回父级*/
let parent = sourceFiber.return;
while(parent ! = = null){
/* TODO:更新ChildLanes字段*/
parent.childLanes = mergeLanes(parent.childLanes, lane);
if (alternate ! = = null) {
alternate.childLanes = mergeLanes(alternate.childLanes, lane;
}
/*递归遍历更新*/
node = parent;
parent = parent.return;
}
}
先来看下入参,注意这里 sourceFiber 指的是 current 树的Fiber
我们先来看下关注点1的地方,为什么 要更新当前 fiber 的缓冲树 alternate 上的优先级?
这个问题困扰了我很久,搜索了ai寻求答案很多次,都没有很理解,今天又搜寻了一次,算是明白了,我们来看下 ai的回答 (有点长)
我的疑问是:使用的 workingProgress 树不也是通过current树拷贝的吗,为啥还要提前给 workingProgress 树标记,是不是多此一举?
我们标记当前Fiber节点时,同时标记其alternate节点,是因为React在更新过程中会交替
使用两棵树。当我们在当前树(current)上标记更新时,如果此时React已经在基于备用树(workInProgress)进行更新,那么如果不更新备用树上对应节点的lanes,这个新更新可能会在本次渲染中被遗漏。
具体过程:
-
假设当前屏幕上显示的是current树。
-
当我们触发更新时,React开始准备构建新的workInProgress树。但是,构建workInProgress树有两种情况:
a. 如果当前没有进行中的渲染,那么会从root开始创建一个新的workInProgress树(通过复制current树上的节点,并创建新的备用关系)。
b. 如果当前已经有一个渲染在进行中(即已经有一个workInProgress树正在构建),那么我们会复用这个workInProgress树,并将新的更新合并进去。 **
-
在更新过程中,React会从发生更新的节点开始,向上标记lanes直到root。但是,在标记的过程中,我们需要同时更新current节点和workInProgress节点(即alternate节点)的lanes,原因如下:
-
情况1:如果更新发生在current树的节点上,而此时已经有一个workInProgress树正在构建(可能由于之前触发的更新),那么新的更新需要被合并到正在构建的workInProgress树中。因此,我们必须同时更新workInProgress树上对应节点的lanes,这样React在继续构建workInProgress树时,才能知道该节点有新的更新需要处理。
-
情况2:如果更新发生时,没有正在进行的渲染,那么我们会创建一个新的workInProgress树。创建workInProgress树的过程是复制current树上的节点,但是注意,此时我们在current树上标记的lanes已经存在。在创建workInProgress节点时,会复制current节点的lanes(包括childLanes)到workInProgress节点。所以看起来似乎不需要在标记current节点时同时标记alternate节点?但是,这里有一个关键点:在创建workInProgress树之前,我们已经执行了
markUpdateLaneFromFiberToRoot
,此时current节点被标记了更新。然后创建workInProgress树时,会从current节点复制lanes。所以在这种情况下,似乎不更新alternate也没关系? -
然而,问题在于:
markUpdateLaneFromFiberToRoot函数在更新过程中可能被多次调用(比如多次setState)。有可能在第一次更新时,我们创建了workInProgress树,然后第二次更新发生在workInProgress树已经创建之后,那么第二次更新就需要同时标记workInProgress树(因为此时workInProgress树已经存在),否则第二次更新可能不会被处理。
-
-
因此,为了统一处理逻辑,无论当前是否有workInProgress树,我们都同时更新当前节点和备用节点(如果存在的话)。这样确保在任何情况下,两棵树上的更新信息都是同步的。
-
另外,在更新过程中,React可能会中断渲染并恢复。当恢复时,它会继续使用之前创建的workInProgress树。如果在这期间发生了新的更新,那么这些更新需要被标记到workInProgress树上,否则这些更新会被遗漏,直到下一次渲染。
所以,结论是:为了确保在任何情况下更新都不会遗漏,必须同时标记当前节点和备用节点(如果备用节点存在)。
不知道你明白了吗?欢迎大家一起交流
第二关注点呢就是为什么要递归向上将父级上的 ChildLanes 都更新,更新成当前的任务优先级。
我们来看下原文解释:
(1)ChildLanes 在整个 React 应用中究竟起到了什么作用?
在整个初始化阶段,因为整个 fiber 树并没有构建,这个过程中,ChildLanes 也就没什么作用,当 一个组件 A 发生更新的时候,只要发生更新的组件的父组件上有一个属性能够证明子代组件发生更 新即可,可以根据 ChildLanes 找到发生更新的 A 组件。
(2)为什么要向上递归更新所有父级的 ChildLanes 呢?
首先,前面讲过,所有的 fiber 是通过一棵 fiber 树关联到一起的,如果组件 A 发生一次更新, React 是从 Root 开始深度遍历更新 fiber 树。
更新过程中需要深度遍历整个 fiber 树吗?当然也不是,只有一个组件更新,所有的 fiber 节点都 调和,无疑是性能上的浪费
既然要从头更新,又不想调和整个 fiber 树,如何找到更新的组件 A 呢?这时 ChildLanes 就派上 用场了。如果 A 发生了更新,那么先向上递归更新父级链的 ChildLanes,接下来从 Root Fiber 向下调和时,发现 ChildLanes 等于当前更新优先级,说明它的 child 链上有新的更新任务,则会继续向下调和,反之退出调和流程。
Root Fiber 是通过 ChildLanes 逐渐向下调和找到需要更新的组件的,为了更清晰地了解流程,这 里画了一个流程图

该图说明了当 fiber 节点 F 对应的组件触发一次更新后,React 是如何找到 F 组件,并触发重新渲 染更新组件的。
第一阶段是发生更新,产生一个更新优先级 lane。
第二阶段向上标记 ChildLanes 过程。
第三阶段是向下调和过程。有的读者会问:为什么 A 会被调和?原因是 A 和 B 是同级,如果父 级元素调和,并且向下调和,那么父级的第一级子链上的 fiber 都会进入调和流程。从 fiber 关系上看,Root 先调和的是 Child 指针上的 A,然后 A 会退出向下调和,接下来才是 sibling B,B 会向下调和,通过 ChildLanes 找到当事人 F,然后 F 会触发渲染更新。
现在我们知道了如何找到 F 并执行渲染,那么还有一个问题,就是 B、E 会向下调和,如果它们 是组件,那么会重新渲染吗?答案是否定的,要记住的是调和过程并非渲染过程,调和过程有可能会 触发渲染函数,也有可能只是继续向下调和,而本身不会执行渲染
作者已经 讲的蛮清楚了,配合流程图也更好理解了,至于关于 调和 和 渲染的关系我们后面会讲
performSyncWorkOnRoot :开始更新:两大阶段渲染和 commit
下面是 performSyncWorkOnRoot 的核心流程
js
function performSyncWorkOnRoot(){
/* 渲染阶段 */
let exitStatus = renderRootSync(root, lanes);
/* commit 阶段 */
commitRoot(
root,
workInProgressRootRecoverableErrors,
workInProgressTransitions,
);
/* 如果有其他等待中的任务,那么继续更新 */
ensureRootIsScheduled(root, now());
}
可以看出 performSyncWorkOnRoot 函数最核心 的部分是执行了两个函数 renderRootSync 和 commitRoot,
这两个函数为整个 React Reconciler 调和的两 大阶段:渲染阶段和 commit 阶段
渲染阶段:这个过程中会执行类组件的渲染函数,也会执行函数组件本身,目的是得到新的 React element,diff 比较出来哪里需要更新,会处理每一个待更新的 fiber 节点,给这个 fiber 打上 flag 标志。
commit 阶段:经过渲染阶段后,待更新的 fiber 会存在不同的 flag 标志,在这个阶段会处理这些 fiber,包括操作真实的 DOM 节点、执行生命周期等
本文到此结束,至于渲染阶段 我们留到10.2节总结,感谢阅读