前端进阶之React原理、源码解读-上篇

前言

不折腾的前端,和咸鱼有什么区别!
当前版本v17.0.0-alpha 地址在这

目录
一 React理念 [理论篇]
 1.1 React理念
 1.2 老的React架构
 1.3 新的React架构
 1.4 Fiber架构的心智模型
 1.5 Fiber架构的实现原理
 1.6 Fiber架构的工作原理
 1.7 总结
二 前置知识 [理论篇]
 2.1 源码的文件结构
 2.2 调试源码
 2.3 深入理解JSX
三 render阶段 [架构篇]
 3.1 流程概览
 3.2 beginWork
 3.3 completeWork
四 commit阶段 [架构篇]
 4.1 流程概览
 4.2 before mutation阶段
 4.3 mutation阶段
 4.4 layout阶段
五 Diff算法 [实现篇]
 5.1 概览
 5.2 单节点Diff
 5.3 多节点Diff
六 状态更新 [实现篇]
 6.1 流程概览
 6.2 心智模型
 6.3 Update
 6.4 深入理解优先级
 6.5 ReactDOM.render
 6.6 this.setState
七 Hooks [实现篇]
 7.1 Hooks理念
 7.2 极简Hooks实现
 7.3 Hooks数据结构
 7.4 useState与useReducer
 7.5 useEffect
 7.6 useRef
 7.7 useMemo与useCallback
八 Concurrent Mode [实现篇]
 8.1 概览
 8.2 Scheduler的原理与实现
 8.3 lane模型
 8.4 异步可中断更新
 8.5 高优任务打断机制
 8.6 batchedUpdates
 8.7 Suspense

理论篇

一 React理念

1.1 React理念

React理念

来自官网的解释:

我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。

可见,关键是实现快速响应。那么制约快速响应的因素是什么呢?

我们日常使用App,浏览网页时,有两类场景会制约快速响应:

  • 当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。

  • 发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。

这两类场景可以概括为:

  • CPU的瓶颈

  • IO的瓶颈

React是如何解决这两个瓶颈的呢?

CPU的瓶颈

考虑如下Demo,我们向视图中渲染3000个li:

function App() {
  const len = 3000;
  return (
    <ul>
      {Array(len).fill(0).map((_, i) => <li>{i}</li>)}
    </ul>
  );
}

const rootEl = document.querySelector("#root");
ReactDOM.render(<App/>, rootEl);  

主流浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。

我们知道,JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。

在每16.6ms时间内,需要完成如下工作:

JS脚本执行 -----  样式布局 ----- 样式绘制

当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。

在Demo中,由于组件数量繁多(3000个),JS脚本执行时间过长,页面掉帧,造成卡顿。

可以从打印的执行堆栈图看到,JS执行时间为73.65ms,远远多于一帧的时间。前端进阶之React原理、源码解读-上篇_第1张图片
如何解决这个问题呢?

答案是:在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件(可以看到,在源码 中,预留的初始时间是5ms)。

当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。

这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)

接下来我们开启Concurrent Mode

// 通过使用ReactDOM.unstable_createRoot开启Concurrent Mode
// ReactDOM.render(, rootEl);  
ReactDOM.unstable_createRoot(rootEl).render(<App/>);

此时我们的长任务被拆分到每一帧不同的task中,JS脚本执行时间大体在5ms左右,这样浏览器就有剩余时间执行样式布局样式绘制,减少掉帧的可能性。
前端进阶之React原理、源码解读-上篇_第2张图片

所以,解决CPU瓶颈的关键是实现时间切片,而时间切片的关键是:将同步的更新变为可中断的异步更新

IO的瓶颈

网络延迟是前端开发者无法解决的。如何在网络延迟客观存在的情况下,减少用户对网络延迟的感知?

React给出的答案是将人机交互研究的结果整合到真实的 UI 中。

为此,React实现了Suspense 功能及配套的hook——useDeferredValue 。

而在源码内部,为了支持这些特性,同样需要将同步的更新变为可中断的异步更新

总结

通过以上内容,我们可以看到,React为了践行“构建快速响应的大型 Web 应用程序”理念做出的努力。

其中的关键是解决CPU的瓶颈与IO的瓶颈。而落实到实现上,则需要将同步的更新变为可中断的异步更新

1.2 老的React架构

React从v15升级到v16后重构了整个架构。因为不能满足快速响应的理念,以至于被重构。

React15架构

React15架构可以分为两层:

  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Reconciler(协调器)

我们知道,在React中可以通过this.setStatethis.forceUpdateReactDOM.render等API触发更新。

每当有更新发生时,Reconciler会做如下工作:

  • 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM
  • 通知Renderer将变化的虚拟DOM渲染到页面上

你可以在这里 看到React官方对Reconciler的解释

Renderer(渲染器)

由于React支持跨平台,所以不同平台有不同的Renderer。我们前端最熟悉的是负责在浏览器环境渲染的Renderer —— ReactDOM 。
除此之外,还有:

  • ReactNative 渲染器,渲染App原生组件
  • ReactTest 渲染器,渲染出纯Js对象用于测试
  • ReactArt 渲染器,渲染到Canvas, SVG 或 VML (IE8)

在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前宿主环境。

你可以在这里 看到React官方对Renderer的解释

React15架构的缺点

