尝试全解 React (一)

426 阅读11分钟

今天开始尝试更加全面深入的了解 React 这个框架的构造及功能实现、源码分析,今天是第一篇,主要介绍基础概念。

本文主要参考了 GitHub 中的《图解 React 源码系列》

一、宏观包结构

React 的工程目录下共有35个包(17.0.2版本),其中比较重要的核心包有4个,他们分别是:

React 基础包

提供定义 react 组件(ReactElement)的必要函数,包括大部分常用的 api。

React-dom 渲染器

可以将 react-reconciler 中的运行结果输出到 web 页面上,其中比较重要的入口函数包括 ReactDOM.render(<App/>,document.getElementByID('root'))

React-reconciler 核心包

主要用来管理 react 应用状态的输入和结果的输出,并且可以将输入信号最终转换成输出信号传递给渲染器。主要的过程如下:

  1. 通过 scheduleUpdateOnFiber 接受输入,封装 fiber 树的生成逻辑到一个回调函数中,其中会涉及到 fiber 的树形结构、fiber.updateQueue 队列、调用及相关的算法。
  2. 利用 scheduler 对回调函数(performSyncWorkOnRoot 或 perfromConcurrentWorkOnRoot)进行调度。
  3. scheduler 控制回调函数执行的时机,在回调函户执行后形成全新的 fiber 树。
  4. 最后调用渲染器(react-dom、react-native 等)将 fiber 树结构渲染到界面上。

scheduler

是调度机制的核心实现,会控制 react-reconciler 送入回调函数的执行时机,并且在 concurrent 模式下可以实现任务分片。主要功能有两点:

  1. 执行回调(回调函数由 react-reconciler 提供)。
  2. 通过控制回调函数的执行时机,来实现任务分片、可中断渲染。

二、架构分层

如果按照 React 应用整体结构来分,可以将整个应用分解为接口层和内核层两个部分。

接口层(api)

包含平时开发所用的绝大多数 api,如 setState、dispatchAction 等,但不包括全部。在 react 启动之后,可以改变渲染的有三个基本操作:

  1. 类组件中调用 setState();
  2. 函数组件中使用 hooks,利用 dispatchAction 来改变 hooks 对象;
  3. 改变 context,实际上也是前二者。

内核层(core)

react 的内核可以分成三个部分来看待,他们分别担任不同的功能:

  1. scheduler(调度器)—— 指责是执行回调。会把 react-reconciler 提供的回调函数包装到任务对象中,并在内部维护一个任务队列(按照优先级排序),循环消费队列,直至队列清空。

  2. react-reconciler(构造器)。首先它会装载渲染器,要求渲染器必须实现 HostConfig 协议,保证在需要时能够正确调用渲染器的 api 并生成相应的节点;接着会接收 react-dom 包和 react 包发起的更新请求;最后会把 fiber 树的构造过程封装进一个回调函数,并将其传入 scheduler 包等待调度。

  3. react-dom(渲染器)。它会引导 react 应用的启动(通过 render),并且实现 HostConfig 协议,重点是能够表现出 fiber 树,生成相对应的 dom 节点和字符串。

三、工作循环

在不同的方向上看过 react 的核心包之后,我们可以发现其中有两个比较重要的工作循环,它们分别是任务调度循环和 fiber 构造循环,分别位于 scheduler 和 react-reconciler 两个核心包中。

任务调度循环

位于 scheduler 中,主要作用是循环调用,控制所有的任务调度。

fiber 构造循环

位于 react-reconciler 中,主要是控制 fiber 树的构造,整体过程是一个深度优先遍历的过程。

两个工作循环的区别与联系

  • 任务调度循环数据结构为二叉树,循环执行堆的顶点,直到堆被清空;逻辑偏向宏观,调度的目标为每一个任务,具体任务就是执行相应的回调函数;
  • fiber 构造循环数据结构为树,从上至下执行深度优先遍历;其逻辑偏向具体实现,只会负责任务的某一个部分,只负责 fiber 树的构造;
  • fiber 构造循环可以看作是任务调度循环的一部分,它们类似从属关系,每个任务都会构造一个 fiber 树。

React 主干逻辑

了解了两个工作循环的区别与联系后,可以发现:React 的运行主干逻辑其实就是任务调度循环负责调度每个任务,fiber 构造循环负责具体实现任务,即输入转换为输出的核心步骤。

