本系列是讲述从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-22个删除的fiberNode, 然后依次执行removeChild就可以删除对应的点。
下一节预告
我们接下来进入React有意思的部分调度