分享最近学习react源码的经历

分享最近学习react源码的经历

分享最近学习react源码的经历

写在前面:最近一段时间在学习React源码,写这篇文章的目的有二:

  1. 分享自己学习的经历,希望之后有相关学习需求的同学有址可寻,少走弯路。
  2. 将学习到的内容转化为文字输出,便于之后回顾(所以可能文中的文字大部分存在生搬硬套的问题,仅有少部分是自己的理解)

我又将这段时间分成如下几个阶段:

  1. 根据教程,手写一个简单的React
  2. 对比React调用栈,了解React大体上的一个工作流程
  3. 下载React源码,根据React源码分析教程,边学习,边调试
  4. 再回顾一遍教程解读,结合其他材料(包括但不限于官网、其他解读材料、视频),在React源码中写上详细注释
  5. 写一篇关于React源码的笔记(正在做的事)

再次声明,本篇文章主要是为了分享学习经理,想要系统学习React源码的同学可以参照文末的优秀文章~

如何手写一个简单的react框架

刚开始学习react源码之前,建议自己先手动实现一个简单的react。推荐跟着下面的视频教程进行学习。

react17源码训练营

照着视频教程撸的源码

如何调试React的源码

  1. 首先从react官方将react源码clone到本地,我这边使用的是V17.0.2的版本
# 拉取代码
1. git clone https://github.com/facebook/react.git
# 也可以使用cnpm镜像
2. git clone https://github.com.cnpmjs.org/facebook/react 
# 或者使用码云的镜像
3. git clone https://gitee.com/mirrors/react.git
  1. 安装依赖
cd react
yarn
  1. 执行打包命令,将react、react-dom、scheduler三个包单独打包成文件,方便调试阅阅读(build过程需要安装jdk)
yarn build react/index,react/jsx,react-dom/index,scheduler --type=NODE

tips: 如果自己build有困难的话,可以直接使用我打包好的

打包好的地址

  1. 使用cra创建自己的react项目
npx create-react-app my-app
  1. 在my-app项目中使用我们打包好的js文件
step1: 在打包好的react目录下执行 yarn link
step2: 在my-app项目目录下执行 yarn link react

step3: 在打包好的react-dom目录下执行 yarn link
step4: 在my-app项目目录下执行 yarn link react-dom
  1. 测试下yarn link 是否生效
// 在react-dom/cjs/react-dom.development.js中加上自己的log

// 1. 应用程序中 调用的入口函数 在这里
function render(element, container, callback) {
  console.log('react render函数执行了');
  if (!isValidContainer(container)) {
    {
      throw Error( "Target container is not a DOM element." );
    }
  }

  {
    var isModernRoot = isContainerMarkedAsRoot(container) && container._reactRootContainer === undefined;

    if (isModernRoot) {
      error('You are calling ReactDOM.render() on a container that was previously ' + 'passed to ReactDOM.createRoot(). This is not supported. ' + 'Did you mean to call root.render(element)?');
    }
  }

  return legacyRenderSubtreeIntoContainer(null, element, container, false, callback);
}

启动my-react项目,打开控制台… 可以看到,react,react-dom这两个包使用的是我们打包好的而不是node_modules里的。接下来我们就可以愉快地调试了!
分享最近学习react源码的经历

React的Fiber架构理念

最新的react架构可以分为三层:

  • Scheduler(调度器) ———— 调度任务的优先级,高优先级任务优先进入Reconciler
  • Reconciler(协调器) ———— 负责对比更新前后节点的变化(diff算法)
  • Renderer(渲染器) ———— 负责将需要变化的节点渲染到页面上

Scheduler(调度器)

scheduler包, 核心职责只有 1 个, 就是执行回调.

  • react-reconciler提供的回调函数, 包装到一个任务对象中.
  • 在内部维护一个任务队列, 优先级高的排在最前面.
  • 循环消费任务队列, 直到队列清空.

Reconciler(协调器)

