react 架构演变
# 01. 旧架构(react15)
React15架构可以分为两层:
- Reconciler(stack Reconciler 协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Reconciler:
每当有更新发生时,Reconciler会做如下工作:
- 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
- 将虚拟DOM和上次更新时的虚拟DOM对比(递归处理虚拟DOM的)
- 通过对比找出本次更新中变化的虚拟DOM
- 通知Renderer将变化的虚拟DOM渲染到页面上
架构图如下所示:
缺陷出现在 stack Reconciler 递归更新,数据放在递归的调用栈中,一旦开始,无法终止,直到遍历完整棵树,才能将主线程释放。 如果主线程有用户操作或动画渲染操作,就必须等到主线程释放,才能被响应,无法快速响应,即失帧。
# 02. 新架构(React16)
React16架构可以分为三层:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
- Reconciler(fiber Reconciler协调器,基于fiber节点实现的)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
相对比15。新增了Scheduler。 我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。 requestIdleCallback (opens new window)在浏览器空闲时期被调用。由于requestIdleCallback兼容性问题和触发频率问题,react没有采用。
基于上述原因,React实现了功能更完备的requestIdleCallback polyfill,就是Scheduler (opens new window),除了在空闲时触发回调外,Scheduler还提供多种调度优先级供任务设置。
Reconciler:
更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}
Reconciler与Renderer不再是交替工作,当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
架构图如下所示:
# 03. fiber架构的心智模型
神马是心智模型?
官方简单理解就是:它是人【主观】认识事物的方法和习惯,是隐藏在你一切行为方式、思考方式背后的那些形式和规律。
# 04. fiber架构的实现原理
- fiber的结构
react/packages/react-reconciler/src/ReactFiber.new.js
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
){
// 作为静态数据结构的属性
this.tag = tag; // 组件类型,
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null; //真实dom节点
// 作为连接其他Fiber节点形成Fiber树
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0; // 对于同级节点,代表他们插入的位置索引
this.ref = null;
// 作为动态的工作单元的属性
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
this.effectTag = NoEffect;
this.subtreeTag = NoSubtreeEffect;
this.deletions = null;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
// 指向该Fiber在另一次更新时对应的Fiber
this.alternate = null; // fiber 架构的工作方式
...
}
- 作为架构来说
每个fiber 节点有个对应的React Element,多个fiber 节点依赖下面三个属性连接:
this.return = null; // 指向父级Fiber节点
this.child = null; // 指向子Fiber节点
this.sibling = null; // 指向右边第一个兄弟Fiber节点
举例说明:
function App() {
return (
<div>
wsh
<span>age: 18</span>
</div>
)
}
对应的fiber树如下:
- 作为静态数据结构:
作为静态的数据结构,保存了组件相关信息
this.tag = tag; // Fiber对应组件的类型 Function/Class/Host...
this.key = key; // key属性
this.elementType = null; // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.type = null; // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.stateNode = null; // Fiber对应的真实DOM节点
- 作为动态的工作单元:
作为动态的工作单元,Fiber中下面参数保存了本次更新相关的信息
// 保存本次更新造成的状态变化相关信息
this.pendingProps = pendingProps;
this.menoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 保存本次更新会造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
保存调度优先级字段在如下字段:
// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;
# expirationTime/ lanes 优先级对比PR (opens new window):
Lanes 模型与 Expiration Times 模型相比有两个主要优点
- Lanes将任务优先级的概念(任务 A 的优先级是否高于任务 B)与任务批处理(任务A是这组任务的一部分吗?)解耦
- Lanes 可以用单一的 32 位数据类型表示许多不同的任务线程。
在旧模型中,为了决定是否在正在处理的批处理中包含给定的工作单元,我们将比较它们的相对优先级:
const isTaskIncludedInBatch = priorityOfTask >= priorityOfBatch;
这是因为react施加了一个约束,即除非还包括更高优先级的任务,否则不允许完成较低优先级的任务。给定优先级 A > B > C,如果不处理 A,就无法处理B;如果不处理完成 B 和 A,你也不能处理 C。 这个约束是在Suspense出现之前设计的,这个在当时是有意义的(具体什么意义,我就不深究了, 😁)。当我们的所有工作都受 CPU 限制时,除了按优先级之外,沒有太多理由按任何顺序处理任务。但是,当我们引入 IO 密集型任务(即 Suspense)时,我们可能会遇到较高优先级 IO 密集型任务阻止较低优先级 CPU 密集型任务完成的情况。
Expiration Times 的一个类似缺陷限制了我们如何表达一组多个优先级。
就内存或计算而言,使用 Set 对象是不切实际的。我们正在处理的存在性检查非常普遍,因此它们需要快速并使用尽可能少的内存。 作为妥协,我们通常会做的是维持一系列优先级。
const isTaskIncludedInBatch = taskPriority <= highestPriorityInRange && taskPriority >= lowestPriorityInRange;
旧模型将 优先级和 批处理这两个概念耦合到一个单一的数据类型中。 我们表达其中一个的能力是有限的,除非用影响另一个的术语。 在新模型中,我们将这两个概念解耦。 相反,任务组不是用相对数字表示,而是用位掩码表示:
const isTaskIncludedInBatch = (task & batchOfTasks) !== 0;
- 表示任务的位掩码类型称为 Lane
- 表示批次的位掩码类型称为 Lanes
在更具体的 React 术语中,由 setState 调度的更新对象包含一个通道字段,一个启用单个位的位掩码。 这将替换旧模型中的 update.expirationTime 字段。 另一方面,一个fiber 不仅仅与单个更新相关联,而是可能与多个更新相关联。 所以它有一个 lanes 字段,一个启用了零个或多个位的位掩码(旧模型中的 fiber.expirationTime); 和一个 childLanes 字段 (fiber.childExpirationTime)。启用零个或多个位(旧模型中的 fiber.expirationTime)和一个 childLanes 字段 (fiber.childExpirationTime)。
Lanes是一种不透明的类型。 只能在 ReactFiberLane 模块内执行直接位掩码操作。 在其他地方,必须从该模块导入一个辅助函数。 这是一个折衷,但我认为最终是值得的,因为处理Lanes可能非常微妙,并且将所有逻辑放在一起将使我们更容易调整我们的启发式算法,而无需每次进行大量重构(像这样)。
常见的 Expiration Time 字段,转化为 Lanes
renderExpirationtime -> renderLanes
update.expirationTime -> update.lane
fiber.expirationTime -> fiber.lanes
fiber.childExpirationTime -> fiber.childLanes
root.firstPendingTime and root.lastPendingTime -> fiber.pendingLanes
# 05. fiber架构的工作原理
# 什么是双缓存?
通俗来说,当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。
这种在内存中构建并直接替换的技术叫做双缓存。
React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。
# 双缓存Fiber树
在React中最多会同时存在两棵Fiber树。
- 当前屏幕上显示内容对应的Fiber树称为current Fiber>树
- 正在内存中构建的Fiber树称为workInProgress Fiber树
current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React应用的根节点fiber Node通过使current指针在不同Fiber树的rootFiber间切换来完成current Fiber树指向的切换。
切换时机:即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。
每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。
function App() {
const [num, add] = useState(0);
return (
<p onClick={() => add(num + 1)}>{num}</p>
)
}
ReactDOM.render(<App/>, document.getElementById('root'));
首次渲染时:
- fiberRootNode的current会指向当前页面上已渲染内容对应Fiber树,即current Fiber树。
- 接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)
- 图中右侧已构建完的workInProgress Fiber树在commit阶段渲染到页面。 此时DOM更新为右侧树对应的样子。fiberRootNode的current指针指向workInProgress Fiber树使其变为current Fiber 树
更新时:
- 接下来我们点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树
- workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。
# jsx 与fiber 关系
jsx是一种描述当前组件内容的数据结构,不包含schedule,reconclie,render所需的相关信息
在新建时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点。 再更新时,Reconclier 将jsx 数据与Fibler 节点保存的数据对比,生成对应的workInProgress fiber。