Reconciler中,mount的组件会调用mountComponent,update的组件会调用updateComponent。这两个方法都会递归更新子组件。

递归更新的缺点

由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。

那么React15的架构支持异步更新么?让我们看一个例子:
前端进阶之React原理、源码解读-上篇_第3张图片
我们可以看到,ReconcilerRenderer是交替工作的,当第一个li在页面上已经变化后,第二个li再进入Reconciler

由于整个过程都是同步的,所以在用户看来所有DOM是同时更新的。

接下来,让我们模拟一下,如果中途中断更新会怎么样?

注意
以下是我们模拟中断的情况,实际上React15并不会中断进行中的更新

前端进阶之React原理、源码解读-上篇_第4张图片
当第一个li完成更新时中断更新,即步骤3完成后中断更新,此时后面的步骤都还未执行。

用户本来期望123变为246。实际却看见更新不完全的DOM!(即223

基于这个原因,React决定重写整个架构。

1.3 新的React架构

React16是如何支持异步更新的

React16架构

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到,相较于React15,React16中新增了Scheduler(调度器),让我们来了解下他。

Scheduler(调度器)

既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。

其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React放弃使用:

  • 浏览器兼容性
  • 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的requestIdleCallback触发的频率会变得很低

基于以上原因,React实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

Scheduler是独立于React的库

Reconciler(协调器)

我们知道,在React15中Reconciler是递归处理虚拟DOM的。让我们看看React16的Reconciler 。

我们可以看见,更新工作从递归变成了可以中断的循环过程。每次循环都会调用shouldYield判断当前是否有剩余时间。

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

那么React16是如何解决中断更新时DOM渲染不完全的问题呢?

在React16中,ReconcilerRenderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

全部的标记在这里

整个SchedulerReconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer

你可以在这里 看到React官方对React16新Reconciler的解释

Renderer(渲染器)

Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。

所以,对于我们在上一节使用过的Demo

在React16架构中整个更新流程为:
前端进阶之React原理、源码解读-上篇_第5张图片
其中红框中的步骤随时可能由于以下原因被中断:

  • 有其他更高优任务需要先更新
  • 当前帧没有剩余时间

由于红框中的工作都在内存中进行,不会更新页面上的DOM,所以即使反复中断,用户也不会看见更新不完全的DOM(即上一节演示的情况)。

实际上,由于SchedulerReconciler都是平台无关的,所以React为他们单独发了一个包react-Reconciler 。你可以用这个包自己实现一个ReactDOM,具体见参考资料-youtube视频

总结

通过本节我们知道了React16采用新的Reconciler

Reconciler内部采用了Fiber的架构。

Fiber是什么?他和Reconciler或者说和React之间是什么关系?我们会在接下来三节解答。

1.4 Fiber架构的心智模型

React核心团队成员Sebastian Markbåge(React Hooks的发明者)曾说:我们在React中做的就是践行代数效应(Algebraic Effects)。
那么,代数效应是什么呢?他和React有什么关系呢。

什么是代数效应

代数效应函数式编程中的一个概念,用于将副作用函数调用中分离。

接下来我们用虚构的语法来解释。

假设我们有一个函数getTotalPicNum,传入2个用户名称后,分别查找该用户在平台保存的图片数量,最后将图片数量相加后返回。

function getTotalPicNum(user1, user2) {
  const picNum1 = getPicNum(user1);
  const picNum2 = getPicNum(user2);

  return picNum1 + picNum2;
}

getTotalPicNum中,我们不关注getPicNum的实现,只在乎“获取到两个数字后将他们相加的结果返回”这一过程。

接下来我们来实现getPicNum

"用户在平台保存的图片数量"是保存在服务器中的。所以,为了获取该值,我们需要发起异步请求。

为了尽量保持getTotalPicNum的调用方式不变,我们首先想到了使用async await

async function getTotalPicNum(user1, user2) {
  const picNum1 = await getPicNum(user1);
  const picNum2 = await getPicNum(user2);

  return picNum1 + picNum2;
}

但是,async await是有传染性的 —— 当一个函数变为async后,这意味着调用他的函数也需要是async,这破坏了getTotalPicNum的同步特性。

有没有什么办法能保持getTotalPicNum保持现有调用方式不变的情况下实现异步请求呢?

没有。不过我们可以虚构一个。

我们虚构一个类似try...catch的语法 —— try...handle与两个操作符performresume

function getPicNum(name) {
  const picNum = perform name;
  return picNum;
}

try {
  getTotalPicNum('kaSong', 'xiaoMing');
} handle (who) {
  switch (who) {
    case 'kaSong':
      resume with 230;
    case 'xiaoMing':
      resume with 122;
    default:
      resume with 0;
  }
}

当执行到getTotalPicNum内部的getPicNum方法时,会执行perform name

此时函数调用栈会从getPicNum方法内跳出,被最近一个try...handle捕获。类似throw Error后被最近一个try...catch捕获。

类似throw ErrorError会作为catch的参数,perform namename会作为handle的参数。

try...catch最大的不同在于:当Errorcatch捕获后,之前的调用栈就销毁了。而handle执行resume后会回到之前perform的调用栈。

对于case 'kaSong',执行完resume with 230;后调用栈会回到getPicNum,此时picNum === 230

注意
再次申明,try...handle的语法是虚构的,只是为了演示代数效应的思想。
虚构一个类似try...catch的语法 —— try...handle 与两个操作符performresume

总结一下:代数效应能够将副作用(例子中为请求图片数量)从函数逻辑中分离,使函数关注点保持纯粹。

并且,从例子中可以看出,perform resume不需要区分同步异步。

代数效应在React中的应用

那么代数效应React有什么关系呢?最明显的例子就是Hooks

对于类似useStateuseReduceruseRef这样的Hook,我们不需要关注FunctionComponentstateHook中是如何保存的,React会为我们处理。

我们只需要假设useState返回的是我们想要的state,并编写业务逻辑就行。

function App() {
  const [num, updateNum] = useState(0);
  
  return (
    <button onClick={() => updateNum(num => num + 1)}>{num}</button>  
  )
}

如果这个例子还不够明显,可以看看官方的Suspense Demo

DemoProfileDetails用于展示用户名称。而用户名称是异步请求的。

但是Demo中完全是同步的写法。

function ProfileDetails() {
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

代数效应与Generator

React15React16,协调器(Reconciler)重构的一大目的是:将老的同步更新的架构变为异步可中断更新

异步可中断更新可以理解为:更新在执行过程中可能会被打断(浏览器时间分片用尽或有更高优任务插队),当可以继续执行时恢复之前执行的中间状态。

这就是代数效应try...handle的作用。

其实,浏览器原生就支持类似的实现,这就是Generator

但是Generator的一些缺陷使React团队放弃了他:

  • 类似asyncGenerator也是传染性的,使用了Generator则上下文的其他函数也需要作出改变。这样心智负担比较重。

  • Generator执行的中间状态是上下文关联的。

function* doWork(A, B, C) {
  var x = doExpensiveWorkA(A);
  yield;
  var y = x + doExpensiveWorkB(B);
  yield;
  var z = y + doExpensiveWorkC(C);
  return z;
}

每当浏览器有空闲时间都会依次执行其中一个doExpensiveWork,当时间用尽则会中断,当再次恢复时会从中断位置继续执行。

只考虑“单一优先级任务的中断与继续”情况下Generator可以很好的实现异步可中断更新

但是当我们考虑“高优先级任务插队”的情况,如果此时已经完成doExpensiveWorkAdoExpensiveWorkB计算出x与y。

此时B组件接收到一个高优更新,由于Generator执行的中间状态是上下文关联的,所以计算y时无法复用之前已经计算出的x,需要重新计算。

如果通过全局变量保存之前执行的中间状态,又会引入新的复杂度。

更详细的解释可以参考这个issue

基于这些原因,React没有采用Generator实现协调器。

代数效应与Fiber

Fiber并不是计算机术语中的新名词,他的中文翻译叫做纤程,与进程(Process)、线程(Thread)、协程(Coroutine)同为程序执行过程。

在很多文章中将纤程理解为协程的一种实现。在JS中,协程的实现便是Generator

所以,我们可以将纤程(Fiber)、协程(Generator)理解为代数效应思想在JS中的体现。

React Fiber可以理解为:

React内部实现的一套状态更新机制。支持任务不同优先级,可中断恢复,并且恢复后可以复用之前的中间状态

其中每个任务更新单元为React Element对应的Fiber节点。

下面,我们具体讲解Fiber架构的实现。

1.5 Fiber架构的实现原理

Fiber因何而来?他的作用是什么?

Fiber的起源

最早的Fiber官方解释来源于2016年React团队成员Acdlite的一篇介绍。

React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。

为了解决这个问题,React16递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已经无法满足需要。于是,全新的Fiber架构应运而生。

Fiber的含义

  1. 作为架构来说,之前React15Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack ReconcilerReact16Reconciler基于Fiber节点实现,被称为Fiber Reconciler

  2. 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件…)、对应的DOM节点等信息。

  3. 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新…)。

