React 源码漂流记:ReactElement 与基础概念
# 目录
# 学习目标
- 学习 React 的整体目录结构、API 概况、核心包的作用。
- 学习 JSX、ReactElement、VDOM 等概念,了解 JSX 的解析原理。
- 了解 React 选择 JSX 和 VDOM 的原因。
# 源码结构
熟悉 React 的小伙伴可能都知道,React 大致上可以分成调和器、调度器、渲染器几个部分。对应到 React 的源码里,最重要的就是有四个包,分别是 react、react-dom、scheduler、react-reconciler。克隆下源码,大概像是这样:
上述几个包的核心作用:
- react:导出 React 的核心 API,供外部应用使用。比如 Fragment、forwardRef、memo、hook 全家桶等。
- react-dom:React 基于 web 的渲染层,导出一些渲染相关的 API,比如说 render、createPortal、createRoot 等。
- scheduler:React 中的调度器,负责任务队列的维护,基于优先级调度任务。
- react-reconciler:React 中的调和器,负责 React 渲染的整体流程,包括 FiberTree 的调和等,与调度器配合完成更新任务的包装与调度、捕获与冒泡过程、DIFF 算法、EffectTag List 的维护、维护 FiberTree 双缓存结构、组件生命周期的调用、配合渲染器完成 DOM 渲染等。
# React API 概况
在 react 包中 React.js 文件中对 React 有如下定义,通过这个定义,我们可以对 React 的核心 API 初步认识。
const React = {
// 提供了用于处理 children 不透明数据结构的实用方法。
// 操作 ReactChildren 的方法。ReactChildren不是数组。这里模拟数组的一些方法。
Children: {
map,
forEach,
count,
toArray,
only,
},
// 创建一个能够通过 ref 属性附加到 React 元素的 ref。
createRef,
// 定义类组件需集成自 Component
Component,
// PureComponent 以浅层对比 prop 和 state 的方式来实现 shouldComponentUpdate 函数。
PureComponent,
// 创建一个 Context 对象。当组件订阅了这个 Context 对象,组件会从组件树中离自身最近的那个匹配的 Provider 中读取到当前的 context 值。
createContext,
// forwardRef 会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。
forwardRef,
// lazy 函数能让你像渲染常规组件一样处理动态引入(的组件)。
lazy,
// memo 检查 props 变更,以此通过记忆组件渲染结果的方式来提高组件的性能表现。
memo,
// Hook API
// 返回一个 memoized 回调函数。
useCallback,
// 接收一个 context 对象,并返回距离 <MyContext.Provider> 最近的 context 的当前值。
useContext,
// 接收一个包含命令式、且可能有副作用代码的函数完成副作用操作。React 的纯函数式世界通往命令式世界的逃生通道
useEffect,
// 在使用 ref 时自定义暴露给父组件的实例值。
useImperativeHandle,
// 可用于在 React 开发者工具中显示自定义 hook 的标签。
useDebugValue,
// 所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。
useLayoutEffect,
// 返回一个 memoized 值。
useMemo,
// useState 的替代方案。它接收一个 reducer,并返回当前的 state 以及与其配套的 dispatch 方法。
useReducer,
// useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数,返回的 ref 对象在组件的整个生命周期内持续存在。
useRef,
// 返回一个 state,以及更新 state 的函数。
useState,
// Fragments 允许你将子列表分组,而无需向 DOM 添加额外节点。
Fragment: REACT_FRAGMENT_TYPE,
// Profiler 测量一个 React 应用多久渲染一次以及渲染一次的“代价”。
Profiler: REACT_PROFILER_TYPE,
// StrictMode 是一个用来突出显示应用程序中潜在问题的工具。
StrictMode: REACT_STRICT_MODE_TYPE,
// Suspense 可以指定加载指示器,以防其组件树中的某些子组件尚未具备渲染条件。
Suspense: REACT_SUSPENSE_TYPE, // 与lazy结合使用,指定一个feedback。
// 创建并返回指定类型的新 React 元素。
createElement: __DEV__ ? createElementWithValidation : createElement,
// 以 element 元素为样板克隆并返回新的 React 元素。
cloneElement: __DEV__ ? cloneElementWithValidation : cloneElement,
// 验证对象是否为 React 元素
isValidElement: isValidElement,
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
其中比较重要的大致为:
- 元素相关:Children、createElement、cloneElement。
- 组件相关:Component、PureComponent、createRef、Fragment、forwardRef
- hooks api: useCallback,useContext,useEffect,useImperativeHandle,useDebugValue,useLayoutEffect,useMemo,useReducer,useRef,useState。
- 优化相关:lazy、memo、Suspense。
- 其他:createContext。
# JSX
# 介绍
什么是 JSX?
JSX (JavaScript Syntax Extension,JavaScript 语法扩展) 是 JavaScript 语法的扩展,最初用于 React,它提供一种类似于 HTML 的语法来 结构化的编写组件。【来自 JSX (opens new window)】
文档
JSX 可以生成 React “元素”,可以很好地描述 UI 应该呈现出它应有交互的本质形式,具有 JavaScript 的全部功能。
React 开发者对于 JSX 应该是很熟悉了,更专业一点来说:
- JSX 是一种将 JS 和 HTML 混合编写组件的语法糖,其语法需要通过 babel 解析之后才能被浏览器识别。
- JSX 语法可以通过 @babel/plugin-transform-react-jsx-source (opens new window) 插件进行解析。
# 为什么使用 JSX?
关于 JSX 更详细的内容,React 官方文档中 JSX 简介 (opens new window) 已经讲的很清楚了,此处不再赘述。但是我想浅谈一下我对 React 选择 JSX 背后的哲学原因的理解。
React 认为渲染逻辑本质上与其他 UI 逻辑内在耦合。
React 并没有采用将标记与逻辑进行分离到不同文件这种人为地分离方式,而是通过将二者共同存放在称之为 “组件” 的松散耦合单元之中,来实现关注点分离。【来自 React 官方文档:为什么使用 JSX? (opens new window)】
渲染逻辑与 UI 逻辑耦合
是 React、Vue 等框架流行带来的组件化理念的必然结果。组件化要求我们将样式(UI 逻辑)与行为(渲染逻辑)封装到组件中已达到代码复用之目的,这也是组件化开发带来的最大的红利。将标记与逻辑进行分离到不同文件
这种思维方式在框架诞生之前编写原生 JS 时最常用的代码分离的手段,当然框架的组件化驱动的理念促使这种做法被放弃,取而代之的有如下的两种常见的新的理念,这两种理念都达到了将二者共同存放在称之为“组件”的松散耦合单元之中,来实现关注点分离
的目的。- JSX:以 React.js、Solid.js 框架为代表。将 HTML 和 JavaScript 混合编写组件。
- SFC:以 Vue.js 和 svelte.js 框架为代表。单文件组件将组件分为
template
、style
和script
三个部分。
# JSX 如何 解析为 JS?
让我们使用 babel-transform-react-jsx (opens new window) 对如下的代码进行转换:
const work = () => {dosomething();}
const Conponent = () => {
return (
<div style={{color: '#ffffff'}}>
<h1 class="title">heading</h1>
<div class="body" onClick={work}>content</div>
</div>
)
}
2
3
4
5
6
7
8
9
转换结果如下:
const work = () => {
dosomething();
};
const Conponent = () => {
return /*#__PURE__*/ React.createElement(
"div",
{
style: {
color: "#ffffff"
}
},
/*#__PURE__*/ React.createElement(
"h1",
{
class: "title"
},
"heading"
),
/*#__PURE__*/ React.createElement(
"div",
{
class: "body",
onClick: work
},
"content"
)
);
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
分析上面的解析过程可知:
- babel 插件在解析 jsx 代码时,js 部分是不需要解析的,html 部分会被解析为 React.createElement 语法。
React.createElement
会被加上/*#__PURE__*/
的静态内容标记。- 多个子节点并不是通过数组传入而是以多个参数的形式传入的,这个可以通过 rest 运算符处理。
JSX 会将代码中 html 转化为渲染函数(如 vue 中的 h 函数、createElement 函数)的语法糖,以方便框架对 JSX 的内容进行处理。这实际上使得 JSX 语法与框架解耦,使 JSX 能够运用到各种实现了渲染函数的框架之中。
# ReactElement
下面将介绍 ReactElement 以及 VDOM 的概念。
# createElement
createElement 创建 React 元素(ReactElement)。先来看一个例子,假如一个经过 babel 解析过的 JSX 代码如下:
React.createElement("div", {
class: "class_name",
id: "id_name",
key: "key_name",
ref: "ref_name"
}, React.createElement("span", null, "Tom"), React.createElement("span", null, "Jerry"));
2
3
4
5
6
以上的执行结果如下:
{
$$typeof: REACT_ELEMENT_TYPE,
type:'div',
key: 'key_name',
ref: "ref_name",
props: {
class: "class_name",
id: "id_name",
children: [
React.createElement("span", null, "Tom"),
React.createElement("span", null, "Jerry")
]
}
_owner: ReactCurrentOwner.current,
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这便是 ReactElement 的真面目了。React 是基于 VDOM 的运行时框架,其内部节点的创建、更新、patch、删除都是通过 VDOM 来实现的。
我们通常熟知的 VDOM 节点,包括 type、attr、children 三个元素,那么我们再来看 ReactElement 的特征,ReactElement 对象也包含了这三个属性,只不过 attr 和 children 是放在 props 中的,我们知道 React 组件的设计哲学是 组件是依赖 props 和 state更新,props 关注组件与外部的状态,state 关注组件内部的状态
,这一点也是符合理念的。
下面的内容我们将从源码的角度继续探究:
createElement 的源码:
// src/react/packages/react/src/ReactElement.js
// 根据元素类型 type,元素属性 config 和元素子节点(数组) children 创建 react 元素
export function createElement(type, config, children) {
let propName;
const props = {};
let key = null;
let ref = null;
let self = null;
let source = null;
if (config != null) {
// 检查是否添加了 ref 属性
if (hasValidRef(config)) {
ref = config.ref;
}
// 检查是否添加了 key 属性
if (hasValidKey(config)) {
key = '' + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source;
// 添加至属性对象
for (propName in config) {
if (
hasOwnProperty.call(config, propName) &&
!RESERVED_PROPS.hasOwnProperty(propName)
) {
props[propName] = config[propName];
}
}
}
// 计算 children 的长度,children 是作为剩余参数传入的
const childrenLength = arguments.length - 2;
if (childrenLength === 1) {
// 单一子节点直接赋值
// children 是放到 props 上的,因此可以通过 props 的 children 获得组件内部内容
props.children = children;
} else if (childrenLength > 1) {
const childArray = Array(childrenLength);
for (let i = 0; i < childrenLength; i++) {
childArray[i] = arguments[i + 2];
}
// 多个子节点转为数组
props.children = childArray;
}
// 元素默认的属性
if (type && type.defaultProps) {
const defaultProps = type.defaultProps;
for (propName in defaultProps) {
if (props[propName] === undefined) {
props[propName] = defaultProps[propName];
}
}
}
// 调用工厂函数创建 ReactElement。
return ReactElement(
type,
key,
ref,
self,
source,
ReactCurrentOwner.current,
props,
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
如上函数具有如下的核心功能:
- 计算 key、ref、self、source 属性。
- 计算 props,包括 configs 中除 RESERVED_PROPS 之外的属性、children 属性、当前类型的 ReactElement 所应该具有的默认属性 (如 'div' 元素的默认属性)。
- 调用 ReactElement 创建 ReactElement。
下面我们接着看下 ReactElement 工厂函数,这对于我们了解 React 虚拟 DOM 的结构至关重要:
const ReactElement = function (type, key, ref, self, source, owner, props) {
// 新建一个ReactElement对象
const element = {
// ReactElement 的独一无二的标志,用来判断 element 是否是 ReactElement。
$$typeof: REACT_ELEMENT_TYPE,
// element 的类型
type: type,
// element 的 key 值,这对 React 复用元素很重要
key: key,
// element 的 ref 属性,元素的引用
ref: ref,
// element 的属性,包括了 children、class、id 等
props: props,
// element 的属主,当前元素所属于的 Fiber,由哪一个 Fiber 所创建。
_owner: owner,
};
return element;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$$typeof
:ReactElement 的独一无二的标志,用来判断 element 是否是 ReactElement,REACT_ELEMENT_TYPE = symbolFor('react.element')
。- type:element 的类型,注意如 'div'、'span' 等。
- key:element 的 key 值,这对 React 复用元素很重要。
- ref::element 的 ref 属性,元素的引用。
- props:element 的属性,包括了 children、class、id 等。
- _owner:element 的属主,当前元素所属于的 Fiber,由哪一个 Fiber 所创建。
# isValidElement
isValidElement 判断 element 是否是合法的 ReactElement。
export function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
// $$typeof: Symbol(react.element)
object.$$typeof === REACT_ELEMENT_TYPE
);
}
2
3
4
5
6
7
8
# VDOM
VDOM 是对 DOM(Document Object Model)的一种轻量级的 JavaScript 呈现方式,多用于 React、Vue 等声明式的前端框架中。使用 VDOM 有如下的优点:
- 轻量级。VDOM 只需要记录很少的信息就能展示 DOM 呈现方式和结构。
- 速度更快。VDOM 能够对连续更新做批量处理,减少 reflow 和 repaint。
- 抽象层,跨平台。VDOM 在 DOM Tree 的基础上抽象出 VDOM Tree,VDOM Tree 是一种数据结构,不依赖于平台特性。这使得核心逻辑能够运行在不同的平台上,屏蔽掉平台的兼容性。
- 可控制,可优化。因为 VDOM 足够简单,JavaScript 能够很方便的操纵和控制 UI 展现,如删除、增加、更新、移动节点等。同时,对于页面状态的更新有了更加可控的优化手段,如 DIFF 算法、节点复用。
当然 VDOM 也存在一些问题:
- 初始化的时间成本。初始化时需要将 UI 的展现转化为具体的 VDOM Tree,这部分的转换需要一定的时间成本。
- DIFF 算法的成本。尽管 JavaScript 操作 VDOM 的效率足够高,但是在非常大的 VDOM Tree 的结构面前,DIFF 的成本就显得很重要。尽管各个框架针对 VDOM 使用了各种的优化手段,如 React 中基于链表的单向 DIFF、Vue 中基于数组的双向 DIFF、基于 key 和 type 比较的节点复用等,DIFF 的成本都是 VDOM 的速度瓶颈所在。
下图是某个节点的全部属性,可见 DOM 是很 “重” 的。
# 扩展
# React 的详细目录结构和作用
packages.
├── create-subscription
├── dom-event-testing-library
├── eslint-plugin-react-hooks
├── jest-mock-scheduler
├── jest-react
├── react // 核心 API
├── react-art // 平台相关,用于 canvas, svg
├── react-cache
├── react-client
├── react-debug-tools
<!-- devtools 相关 -->
├── react-devtools
├── react-devtools-core
├── react-devtools-extensions
├── react-devtools-inline
├── react-devtools-shared
├── react-devtools-shell
├── react-devtools-timeline
├── react-dom // 平台相关,用于 web 环境
├── react-fetch
├── react-fs
├── react-interactions
├── react-is
├── react-native-renderer // 平台相关,用于 ReactNative
├── react-noop-renderer
├── react-pg
├── react-reconciler // 调和器相关
├── react-refresh
<!-- SSR 相关 -->
├── react-server
├── react-server-dom-relay
├── react-server-dom-webpack
├── react-server-native-relay
├── react-suspense-test-utils
├── react-test-renderer
├── scheduler // 调度器相关
├── shared
├── use-subscription
└── use-sync-external-store
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 为什么 React 17 之前需要显式引入 React,17 版本就不需要了呢?
在上面 JSX 的编译过程中,我们可以看到到,JSX 实际上不是浏览器所能够识别的,需要 babel、或者 TS 等工具来进行解析,那么解析之后的结果当然就是带着渲染函数语法糖的 JS 代码,所以如果没引入 React,通常会报 ReferenceError: React is not defined
的错误。
在 React17 中官方与 babel 合作,引入了全新的的 JSX 的转化。
React 17 在 React 的 package 中引入了两个新入口,这些入口只会被 Babel 和 TypeScript 等编译器使用。新的 JSX 转换不会将 JSX 转换为 React.createElement,而是自动从 React 的 package 中引入新的入口函数并调用。【参考:React 官网:介绍全新的 JSX 转换 (opens new window)】
这里将渲染函数与 React 框架进行了解耦,由编译工具从 React 中引入渲染函数,完成编译 JSX 的目标。
// 由编译器引入(禁止自己引入!)
import {jsx as _jsx} from 'react/jsx-runtime';
function App() {
return _jsx('h1', { children: 'Hello world' });
}
2
3
4
5
6
编译 JSX 的工具会自动引入渲染函数 jsx
,我们不在需要自己引入。
# React 中的 VDOM 是 ReactElement 吗?
个人认为,ReactElement 只是 React 中 VDOM 的一部分,另外一部分是后面要展开的 Fiber。React 依赖 Fiber 来达到异步可中断的 concurrent 模式更新的目标,同时依赖 ReactDOM 来描述组件 UI 的状态。
VDOM 本质上是 DOM 元素的 JavaScript 抽象和描述。ReactElement 描述了 DOM 的静态类型、属性和结构,Fiber 则在此基础上描述组件的渲染状态、更新链表、针对 DOM 的 Effect Tag、调度更新的优先级等。
# _owner 是如何连接 ReactElement 和 Fiber 的?_owner 有什么作用?
由上面的分析可以看出,_owner 的赋值其实是 ReactCurrentOwner.current 的值,对 ReactCurrentOwner.current 的赋值可以追到 finishClassComponent
函数中:
// src/react/packages/react-reconciler/src/ReactFiberBeginWork.new.js
ReactCurrentOwner.current = workInProgress;
2
而 finishClassComponent 主要在 updateClassComponent
、 mountIncompleteClassComponent
和 mountIndeterminateComponent
中 ClassComponent 的部分中调用,可见 _owner 实际上是用于类组件的。继续追 workInProgress,发现 workInProgress 是在 performUnitOfWork 函数中赋值的:
function performUnitOfWork(unitOfWork: Fiber): void {
let next;
next = beginWork(current, unitOfWork, subtreeRenderLanes);
if (next === null) {
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
2
3
4
5
6
7
8
9
10
11
12
这个函数将在调和器的部分详细讲,现在可以清楚的是 workInProgress 表示当前调和器正在处理的 Fiber,在渲染类组件时将 workInProgress 记录到 React 内部共享变量 ReactCurrentOwner.current 中,此时 createElement 时就能够获取到当前 ReactElement 所属的 Fiber 了。
_owner 的作用:
- 通过 element._owner 查询到 element 所属的 Fiber 和 组件。
例如,在检查 element.children 的子元素是否具有 key 值的 validateExplicitKey
函数中有如下代码:
if (
element &&
element._owner &&
element._owner !== ReactCurrentOwner.current
) {
// 获取此元素所属的组件的名称
childOwner = ` It was passed a child from ${getComponentNameFromType(
element._owner.type,
)}.`;
}
2
3
4
5
6
7
8
9
10
# 问题
# VDOM Tree 和 Fiber Tree 是如何连接的?
Fiber 中有一个属性 stateNode 存储当前 Fiber 所对应的组件的渲染模板,执行这个模板就可以得到 VDOM Tree。后文详述。
下面以首次渲染过程为例说明两者之间的关系:
- JSX 组件将会编译为带渲染函数的 js 模板(渲染模板);
- 调用 ReactDOM.render 创建 FiberRoot 和 HostRootFiber,并生成首次更新的同步任务,同步任务立即执行;
- 渲染任务被回调,开始渲染,调和 FiberTree,挂载(更新)组件树;
- 在挂载(更新)组件时执行渲染模板,createElement 在运行时被层层调用,生成 ReactElement Tree,也就是 VDOM Tree;
- VDOM Tree 转化为 DOM Tree,渲染节点到屏幕。
- 新的渲染任务被回调时,回到 3。
# 为什么用 className?
参考:为什么 Vue 的 JSX 中的 class 属性用了 class,而 React 却用了 className? (opens new window)
# ReactElement Tree、Fiber Tree 和 DOM Tree 的关系?
# 总结
本文主要讲解 React Element 与基础概念,总结重点如下:
- 阅读 React 源码的原因、方法和意义。
- React 中源码的目录结构和核心包的作用。
- React API 的概况。
- JSX 的解析原理。
- ReactElement 创建过程,以及各个属性的含义。
- VDOM 的概念和优缺点。