React 源码漂流记:ReactChildren 与节点操纵
# 目录
# 前言
在上一篇文章中,我们分享了 ReactElement 与基础概念
的话题,对 JSX、ReactElement、VDOM、Component 等概念有了一定了解。今天这篇文章,我们来分享跟 ReactNode 相关的 ReactChildren 的工具函数的实现原理。
# 学习目标
- 学习 ReactChildren 的工具函数的实现原理。
- 加强对 ReactElement、ReactNode、ReactChildren、组件等概念的理解。
- 学习使用 ReactChildren 工具函数操纵节点的进阶用法。
# 什么是 React.Children?
React.Children 提供了用于处理 this.props.children 不透明数据结构的实用方法。
Children 指的就是组件中 props.children
的值。Children 的类型通常是 ReactNode。ReactNode 是组件的渲染模板执行后的结果(ClassComponent 中 render () 函数执行的结果或者 FunctionComponent 执行的结果)。
由此我们就可以知道为什么 ReactChildren 需要使用工具函数来操作了,这是因为:
- ReactNode 不是数组,ReactNode 的类型很复杂,不能用数组的方法进行遍历、map 等操作。
- 需要相应的工具函数来提供操作 ReactNode 的能力。
言归正传,从 React 的导出看,React Children 有如下四个方法:
// 操作ReactChildren的方法。ReactChildren不是数组。模拟数组的一些方法。
{
map,
forEach,
count,
toArray,
only,
}
2
3
4
5
6
7
8
下面我们来详细展开这几个函数的源码,了解其实现原理。
# map
map 提供对 ReactChildren 的遍历映射操作。
map 内部由 mapChildren
函数实现。mapChildren 内部调用 mapIntoArray。
function mapChildren(
children: ?ReactNodeList,
func: MapFunc,
context: mixed,
): ?Array<React$Node> {
if (children == null) {
return children;
}
const result = [];
let count = 0;
// 调用 mapIntoArray 遍历 children,并在回调中调用 func,回调的结果放入 result
mapIntoArray(children, result, '', '', function(child) {
return func.call(context, child, count++);
});
return result;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这个 mapIntoArray
比较复杂,它所支持的 children 的类型很多,基础类型包括 undefined、boolean、null、string、number 或者标记为 ReactElement 或者 ReactPortal 的 object。如果把基础类型标记为 T 的话,mapIntoArray 还支持 children 的类型为 T [] 或者迭代器 object。
后者是通过循环、迭代实现的,不再赘述,这里我们重点看基础类型。
function mapIntoArray(
children: ?ReactNodeList,
array: Array<React$Node>,
escapedPrefix: string,
nameSoFar: string,
callback: (?React$Node) => ?ReactNodeList,
): number {
// 如果是 undefined、boolean、null、string、number、标记为 object 的 ReactElement 或者 ReactPortal 等基础类型
// invokeCallback 为 true,执行如下的逻辑
if (invokeCallback) {
const child = children;
// 回调 callback
let mappedChild = callback(child);
// 当前层级的 key 的串
const childKey =
nameSoFar === '' ? SEPARATOR + getElementKey(child, 0) : nameSoFar;
// 如果 map 返回是一个数组,则需要对数组继续递归
if (isArray(mappedChild)) {
// ......
// 这里的 callback 是 c => c,说明返回的数组将会被 flat
mapIntoArray(mappedChild, array, escapedChildKey, '', c => c);
} else if (mappedChild != null) {
// 检查 mappedChild 是否是合法的 ReactElement
if (isValidElement(mappedChild)) {
// 规整 mappedChild 的 key 值
mappedChild = cloneAndReplaceKey(
mappedChild,
// 计算 mappedChild 的 key
escapedPrefix +
(mappedChild.key && (!child || child.key !== mappedChild.key)
? escapeUserProvidedKey('' + mappedChild.key) + '/'
: '') +
childKey,
);
}
// 将 mappedChild 推入结果数组
array.push(mappedChild);
}
// 返回 count 次数,即 callback 被调用的次数
return 1;
}
// ......
return subtreeCount;
}
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
总结一下如上的函数:
- 主要运用了递归、循环、迭代的方法遍历 children,并且执行 callback,如果 callback 返回数组,则继续递归。注意,callback 返回的数组将会被摊平。这主要是因为在渲染节点的时候,节点的嵌套结构是由 ReactElement.props.children 决定的,所有的多维节点数组对会被摊平。
- 因为 map 的返回结果 result 是一个数组,因此其中的节点都需要设置唯一的 key 值。
# forEach
forEach 提供对 ReactChildren 的遍历操作。
forEach 是由 forEachChildren
实现的。有了上面 mapChildren 的基础,实现 forEach 就顺理成章了。
function forEachChildren(
children: ?ReactNodeList,
forEachFunc: ForEachFunc,
forEachContext: mixed,
): void {
mapChildren(
children,
function() {
// arguments 表示接受 callback 回调的所有参数
forEachFunc.apply(this, arguments);
},
forEachContext,
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
forEach 只需要在调用 map 时,舍弃 map 的返回值就可以了。
# count
count 计算 children 中的组件总数量。
count 内部由 countChildren
实现的。思路是在遍历过程中计算长度,除非传的是数组或者迭代器,否则返回的长度都是 1。
function countChildren(children: ?ReactNodeList): number {
let n = 0;
mapChildren(children, () => {
n++;
});
return n;
}
2
3
4
5
6
7
# toArray
toArray 将 children 以数组形式返回。因为 mapChildren 的结果已经是数组了,所以直接返回。
function toArray(children: ?ReactNodeList): Array<React$Node> {
return mapChildren(children, child => child) || [];
}
2
3
# only
only 验证 children 是否是单节点,如果是单节点将之返回,否则报错。
only 内部由 onlyChild
实现。代码如下:
function onlyChild<T>(children: T): T {
if (!isValidElement(children)) {
throw new Error(
'React.Children.only expected to receive a single React element child.',
);
}
return children;
}
2
3
4
5
6
7
8
# cloneElement
# 应用
在组件库等复杂的节点运用场景中常常会使用到 React.Children 工具,而且通常会与其他的节点操作 API 如 cloneElement
或者 createElement
一起使用。下面以 antd 代码中 Timeline
组件的一处使用场景作为示例:
// components/timeline/Timeline.tsx
// 对 truthyItems 中的节点进行修改
const items = React.Children.map(
truthyItems,
(ele: React.ReactElement<any>, idx) => {
const pendingClass = idx === itemsCount - 2 ? lastCls : "";
const readyClass = idx === itemsCount - 1 ? lastCls : "";
// 克隆原节点并覆盖 className 属性
return cloneElement(ele, {
className: classNames([
ele.props.className,
!reverse && !!pending ? pendingClass : readyClass,
getPositionCls(ele, idx),
]),
});
}
);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 扩展
# ChildrenKey 是如何生成的?
从 mapIntoArray
中传递给 cloneAndReplaceKey
的 key 值的计算逻辑:
// key =
const SEPARATOR = '.';
const SUBSEPARATOR = ':';
// 初始值为 '',用于 mappedChild
const escapedPrefix = escapeUserProvidedKey(childKey) + '/';
const nextNamePrefix = nameSoFar === '' ? SEPARATOR : nameSoFar + SUBSEPARATOR;
// 初始值为 '',用于数组和迭代器
const nameSoFar = nextNamePrefix + getElementKey(child, i);
const childKey = nameSoFar === '' ? SEPARATOR + getElementKey(child, 0) : nameSoFar;
const key = escapedPrefix +
(mappedChild.key && (!child || child.key !== mappedChild.key) ? escapeUserProvidedKey('' + mappedChild.key) + '/' : '') +
childKey;
2
3
4
5
6
7
8
9
10
11
12
分成如下几种情况:
- 如果是 children 是普通类型,key1 = escapeUserProvidedKey (mappedChild.key) + '/' + SEPARATOR + randomKey;
- 如果是 mappedChild 数组下的 children,key2 = escapeUserProvidedKey (SEPARATOR + randomKey + '/') + '/' + key1;
- 如果是数组或者迭代器,key3 = escapeUserProvidedKey (mappedChild.key) + '/' + SUBSEPARATOR + randomKey;
- 如果是数组或者迭代器下的 mappedChild 数组下的 children,key4 = escapeUserProvidedKey (SUBSEPARATOR + randomKey + '/') + '/' + key3;
这里的逻辑比较复杂,我们只需要知道 mapIntoArray 能够为结果数组中的节点生成唯一的 key 值即可。
如何生成 randomKey?
function getElementKey(element: any, index: number): string {
// 如果组件有 key 值则使用
if (typeof element === 'object' && element !== null && element.key != null) {
return escape('' + element.key);
}
// 使用 36 进制,即 0-9-a-z。如 (35).toString(36) === 'z'
return index.toString(36);
}
// 因为 key 中使用了固定的分隔符,所以用户传递的 key 需要 escape 做等意替换,并且添加前缀 `$`
function escape(key: string): string {
const escapeRegex = /[=:]/g;
const escaperLookup = {
'=': '=0',
':': '=2',
};
const escapedString = key.replace(escapeRegex, function(match) {
return escaperLookup[match];
});
return '$' + escapedString;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
通过 36 进制生成随机字符串是比较常见的一种生成 id、key 标识的方式。例如下面这个示例:
// 生成一个随机小写字母或者数字
const genSeed = () => (~~(Math.random()*36)).toString(36); // "s"
function randomStringGenerator(length){
let s = '';
while(s.length < length) s += genSeed();
return s;
}
randomStringGenerator(6); // "6muky"
2
3
4
5
6
7
8
9
# ReactElement、JSX.Element 和 ReactNode 的区别
要直观地理解三者的区别,需要我们从 React 的类型声明文件中去找答案。下面是一些类型:
interface ReactElement<P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>> {
type: T;
props: P;
key: Key | null;
}
type ReactText = string | number;
type ReactChild = ReactElement | ReactText;
type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefined;
namespace JSX {
interface Element extends React.ReactElement<any, any> { }
}
2
3
4
5
6
7
8
9
10
11
12
从这些类型可以看出来,三者是具有包含关系的,其中 ReactNode > JSX.Element > ReactElement
。
- ReactNode:ReactNode 代表 React 节点,其类型中包含了 ReactChild,ReactChild 中又包含了 ReactElement。
- JSX.Element:JSX.Element 和 ReactElement 基本一致,但是 JSX.Element 作为 JSX 的规范,它比 ReactNode 更加通用,因为 type 和 props 都被定义为 any。
- ReactElement:ReactElement 是包含
type
、props
、key
等属性的 object,它是 DOM 的一种抽象表示,是 FunctionComponent (或者 ClassComponent 中 render 函数)执行的结果。
# 原版代码中的池化模式的应用
React 17 中对 ReactChildren 中的代码进行了重构,在 React 16 的旧版代码中有一个对池化模式的应用,我觉得很有意思,作为扩展分享给大家。
在源码中利用池化模式对 traverseContext 进行缓存,减少对象创建的成本。
// traverseContextPool 的 size
const POOL_SIZE = 10;
const traverseContextPool = [];
// 从 traverseContextPool 获取 traverseContext
function getPooledTraverseContext(
mapResult, // 遍历结果数组
keyPrefix, // traverseContext 的 key
mapFunction, // 遍历回调函数
mapContext, // 遍历的 context
) {
// 如果当前缓存池非空
if (traverseContextPool.length) {
// 取出队尾的traverseContext
const traverseContext = traverseContextPool.pop();
traverseContext.result = mapResult;
traverseContext.keyPrefix = keyPrefix;
traverseContext.func = mapFunction;
traverseContext.context = mapContext;
traverseContext.count = 0;
// 返回缓存的 traverseContext
return traverseContext;
} else {
// 缓存池为空则新建一个 traverseContext,最多 10 个
return {
result: mapResult,
keyPrefix: keyPrefix,
func: mapFunction,
context: mapContext,
count: 0,
};
}
}
// 释放 traverseContext 到 traverseContextPool
function releaseTraverseContext(traverseContext) {
traverseContext.result = null;
traverseContext.keyPrefix = null;
traverseContext.func = null;
traverseContext.context = null;
traverseContext.count = 0;
if (traverseContextPool.length < POOL_SIZE) {
traverseContextPool.push(traverseContext);
}
}
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
池化模式在需要频繁创建对象(连接)的场景中可以参考。缓存池大小 POOL_SIZE 需要权衡考虑效率和内存问题。如果 POOL_SIZE 太小,就不能很好的起到缓存的效果,如果太大缓存池本身就需要占用太多内存,而且用不完的 context 对象也容易造成浪费和低效。
# 总结
本篇文章包含了如下的核心知识点,总结如下:
- ReactNode、ReactElement、ReactChildren 等概念的深入理解。
- React.children 工具函数:map,forEach,count,toArray,only 的原理。
- 使用 ReactChildren 工具函数应用到组件库等复杂场景中操作节点。