《自顶向下学React源码》学习笔记(一)—— 理念与架构

2,170 阅读8分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

最近在学习react的Fiber架构相关内容,源码学习实在是一件很费劲的事情,正好看到 React技术揭秘,大神讲的还是挺清楚的,听了一遍下来,总算有点门路了,赶紧记录下来,好记性不如烂笔头~

一遍当然远远不够的,之后有时间还需要多刷几遍,再跟着demo自己多试试。相信书读百遍,其义自现~

React理念:快速响应

两大制约:

  • CPU的瓶颈:当遇到大计算量的操作或者设备性能不足使页面掉帧,导致卡顿。
  • IO的瓶颈:发送网络请求后,由于需要等待数据返回才能进一步操作导致不能快速响应。

解决方案:

  • 时间切片,长任务分拆到每一帧。在浏览器每一帧的时间中,预留一些时间(5ms)给JS线程,React利用这部分时间更新组件,当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
  • Suspense特性,同步加载代码和数据,在接收到数据的过程中,React迭代地渲染需要数据的组件,直到渲染完所有内容为止。

新老React架构

  1. React15老架构是同步更新,可以分为两层:
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

ReconcilerRenderer是交替工作的,整个过程是同步的,且Reconciler通过递归子组件判断更新,层级很深时递归会占用线程很多时间,引起页面卡顿。

  1. React16新架构是异步可中断的,可以分为三层:
  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件,内部采用了Fiber的架构
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记。只有当所有组件都完成Reconciler的工作,才会统一交给RendererRenderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。

SchedulerReconciler工作都在内存中进行,不会更新页面上的DOM,可能被以下情况反复中断: 1. 有其他更高优任务需要先更新; 2. 当前帧没有剩余时间。

Fiber结构

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // ------作为静态数据结构的属性------
  // 每个`Fiber节点`对应一个`React element`,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息

  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节点形成Fiber树
  this.return = null;  // 指向父级Fiber节点
  this.child = null;   // 指向子Fiber节点
  this.sibling = null; // 指向右边第一个兄弟Fiber节点
  this.index = 0;

  this.ref = null;

  // ------作为动态的工作单元的属性------
  // 保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.updateQueue = null;          // 保存update阶段处理的props
  this.memoizedState = null;
  this.dependencies = null;

  this.mode = mode;
  
  // 保存本次更新会造成的DOM操作
  this.effectTag = NoEffect;
  this.nextEffect = null;

  this.firstEffect = null;  // effectList中第一个Fiber节点
  this.lastEffect = null;   // effectList中最后一个Fiber节点

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

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

Fiber工作原理

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

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

当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树

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

整个应用的根节点只有一个,那就是fiberRootNode。 我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiberfiberRootNodecurrent会指向当前页面上已渲染内容对应Fiber树,即current Fiber树

Diff发生在workInProgress fiber的创建,可以复用current Fiber树对应的节点数据,也可以新建。

深入理解JSX

  • JSX在编译时会被Babel编译为React.createElement方法;
  • React.createElement最终会调用ReactElement方法返回一个包含组件数据的对象,该对象有个参数$$typeof: REACT_ELEMENT_TYPE标记了该对象是个React Element
  • 我们常使用ClassComponentFunctionComponent构建组件,并作为第一个参数传给React.createElement
  • 在组件mount时,Reconciler根据JSX描述的组件内容生成组件对应的Fiber节点,在update时,ReconcilerJSXFiber节点保存的数据对比,生成组件对应的Fiber节点,并根据对比结果为Fiber节点打上标记
  • Fiber节点中包含了更多信息:
    • 组件在更新中的优先级
    • 组件的state
    • 组件被打上的用于Renderer标记

Fiber树的创建过程

render阶段

rootFiber开始向下深度优先遍历。“递”阶段为遍历到的每个Fiber节点调用beginWork方法。该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。 “归”阶段会调用completeWork处理Fiber节点。 “递”和“归”阶段会交错执行直到“归”到rootFiber

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