react-reconciler包, 有 3 个核心职责:

  1. 装载渲染器, 渲染器必须实现HostConfig协议(如: react-dom), 保证在需要的时候, 能够正确调用渲染器的 api, 生成实际节点(如: dom节点).
  2. 接收react-dom包(初次render)和react包(后续更新setState)发起的更新请求.
  3. fiber树的构造过程包装在一个回调函数中, 并将此回调函数传入到scheduler包等待调度.

react中最广为人知的是可中断渲染。之所以react16之后UNSAFE_componentWillMount, UNSAFE_componentWIllReceivePropsrender阶段执行的声明周期函数是不安全的,是因为render阶段是可中断的。但是!只有在HostRootFiber.mode === ConcurrentRoot | BlockingRoot才会开启。如果是legacy模式,即通过ReactDOM.render(<App/>, dom)这种方式启动时,这种情况下无论是首次 render 还是后续 update 都只会进入同步工作循环, reconciliation没有机会中断, 所以生命周期函数只会调用一次。所以,虽然在react17中可中断渲染已经实现,但目前为止,还是实验性功能

Renderer(渲染器)

react-dom包, 有 2 个核心职责:

  1. 引导react应用的启动(通过ReactDOM.render).
  2. 实现HostConfig协议(源码在 ReactDOMHostConfig.js 中), 能够将react-reconciler包构造出来的fiber树表现出来, 生成 dom 节点(浏览器中), 生成字符串(ssr).

React的Fiber架构工作原理

双缓存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应用的根节点(fiberRootNode)通过改变current指针在不同Fiber树的指向来完成current树workInProgress树的替换。

当workInProgress树构建完成交给Renderer渲染在页面上后,应用跟节点(fiberRootNode)指针指向workInProgress Fiber树,此时workInProgress 树变为current Fuber树

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

mount时

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      number: 1
    }
  }

  render() {
    const { number } = this.state;
    return (
      <div onClick={() => {this.setState({number: number+1})}}>
         classComponent: { number}
      </div>
    )
  }
}


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

由于我们的应用中,可以调用多次ReactDOM.render。那么rootFiber就会有多个,但是fiberRootNode仅有一个。fiberRootNode永远指向当前页面上渲染的Fiber 树,即current Fiber树

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

在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。

分享最近学习react源码的经历

  1. 上图中右侧构建完的workInProgress Fiber树commit阶段渲染到页面上后。此时DOM更新为右侧对应的样子。fiberRootNodecurrent指针指向workInProgress Fiber树使其变为current树

分享最近学习react源码的经历

对应的代码:

// commitRootImpl函数中 重点关注下这行代码!!! workInProgress树和current树的切换
root.current = finishedWork;

update时

  1. 接下来我们几点,调用setState触发更新。这一次会开启一次新的render阶段并且构建一颗新的workInProgress 树
    分享最近学习react源码的经历

mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。

这个决定是否复用的过程就是Diff算法

  1. workInProgress Fiber 树render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber树变为current Fiber树。

React中的高频对象解释

Fiber 对象

先看数据结构, 其 type 类型的定义在ReactInternalTypes.js中:

export type Fiber = {|
  tag: WorkTag, // 标识fiber类型,根据ReactElemnt组件的type进行生成
  key: null | string, // 该节点的唯一表示,用于diff算法的优化
  elementType: any, // 一般来讲和ReactElement组件的 type 一致
  type: any, // 一般来讲和fiber.elementType一致. 一些特殊情形下, 比如在开发环境下为了兼容热更新(HotReloading), 会对function, class, ForwardRef类型的ReactElement做一定的处理, 这种情况会区别于fiber.elementType
  stateNode: any, // 与fiber关联的局部状态节点(比如: HostComponent类型指向与fiber节点对应的 dom 节点; 根节点fiber.stateNode指向的是FiberRoot; class 类型节点其stateNode指向的是 class 实例).
  
  return: Fiber | null, // 该节点的父节点
  child: Fiber | null, // 该节点的第一个子节点
  sibling: Fiber | null, // 该节点的下一个子节点
  index: number, // 该节点的下标
  ref:
    | null
    | (((handle: mixed) => void) & { _stringRef: ?string, ... })
    | RefObject,
  pendingProps: any, // 从`ReactElement`对象传入的 props. 用于和`fiber.memoizedProps`比较可以得出属性是否变动
  memoizedProps: any, // 上一次生成子节点时用到的属性, 生成子节点之后保持在内存中
  updateQueue: mixed, // 存储state更新的队列, 当前节点的state改动之后, 都会创建一个update对象添加到这个队列中.
  memoizedState: any, // 用于输出的state, 最终渲染所使用的state
  dependencies: Dependencies | null, // 该fiber节点所依赖的(contexts, events)等
  mode: TypeOfMode, // 二进制位Bitfield,继承至父节点,影响本fiber节点及其子树中所有节点. 与react应用的运行模式有关(有ConcurrentMode, BlockingMode, NoMode等选项).

  // Effect 副作用相关
  flags: Flags, // 标志位
  subtreeFlags: Flags, //替代16.x版本中的 firstEffect, nextEffect. 当设置了 enableNewReconciler=true才会启用
  deletions: Array<Fiber> | null, // 存储将要被删除的子节点. 当设置了 enableNewReconciler=true才会启用

  nextEffect: Fiber | null, // 单向链表, 指向下一个有副作用的fiber节点
  firstEffect: Fiber | null, // 指向副作用链表中的第一个fiber节点
  lastEffect: Fiber | null, // 指向副作用链表中的最后一个fiber节点

  // 优先级相关
  lanes: Lanes, // 本fiber节点的优先级
  childLanes: Lanes, // 子节点的优先级
  alternate: Fiber | null, // 指向内存中的另一个fiber, 每个被更新过fiber节点在内存中都是成对出现(current和workInProgress)

  // 性能统计相关(开启enableProfilerTimer后才会统计)
  // react-dev-tool会根据这些时间统计来评估性能
  actualDuration?: number, // 本次更新过程, 本节点以及子树所消耗的总时间
  actualStartTime?: number, // 标记本fiber节点开始构建的时间
  selfBaseDuration?: number, // 用于最近一次生成本fiber节点所消耗的实现
  treeBaseDuration?: number, // 生成子树所消耗的时间的总和
|};

Update 与 UpdateQueue 对象

当react程序触发状态更新的时候,我们首先会去创建一个update对象。

状态更新的流程: 触发状态更新 —> 创建Update对象 -> 从fiber到root(markUpdateLaneFromFiberToRoot) -> 调度更新(ensureRootIsScheduled) -> render阶段(performSyncWorkOnRootperformConcurrentWorkOnRoot) -> commit阶段(commitRoot)

export type Update<State> = {|
  eventTime: number, // 发起update事件的时间(17.0.1中作为临时字段, 即将移出)
  lane: Lane, // update所属的优先级

  tag: 0 | 1 | 2 | 3, //  共 4 种. UpdateState,ReplaceState,ForceUpdate,CaptureUpdate
  payload: any, // 载荷, 根据场景可以设置成一个回调函数或者对象(setState中的第一个参数)
  callback: (() => mixed) | null, // 回调函数(setState中的第二个参数)

  next: Update<State> | null, // 指向链表中的下一个, 由于UpdateQueue是一个环形链表, 最后一个update.next指向第一个update对象
|};

// =============== UpdateQueue ==============
type SharedQueue<State> = {|
  pending: Update<State> | null,
|};

export type UpdateQueue<State> = {|
  baseState: State,
  firstBaseUpdate: Update<State> | null,
  lastBaseUpdate: Update<State> | null,
  shared: SharedQueue<State>,
  effects: Array<Update<State>> | null,
|};