Fiber的结构

你可以从这里看到Fiber节点的属性定义。虽然属性很多,但我们可以按三层含义将他们分类来看

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;

  // 用于连接其他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.nextEffect = null;

  this.firstEffect = null;
  this.lastEffect = null;

  // 调度优先级相关
  this.lanes = NoLanes;
  this.childLanes = NoLanes;

  // 指向该fiber在另一次更新时对应的fiber
  this.alternate = null;
}

作为架构来说

每个Fiber节点有个对应的React element,多个Fiber节点是如何连接形成树呢?靠如下三个属性:

// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;

举个例子,如下的组件结构:

function App() {
  return (
    <div>
      i am
      <span>KaSong</span>
    </div>
  )
}

对应的Fiber树结构:
前端进阶之React原理、源码解读-上篇_第6张图片

这里需要提一下,为什么父级指针叫做return而不是parent或者father呢?因为作为一个工作单元,return指节点执行完completeWork(后面会介绍)后会返回的下一个节点。子Fiber节点及其兄弟节点完成工作后会返回其父级节点,所以用return指代父级节点。

作为静态的数据结构

作为一种静态的数据结构,保存了组件相关的信息:

// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;

作为动态的工作单元

作为动态的工作单元,Fiber中如下参数保存了本次更新相关的信息,我们会在后续的更新流程中使用到具体属性时再详细介绍

// 保存本次更新造成的状态改变相关信息
this.pendingProps = pendingProps;
this.memoizedProps = 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;

如下两个字段保存调度优先级相关的信息,会在讲解Scheduler时介绍。

// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;

总结

本节我们了解了Fiber的起源与架构,其中Fiber节点可以构成Fiber树。那么Fiber树和页面呈现的DOM树有什么关系,React又是如何更新DOM的呢?

1.6 Fiber架构的工作原理

我们了解了Fiber是什么,知道Fiber节点可以保存对应的DOM节点

相应的,Fiber节点构成的Fiber树就对应DOM树

那么如何更新DOM呢?这需要用到被称为“双缓存”的技术。

什么是“双缓存”

当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。

如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。

为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。

这种在内存中构建并直接替换的技术叫做双缓存。

React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。

双缓存Fiber树

React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber`树。

current Fiber树中的Fiber节点被称为current fiberworkInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过使current指针在不同Fiber树rootFiber间切换来完成current Fiber树指向的切换。

即当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树

每次状态更新都会产生新的workInProgress Fiber树,通过currentworkInProgress的替换,完成DOM更新。

接下来我们以具体例子讲解mount时update时的构建/替换流程。

mount时

function App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

ReactDOM.render(<App/>, document.getElementById('root'));
  1. 首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中fiberRootNode是整个应用的根节点,rootFiber所在组件树的根节点。

    之所以要区分fiberRootNoderootFiber,是因为在应用中我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiber。但是整个应用的根节点只有一个,那就是fiberRootNode

    fiberRootNodecurrent会指向当前页面上已渲染内容对应Fiber树,即current Fiber树
    前端进阶之React原理、源码解读-上篇_第7张图片

    fiberRootNode.current = rootFiber;
    

    由于是首屏渲染,页面中还没有挂载任何DOM,所以fiberRootNode.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。

  2. 接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)

    在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。
    前端进阶之React原理、源码解读-上篇_第8张图片

  3. 图中右侧已构建完的workInProgress Fiber树在commit阶段渲染到页面。

    此时DOM更新为右侧树对应的样子。fiberRootNodecurrent指针指向workInProgress Fiber树使其变为current Fiber 树
    前端进阶之React原理、源码解读-上篇_第9张图片

update时

  1. 接下来我们点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树
    前端进阶之React原理、源码解读-上篇_第10张图片
    mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。

    这个决定是否复用的过程就是Diff算法,后面章节会详细讲解

  2. workInProgress Fiber 树render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树
    前端进阶之React原理、源码解读-上篇_第11张图片

总结

本文介绍了Fiber树的构建与替换过程,这个过程伴随着DOM的更新。

那么在构建过程中每个Fiber节点具体是如何创建的呢?我们会在下文的render阶段讲解。

1.7 总结

通过本章的学习,我们了解了ReactScheduler-Reconciler-Renderer架构体系,在结束本章前,介绍几个源码内的术语:

  • Reconciler工作的阶段被称为render阶段。因为在该阶段会调用组件的render方法
  • Renderer工作的阶段被称为commit阶段。就像你完成一个需求的编码后执行git commit提交代码。commit阶段会把render阶段提交的信息渲染在页面上。
  • rendercommit阶段统称为work,即React在工作中。相对应的,如果任务正在Scheduler内调度,就不属于work

二 前置知识

2.1 源码的文件结构

顶层目录

除去配置文件和隐藏文件夹,根目录的文件夹包括三个:

根目录
├── fixtures # 包含一些给贡献者准备的小型 React 测试项目
├── packages # 包含元数据(比如 package.json)和 React 仓库中所有 package 的源码(子目录 src)
├── scripts # 各种工具链的脚本,比如git、jest、eslint等

这里我们关注packages目录

packages目录

React文件夹

React的核心,包含所有全局 React API,如:

  • React.createElement
  • React.Component
  • React.Children

这些 API 是全平台通用的,它不包含ReactDOMReactNative等平台特定的代码。在 NPM 上作为单独的一个包发布。

scheduler 文件夹

Scheduler(调度器)的实现。

shared 文件夹

源码中其他模块公用的方法和全局变量,比如在shared/ReactSymbols.js中保存React不同组件类型的定义。

// ...
export let REACT_ELEMENT_TYPE = 0xeac7;
export let REACT_PORTAL_TYPE = 0xeaca;
export let REACT_FRAGMENT_TYPE = 0xeacb;
// ...
Renderer相关的文件夹

如下几个文件夹为对应的Renderer

- react-art
- react-dom                 # 注意这同时是DOMSSR(服务端渲染)的入口
- react-native-renderer
- react-noop-renderer       # 用于debug fiber(后面会介绍fiber)
- react-test-renderer
试验性包的文件夹

React将自己流程中的一部分抽离出来,形成可以独立使用的包,由于他们是试验性质的,所以不被建议在生产环境使用。包括如下文件夹:

- react-server        # 创建自定义SSR- react-client        # 创建自定义的流
- react-fetch         # 用于数据请求
- react-interactions  # 用于测试交互相关的内部特性,比如React的事件模型
- react-reconciler    # Reconciler的实现,你可以用他构建自己的Renderer
辅助包的文件夹

React将一些辅助功能形成单独的包。包括如下文件夹:

- react-is       # 用于测试组件是否是某类型
- react-client   # 创建自定义的流
- react-fetch    # 用于数据请求
- react-refresh  # “热重载”的React官方实现
react-reconciler文件夹

我们需要重点关注react-reconciler,在源码学习中 80%的代码量都来自这个包。

虽然他是一个实验性的包,内部的很多功能在正式版本中还未开放。但是他一边对接Scheduler,一边对接不同平台的Renderer,构成了整个 React16 的架构体系。

2.2 调试源码

2.3 深入理解JSX

JSX作为描述组件内容的数据结构,为JS赋予了更多视觉表现力。在React中我们大量使用他。在深入源码之前,有些疑问我们需要先解决:

  • JSX和Fiber节点是同一个东西么?
  • React Component、React Element是同一个东西么,他们和JSX有什么关系?

JSX简介

相信作为React的使用者,你已经接触过JSX。如果你还不了解他,可以看下官网对其的描述 。

JSX在编译时会被Babel编译为React.createElement方法。

这也是为什么在每个使用JSX的JS文件中,你必须显式的声明

import React from 'react';

否则在运行时该模块内就会报未定义变量 React的错误。

注意
在React17中,已经不需要显式导入React了。详见介绍全新的 JSX 转换

JSX并不是只能被编译为React.createElement方法,你可以通过@babel/plugin-transform-react-jsx插件显式告诉Babel编译时需要将JSX编译为什么函数的调用(默认为React.createElement)。

比如在preact这个类React库中,JSX会被编译为一个名为h的函数调用。

// 编译前
<p>KaSong</p>
// 编译后
h("p", null, "KaSong");

React.createElement

既然JSX会被编译为React.createElement,让我们看看他做了什么:

export function createElement(type, config, children) {
  let propName;

  const props = {};

  let key = null;
  let ref = null;
  let self = null;
  let source = null;

  if (config != null) {
    // 将 config 处理后赋值给 props
    // ...省略
  }

  const childrenLength = arguments.length - 2;
  // 处理 children,会被赋值给props.children
  // ...省略

  // 处理 defaultProps
  // ...省略

  return ReactElement(
    type,
    key,
    ref,
    self,
    source,
    ReactCurrentOwner.current,
    props,
  );
}

const ReactElement = function(type, key, ref, self, source, owner, props) {
  const element = {
    // 标记这是个 React Element
    $$typeof: REACT_ELEMENT_TYPE,

    type: type,
    key: key,
    ref: ref,
    props: props,
    _owner: owner,
  };

  return element;
};

我们可以看到,React.createElement最终会调用ReactElement方法返回一个包含组件数据的对象,该对象有个参数$$typeof: REACT_ELEMENT_TYPE标记了该对象是个React Element

所以调用React.createElement返回的对象就是React Element么?

React提供了验证合法React Element的全局API React.isValidElement,我们看下他的实现:

export function isValidElement(object) {
  return (
    typeof object === 'object' &&
    object !== null &&
    object.$$typeof === REACT_ELEMENT_TYPE
  );
}

可以看到,$$typeof === REACT_ELEMENT_TYPE的非null对象就是一个合法的React Element。换言之,在React中,所有JSX在运行时的返回结果(即React.createElement()的返回值)都是React Element

那么JSXReact Component的关系呢?

React Component

React中,我们常使用ClassComponentFunctionComponent构建组件。

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对应的Elementtype字段为AppClass自身。

FunctionComponent对应的Elementtype字段为AppFunc自身,如下所示:

{
  $$typeof: Symbol(react.element),
  key: null,
  props: {},
  ref: null,
  type: ƒ AppFunc(),
  _owner: null,
  _store: {validated: false},
  _self: null,
  _source: null 
}

值得注意的一点,由于

AppClass instanceof Function === true;
AppFunc instanceof Function === true;

所以无法通过引用类型区分ClassComponent和FunctionComponent。React通过ClassComponent实例原型上的isReactComponent变量判断是否是ClassComponent。

ClassComponent.prototype.isReactComponent = {};

JSX与Fiber节点

从上面的内容我们可以发现,JSX是一种描述当前组件内容的数据结构,他不包含组件schedulereconcilerender所需的相关信息。

比如如下信息就不包括在JSX中:

  • 组件在更新中的优先级

  • 组件的state

  • 组件被打上的用于Renderer标记

    这些内容都包含在Fiber节点中。

所以,在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点

update时,ReconcilerJSXFiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记


架构篇

三 render阶段

3.1 流程概览

render阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新。

我们现在还不需要学习这两个方法,只需要知道在这两个方法中会调用如下两个方法:

// performSyncWorkOnRoot会调用该方法
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

// performConcurrentWorkOnRoot会调用该方法
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

可以看到,他们唯一的区别是是否调用shouldYield。如果当前浏览器帧没有剩余时间,shouldYield会中止循环,直到浏览器有空闲时间后再继续遍历。

workInProgress代表当前已创建的workInProgress fiber

performUnitOfWork方法会创建下一个Fiber节点并赋值给workInProgress,并将workInProgress与已创建的Fiber节点连接起来构成Fiber树

你可以从这里看到workLoopConcurrent的源码

我们知道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"));

对应的Fiber树结构:Fiber架构
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

注意
之所以没有“KaSong” FiberbeginWork/completeWork,是因为作为一种性能优化手段,针对只有单一文本子节点的FiberReact会特殊处理。

3.2 beginWork

方法概览

源码在这里

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
  // ...省略函数体
}
  • current:当前组件对应的Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate
  • workInProgress:当前组件对应的Fiber节点
  • renderLanes:优先级相关

组件mount时,由于是首次渲染,是不存在当前组件对应的Fiber节点在上一次更新时的Fiber节点,即mountcurrent === 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节点

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes
): Fiber | null {

  // update时:如果current存在可能存在优化路径,可以复用current(即上一次更新的Fiber节点)
  if (current !== null) {
    // ...省略

    // 复用current
    return bailoutOnAlreadyFinishedWork(
      current,
      workInProgress,
      renderLanes,
    );
  } else {
    didReceiveUpdate = false;
  }

  // mount时:根据tag不同,创建不同的子Fiber节点
  switch (workInProgress.tag) {
    case IndeterminateComponent: 
      // ...省略
    case LazyComponent: 
      // ...省略
    case FunctionComponent: 
      // ...省略
    case ClassComponent: 
      // ...省略
    case HostRoot:
      // ...省略
    case HostComponent:
      // ...省略
    case HostText:
      // ...省略
    // ...省略其他类型
  }
}

update时

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;
  }

我们可以看到,满足如下情况时didReceiveUpdate === false(即可以直接复用前一次更新的子Fiber,不需要新建子Fiber

  1. oldProps === newProps && workInProgress.type === current.type,即propsfiber.type不变
  2. !includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够

mount时

当不满足优化路径时,我们就进入第二部分,新建子Fiber

我们可以看到,根据fiber.tag不同,进入不同类型Fiber的创建逻辑。

tag对应的组件类型

// 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 ?区分mountupdate

不论走哪个逻辑,最终他会生成新的子Fiber节点并赋值给workInProgress.child,作为本次beginWork返回值,并作为下次performUnitOfWork执行时workInProgress的传参。

注意
值得一提的是,mountChildFibersreconcileChildFibers这两个方法的逻辑基本一致。唯一的区别是:reconcileChildFibers会为生成的Fiber节点带上effectTag属性,而mountChildFibers不会。

effectTag

我们知道,render阶段的工作是在内存中进行,当工作结束后会通知Renderer需要执行的DOM操作。要执行DOM操作的具体类型就保存在fiber.effectTag中。

你可以从这里看到effectTag对应的DOM操作

// DOM需要插入到页面中
export const Placement = /*                */ 0b00000000000010;
// DOM需要更新
export const Update = /*                   */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /*       */ 0b00000000000110;
// DOM需要删除
export const Deletion = /*                 */ 0b00000000001000;

那么,如果要通知RendererFiber节点对应的DOM节点插入页面中,需要满足两个条件:

  1. fiber.stateNode存在,即·Fiber节点·中保存了对应的DOM节点

  2. (fiber.effectTag & Placement) !== 0,即Fiber节点存在Placement effectTag

我们知道,mount时,fiber.stateNode === null,且在reconcileChildren中调用的mountChildFibers不会为Fiber节点赋值effectTag。那么首屏渲染如何完成呢?

针对第一个问题,fiber.stateNode会在completeWork中创建。

第二个问题的答案十分巧妙:假设mountChildFibers也会赋值effectTag,那么可以预见mount时整棵Fiber树所有节点都会有Placement effectTag。那么commit阶段在执行DOM操作时每个节点都会执行一次插入操作,这样大量的DOM操作是极低效的。

为了解决这个问题,在mount时只有rootFiber会赋值Placement effectTag,在commit阶段只会执行一次插入操作。

3.3 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节点),其他类型Fiber的处理留在具体功能实现时讲解。

处理HostComponent

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时

update时,Fiber节点已经存在对应DOM节点,所以不需要生成DOM节点。需要做的主要是处理props,比如:

  • onClickonChange等回调函数的注册
  • 处理style prop
  • 处理DANGEROUSLY_SET_INNER_HTML prop
  • 处理children prop

我们去掉一些当前不需要关注的功能(比如ref)。可以看到最主要的逻辑是调用updateHostComponent方法。

你可以从这里看到updateHostComponent方法定义。

updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue,并最终会在commit阶段被渲染在页面上。

workInProgress.updateQueue = (updatePayload: any);

其中updatePayload为数组形式,他的偶数索引的值为变化的prop key,奇数索引的值为变化的prop value

mount时

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);
}

commit阶段是如何通过一次插入DOM操作(对应一个Placement effectTag)将整棵DOM树插入页面的呢?

原因就在于completeWork中的appendAllChildren方法。

由于completeWork属于“归”阶段调用的函数,每次调用appendAllChildren时都会将已生成的子孙DOM节点插入当前生成的DOM节点下。那么当“归”到rootFiber时,我们已经有一个构建好的离屏DOM树

effectList

至此render阶段的绝大部分工作就完成了。

还有一个问题:作为DOM操作的依据,commit阶段需要找到所有有effectTagFiber节点并依次执行effectTag对应操作。难道需要在commit阶段再遍历一次Fiber树寻找effectTag !== nullFiber节点么?

这显然是很低效的。

为了解决这个问题,在completeWork的上层函数completeUnitOfWork中,每个执行完completeWork且存在effectTagFiber节点会被保存在一条被称为effectList的单向链表中。

effectList中第一个Fiber节点保存在fiber.firstEffect,最后一个元素保存在fiber.lastEffect

类似appendAllChildren,在“归”阶段,所有有effectTagFiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表。

                       nextEffect         nextEffect
rootFiber.firstEffect -----------> fiber -----------> fiber

这样,在commit阶段只需要遍历effectList就能执行所有effect了。

你可以在这里看到这段代码逻辑。

借用React团队成员Dan Abramov的话:effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯。

流程结尾

至此,render阶段全部工作完成。在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程。

commitRoot(root);

代码在这里

四 commit阶段

4.1 流程概览

commitRoot方法是commit阶段工作的起点。fiberRootNode会作为传参。

commitRoot(root);

你可以从这里看到commit阶段的完整代码

rootFiber.firstEffect上保存了一条需要执行副作用的Fiber节点的单向链表effectList,这些Fiber节点updateQueue中保存了变化的props

这些副作用对应的DOM操作commit阶段执行。

除此之外,一些生命周期钩子(比如componentDidXXX)、hook(比如useEffect)需要在commit阶段执行。

commit阶段的主要工作(即Renderer的工作流程)分为三部分:

  • before mutation阶段(执行DOM操作前)

  • mutation阶段(执行DOM操作)

  • layout阶段(执行DOM操作后)

before mutation阶段之前和layout阶段之后还有一些额外工作,涉及到比如useEffect的触发、优先级相关的重置、ref的绑定/解绑。

before mutation之前

commitRootImpl方法中直到第一句if (firstEffect !== null)之前属于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;
  }

可以看到,before mutation之前主要做一些变量赋值,状态重置的工作。

这一长串代码我们只需要关注最后赋值的firstEffect,在commit的三个子阶段都会用到他。

layout之后

接下来让我们简单看下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;

你可以在这里看到这段代码

主要包括三点内容:

  1. useEffect相关的处理。

    我们会在讲解layout阶段时讲解。

  2. 性能追踪相关。

  3. commit阶段会触发一些生命周期钩子(如 componentDidXXX)和hook(如useLayoutEffectuseEffect)。

    在这些回调方法中可能触发新的更新,新的更新会开启新的render-commit流程。

4.2 before mutation阶段

前面说到Renderer工作的阶段被称为commit阶段commit阶段可以分为三个子阶段:

  • before mutation阶段(执行DOM操作前)

  • mutation阶段(执行DOM操作)

  • layout阶段(执行DOM操作后)

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;

我们重点关注beforeMutation阶段的主函数commitBeforeMutationEffects做了什么。

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

commitBeforeMutationEffectOnFibercommitBeforeMutationLifeCycles的别名。

在该方法内会调用getSnapshotBeforeUpdate

你可以在这里看到这段逻辑

Reactv16开始,componentWillXXX钩子前增加了UNSAFE_前缀

究其原因,是因为Stack Reconciler重构为Fiber Reconciler后,render阶段的任务可能中断/重新开始,对应的组件在render阶段的生命周期钩子(即componentWillXXX)可能触发多次。

这种行为和Reactv15不一致,所以标记为UNSAFE_

为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate。

我们可以看见,getSnapshotBeforeUpdate是在commit阶段内的before mutation阶段调用的,由于commit阶段是同步的,所以不会遇到多次调用的问题。

调度useEffect(具体useEffect的实现在实现篇有解释)

在这几行代码内,scheduleCallback方法由Scheduler模块提供,用于以某个优先级异步调度一个回调函数。

// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
  if (!rootDoesHavePassiveEffects) {
    rootDoesHavePassiveEffects = true;
    scheduleCallback(NormalSchedulerPriority, () => {
      // 触发useEffect
      flushPassiveEffects();
      return null;
    });
  }
}

在此处,被异步调度的回调函数就是触发useEffect的方法flushPassiveEffects

如何异步调度

flushPassiveEffects方法内部会从全局变量rootWithPendingPassiveEffects获取effectList

在completeWork中我们讲到,effectList中保存了需要执行副作用的Fiber节点。其中副作用包括

  • 插入DOM节点(Placement)
  • 更新DOM节点(Update)
  • 删除DOM节点(Deletion)

除此外,当一个FunctionComponent含有useEffectuseLayoutEffect,他对应的Fiber节点也会被赋值effectTag

你可以从这里看到hook相关的effectTag

flushPassiveEffects方法内部会遍历rootWithPendingPassiveEffects(即effectList)执行effect回调函数

如果在此时直接执行,rootWithPendingPassiveEffects === null

那么rootWithPendingPassiveEffects会在何时赋值呢?

在上面layout之后的代码片段中会根据rootDoesHavePassiveEffects === true?决定是否赋值rootWithPendingPassiveEffects

const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
  rootDoesHavePassiveEffects = false;
  rootWithPendingPassiveEffects = root;
  pendingPassiveEffectsLanes = lanes;
  pendingPassiveEffectsRenderPriority = renderPriorityLevel;
}

所以整个useEffect异步调用分为三步:

  • before mutation阶段scheduleCallback中调度flushPassiveEffects
  • layout阶段之后将effectList赋值给rootWithPendingPassiveEffects
  • scheduleCallback触发flushPassiveEffectsflushPassiveEffects内部遍历rootWithPendingPassiveEffects
为什么需要异步调用

摘录自React文档effect 的执行时机:

componentDidMountcomponentDidUpdate 不同的是,传给 useEffect 的函数会在浏览器完成布局与绘制之后,在一个延迟事件中被调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因为绝大多数操作不应阻塞浏览器对屏幕的更新。

可见,useEffect异步执行的原因主要是防止同步执行时阻塞浏览器渲染。

总结

在before mutation阶段,会遍历effectList,依次执行:

  • 处理DOM节点渲染/删除后的 autoFocus、blur逻辑

  • 调用getSnapshotBeforeUpdate生命周期钩子

  • 调度useEffect

4.3 mutation阶段

mutation阶段:执行DOM操作

类似before mutation阶段mutation阶段也是遍历effectList,执行函数。这里执行的是commitMutationEffects

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)

我们关注步骤三中的Placement | Update | Deletion。Hydrating作为服务端渲染相关,我们先不关注。

Placement effect

Fiber节点含有Placement effectTag,意味着该Fiber节点对应的DOM节点需要插入到页面中。

调用的方法为commitPlacement

你可以在这里看到commitPlacement源码

该方法所做的工作分为三步:

  1. 获取父级DOM节点。其中finishedWork为传入的Fiber节点

    const parentFiber = getHostParentFiber(finishedWork);
    // 父级DOM节点
    const parentStateNode = parentFiber.stateNode;
    
  2. 获取Fiber节点DOM兄弟节点

    const before = getHostSibling(finishedWork);
    
  3. 根据DOM兄弟节点是否存在决定调用parentNode.insertBeforeparentNode.appendChild执行DOM插入操作。

    // parentStateNode是否是rootFiber
    if (isContainer) {
      insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
    } else {
      insertOrAppendPlacementNode(finishedWork, before, parent);
    }		
    

    值得注意的是, getHostSibling (获取兄弟 DOM节点 )的执行很耗时,当在同一个父 Fiber节点 下依次执行多个插入操作, getHostSibling 算法的复杂度为指数级。

    这是由于Fiber节点不只包括HostComponent,所以Fiber树和渲染的DOM树节点并不是一一对应的。要从Fiber节点找到DOM节点很可能跨层级遍历。

Update effect

Fiber节点含有Update effectTag,意味着该Fiber节点需要更新。调用的方法为commitWork,他会根据Fiber.tag分别处理。

你可以在这里看到commitWork源码

Deletion effect

Fiber节点含有Deletion effectTag,意味着该Fiber节点对应的DOM节点需要从页面中删除。调用的方法为commitDeletion

你可以在这里看到commitDeletion源码

该方法会执行如下操作:

  1. 递归调用Fiber节点及其子孙Fiber节点fiber.tagClassComponent的componentWillUnmount生命周期钩子,从页面移除Fiber节点对应DOM节点
  2. 解绑ref
  3. 调度useEffect的销毁函数

总结

mutation阶段会遍历effectList,依次执行commitMutationEffects。该方法的主要工作为“根据effectTag调用不同的处理函数处理Fiber

4.4 layout阶段

该阶段之所以称为layout,因为该阶段的代码都是在DOM渲染完成(mutation阶段完成)后执行的。

该阶段触发的生命周期钩子和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

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一共做了两件事:

  1. commitLayoutEffectOnFiber(调用生命周期钩子和hook相关操作)

  2. 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及相关类型,他会调用useLayoutEffect hook的回调函数,调度useEffect的销毁与回调函数

    相关类型指特殊处理后的FunctionComponent,比如ForwardRefReact.memo包裹的FunctionComponent,你可以从这里看到下面这段代码

  • 对于HostRoot,即rootFiber,如果赋值了第三个参数回调函数,也会在此时调用。

    ReactDOM.render(<App />, document.querySelector("#root"), function() {
      console.log("i am mount~");
    });
    

commitAttachRef

commitLayoutEffects会做的第二件事是commitAttachRef。

代码逻辑很简单:获取DOM实例,更新ref

current Fiber树切换

至此,整个layout阶段就结束了。

在结束本节的学习前,我们关注下这行代码:

root.current = finishedWork;

在这里查看这行源码

workInProgress Fiber树commit阶段完成渲染后会变为current Fiber树。这行代码的作用就是切换fiberRootNode指向的current Fiber树

那么这行代码为什么在这里呢?(在mutation阶段结束后,layout阶段开始前。)

我们知道componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的。

componentDidMountcomponentDidUpdate会在layout阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。

总结

从这节我们学到,layout阶段会遍历effectList,依次执行commitLayoutEffects。该方法的主要工作为“根据effectTag调用不同的处理函数处理Fiber更新ref


实现篇

后续内容转移到下篇查看
前端进阶之React原理、源码解读-下篇

五 Diff算法

5.1 概览

5.2 单节点Diff

5.3 多节点Diff

六 状态更新

6.1 流程概览

6.2 心智模型

6.3 Update

6.4 深入理解优先级

6.5 ReactDOM.render

6.6 this.setState

七 Hooks

7.1 Hooks理念

7.2 极简Hooks实现

7.3 Hooks数据结构

7.4 useState与useReducer

7.5 useEffect

7.6 useRef

7.7 useMemo与useCallback

八 Concurrent Mode

8.1 概览

8.2 Scheduler的原理与实现

8.3 lane模型

8.4 异步可中断更新

8.5 高优任务打断机制

8.6 batchedUpdates

8.7 Suspense

你可能感兴趣的:(JavaScript,react,react.js,前端,javascript)