递调用beginWork

主要工作:创建子Fiber节点,并标记effectTag

function beginWork(
  current: Fiber | null,  // 当前组件对应的`Fiber节点`在上一次更新时的`Fiber节点`,即`workInProgress.alternate`, 也就是当前渲染页面的节点
  workInProgress: Fiber,  // 当前组件对应的`Fiber节点`,内存中的节点
  renderLanes: Lanes,     // 优先级相关
): Fiber | null {
  if (current !== null) { // 通过`current === null ?`来区分组件是处于`mount`还是`update`
    // updatea阶段,满足如下情况时`didReceiveUpdate === false`(即可以直接复用前一次更新的`子Fiber`,不需要新建`子Fiber`)
    // 1. `oldProps === newProps && workInProgress.type === current.type`,即`props`与`fiber.type`不变
    // 2. `!includesSomeLane(renderLanes, updateLanes)`,即当前`Fiber节点`优先级不够
  } else {
    didReceiveUpdate = false;
  }
  
  //   根据 fiber.tag 不同,创建不同的子Fiber节点,最终会进入reconcileChildren方法。
  switch (workInProgress.tag) {
    case IndeterminateComponent: 
      // ...省略
    case LazyComponent: 
      // ...省略
    case FunctionComponent: 
      // ...省略
    case ClassComponent: 
      // ...省略
    case HostRoot:
      // ...省略
    case HostComponent:
      // ...省略
    case HostText:
      // ...省略
    // ...省略其他类型
  }
}
function reconcileChildren(
  current: Fiber | null,
  workInProgress: Fiber,
  nextChildren: any,
  renderLanes: Lanes
) {
  if (current === null) {
    // 对于`mount`的组件,他会创建新的`子Fiber节点`
  } else {
    // 对于`update`的组件,他会将当前组件与该组件在上次更新时对应的`Fiber节点`比较(也就是俗称的`Diff`算法),将比较的结果生成新`Fiber节点`
  }
}

归调用completeWork

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

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

mount情况下,主要逻辑包括三个:

  • Fiber节点生成对应的DOM节点
  • 将子孙DOM节点插入刚生成的DOM节点
  • update逻辑中类似的处理props的过程

注意

  1. update时,updateHostComponent内部,被处理完的props会被赋值给workInProgress.updateQueue
  2. mount时只会在rootFiber存在Placement effectTag,通过appendAllChildren方法插入整颗DOM树。
  3. 每个执行完completeWork且存在effectTagFiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表。commit阶段只需要遍历effectList就能执行所有effect了。
  4. effect副作用包括:插入DOM节点(Placement)、更新DOM节点(Update)、删除DOM节点(Deletion)、以及useEffectuseLayoutEffect的相关操作。

commit阶段

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

commit阶段可以分为三个子阶段,三个阶段都会遍历一遍`effectList:

  • before mutation阶段(执行DOM操作前)
    1. 处理DOM节点渲染/删除后的 autoFocusblur逻辑
    2. 调用getSnapshotBeforeUpdate生命周期钩子
    3. 调度useEffect
  • mutation阶段(执行DOM操作) 执行的是commitMutationEffects
    1. 根据ContentReset effectTag重置文字节点
    2. 更新ref
    3. 根据effectTag分别处理,其中effectTag包括(Placement | Update | Deletion | Hydrating)
    4. 对于FunctionComponent,mutation阶段会执行useLayoutEffect的销毁函数。
    5. 对于HostComponent,mutation阶段会将updateQueuerender阶段生成)对应的内容渲染在页面上。
  • layout阶段(执行DOM操作后)
    1. 调用生命周期钩子hook相关操作
    2. 赋值 ref
    3. 切换fiberRootNode指向的current Fiber树mutation阶段结束后,layout阶段开始前
    4. componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的。
    5. componentDidMountcomponentDidUpdate会在layout阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。

参考

  1. React技术揭秘