剖析React系列九-Fragment的部分实现逻辑

337 阅读7分钟

本系列是讲述从0开始实现一个react18的基本版本。由于React源码通过Mono-repo 管理仓库,我们也是用pnpm提供的workspaces来管理我们的代码仓库,打包我们使用rollup进行打包。

上一讲我们讲了同级节点的diff过程,这一节,我们主要是实现Fragment和缩写<></>的逻辑。主要是通过几个例子来说明React内部是如何实现Fragment

仓库地址

具体章节代码commit

系列文章:

  1. React实现系列一 - jsx
  2. 剖析React系列二-reconciler
  3. 剖析React系列三-打标记
  4. 剖析React系列四-commit
  5. 剖析React系列五-update流程
  6. 剖析React系列六-dispatch update流程
  7. 剖析React系列七-事件系统
  8. 剖析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属性: fragment1.png

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>  
    </>)
}

Appwip的调和时候,由于顶层的Fragment对应的isUnkeyedTopLevelFragmenttrue。 所以就相当于如下结构,跳过了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个部分:

  1. 新建Fragment对应的fiberNode(初始化 / 更新前不是Fragment更新后是)
  2. 更新前后都是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的时候,此时的结构如图所示。 fragment2.png

接下来就需要继续向下调和执行beginWork,所以我们需要新增beginWork中对于Fragment处理的逻辑部分。

beginWork处理Fragment

当向下调和到ul的子fiberNode的时候,再次进入beginWork阶段,传入一个Fragment对应的fiberNode

其中pendingProps为如下所示:

{
  $$typeof: Symbol(react.element)
  key: null
  props: {childrenArray(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树如图所示 fragment3.png

三、数组形式的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.png

  1. Fragment会被标记ChildDeletion, 然后我们执行commitDeletion传入Fragment-fiberNode
  2. 向下递归到li-1, 执行onCommitUnmount, 由于是host节点, 执行recordHostChildrenToDelete。填充到childrenToDelete的第一个元素
  3. 向下递归到文本1-HostText, 由于是host节点, 执行recordHostChildrenToDelete,由于第二步已经填充了一个元素。所以lastOne不为空,获取lastOne.sibling, 1-HostText不等于lastOne.sibling。所以不填充
  4. 向上递归到li-1,执行node = node.sibling,到达li-2, 执行onCommitUnmount, 由于是host节点, 执行recordHostChildrenToDelete。并且1-HostText不等于lastOne.sibling。所以填充进rootChildrenToDelete
  5. 向下递归到2-HostText,重复步骤3。
  6. 向上递归li-2、继续向上递归Fragment 退出。

最后我们收集到li-1、li-22个删除的fiberNode, 然后依次执行removeChild就可以删除对应的点。

下一节预告

我们接下来进入React有意思的部分调度