fiber对象中有一个updateQueue,是一个链式队列,下面通过一张图来描述Fiber,UpdateQueue,Update对象之间的关系

分享最近学习react源码的经历

Hook 对象

Hook的出现使得function函数具有状态管理的能力,从react@16.8版本之后,官方开始推荐Hook语法。官方一共定义了14种Hook类型

export type Hook = {|
  memoizedState: any, // 内存状态,用于输出给行程最终的fiber树
  baseState: any, // 基础状态,当Hook.queue更新过后,baseState也会更新 
  baseQueue: Update<any, any> | null, // 基础状态队列, 在reconciler阶段会辅助状态合并.
  queue: UpdateQueue<any, any> | null, // 指向一个Update队列
  next: Hook | null, // 指向该function组件的下一个Hook对象, 使得多个Hook之间也构成了一个链表.
|};

// UpdateQueue和Update是为了保证Hook对象能够顺利更新, 与上文fiber.updateQueue中的UpdateQueue和Update是不一样的(且它们在不同的文件)
type Update<S, A> = {|
  lane: Lane,
  action: A,
  eagerReducer: ((S, A) => S) | null,
  eagerState: S | null,
  next: Update<S, A>,
  priority?: ReactPriorityLevel,
|};

//UpdateQueue和Update是为了保证Hook对象能够顺利更新, 与上文fiber.updateQueue中的UpdateQueue和Update是不一样的(且它们在不同的文件)
type UpdateQueue<S, A> = {|
  pending: Update<S, A> | null,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
|};

更多详细的高频对象解请参考图解React

初次体验React的工作流程

打开浏览器的performance,我们可以看到react框架的调用栈,首次渲染时大体上可以分为三个部分
分享最近学习react源码的经历

点击事件,触发setState更新时的调用栈

分享最近学习react源码的经历

React项目的详细启动过程

ReactDOM.render(
  <App />,
  document.getElementById('root')
)

在之前的Fiber架构工作原理中,我们提到,在mount时会创建fiberRootNoderootFiber对象。其实还创建了一个ReactDOMRoot对象,并且调用其render方法,开始渲染我们的react程序。

render阶段

render阶段的主要任务是创建Fiber节点并且构建Fiber树

render阶段开始于performSyncWorkOnRootperformConcurrentWorkOnRoot方法的调用。这取决于本次更新是同步更新还是异步更新(由于我们渲染时调用的是render方法,那么就默认接下来的更新都是同步更新)。

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

performUnitOfWork的工作可以分为两部分:“递”和“归”。

在构建Fiber树之前,我想用一个’王朝’的故事来描述深度优先遍历。说是当皇帝驾崩之后(当前Fiber节点处理完了),会将王位传给太子(第一个儿子也就是Fiber中的child),如果没有太子,就会传给自己的兄弟(兄弟也就是Fiber节点中的sibling),如果找不到兄弟节点时,又向上找父亲的兄弟,当找到的人又是刚开始那个皇帝时,说明后继无人。这个王朝也就覆灭了(该Fiber树的遍历也就结束了)

“递”阶段

首先从rootFiber开始向下深度优先遍历。为每个遍历到的Fiber节点调用beginWork方法(后面会详细解释),该方法会根据传入的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"));

分享最近学习react源码的经历

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

render阶段中的beginWork

/**
 * @desc beginWork的工作是传入当前Fiber节点,创建子Fiber节点,并根据diff算法给对应的Fiber打上effectTag
 * @params current  当前组件对应的Fiber节点在上一次更新时的Fiber节点,即workInProgress.alternate
 * @params workInProgress 当前组件对应的Fiber节点
 * @params renderLanes 优先级相关
*/
function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderLanes: Lanes,
): Fiber | null {
}


流程概览
  • current: 当前组件对应的Fiber节点在上一次更新时的Fiber节点,即fiberRootNode指向的节点
  • workInProgress: 当前组件对应的Fiber节点(jsx和state的共同结果)
  • renderLanes: 优先级相关