也可以总结如下:

  1. 输入:每一次节点需要更新就视作一次更新需求;
  2. 注册调度任务:react-reconciler 接收到更新需求后,会去 scheduler 调度中心注册一个新的任务,把具体需求转换成一个任务;
  3. 执行调度任务(输出):scheduler 通过任务调度循环来执行具体的任务,此时执行具体过程在 react-reconciler 中。而后通过 fiber 构造循环构造出最新的 fiber 树,最后通过 commitRoot 把最新的 fiber 树渲染到页面上,此时任务才算完成。

四、高频对象

接下来介绍一下从 react 启动到页面渲染过程中出现频率较高的各个包中的高频对象。

react 包

此包中包含 react 组件的必要函数以及一些 api。其中,需要重点理解的是 ReactElment 对象,我们可以假设有一个入口函数:

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

可以认为,App 及其所有子节点都是 ReactElement 对象,只是它们的 type 会有区别。

  1. ReactElement 对象。

可以认为所有采用 JSX 语法书写的节点都会被编译器编译成 React.createElement(...) 的形式,所以它们创建出来的也就是一个个 ReactElment 对象。其数据结构如下:

export type ReactElement = {|
    //辨别是否为 ReactElement 的标志
    $$typeof: any,
    //内部属性
    type: any,
    key: any,
    ref: any,
    props: any,
    //ReactFiber 记录创建本对象的 Fiber 节点,未关联到 Fiber 树前为 null
    _owner: any,
    //__DEV__dev 环境下的额外信息
    _store: {validated: boolean, ...},
    _self: React$Element<any>,
    _shadowChildren: any,
    _source: Source,
|}

其中值得注意的有:

  • key:在 reconciler 阶段中会用到,所有 ReactElment 对象都有 key 属性,且默认值为 null;
  • type:决定了节点的种类。它的值可以是字符串,函数或 react 内部定义的节点类型;在 reconciler 阶段会根据不同的 type 来执行不同的逻辑,如 type 为字符串类型则直接调用,是 ReactComponent 类型则调用其 render 方法获取子节点,是 function 类型则调用方法获取子节点等。
  1. ReactComponent 对象

这是 type 的一种类型,可以把它看作一种特殊的 ReactElement。这里也引用原作者的一个简单例子:

class App extends React.Component {
  render() {
    return (
      <div className="app">
        <header>header</header>
        <Content />
        <footer>footer</footer>
      </div>
    );
  }
}

class Content extends React.Component {
  render() {
    return (
      <React.Fragment>
        <p>1</p>
        <p>2</p>
        <p>3</p>
      </React.Fragment>
    );
  }
}

export default App;

我们可以观察它编译之后得到的代码,可以发现,createElement 函数的第一个参数将作为创建 ReactElement 的 type,而这个 Content 变量会被命名为 App_Content,作为第一个参数传入 createElement。

class App_App extends react_default.a.Component {
  render() {
    return /*#__PURE__*/ react_default.a.createElement(
      'div',
      {
        className: 'app',
      } /*#__PURE__*/,
      react_default.a.createElement('header', null, 'header') /*#__PURE__*/,

      // 此处直接将Content传入, 是一个指针传递
      react_default.a.createElement(App_Content, null) /*#__PURE__*/,
      react_default.a.createElement('footer', null, 'footer'),
    );
  }
}
class App_Content extends react_default.a.Component {
  render() {
    return /*#__PURE__*/ react_default.a.createElement(
      react_default.a.Fragment,
      null /*#__PURE__*/,
      react_default.a.createElement('p', null, '1'),
      /*#__PURE__*/

      react_default.a.createElement('p', null, '2'),
      /*#__PURE__*/

      react_default.a.createElement('p', null, '3'),
    );
  }
}

自此,可以得出两点结论:

  • ReactComponent 是 class 类型,继承父类 Component,拥有特殊方法 setState 和 forceUpdate,特殊属性 context 和 updater 等。
  • 在 reconciler 阶段,会根据 ReactElement 对象的特征生成对应的 fiber 节点。

