本系列是讲述从0开始实现一个react18的基本版本。由于React
源码通过Mono-repo 管理仓库,我们也是用pnpm
提供的workspaces
来管理我们的代码仓库,打包我们使用rollup
进行打包。
上一讲我们讲了同级节点的diff过程,这一节,我们主要是实现Fragment
和缩写<></>
的逻辑。主要是通过几个例子来说明React内部是如何实现Fragment
。
系列文章:
- React实现系列一 - jsx
- 剖析React系列二-reconciler
- 剖析React系列三-打标记
- 剖析React系列四-commit
- 剖析React系列五-update流程
- 剖析React系列六-dispatch update流程
- 剖析React系列七-事件系统
- 剖析React系列八-同级节点diff
Fragment为什么需要存在
我们知道当我们写jsx语法的时候,会被babel
转换成ReactElment
对象。例如这样写jsx代码:
<Button>1</Button>
经过babel
转换后,类似如下代码:babel链接地址
jsxs(Button, { children: 1 });
那如果有2个button
并排一起的话, 应该会类似转换成如下所示:
jsxs(Button, { children: 1 }),
jsxs(Button, { children: 2 })
但是在javascript
中没有这种直接写2个对象的语法。我们如果需要2个对象一起的话,一般是通过数组包裹起来。例如:
[
jsxs(Button, { children: 1 }),
jsxs(Button, { children: 2 })
]
基于这种表现,所以React
提供了Fragment
去包裹多个元素并排,这样既可以不用渲染多余的元素,也可以实现正常的html
多个元素
书写。
<>
<Button>1</Button>
<Button>2</Button>
</>
一、Fragment包裹其他组件
在我们写代码的时候,经常会写如下代码:
<>
<div>hcc</div>
<div>yx</div>
</>
通过babel
进行转换成这样,Fragment
会被转换为特性的ReactElement元素。真正的渲染内容存在其props.children
中
jsxs(Fragment, {
children: [
jsx("div", {children: 'hcc'}),
jsx("div", {children: 'yx'})
]
});
对于这种没有key的缩写<></>
, 我们称之为isUnkeyedTopLevelFragment
。内部其实并没有进行渲染。在reconcileChildFibers
阶段。直接读取内部props.children
属性:
return function reconcileChildFibers(
returnFiber: FiberNode,
currentFiber: FiberNode | null,
newChild?: any
) {
// 判断Fragment/<></> 顶部根节点包裹
const isUnkeyedTopLevelFragment =
typeof newChild === "object" &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null;
if (isUnkeyedTopLevelFragment) {
newChild = newChild?.props.children;
}
xxxxxx
}
从上面的实现中,可以看出如果是如下例子:
function App() {
return (<>
<div>hcc</div>
<div>yx</div>
</>)
}
当App
为wip
的调和时候,由于顶层的Fragment
对应的isUnkeyedTopLevelFragment
为true
。
所以就相当于如下结构,跳过了Fragment
的创建:
function App() {
return [<div>hcc</div>, <div>yx</div>];
}
等价于传递一个数组的ReactElement
的类型,在渲染的fiberNode
树中,实际并没有创建Fragment
对应的fiberNode
。
二、Fragment与其他组件同级<reconcileChildrenArray>
当我们写如下代码的时候,也是可以渲染出正确的Dom样式的。
<ul>
<>
<li>1</li>
<li>2</li>
</>
<li>3</li>
<li>4</li>
</ul>
// 对应DOM
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
这种的jsx通过babel
转换成如下格式, 相当于传递了一个数组,但是数组中某一个项是Fragment
样式。
jsxs('ul', {
children: [
jsxs(Fragment, {
children: [
jsx('li', {
children: '1'
}),
jsx('li', {
children: '2'
})
]
}),
jsx('li', {
children: '3'
}),
jsx('li', {
children: '4'
})
]
});
从上一节中我们晓得如果children
为数组类型的话,会进入reconcileChildrenArray
的逻辑。所以我们需要新增对Fragment
的逻辑。
在上一节中,对于reconcileChildrenArray
中的每一项都会通过updateFromMap
,所以我们新增Fragment
的处理逻辑。
switch (element.$$typeof) {
case REACT_ELEMENT_TYPE:
if (element.type === REACT_FRAGMENT_TYPE) {
return updateFragment(
returnFiber,
before,
element,
keyToUse,
existingChildren
);
}
xxxx
}
当单个遍历的时候,如果是ReactElment
对象,并且type
类型为Fragment
,就会进入updateFragment
的逻辑:
updateFragment
细节
updateFragment
中主要是分为2个部分:
- 新建
Fragment
对应的fiberNode(初始化 / 更新前不是Fragment
更新后是) - 更新前后都是
Fragment
,复用逻辑
function updateFragment(
returnFiber: FiberNode,
current: FiberNode | undefined,
elements: any[],
key: Key,
existingChildren: ExistingChildren
) {
let fiber;
if (!current || current.tag !== Fragment) {
// 不存在/更新前的tag不是Fragment
fiber = createFiberFromFragment(elements, key);
} else {
// 存在并且类型还是Fragment
existingChildren.delete(key); // 删除之前的FiberNode
fiber = useFiber(current, elements); // 复用
}
fiber.return = returnFiber;
return fiber;
}
例如当我们初始化的时候,遍历到ul
的子Fragment
的时候,此时的结构如图所示。
接下来就需要继续向下调和执行beginWork
,所以我们需要新增beginWork
中对于Fragment
处理的逻辑部分。
beginWork
处理Fragment
当向下调和到ul
的子fiberNode
的时候,再次进入beginWork
阶段,传入一个Fragment
对应的fiberNode
。
其中pendingProps
为如下所示:
{
$$typeof: Symbol(react.element)
key: null
props: {children: Array(2)}
ref: null
type: Symbol(react.fragment)
}
所以在beginWork
调和阶段需要新增对Fragment
的处理逻辑:
export const beginWork = (wip: FiberNode) => {
switch (wip.tag) {
// xxx
case Fragment:
return updateFragment(wip);
default:
if (__DEV__) {
console.warn("beginWork未实现的类型");
}
break;
}
return null;
};
进入updateFragment
的部分:
function updateFragment(wip: FiberNode) {
const nextChildren = wip.pendingProps;
reconcileChildren(wip, nextChildren);
return wip.child;
}
获取pendingProps
传入reconcileChildren
中。等同于Fragment包裹其他组件
, 进入后标记为isUnkeyedTopLevelFragment
, 获取newChild = newChild?.props.children
为一个数组。
然后进入reconcileChildrenArray
进行渲染页面。
然后进入reconcileChildrenArray
进行渲染界面。此时的fiberNode树如图所示
三、数组形式的Fragment<reconcileChildrenArray>
当我们如下形式的书写的时候:
// arr = [<li>1</li>, <li>2</li>]
<ul>
{arr}
<li>3</li>
<li>4</li>
</ul>
// 对应DOM
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
<li>d</li>
</ul>
通过babel转换后如下所示:
jsxs('ul', {
children: [
jsx('li', {
children: 'a'
}),
jsx('li', {
children: 'b'
}),
arr
]
});
由于children
属性为一个数组类型,所以也会进入reconcileChildrenArray
阶段,在上一部分中,我们是针对数组中的某一项是fragment
的处理。
updateFromMap
数组处理
这一部分主要是针对数组中某一项还是一个数组的逻辑处理。每一项都会通过updateFromMap
,所以我们新增数组
的处理逻辑。
function updateFromMap(
returnFiber: FiberNode,
existingChildren: ExistingChildren,
index: number,
element: any
): FiberNode | null {
const keyToUse = element.key !== null ? element.key : index;
const before = existingChildren.get(keyToUse);
if (typeof element === "object" && element !== null) {
// 数组类型<新增>
if (Array.isArray(element)) {
return updateFragment(
returnFiber,
before,
element,
keyToUse,
existingChildren
);
}
}
return null;
}
本质上还是调用updateFragment
,传入一个包裹2个ReactElement的数组,去创建一个Fragment
去包裹数组。
beginWork
处理Fragment
当向下调和到ul
的子fiberNode
的时候,再次进入beginWork
阶段,传入一个Fragment
对应的fiberNode
。
其中pendingProps
为上一步创建的数组,包裹2个ReactElement
元素,如下所示:
[
{
"type": "li",
"key": null,
"ref": null,
"props": {
"children": "1"
},
"__mark": "hcc"
},
{
"type": "li",
"key": null,
"ref": null,
"props": {
"children": "2"
},
"__mark": "hcc"
}
]
然后再次进入reconcileChildFibers
的时候,newChild
为数组,进入reconcileChildrenArray
进行渲染页面。
最后渲染的fiberNode
树和上一部分一样。通过Fragment
进行包裹,所以当我们使用数组的时候,相当于React
内部给我们套了一层Fragment
。
四、Fragment对ChildDeletion的影响
在之前的章节中,我们标记ChildDeletion
的进行commitDeletion
的时候,会经过如下步骤:
- 找到子树的根Host节点
- 找到子树对应的父级Host节点
- 从父级Host节点中删除子树根Host节点
都是删除的单个子节点,例如:
<div>
<p>hcc</p>
</div>
但是如果现在我们新增了Fragment
的,当我们创建了Fragment
后,会出现包裹2个节点的情况,我们要删除多个节点。
例如从二和三部分中,我们晓得会出现Fragment
包裹了多个FiberNode
的情况。如下所示:
<React.Fragment>
<li>1</li>
<li>2</li>
</React.Fragment>
所以我们需要修改之前commitDeletion
逻辑,将删除单个元素变成一个数组记录删除元素。
commitDeletion
的改变
我们需要记录需要删除的多个元素,例如li-1, li-2
, 所以在递归子树commitNestedComponent
的过程中,我们针对每一个子节点进行判断。
/**
* 删除对应的子fiberNode
* @param {FiberNode} childToDelete
*/
function commitDeletion(childToDelete: FiberNode) {
const rootChildrenToDelete: FiberNode[] = [];
// 递归子树
commitNestedComponent(childToDelete, (unmountFiber) => {
switch (unmountFiber.tag) {
case HostComponent:
recordHostChildrenToDelete(rootChildrenToDelete, unmountFiber);
return;
case HostText:
recordHostChildrenToDelete(rootChildrenToDelete, unmountFiber);
return;
case FunctionComponent:
return;
}
});
// 移除rootHostNode的DOM
if (rootChildrenToDelete.length) {
const hostParent = getHostParent(childToDelete);
if (hostParent !== null) {
rootChildrenToDelete.forEach((node) => {
removeChild(node.stateNode, hostParent);
});
}
}
childToDelete.return = null;
childToDelete.child = null;
}
我们新增一个rootChildrenToDelete
数组,用于保存需要删除的子树的host节点。
在recordHostChildrenToDelete
中,我们需要根据传入的子节点判断是否是要删除的兄弟节点。
例如在li-1、li-2
中,当处理到li-1
的时候,当遍历到li-2
的时候,根据是否等于第一个元素的sibling
判断。
/**
* 记录要删除的host子节点
* @param childrenToDelete
* @param unmountFiber
*/
function recordHostChildrenToDelete(
childrenToDelete: FiberNode[],
unmountFiber: FiberNode
) {
// 1. 找到第一个root host 节点
const lastOne = childrenToDelete[childrenToDelete.length - 1];
if (!lastOne) {
childrenToDelete.push(unmountFiber);
} else {
// 2. 每找到一个 host节点,判断下这个节点是不是 第一个的兄弟节点
let node = lastOne.sibling;
while (node !== null) {
if (unmountFiber === node) {
childrenToDelete.push(unmountFiber);
}
node = node.sibling;
}
}
}
最后根据rootChildrenToDelete
的数组长度依次的删除节点。
例如:当我们需要删除如下Fragment
的时候:
Fragment
会被标记ChildDeletion
, 然后我们执行commitDeletion
传入Fragment-fiberNode
- 向下递归到
li-1
, 执行onCommitUnmount
, 由于是host节点
, 执行recordHostChildrenToDelete
。填充到childrenToDelete
的第一个元素 - 向下递归到文本
1-HostText
, 由于是host节点
, 执行recordHostChildrenToDelete
,由于第二步已经填充了一个元素。所以lastOne
不为空,获取lastOne.sibling
,1-HostText
不等于lastOne.sibling
。所以不填充 - 向上递归到
li-1
,执行node = node.sibling
,到达li-2
, 执行onCommitUnmount
, 由于是host节点
, 执行recordHostChildrenToDelete
。并且1-HostText
不等于lastOne.sibling
。所以填充进rootChildrenToDelete
- 向下递归到
2-HostText
,重复步骤3。 - 向上递归
li-2
、继续向上递归Fragment
退出。
最后我们收集到li-1、li-2
2个删除的fiberNode, 然后依次执行removeChild
就可以删除对应的点。
下一节预告
我们接下来进入React
有意思的部分调度