从性能上考虑,React程序在运行时候,不可能每次重新渲染都重新创建Fiber节点,相信大家多少也都听说过diff算法。所以在beginWork中,需要区分一下是首次渲染(mount)还是更新(update),减少不必要的渲染。

之前我们讲过,React中使用双缓存机制,但是在首次渲染的时候,current树是不存在的,可以作为我们判断是否是首次渲染的依据(即 current === null)。

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:
      // ...省略
    // ...省略其他类型
  }
}
mount时

从上面的代码中可以看出,当我们通过current===null来确定是首次渲染时,我们需要根据Fiber中的tag属性,创建不同的内容(所以,首屏渲染实际上是很耗时的,这也是单页面应用存在的一个问题)。

update时

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

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

updateFunctionComponent举例,如果我们经过一系列判断,后发现该Fiber节点是可以复用的。那么,就不需要花费大量的操作去diff,直接复用现有的Fiber节点

function updateFunctionComponent {
  ...
   if (current !== null && !didReceiveUpdate) {
    bailoutHooks(current, workInProgress, renderLanes);
    return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
  } // React DevTools reads this flag.
}
reconcileChildren

从该函数名就能看出这是Reconciler模块的核心部分。

  • 对于mount的组件,他会创建子Fiber节点
  • 对于update的组件,他会将当前组件于上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点(复用)
/**
 * reconcileChildren 调和函数
 * 调和函数是 `updateXXX`函数中的一项重要逻辑, 它的作用是向下生成子节点, 并设置`fiber.flags`.
 * 初次创建时 `fiber`节点没有比较对象, 所以在向下生成子节点的时候没有任何多余的逻辑, 只管创建就行.
 * 对比更新时 需要把`ReactElement`对象与`旧fiber`对象进行比较, 来判断是否需要复用`旧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,
    );
  }
}

对于diff算法,这边不详细展开,想要深入了解的同学可以参考diff算法

Flags(react16中的effectTag)

我们知道,render阶段的工作是在内存中进行的,当工作结束后需要通知渲染器Renderer执行具体的DOM操作。需要执行DOM,执行哪种DOM操作呢?就需要根据fiber.flags

比如:

// DOM需要插入到页面中
export const Placement = /*                */ 0b00000000000010;
// DOM需要更新
export const Update = /*                   */ 0b00000000000100;
// DOM需要插入到页面中并更新
export const PlacementAndUpdate = /*       */ 0b00000000000110;
// DOM需要删除
export const Deletion = /*                 */ 0b00000000001000;
function markUpdate(workInProgress) {
  workInProgress.flags |= Update; // 打上Update的标签(react16中的 effectTag), beginWork阶段还是completeWork阶段???
}
updateClassComponent{
  ...
  // 打上Placement标签
  workInProgress.flags |= Placement;
}

render阶段中的completeWork

/**
 * 在上一个节点diff完成之后,对他进行一些收尾工作。
 * 1. 将需要更新的属性名放入到Fiber节点的updateQueue属性中
 * 2. 生成EffectList(subtreeFlags)
*/
function completeWork(current, workInProgress, renderLanes) {
  var newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    ...
  }
}

流程概览

beginWrok中我们提到了执行了beginWork之后会创建子Fiber节点,该Fiber节点上可能存在effectTag

类似beginWorkcompleteWork也是针对不同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节点)。

处理HostComponent

和beginWork一样,我们根据current === null ?判断是mount还是update。

Update时

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

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

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

workInProgress.updateQueue = (updatePayload: any);

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

Mount时

可以看到,mount时的主要逻辑包括三个:

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

commit阶段

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

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

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

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

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

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

before mutation阶段

before mutation阶段的代码很短,整个过程就是遍历effectList并调用commitBeforeMutationEffects函数处理。