顺带也可以带出 ReactElement 的内存结构,很明显它应该是一种类似树形结构,但也具有链表的特征:

  • class 和 function 类型的组件,子节点要在组件 render 后才生成;
  • 父级对象和子对象之间是通过 props.children 属性进行关联的;
  • ReactElement 生成过程自上而下,是所有组件节点的总和;
  • ReactElement 树和 fiber 树是以 props.children 为单位先后交替生成的;
  • reconciler 阶段会根据 ReactElement 的类型生成对应的 fiber 节点,但不是一一对应的,比如 Fragment 类型的组件在生成 fiber 节点的时候就会略过。

react-reconciler 包

react-reconciler 连接渲染器和调度中心,同时自身也会负责 fiber 树的构造。

  1. Fiber 对象

Fiber 对象是 react 中的数据核心,我们可以在 ReactInternalTypes.js 中找到其 type 的定义:

// 一个Fiber对象代表一个即将渲染或者已经渲染的组件(ReactElement), 一个组件可能对应两个fiber(current和WorkInProgress)
// 单个属性的解释在后文(在注释中无法添加超链接)
export type Fiber = {|
  tag: WorkTag,//表示 fiber 类型
  key: null | string,//和 ReactElement 一致
  elementType: any,//一般来讲和 ReactElement 一致
  type: any,//一般和 ReactElement 一致,为了兼容热更新可能会进行一定的处理
  stateNode: any,//与 fiber 关联的局部状态节点
  return: Fiber | null,//指向父节点
  child: Fiber | null,//指向第一个子节点
  sibling: Fiber | null,//指向下一个兄弟节点
  index: number,//fiber 在兄弟节点中的索引,如果是单节点则默认为0
  ref://指向 ReactElement 组件上设置的 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, // 生成子树所消耗的时间的总和
|};
  1. Update 与 UpdateQueue 对象

在 fiber 对象中有一个属性 fiber.updateQueue,是一个链式队列,一样来看一下源码:

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

  tag: 0 | 1 | 2 | 3, //
  payload: any, // 载荷, 根据场景可以设置成一个回调函数或者对象
  callback: (() => mixed) | null, // 回调函数

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

// =============== UpdateQueue ==============
type SharedQueue<State> = {|
  pending: Update<State> | null,//指向即将输入的 queue 队列,class 组件调用 setState 后会将新的 update 对象添加到队列中来
|};

export type UpdateQueue<State> = {|
  baseState: State,//队列的基础 state
  firstBaseUpdate: Update<State> | null,//指向基础队列的队首
  lastBaseUpdate: Update<State> | null,//指向基础队列的队尾
  shared: SharedQueue<State>,//共享队列
  effects: Array<Update<State>> | null,//用于保存有 callback 函数的 update 对象,commit 后会依次调用这里的回调函数
|};
  1. Hook 对象

Hook 主要用于函数组件中,能够保持函数组件的状态。常用的 api 有 useState、useEffect、useCallback 等。一样,我们来看看源码是如何定义 Hook 对象的数据结构的:

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

type Update<S, A> = {|
  lane: Lane,
  action: A,
  eagerReducer: ((S, A) => S) | null,
  eagerState: S | null,
  next: Update<S, A>,
  priority?: ReactPriorityLevel,
|};

type UpdateQueue<S, A> = {|
  pending: Update<S, A> | null,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
|};

由此我们可以看出 Hook 和 fiber 的联系:在 fiber 对象中有一个属性 fiber.memoizedState 会指向 fiber 节点的内存状态。而在函数组件中,其会指向 Hook 队列。

scheduler 包

scheduler 内部会维护一个任务队列,是一个最小堆数组,其中存储了任务 task 对象。

  1. Task 对象

task 对象的类型定义不在 scheduler 中,而是直接定义在 js 代码中:

var newTask = {
  id: taskIdCounter++,//位移标识
  callback,//task 最核心的字段,指向 react-reconciler 包所提供的回调函数
  priorityLevel,//优先级
  startTime,//代表 task 开始的时间,包括创建时间 + 延迟时间 
  expirationTime,//过期时间
  sortIndex: -1,//控制 task 队列中的次序,值越小越靠前
};

总结

今天主要总结了 react 包中的宏观结构可以分成 scheduler、react-reconciler 以及 react-dom 三个部分、两大工作循环(任务调度循环、fiber 构造循环)的区别与联系和一些高频对象的类型定义等,这些都将作为后面源码解读的敲门砖。最后补上整体的工作流程示意图,方便理解记忆~

image.png

©本总结教程版权归作者所有,转载需注明出处