今天开始尝试更加全面深入的了解 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 应用状态的输入和结果的输出,并且可以将输入信号最终转换成输出信号传递给渲染器。主要的过程如下:
- 通过 scheduleUpdateOnFiber 接受输入,封装 fiber 树的生成逻辑到一个回调函数中,其中会涉及到 fiber 的树形结构、fiber.updateQueue 队列、调用及相关的算法。
- 利用 scheduler 对回调函数(performSyncWorkOnRoot 或 perfromConcurrentWorkOnRoot)进行调度。
- scheduler 控制回调函数执行的时机,在回调函户执行后形成全新的 fiber 树。
- 最后调用渲染器(react-dom、react-native 等)将 fiber 树结构渲染到界面上。
scheduler
是调度机制的核心实现,会控制 react-reconciler 送入回调函数的执行时机,并且在 concurrent 模式下可以实现任务分片。主要功能有两点:
- 执行回调(回调函数由 react-reconciler 提供)。
- 通过控制回调函数的执行时机,来实现任务分片、可中断渲染。
二、架构分层
如果按照 React 应用整体结构来分,可以将整个应用分解为接口层和内核层两个部分。
接口层(api)
包含平时开发所用的绝大多数 api,如 setState、dispatchAction 等,但不包括全部。在 react 启动之后,可以改变渲染的有三个基本操作:
- 类组件中调用 setState();
- 函数组件中使用 hooks,利用 dispatchAction 来改变 hooks 对象;
- 改变 context,实际上也是前二者。
内核层(core)
react 的内核可以分成三个部分来看待,他们分别担任不同的功能:
-
scheduler(调度器)—— 指责是执行回调。会把 react-reconciler 提供的回调函数包装到任务对象中,并在内部维护一个任务队列(按照优先级排序),循环消费队列,直至队列清空。
-
react-reconciler(构造器)。首先它会装载渲染器,要求渲染器必须实现 HostConfig 协议,保证在需要时能够正确调用渲染器的 api 并生成相应的节点;接着会接收 react-dom 包和 react 包发起的更新请求;最后会把 fiber 树的构造过程封装进一个回调函数,并将其传入 scheduler 包等待调度。
-
react-dom(渲染器)。它会引导 react 应用的启动(通过 render),并且实现 HostConfig 协议,重点是能够表现出 fiber 树,生成相对应的 dom 节点和字符串。
三、工作循环
在不同的方向上看过 react 的核心包之后,我们可以发现其中有两个比较重要的工作循环,它们分别是任务调度循环和 fiber 构造循环,分别位于 scheduler 和 react-reconciler 两个核心包中。
任务调度循环
位于 scheduler 中,主要作用是循环调用,控制所有的任务调度。
fiber 构造循环
位于 react-reconciler 中,主要是控制 fiber 树的构造,整体过程是一个深度优先遍历的过程。
两个工作循环的区别与联系
- 任务调度循环数据结构为二叉树,循环执行堆的顶点,直到堆被清空;逻辑偏向宏观,调度的目标为每一个任务,具体任务就是执行相应的回调函数;
- fiber 构造循环数据结构为树,从上至下执行深度优先遍历;其逻辑偏向具体实现,只会负责任务的某一个部分,只负责 fiber 树的构造;
- fiber 构造循环可以看作是任务调度循环的一部分,它们类似从属关系,每个任务都会构造一个 fiber 树。
React 主干逻辑
了解了两个工作循环的区别与联系后,可以发现:React 的运行主干逻辑其实就是任务调度循环负责调度每个任务,fiber 构造循环负责具体实现任务,即输入转换为输出的核心步骤。
也可以总结如下:
- 输入:每一次节点需要更新就视作一次更新需求;
- 注册调度任务:react-reconciler 接收到更新需求后,会去 scheduler 调度中心注册一个新的任务,把具体需求转换成一个任务;
- 执行调度任务(输出):scheduler 通过任务调度循环来执行具体的任务,此时执行具体过程在 react-reconciler 中。而后通过 fiber 构造循环构造出最新的 fiber 树,最后通过 commitRoot 把最新的 fiber 树渲染到页面上,此时任务才算完成。
四、高频对象
接下来介绍一下从 react 启动到页面渲染过程中出现频率较高的各个包中的高频对象。
react 包
此包中包含 react 组件的必要函数以及一些 api。其中,需要重点理解的是 ReactElment 对象,我们可以假设有一个入口函数:
ReactDOM.render(<App/>, document.getElementById('root'));
可以认为,App 及其所有子节点都是 ReactElement 对象,只是它们的 type 会有区别。
- 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 类型则调用方法获取子节点等。
- 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 树的构造。
- 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, // 生成子树所消耗的时间的总和
|};
- 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 后会依次调用这里的回调函数
|};
- 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 对象。
- 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 构造循环)的区别与联系和一些高频对象的类型定义等,这些都将作为后面源码解读的敲门砖。最后补上整体的工作流程示意图,方便理解记忆~