/** 
 * 1. 处理DOM节点渲染/删除后的 autoFocus、blur逻辑
 * 2. 调用getSnapshotBeforeUpdate生命周期钩子
 * 3. 调度useEffect
*/
function commitBeforeMutationEffects(root, firstChild) {
  // 1. 处理DOM节点渲染/删除后的 autoFocus、blur逻辑
  focusedInstanceHandle = prepareForCommit(root.containerInfo);
  nextEffect = firstChild;
  // 调用 getSnapshotBeforeUpdate useEffect 生命周期函数
  commitBeforeMutationEffects_begin(); // We no longer need to track the active instance fiber

  var shouldFire = shouldFireAfterActiveInstanceBlur;
  shouldFireAfterActiveInstanceBlur = false;
  focusedInstanceHandle = null;
  return shouldFire;
}
getSnapshotBeforeUpdate

说到getSnapshotBeforeUpdate这个生命周期函数,我们不得不想起componentWillXXX钩子前新增的UNSAFE_前缀。由于render阶段是可中断/重新开始的,所以这些UNSAFE的生命周期函数可能会重复执行,但是commit阶段是同步的,所以不会遇到重复执行的问题。

mutation阶段

类似before mutation阶段,mutation阶段也是遍历effectList,执行函数。这里执行的是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节点执行如下三个操作:

  1. 根据ContentReset effectTag重置文字节点
  2. 更新ref
  3. 根据effectTag分别处理,其中effectTag包括(Placement | Update | Deletion | Hydrating)
Placement effect

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

调用的方法为commitPlacement

我们可以看下,最终调用的原生DOM操作

function appendChildToContainer(container, child) {
  var parentNode;

  if (container.nodeType === COMMENT_NODE) {
    parentNode = container.parentNode;
    parentNode.insertBefore(child, container); // 熟悉的原生DOM操作
  } else {
    parentNode = container;
    parentNode.appendChild(child); // 熟悉的原生DOM操作
  }
}
Update effect

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

fiber.tagFunctionComponent,会调用commitHookEffectListUnmount。该方法会遍历effectList,执行所有useLayoutEffect hook的销毁函数。

useLayoutEffect(() => {
  // ...一些副作用逻辑

  return () => {
    // ...这就是销毁函数
  }
})

fiber.tagHostComponent,会调用commitUpdate

最终会在updateDOMProperties(opens new window)中将render阶段 completeWork (opens new window)中为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

该方法会执行如下操作:

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

layout阶段

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

与前两个阶段类似,layout阶段也是遍历effectList,执行函数。

具体执行的函数是commitLayoutEffectscommitLayoutEffects主要做两件事

  1. commitLayoutEffectOnFiber(调用生命周期钩子和hook相关操作)
  2. commitAttachRef(赋值 ref)
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;
commitLayoutEffectOnFiber

commitLayoutEffectOnFiber方法会根据fiber.tag对不同类型的节点分别处理。

对于classComponent,他会根据是mount还是update调用componentDidMountcomponentDidUpdate

触发状态更新的this.setState如果赋值了第二个参数回调函数,也会在此时调用。

对于FunctionComponent及相关类型,他会调用useLayoutEffect hook的回调函数,调度useEffect的销毁与回调函数

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

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

commitLayoutEffects会做的第二件事是commitAttachRef

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

current Fiber树切换

之前我们提过React中的双缓存技术,workInProgress Fiber树在commit阶段完成渲染后会变为current Fiber树。这行代码的作用就是切换fiberRootNode指向的current Fiber树。

我的React源码注释

仅供参考

阅读React源码之后解决的问题

因为使用数组下标作为key产生的bug

// 大概是这样的,由于使用了数组下标作为key
// react的在diff时当判断componenet.type 和 key相同,那么就会复用之前的节点,顶多是个Update,那么这样一来,componentDidMount/useState中的初始值都无效了
list.map((item, key) => (
  <Component key={key} dataSource={item}/>
))

Q&A

参考链接

React技术揭秘

图解React原理

react17源码解析

react17源码训练营

上一篇:React Fiber是什么


下一篇:react fiber摘要