第18期 - xxx
React-原理解析
Fiber 架构
架构-React15的Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack Reconciler。React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler 静态的数据结构-每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息 动态的-每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作,需要被删除/被插入页面中/被更新…
React 15 使用的是 Stack Reconciler(栈协调器) ,是 React 早期版本的核心协调算法,负责比较虚拟 DOM 树的差异并更新真实 DOM。 React16的Reconciler基于Fiber节点实现,被称为Fiber Reconciler。 每个Fiber节点有个对应的React element,多个Fiber节点连接形成树
React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新
双缓存
在React中最多会同时存在两棵Fiber树, current Fiber-当前屏幕上显示内容对应的Fiber树 workInProgress Fiber-正在内存中构建的Fiber树 当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树
生命周期
mount
首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中fiberRootNode是整个应用的根节点,rootFiber是
首屏渲染 fiberRootNode的current会指向当前页面上已渲染内容对应Fiber树,即current Fiber树。 fiberRootNode.current = rootFiber; 由于是首屏渲染,页面中还没有挂载任何DOM,所以fiberRootNode.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。
render阶段 根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树
在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber
update
点击p节点触发状态改变,会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树。 和mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。决定是否复用的过程就是 Diff 算法
workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。
总结 React的Scheduler-Reconciler-Renderer架构体系 Reconciler工作的阶段被称为render阶段。在该阶段会调用组件的render方法 Renderer工作的阶段被称为commit阶段。就像你完成一个需求的编码后执行git commit提交代码。commit阶段会把render阶段提交的信息渲染在页面上。 render与commit阶段统称为work,即React在工作中。相对应的,如果任务正在Scheduler内调度,就不属于work。 在React中, work 特指React正在执行实际渲染相关工作的阶段: Render阶段:
- 构建Fiber树,执行组件逻辑
- 特点 :可中断、可恢复
- 工作内容 :
- 执行函数组件
- 调用生命周期方法
- 计算state和props
- 执行Hooks
- 进行Diff算法比较
Commit阶段(提交阶段)
- 作用 :将变化应用到真实DOM
- 特点 :同步执行,不可中断
- 工作内容 :
- DOM操作(增删改)
- 执行副作用(useEffect等)
- 调用生命周期方法(componentDidMount等)
Scheduler调度阶段不属于”work”,当任务在 Scheduler内部进行调度 时,React还没有开始实际的渲染工作
React16的架构分为三层: Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler Reconciler(协调器)—— 负责找出变化的组件 Renderer(渲染器)—— 负责将变化的组件渲染到页面上
JSX在编译时会被Babel编译为React.createElement方法 也可以通过@babel/plugin-transform-react-jsx插件显式告诉Babel编译时需要将JSX编译为什么函数的调用(默认为React.createElement)。React.createElement最终会调用ReactElement方法返回一个包含组件数据的对象
验证合法React Element的全局 API $$typeof === REACT_ELEMENT_TYPE的非null对象就是一个合法的React Element
export function isValidElement(object) {
return (
typeof object === "object" &&
object !== null &&
object.$$typeof === REACT_ELEMENT_TYPE
);
}
在React中,我们常使用ClassComponent与FunctionComponent构建组件。
class AppClass extends React.Component {
render() {
return <p>KaSong</p>;
}
}
console.log("这是ClassComponent:", AppClass);
console.log("这是Element:", <AppClass />);
function AppFunc() {
return <p>KaSong</p>;
}
console.log("这是FunctionComponent:", AppFunc);
console.log("这是Element:", <AppFunc />);
从 Demo 控制台打印的对象看出,ClassComponent对应的Element的type字段为AppClass自身,FunctionComponent对应的Element的type字段为AppFunc自身
JSX 与 Fiber 节点 JSX是一种描述当前组件内容的数据结构,他不包含组件schedule、reconcile、render所需的相关信息。
以下信息都包含在Fiber节点中,不包括在JSX中: 组件在更新中的优先级 组件的state 组件被打上的用于Renderer的标记
在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点 在update时,Reconciler将JSX与Fiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记。
Fiber节点是如何被创建并构建Fiber树
render阶段开始于performSyncWorkOnRoot或performConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。两个方法唯一的区别是是否调用shouldYield(如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历)
// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
workInProgress代表当前已创建的workInProgress fiber。 performUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树
Fiber Reconciler是从Stack Reconciler重构而来,通过遍历的方式实现可中断的递归,所以performUnitOfWork的工作可以分为两部分:“递”和“归”
首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork 方法。 该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。 当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。
在“归”阶段会调用completeWork处理Fiber节点。 当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。 如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。 “递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。
例子🌰
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
render阶段会依次执行:
1. rootFiber beginWork
2. App Fiber beginWork
3. div Fiber beginWork
4. "i am" Fiber beginWork
5. "i am" Fiber completeWork
6. span Fiber beginWork
7. span Fiber completeWork
8. div Fiber completeWork
9. App Fiber completeWork
10. rootFiber completeWork
如果将performUnitOfWork转化为递归版本
function performUnitOfWork(fiber) {
// 执行beginWork
if (fiber.child) {
performUnitOfWork(fiber.child);
}
// 执行completeWork
if (fiber.sibling) {
performUnitOfWork(fiber.sibling);
}
}
render阶段的工作可以分为“递”阶段和“归”阶段。其中“递”阶段会执行beginWork,“归”阶段会执行completeWork。
beginWork的工作是传入当前Fiber节点,创建子Fiber节点
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes
): Fiber | null {
// ...省略函数体
}
current:当前组件对应的Fiber节点在上一次更新时的Fiber节点,workInProgress.alternate workInProgress:当前组件对应的Fiber节点 renderLanes:优先级相关-Scheduler
组件mount时,由于是首次渲染,是不存在当前组件对应的Fiber节点在上一次更新时的Fiber节点,即mount时current === null。组件update时,由于之前已经mount过,所以current !== null 所以我们可以通过current === null ?来区分组件是处于mount还是update。
基于此原因,beginWork的工作可以分为两部分: update时:如果current存在,在满足一定条件时可以复用current节点,这样就能克隆current.child作为workInProgress.child,而不需要新建workInProgress.child。 mount时:除fiberRootNode以外,current === null。会根据fiber.tag不同,创建不同类型的子Fiber节点
update 时 满足如下情况时didReceiveUpdate === false(即可以直接复用前一次更新的子Fiber,不需要新建子Fiber) oldProps === newProps && workInProgress.type === current.type,即props与fiber.type不变 !includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够-Scheduler
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
(__DEV__ ? workInProgress.type !== current.type : false)
) {
didReceiveUpdate = true;
} else if (!includesSomeLane(renderLanes, updateLanes)) {
didReceiveUpdate = false;
switch (
workInProgress.tag
// 省略处理
) {
}
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
} else {
didReceiveUpdate = false;
}
} else {
didReceiveUpdate = false;
}
mount 时 当不满足优化路径时,我们就进入第二部分,新建子Fiber。根据fiber.tag不同,进入不同类型Fiber的创建逻辑。
// mount时:根据tag不同,创建不同的Fiber节点
switch (workInProgress.tag) {
case IndeterminateComponent:
// ...省略
case LazyComponent:
// ...省略
case FunctionComponent:
// ...省略
case ClassComponent:
// ...省略
case HostRoot:
// ...省略
case HostComponent:
// ...省略
case HostText:
// ...省略
// ...省略其他类型
}
对于我们常见的组件类型,如(FunctionComponent/ClassComponent/HostComponent),最终会进入reconcileChildren方法。
reconcileChildren 从该函数名就能看出这是Reconciler模块的核心部分。 -对于mount的组件,他会创建新的子Fiber节点 -对于update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes
) {
if (current === null) {
// 对于mount的组件
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes
);
} else {
// 对于update的组件
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes
);
}
}
和beginWork一样,他也是通过current === null ?区分mount与update。 不论走哪个逻辑,最终他会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值,并作为下次performUnitOfWork执行时workInProgress的传参
effectTag render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中。
render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中
// DOM需要插入到页面中
export const Placement = /* */ 0b00000000000010;
// DOM需要更新
export const Update = /* */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /* */ 0b00000000000110;
// DOM需要删除
export const Deletion = /* */ 0b00000000001000;
通过二进制表示effectTag,可以方便的使用位操作为fiber.effectTag赋值多个effect。
如果要通知Renderer将Fiber节点对应的DOM节点插入页面中,需要满足两个条件: -fiber.stateNode存在,即Fiber节点中保存了对应的DOM节点(fiber.stateNode会在completeWork中创建) -(fiber.effectTag & Placement) !== 0,即Fiber节点存在Placement effectTag
mount时,fiber.stateNode === null,且在reconcileChildren中调用的mountChildFibers不会为Fiber节点赋值effectTag
假设mountChildFibers也会赋值effectTag,那么可以预见mount时整棵Fiber树所有节点都会有Placement effectTag。那么commit阶段在执行DOM操作时每个节点都会执行一次插入操作,这样大量的DOM操作是极低效的。-为了解决这个问题,在mount时只有rootFiber会赋值Placement effectTag,在commit阶段只会执行一次插入操作。
根 Fiber 节点 第一个进入beginWork方法的Fiber节点就是rootFiber,他的alternate指向current rootFiber(即他存在current)。由于存在current,rootFiber在reconcileChildren时会走reconcileChildFibers逻辑。而之后通过beginWork创建的Fiber节点是不存在current的(即 fiber.alternate === null),会走mountChildFibers逻辑
组件在render阶段会经历beginWork与completeWork,组件执行beginWork后会创建子Fiber节点,节点上可能存在effectTag。
completeWork
类似beginWork,completeWork也是针对不同fiber.tag调用不同的处理逻辑。
function completeWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case IndeterminateComponent:
case LazyComponent:
case SimpleMemoComponent:
case FunctionComponent:
case ForwardRef:
case Fragment:
case Mode:
case Profiler:
case ContextConsumer:
case MemoComponent:
return null;
case ClassComponent: {
// ...省略
return null;
}
case HostRoot: {
// ...省略
updateHostContainer(workInProgress);
return null;
}
case HostComponent: {
// ...省略
return null;
}
// ...省略
页面渲染所必须的HostComponent(即原生DOM组件对应的Fiber节点) 和beginWork一样,我们根据current === null ?判断是mount还是update。同时针对HostComponent,判断update时我们还需要考虑workInProgress.stateNode != null ?(即该Fiber节点是否存在对应的DOM节点)
case HostComponent: {
popHostContext(workInProgress);
const rootContainerInstance = getRootHostContainer();
const type = workInProgress.type;
if (current !== null && workInProgress.stateNode != null) {
// update的情况
// ...省略
} else {
// mount的情况
// ...省略
}
return null;
}
当update时,Fiber节点已经存在对应DOM节点,所以不需要生成DOM节点。 需要做的主要是处理props,比如: onClick、onChange等回调函数的注册 处理style prop 处理DANGEROUSLY_SET_INNER_HTML prop 处理children prop
if (current !== null && workInProgress.stateNode != null) {
// update的情况
updateHostComponent(
current,
workInProgress,
type,
newProps,
rootContainerInstance
);
}
在updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue,并最终会在commit阶段被渲染在页面上, 其中updatePayload为数组形式,他的偶数索引的值为变化的prop key,奇数索引的值为变化的prop value。
workInProgress.updateQueue = (updatePayload: any);
mount时的主要逻辑包括三个: 为Fiber节点生成对应的DOM节点 将子孙DOM节点插入刚生成的DOM节点中 与update逻辑中的updateHostComponent类似的处理props的过程
// mount的情况
// ...省略服务端渲染相关逻辑
const currentHostContext = getHostContext();
// 为fiber创建对应DOM节点
const instance = createInstance(
type,
newProps,
rootContainerInstance,
currentHostContext,
workInProgress
);
// 将子孙DOM节点插入刚生成的DOM节点中
appendAllChildren(instance, workInProgress, false, false);
// DOM节点赋值给fiber.stateNode
workInProgress.stateNode = instance;
// 与update逻辑中的updateHostComponent类似的处理props的过程
if (
finalizeInitialChildren(
instance,
type,
newProps,
rootContainerInstance,
currentHostContext
)
) {
markUpdate(workInProgress);
}
mount时只会在rootFiber存在Placement effectTag。那么commit阶段是通过 completeWork中的appendAllChildren方法一次插入DOM操作(对应一个Placement effectTag)将整棵DOM树插入页面的
由于completeWork属于“归”阶段调用的函数,每次调用appendAllChildren时都会将已生成的子孙DOM节点插入当前生成的DOM节点下。那么当“归”到rootFiber时,我们已经有一个构建好的离屏DOM树。
effectList 作为DOM操作的依据,commit阶段需要找到所有有effectTag的Fiber节点并依次执行effectTag对应操作 在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTag的Fiber节点会被保存在一条被称为effectList的单向链表中。
effectList中第一个Fiber节点保存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect。
类似appendAllChildren,在“归”阶段,所有有effectTag的Fiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表,在commit阶段只需要遍历effectList就能执行所有effect了。 effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯。
render阶段全部工作完成。在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程。
commit
Commit 阶段是Renderer(渲染器)的核心工作流程 。这是React将虚拟DOM的变化应用到真实宿主环境的关键阶段。
commitRoot方法是commit阶段工作的起点。fiberRootNode会作为传参。
commitRoot(root);
在rootFiber.firstEffect上保存了一条需要执行副作用的Fiber节点的单向链表effectList 这些副作用对应的DOM操作在commit阶段执行。除此之外,一些生命周期钩子(比如componentDidXXX)、hook(比如useEffect)需要在commit阶段执行。
commit阶段的主要工作(即Renderer的工作流程)分为三部分: -before mutation 阶段(执行DOM操作前) -mutation 阶段(执行DOM操作) -layout 阶段(执行DOM操作后)
在before mutation阶段之前和layout阶段之后还有一些额外工作,涉及到比如useEffect的触发、优先级相关的重置、ref的绑定/解绑。
完成Render阶段的收尾工作
function commitRoot(root) {
// 确保render阶段已经完全结束
const finishedWork = root.finishedWork;
const lanes = root.finishedLanes;
// 清理render阶段的状态
root.finishedWork = null;
root.finishedLanes = NoLanes;
}
源码中 commitRootImpl方法中直到第一句if (firstEffect !== null)之前属于before mutation之前 before mutation之前主要做一些变量赋值,状态重置的工作。
do {
// 触发useEffect回调与其他同步任务。由于这些任务可能触发新的渲染,所以这里要一直遍历执行直到没有任务
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// root指 fiberRootNode
// root.finishedWork指当前应用的rootFiber
const finishedWork = root.finishedWork;
// 凡是变量名带lane的都是优先级相关
const lanes = root.finishedLanes;
if (finishedWork === null) {
return null;
}
root.finishedWork = null;
root.finishedLanes = NoLanes;
// 重置Scheduler绑定的回调函数
root.callbackNode = null;
root.callbackId = NoLanes;
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
// 重置优先级相关变量
markRootFinished(root, remainingLanes);
// 清除已完成的discrete updates,例如:用户鼠标点击触发的更新。
if (rootsWithPendingDiscreteUpdates !== null) {
if (
!hasDiscreteLanes(remainingLanes) &&
rootsWithPendingDiscreteUpdates.has(root)
) {
rootsWithPendingDiscreteUpdates.delete(root);
}
}
// 重置全局变量
if (root === workInProgressRoot) {
workInProgressRoot = null;
workInProgress = null;
workInProgressRootRenderLanes = NoLanes;
} else {
}
// 将effectList赋值给firstEffect
// 由于每个fiber的effectList只包含他的子孙节点
// 所以根节点如果有effectTag则不会被包含进来
// 所以这里将有effectTag的根节点插入到effectList尾部
// 这样才能保证有effect的fiber都在effectList中
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
// 根节点没有effectTag
firstEffect = finishedWork.firstEffect;
}
tips: firstEffect,在commit的三个子阶段都会用到
在Before Mutation阶段之前的准备工作主要包括: 状态转换 :从Render阶段切换到Commit阶段 Effect收集 :构建完整的副作用链 上下文设置 :保存当前状态,设置commit上下文 性能监控 :开始性能测量和调试追踪 错误处理 :准备错误边界和异常捕获 调度控制 :暂停其他任务,确保commit不被中断 这些准备工作确保了Commit阶段能够安全、高效地执行,同时为后续的三个子阶段(Before Mutation、Mutation、Layout)提供了必要的基础设施。
构建Effect链表,收集所有副作用 Effect链表的结构
- firstEffect : 指向第一个有副作用的Fiber节点
- lastEffect : 指向最后一个有副作用的Fiber节点
- nextEffect : 链接下一个有副作用的节点
layout 之后
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
// useEffect相关
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsLanes = lanes;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
} else {
}
// 性能优化相关
if (remainingLanes !== NoLanes) {
if (enableSchedulerTracing) {
// ...
}
} else {
// ...
}
// 性能优化相关
if (enableSchedulerTracing) {
if (!rootDidHavePassiveEffects) {
// ...
}
}
// ...检测无限循环的同步任务
if (remainingLanes === SyncLane) {
// ...
}
// 在离开commitRoot函数前调用,触发一次新的调度,确保任何附加的任务被调度
ensureRootIsScheduled(root, now());
// ...处理未捕获错误及老版本遗留的边界问题
// 执行同步任务,这样同步任务不需要等到下次事件循环再执行
// 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行
// 或useLayoutEffect
flushSyncCallbackQueue();
return null;
主要包括三点内容: -useEffect相关的处理 -性能追踪相关(源码里有很多和interaction相关的变量。他们都和追踪React渲染时间、性能相关) -在commit阶段会触发一些生命周期钩子(如 componentDidXXX)和hook(如useLayoutEffect、useEffect) 在这些回调方法中可能触发新的更新,新的更新会开启新的render-commit流程
before mutation 阶段
before mutation阶段的代码很短,整个过程就是遍历effectList并调用commitBeforeMutationEffects函数处理。
// 保存之前的优先级,以同步优先级执行,执行完毕后恢复之前优先级
const previousLanePriority = getCurrentUpdateLanePriority();
setCurrentUpdateLanePriority(SyncLanePriority);
// 将当前上下文标记为CommitContext,作为commit阶段的标志
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// 处理focus状态
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
// beforeMutation阶段的主函数
commitBeforeMutationEffects(finishedWork);
focusedInstanceHandle = null;
commitBeforeMutationEffects
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const current = nextEffect.alternate;
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
// ...focus blur相关
}
const effectTag = nextEffect.effectTag;
// 调用getSnapshotBeforeUpdate
if ((effectTag & Snapshot) !== NoEffect) {
commitBeforeMutationEffectOnFiber(current, nextEffect);
}
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
整体可以分为三部分: -处理DOM节点渲染/删除后的 autoFocus、blur 逻辑 -调用getSnapshotBeforeUpdate生命周期钩子 -调度useEffect
调用getSnapshotBeforeUpdate
从Reactv16开始,componentWillXXX钩子前增加了UNSAFE_前缀。 因为Stack Reconciler重构为Fiber Reconciler后,render阶段的任务可能中断/重新开始,对应的组件在render阶段的生命周期钩子(即componentWillXXX)可能触发多次。 这种行为和Reactv15不一致,所以标记为UNSAFE_。为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate。
getSnapshotBeforeUpdate是在commit阶段内的before mutation阶段调用的,由于commit阶段是同步的,所以不会遇到多次调用的问题。
调度useEffect
scheduleCallback方法由Scheduler模块提供,用于以某个优先级异步调度一个回调函数。
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
// 触发useEffect
flushPassiveEffects();
return null;
});
}
}
被异步调度的回调函数就是触发useEffect的方法flushPassiveEffects 在flushPassiveEffects方法内部会从全局变量rootWithPendingPassiveEffects获取effectList
effectList中保存了需要执行副作用的Fiber节点。其中副作用包括 插入DOM节点(Placement) 更新DOM节点(Update) 删除DOM节点(Deletion)
并且,当FunctionComponent使用了useEffect或useLayoutEffect时,React会在对应的Fiber节点上设置特定的effectTag(副作用标记),用于在commit阶段正确处理这些副作用。
在flushPassiveEffects方法内部会遍历rootWithPendingPassiveEffects(即effectList)执行effect回调函数。
为什么需要异步调用 与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。
useEffect异步执行的原因主要是防止同步执行时阻塞浏览器渲染
mutation阶段
类似before mutation阶段,mutation阶段也是遍历effectList,执行函数。这里执行的是commitMutationEffects。
nextEffect = firstEffect;
do {
try {
commitMutationEffects(root, renderPriorityLevel);
} catch (error) {
invariant(nextEffect !== null, 'Should be working on an effect.');
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
commitMutationEffects
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
// 遍历effectList
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 根据 ContentReset effectTag重置文字节点
if (effectTag & ContentReset) {
commitResetTextContent(nextEffect);
}
// 更新ref
if (effectTag & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
// 根据 effectTag 分别处理
const primaryEffectTag =
effectTag & (Placement | Update | Deletion | Hydrating);
switch (primaryEffectTag) {
// 插入DOM
case Placement: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
break;
}
// 插入DOM 并 更新DOM
case PlacementAndUpdate: {
// 插入
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 更新
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// SSR
case Hydrating: {
nextEffect.effectTag &= ~Hydrating;
break;
}
// SSR
case HydratingAndUpdate: {
nextEffect.effectTag &= ~Hydrating;
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新DOM
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 删除DOM
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
}
nextEffect = nextEffect.nextEffect;
}
}
commitMutationEffects会遍历effectList,对每个Fiber节点执行如下三个操作: 根据ContentReset effectTag重置文字节点 更新ref 根据effectTag分别处理,其中effectTag包括(Placement | Update | Deletion | Hydrating-Hydrating作为服务端渲染相关)
Placement effect 当Fiber节点含有Placement effectTag,意味着该Fiber节点对应的DOM节点需要插入到页面中。 调用的方法为commitPlacement, 该方法所做的工作分为三步: 获取父级DOM节点。其中finishedWork为传入的Fiber节点。
const parentFiber = getHostParentFiber(finishedWork);
// 父级DOM节点
const parentStateNode = parentFiber.stateNode;
获取Fiber节点的DOM兄弟节点
const before = getHostSibling(finishedWork);
根据DOM兄弟节点是否存在决定调用parentNode.insertBefore或parentNode.appendChild执行DOM插入操作。
// parentStateNode是否是rootFiber
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
由于Fiber节点不只包括HostComponent,所以Fiber树和渲染的DOM树节点并不是一一对应的。要从Fiber节点找到DOM节点很可能跨层级遍历。因此 getHostSibling(获取兄弟DOM节点)的执行很耗时,当在同一个父Fiber节点下依次执行多个插入操作,getHostSibling算法的复杂度为指数级。
Update effect 当Fiber节点含有Update effectTag,意味着该Fiber节点需要更新。调用的方法为commitWork,据Fiber.tag分别处理。关注 commitWork 内的 FunctionComponent和HostComponent
FunctionComponent mutation 当fiber.tag为FunctionComponent,会调用commitHookEffectListUnmount。该方法会遍历effectList,执行所有useLayoutEffect hook的销毁函数。
useLayoutEffect(() => {
// ...一些副作用逻辑
return () => {
// ...这就是销毁函数
}
})
HostComponent mutation 最终会在updateDOMProperties中将render阶段 completeWork中为Fiber节点赋值的updateQueue对应的内容渲染在页面上。
for (let i = 0; i < updatePayload.length; i += 2) {
const propKey = updatePayload[i];
const propValue = updatePayload[i + 1];
// 处理 style
if (propKey === STYLE) {
setValueForStyles(domElement, propValue);
// 处理 DANGEROUSLY_SET_INNER_HTML
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
setInnerHTML(domElement, propValue);
// 处理 children
} else if (propKey === CHILDREN) {
setTextContent(domElement, propValue);
} else {
// 处理剩余 props
setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
}
}
Deletion effect 当Fiber节点含有Deletion effectTag,意味着该Fiber节点对应的DOM节点需要从页面中删除。调用的方法为commitDeletion。
该方法会执行如下操作: 递归调用Fiber节点及其子孙Fiber节点中fiber.tag为ClassComponent的componentWillUnmount生命周期钩子,从页面移除Fiber节点对应DOM节点 解绑ref 调度useEffect的销毁函数
总结 mutation阶段会遍历effectList,依次执行commitMutationEffects。该方法的主要工作为“根据effectTag调用不同的处理函数处理Fiber。
layout阶段
该阶段之所以称为layout,因为该阶段的代码都是在DOM修改完成(mutation阶段完成)后执行的。
由于 JS 的同步执行阻塞了主线程,所以此时 JS 已经可以获取到新的DOM,但是浏览器对新的DOM并没有完成渲染。该阶段触发的生命周期钩子和hook可以直接访问到已经改变后的DOM,即该阶段是可以参与DOM layout的阶段。
layout阶段也是遍历effectList,执行函数。具体执行的函数是commitLayoutEffects。
root.current = finishedWork;
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, lanes);
} catch (error) {
invariant(nextEffect !== null, "Should be working on an effect.");
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
nextEffect = null;
commitLayoutEffects
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 调用生命周期钩子和hook
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
}
// 赋值ref
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
commitLayoutEffects一共做了两件事: -commitLayoutEffectOnFiber(调用生命周期钩子和hook相关操作) -commitAttachRef(赋值 ref)
commitLayoutEffectOnFiber commitLayoutEffectOnFiber方法会根据fiber.tag对不同类型的节点分别处理。(commitLayoutEffectOnFiber源码(commitLayoutEffectOnFiber为别名,方法原名为commitLifeCycles)
对于ClassComponent,会通过current === null?区分是mount还是update,调用componentDidMount或componentDidUpdate。 触发状态更新的this.setState。如果赋值了第二个参数回调函数,也会在此时调用。
this.setState({ xxx: 1 }, () => {
console.log("i am update~");
});
对于FunctionComponent及相关类型(指特殊处理后的FunctionComponent,比如ForwardRef、React.memo包裹的FunctionComponent),他会调用useLayoutEffect hook的回调函数,调度useEffect的销毁与回调函数
switch (finishedWork.tag) {
// 以下都是FunctionComponent及相关类型
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// 执行useLayoutEffect的回调函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// 调度useEffect的销毁函数与回调函数
schedulePassiveEffects(finishedWork);
return;
}
mutation阶段会执行useLayoutEffect hook的销毁函数。
结合这里我们可以发现,useLayoutEffect hook从上一次更新的销毁函数调用到本次更新的回调函数调用是同步执行的。 而useEffect则需要先调度,在Layout阶段完成后再异步执行。 这就是useLayoutEffect与useEffect的区别
对于HostRoot,即rootFiber,如果赋值了第三个参数回调函数,也会在此时调用。
ReactDOM.render(<App />, document.querySelector("#root"), function() {
console.log("i am mount~");
});
commitAttachRef commitLayoutEffects会做的第二件事是commitAttachRef。获取DOM实例,更新ref。
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
// 获取DOM实例
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
if (typeof ref === "function") {
// 如果ref是函数形式,调用回调函数
ref(instanceToUse);
} else {
// 如果ref是ref实例形式,赋值ref.current
ref.current = instanceToUse;
}
}
}
current Fiber树切换 React渲染流程中的一个关键环节
root.current = finishedWork;
workInProgress Fiber树在commit阶段完成渲染后会变为current Fiber树。这行代码的作用就是切换fiberRootNode指向的current Fiber树。
在mutation阶段结束后,layout阶段开始前 componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的 componentDidMount和componentDidUpdate会在layout阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。
// 在commitLayoutEffects之后进行树切换
function commitRootImpl(root, renderPriorityLevel) {
// ... 前面的阶段
// 1. before mutation阶段
commitBeforeMutationEffects(root, finishedWork);
// 2. mutation阶段
commitMutationEffects(root, finishedWork);
// 3. layout阶段
commitLayoutEffects(finishedWork, root);
// 4. 关键:在layout阶段之后进行树切换
root.current = finishedWork;
// 5. 后续处理
commitPassiveEffects(root);
}
为什么在layout阶段之后切换? DOM操作完成
- mutation阶段已经完成所有DOM的增删改
- layout阶段完成了所有同步副作用的执行
- 此时DOM状态与Fiber树状态完全一致
layout阶段会遍历effectList,依次执行commitLayoutEffects。该方法的主要工作为“根据effectTag调用不同的处理函数处理Fiber并更新ref
Diff算法
在beginWork 中,对于update的组件,他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点。
一个DOM节点在某一时刻最多会有4个节点和他相关 current Fiber:如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。 workInProgress Fiber:如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。 DOM节点本身 JSX对象:即ClassComponent的render方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息
Diff操作会带来性能损耗,将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),其中n是树中元素的数量
为了降低算法复杂度,React的diff会预设三个限制: -只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他 -两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点 -开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定 例子🌰
/ 更新前
<div>
<p key="ka">ka</p>
<h3 key="song">song</h3>
</div>
// 更新后
<div>
<h3 key="song">song</h3>
<p key="ka">ka</p>
</div>
如果没有key,React会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p
从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// // ...省略其他case
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...省略
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...省略
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
从同级的节点数量将Diff分为两类: 当newChild类型为object、number、string,代表同级只有一个节点 当newChild类型为Array,同级有多个节点
单节点diff 以类型object为例,会进入reconcileSingleElement
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// ...其他case
}
}
判断DOM节点是否可以复用是
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 首先判断是否存在对应DOM节点
while (child !== null) {
// 上一次更新存在DOM节点,接下来判断是否可复用
// 首先比较key是否相同
if (child.key === key) {
// key相同,接下来比较type是否相同
switch (child.tag) {
// ...省略case
default: {
if (child.elementType === element.type) {
// type相同则表示可以复用
// 返回复用的fiber
return existing;
}
// type不同则跳出switch
break;
}
}
// 代码执行到这里代表:key相同但是type不同
// 将该fiber及其兄弟fiber标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不同,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新Fiber,并返回 ...省略
}
React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。 当child !== null且key相同且type不同时执行deleteRemainingChildren将child及其兄弟fiber都标记删除。 当child !== null且key不同时仅将child标记删除。
多节点diff
function List () {
return (
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
<li key="3">3</li>
</ul>
)
}
等价转换
{
$$typeof: Symbol(react.element),
key: null,
props: {
children: [
{$$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {…}, …}
]
},
ref: null,
type: "ul"
}
在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新
Diff算法的整体逻辑会经历两轮遍历: 第一轮遍历:处理更新的节点 第二轮遍历:处理剩下的不属于更新的节点
第一轮遍历步骤 遍历newChildren,将newChildren[i]与oldFiber比较,判断DOM节点是否可复用。 如果可复用继续比较newChildren[i]与oldFiber.sibling,可以复用则继续遍历。 如果不可复用,分两种情况: key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。 key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历 如果newChildren遍历完或者oldFiber遍历完,跳出遍历,第一轮遍历结束。
第一轮遍历是一个逐个对比新旧子元素的过程:从索引0开始,依次比较新列表中的元素与旧Fiber节点,如果key和type都相同则复用该节点并继续下一个,如果key不同则立即结束遍历,如果key相同但type不同则标记旧节点删除但继续遍历,直到新列表或旧链表遍历完毕为止,这一轮主要目的是快速识别出可以直接复用的节点,为后续的复杂操作(如节点移动、插入、删除)做准备。
第二轮遍历 基于第一轮可能发生的情况: newChildren与oldFiber同时遍历完,只需在第一轮遍历进行组件更新。此时Diff结束。 newChildren没遍历完,oldFiber遍历完,意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion。 newChildren与oldFiber都没遍历完,意味着有节点在这次更新中改变了位置。diff重难点:
处理移动的节点 由于有节点改变了位置,所以不能再用位置索引i对比前后的节点,需要使用key将同一个节点在两次更新中对应上 为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber。
标记节点是否移动 寻找移动的节点,需要明确节点是否移动是以什么为参照物, 参照物是最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)-在新列表中已经处理过的节点里,在旧列表中位置最靠后的那个节点的旧位置索引。
更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右的那个,即一定在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面。只需要比较遍历到的可复用节点在上次更新时是否也在lastPlacedIndex对应的oldFiber后面,就能知道两次更新中这两个节点的相对位置改变没有。用变量oldIndex表示遍历到的可复用节点在oldFiber中的位置索引。如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex 具体过程,参考:https://react.iamkasong.com/diff/multi.html#demo1
状态更新
commit阶段开始于commitRoot方法的调用。其中rootFiber会作为传参。render阶段完成后会进入commit阶段。让我们继续补全从触发状态更新到render阶段的路径。
在React中,有如下方法可以触发状态更新(排除SSR相关): ReactDOM.render this.setState this.forceUpdate useState useReducer
接入同一套状态更新机制,每次状态更新都会创建一个保存更新状态相关内容的对象,我们叫他Update。在render阶段的beginWork中会根据Update计算新的state。
调用markUpdateLaneFromFiberToRoot方法,从触发状态更新的fiber一直向上遍历到rootFiber,并返回rootFiber。