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

591 阅读28分钟

分享最近学习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的Fiber架构理念

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

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

Scheduler(调度器)

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

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

Reconciler(协调器)

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

1. 装载渲染器, 渲染器必须实现[`HostConfig`协议](https://github.com/facebook/react/blob/v17.0.1/packages/react-reconciler/README.md#practical-examples)(如: `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)。

图片引自https://react.iamkasong.com/

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

图片引自https://react.iamkasong.com/

对应的代码:

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

update时

  1. 接下来我们几点,调用setState触发更新。这一次会开启一次新的render阶段并且构建一颗新的workInProgress 树

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对象之间的关系

图片引自https://github.com/7kms/react-illustration-series

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项目的启动过程

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

图片引自https://react.iamkasong.com/

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项目的更新过程

以下例以classComponnt为例。setState时的调用栈

首先我们来看一下setState的定义,在Component的原型上挂在了一个setState方法

Component.prototype.setState = function (partialState, callback) {  if (!(typeof partialState === 'object' || typeof partialState === 'function' || partialState == null)) {    {      throw Error( "setState(...): takes an object of state variables to update or a function which returns an object of state variables." );    }  }  this.updater.enqueueSetState(this, partialState, callback, 'setState');};

enqueueSetState

  1. 首先会通过该组件实例的_reactInternals拿到fiber节点。
  2. 然后创建本次setStateupdate对象
  3. 带着挂载了update对象fiber节点调度更新(执行render,commit)
enqueueSetState: function (inst, payload, callback) {
    var fiber = get(inst);  // 获取当前组件的fiber节点
    ...
     var update = createUpdate(eventTime, lane);  // 创建本次更新的update对象
    enqueueUpdate(fiber, update);  // 将update对象挂到fiber节点上
    var root = scheduleUpdateOnFiber(fiber, lane, eventTime); // 调度更新
  },

enqueueUpdate

新产生的 update 会以单向环状链表保存在shared.pending上(计算state的时候会剪开这个环状链表,并且链接在lastBaseUpdate后,计算state的工作在render阶段的getStateFromUpdate中完成)

下面是构建环状链表,并且挂载在shared.pending下的代码

{
    var pending = sharedQueue.pending;

    if (pending === null) {
      // This is the first update. Create a circular list.
      update.next = update;  // 构建 单向环状链表
    } else {
      // console.log('  if (pending !== null) { ', pending); // 批量更新
      update.next = pending.next; // 构建 单向环状链表
      pending.next = update; // 构建  单向环状链表
    }

    sharedQueue.pending = update;
  }

markUpdateLaneFromFiberToRoot

由于我们调度更新时候,需要知道对哪个rootFiber进行更新(当ReactDom.render执行多次rootFiber可能存在d多个),我们需要通过markUpdateLaneFromFiberToRoot获取到当前的rootFiber

ensureRootIsScheduled

调度更新,我们可以看到,performSyncWorkOnRoot并不是立即执行的,而是放在一个回调中,最终存在syncQueue中(1.setState后并不是立即更新的,2.并且多次setState时会先将更新的内容攒起来,一次性批量更新)

if (newCallbackPriority === SyncLanePriority) {
    // Special case: Sync React callbacks are scheduled on a special
    // internal queue
    // 任务已经过期,需要同步执行render阶段
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    newCallbackNode = null;
  } else if (newCallbackPriority === SyncBatchedLanePriority) {
    // 从这里可以看出setState之后的更新并不是同步立即执行的,而是根据回调
    newCallbackNode = scheduleCallback(ImmediatePriority, performSyncWorkOnRoot.bind(null, root)); 
  } else {
    // 根据任务优先级异步执行render阶段
    var schedulerPriorityLevel = lanePriorityToSchedulerPriority(newCallbackPriority);
    newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
  }

performSyncWorkOnRoot之后的话,就又回到了render阶段commit阶段(计算新的state逻辑在getStateFromUpdate中,不过这是render阶段的内容,这里不作过多解释)。

状态更新流程总结

触发状态更新(根据场景调用不同方法)

    |
    |
    v

创建Update对象

    |
    |
    v

从fiber到root(`markUpdateLaneFromFiberToRoot`)

    |
    |
    v

调度更新(`ensureRootIsScheduled`)

    |
    |
    v

render阶段(`performSyncWorkOnRoot``performConcurrentWorkOnRoot`)

    |
    |
    v

commit阶段(`commitRoot`

tips: setState是同步的还是异步的? 多次setState后的结果是什么?为什么说setTimeout中的setState会立即执行?

我的React源码注释

上述内容就是本人最近一段时间读react源码后一些粗略的见解,当然文中只是罗列了一小部分内容,想看详细的注释请参考详细注释

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

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

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

Q&A

  1. setState到底是同步还是异步的?

    需要分场景,React本身的渲染分为同步渲染和异步渲染。我们目前用的ReactDOM.render就是一种同步渲染。同步渲染的场景下,在React能控制的情况下setState是异步更新的。但是也存在同步更新的情况,比如setTimeoutsetInterval,addEventLister等原生事件中(脱离了react的管控,新启了一个线程)。

  2. 多次setState会发生什么情况?

    首先,多次setState是延迟更新的,setState之后并不能马上拿到结果

      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    

    其次,多次setState会被攒起来,一次性批量更新(为什么要批量更新?有人说是为了优化。通过异步的操作方式,累积更新后,批量合并处理,减少渲染次数,提升性能)。React团队的官方答复是:

    • 保持内部一致性。如果改为同步更新的方式,尽管 setState 变成了同步,但是 props 不是。

    • 为后续的架构升级启用并发更新。为了完成异步渲染,React 会在 setState 时,根据它们的数据来源分配不同的优先级,这些数据来源有:事件回调句柄、动画效果等,再根据优先级并发处理,提升渲染性能。

  3. 为什么hooks不能写在判断条件中

    首先,我们看hooks的数据结构,hooks内部是通过next属性连接起来的,如果写在判断、循环、嵌套中会导致数组取值错位。

    • 初次渲染的时候,按照 useState,useEffect 的顺序,把 state,deps 等按顺序塞到 memoizedState 数组中。
    • 更新的时候,按照顺序,从 memoizedState 中把上次记录的值拿出来。

    参考链接pl

  4. Jsx、Fiber有什么关系?

    Jsx本身是一种语法糖,可以理解成是js+html的形式。Fiber是React中的一种数据结构,Jsx需要由@babel/plugin-transfrom-react-jsx插件转化出来。而Fiber又是通过ReactElement对象创建的。

    /**
     * @desc 根据 jsx 转化出来的 ReactElement 生成 Fiber 节点
    */
    function createFiberFromElement(element, mode, lanes) {
      ...
      var type = element.type;
      var key = element.key;
      var pendingProps = element.props;
      var fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);
     ...
      return fiber;
    }
    
  5. componentWillMount、componentWillMount、componentWillUpdate为什么标记UNSAFE?

    首先,这三个声明周期函数是在reactrender阶段执行的。其次,在concurrent|blocking模式下,render中的任务可能执行一半的时候会被高优先级的任务打断后,再重复执行,所以这几个声明周期函数可能会在被多次重复渲染。

  6. 为什么说string类型的refs会被废弃?

  7. 请简述diff算法

    首先,为了降低算法的复杂度,在React中的diff算法是有一些大前提的。

    • react中只会对同级的节点进行diff,跨层级的操作后的节点是需要删除并且重新挂载的。

    • react中只要组件类型是不相同的,那么不会继续复用。

    • react中做了一些优化,给每个节点带上key属性,如果keyelementType是相同的,那么就默认该节点是可以复用的。

      根据同级节点的数量,又可以分为单节diff和多节点diff(以newChild为准)。

      7.1 单节点diff

      单节点diff时,首先会判断current树上是否有该节点,其次判断key(所以,使用map来渲染jsx时务必加上key),elementType是否是相同的,如果是,则复用该节点,删除current树该层级下的所有节点。

      function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
          while (child !== null) {
            // 首先比较 key是否相同 所以我们在写.map的时候一定一定千万要加上key(默认会使用下标)
            if (child.key === key) {
               ...
                if (child.elementType === elementType || ( // Keep this check inline so it only runs on the false path:
                 typeof elementType === 'object' && elementType !== null && elementType.$$typeof === REACT_LAZY_TYPE && resolveLazy(elementType) === child.type) {
                  // 如果 key 相同 type也相同的情况下,删除剩下的节点
                  deleteRemainingChildren(returnFiber, child.sibling);
                  // key相同type相同。可以 复用的节点
                  var _existing = useFiber(child, element.props);
                  return _existing;
                }
              } 
              deleteRemainingChildren(returnFiber, child);
              break;
            } else {
              deleteChild(returnFiber, child); // key如果不相同,直接删除
            }
      
            child = child.sibling; // 虽然是单节点diff,但是current数中当前可能存在多个节点,需要遍历完
          }
        }
      

      7.2 多节点diff

      多节点diff的情况比较复杂,不多赘述,可以参考react技术揭秘

  8. Fiber是什么,它为什么能提高性能?

    Fiberreact中用来表示虚拟DOM。并且通过借助Fiber数据结构,未来React能实现异步的可中断更新。React的核心思想是对于每次界面上状态的改变,都通过diff算法去找出差异,然后重新渲染页面。但是存在一些问题。

    • 并不是所有的状态更新都是需要马上执行的
    • 需要更新的内容也是需要分轻重缓急的,比如动画,用户主动触发的交互响应结果,应该是高优先级需要立即响应的。网络请求产生的更新是一般优先级。当我们某一个低优先级长时间占用js线程进行计算,从而导致高优先级的任务被阻断时,就会影响用户体验。Fiber的提出以及可中断渲染的设计理念就是为了解决类似的问题
  9. react怎么区分Class组件和Function组件

    由于本质上Class组件和Function组件都是函数,所以只能在Component组件上加上isReactComponent属性来区分函数组件和类组件

    function Component(props, context, updater) {
    ...
    }
    // 加上属性 用来区分函数组件和类组件
    Component.prototype.isReactComponent = {};
    
  10. react有哪些优化手段?

    • SuspenseLazy懒加载,减少加载不必的元素
    • Memo,pureComponent,shouldComponnetUpdate来减少不必要的重复渲染
    • useMemo来缓存耗时的计算
    • 避免使用内联对象 参考链接
    • 避免使用匿名函数 参考链接
    • 调整CSS而不是强制组件加载和卸载
    • 使用React.Fragment来避免加载额外的DOM

参考链接

React技术揭秘

图解React原理

react17源码解析

[react17源码训练营](