跳至主要內容

13. 实现 Fragment


13. 实现 Fragment

摘要

  • Diff 算法支持 Fragment
  • 处理 Reconciliation 阶段
  • 处理 Commit 阶段
  • 处理 React 导出

相关代码可在 git tag v1.13open in new window 查看

Fragment 是一种特殊的组件,用于在不引入额外 DOM 元素的情况下,包裹多个子元素。使用 Fragment 可以让组件结构更加清晰,而不引入不必要的父级 DOM 节点,以免影响页面布局和性能。

1. Diff 算法支持 Fragment

上一节我们实现了单节点和多节点的 Diff 算法,现在我们就来处理节点为 Fragment 和嵌套数组的情况,主要分为以下三种情况:

  • Fragment 包裹其他组件
  • Fragment 与其他组件同级
  • 数组形式的 Fragment

1. Fragment 包裹其他组件

ReactElement
const element = (
	<>
		<div>1</div>
		<div>2</div>
	</>
);

其中,<></> 是 Fragment 的简写,完整的写法是 <React.Fragment>。可以看到,在上面的例子中,element 是一个 type 为 Fragment 的 ReactElement,因此,在 Diff 算法中,需要考虑单一节点为 Fragment 的情况。

先在 ReactSymbols.ts 文件中定义一下 REACT_FRAGMENT_TYPE,表示 Fragment 组件:

// packages/shared/ReactSymbols.ts
// 表示 Fragment 组件,即 <React.Fragment> 或短语法 <></> 创建的 Fragment
export const REACT_FRAGMENT_TYPE = supportSymbol
	? Symbol.for('react.fragment')
	: 0xeacb;

接着在 reconcileChildFibers 函数中增加对顶层无 key 的 Fragment 情况的处理,以下是代码的主要逻辑:

  1. 判断是否为顶层的无 key 的 Fragment:

    • 如果 newChild 是对象,且不为 null,且为顶层的 Fragment,且其 key 为 null,则为顶层的无 key 的 Fragment;
    • 处理方法是将 newChild 重新赋值为 Fragment 的 props.children
  2. 判断当前 Fiber 的类型:

    • 如果 newChild 是 ReactElement 节点,则进一步判断其类型;
      • 如果 newChild 是数组,说明存在多个子节点,调用 reconcileChildrenArray 处理多节点的 Diff 逻辑。
      • 如果 newChild 是单个 React 元素节点,调用 reconcileSingleElement 处理单节点的 Diff 逻辑。
    • 如果 newChild 是文本节点,则保持原逻辑,不用修改;
// packages/react-reconciler/src/childFiber.ts
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;
	}

	// 判断当前 fiber 的类型
	// ReactElement 节点
	if (typeof newChild == 'object' && newChild !== null) {
		// 处理多个 ReactElement 节点的情况
		if (Array.isArray(newChild)) {
			return reconcileChildrenArray(returnFiber, currentFiber, newChild);
		}

		// 处理单个 ReactElement 节点的情况
		switch (newChild.$$typeof) {
			case REACT_ELEMENT_TYPE:
				return placeSingleChild(
					reconcileSingleElement(returnFiber, currentFiber, newChild)
				);

			default:
				if (__DEV__) {
					console.warn('未实现的 reconcile 类型', newChild);
				}
				break;
		}
	}

	// ...
};






 
 
 
 
 
 
 
 
 





 
 
 



 
 
 
 











2. Fragment 与其他组件同级

ReactElement
const element = (
	<ul>
		<>
			<li>1</li>
			<li>2</li>
		</>
		<li>3</li>
		<li>4</li>
	</ul>
);

这种情况中,若 newChild.children 为单个 Fragment 节点,则需要进入 reconcileSingleElement 方法进行处理;若 newChild.children 为数组类型,数组中的某一项为 Fragment,与其他组件同级,则需要进入 reconcileChildrenArray 方法进行处理。

我们先在 reconcileSingleElement 函数中增加对 Fragment 组件的处理:

// packages/react-reconciler/src/childFiber.ts
// 处理单个 Element 节点的情况
// 对比 currentFiber 与 ReactElement,生成 workInProgress FiberNode
function reconcileSingleElement(
	returnFiber: FiberNode,
	currentFiber: FiberNode | null,
	element: ReactElementType
) {
	// 组件的更新阶段
	while (currentFiber !== null) {
		if (currentFiber.key === element.key) {
			if (element.$$typeof === REACT_ELEMENT_TYPE) {
				if (currentFiber.type === element.type) {
					// key 和 type 都相同,当前节点可以复用旧的 Fiber 节点
					// 处理 Fragment 的情况
					let props: Props = element.props;
					if (element.type === REACT_FRAGMENT_TYPE) {
						props = element.props.children;
					}

					const existing = useFiber(currentFiber, props);
					existing.return = returnFiber;
					// 剩下的兄弟节点标记删除
					deleteRemainingChildren(returnFiber, currentFiber.sibling);
					return existing;
				}
    // ...

	// 创建新的 Fiber 节点
	let fiber;
	if (element.type === REACT_FRAGMENT_TYPE) {
		fiber = createFiberFromFragment(element.props.children, element.key);
	} else {
		fiber = createFiberFromElement(element);
	}
	fiber.return = returnFiber;
	return fiber;
}














 
 
 
 
 
 
 








 
 
 
 
 
 



如果可以复用旧的 Fiber 节点,对于 Fragment 类型的节点来说,需要将 element.props.children 作为 props 参数传给旧节点;

如果不能复用,对于 Fragment 类型的节点来说,则需要调用 createFiberFromFragment 函数,来创建新的 Fragment 类型的 Fiber 节点:

// packages/react-reconciler/src/fiber.ts
export function createFiberFromFragment(elements: any[], key: Key): FiberNode {
	const fiber = new FiberNode(Fragment, elements, key);
	return fiber;
}

这里要注意,我们在判断节点类型时,用到了 $$typeof 属性和 type 属性两个属性,这两个属性在 React 中有不同的含义:

  • $$typeof 属性:

    $$typeof 属性是 React 内部用于标识元素类型的一个特殊属性。它通常用于区分不同类型的 React 元素,例如普通的 React 元素和 Fragment 元素。例如,$$typeof 的值为 Symbol.for('react.element') 表示一个普通的 React 元素,而值为 Symbol.for('react.fragment') 表示一个 Fragment 元素。

  • type 属性:

    type 属性表示 React 元素的类型。对于普通的 React 组件元素,type 是组件函数或类;对于 DOM 元素,type 是字符串(例如,divspan);对于 Fragment 元素,type 是特殊的 Fragment 标识。

以下是一个简单的例子,演示了 $$typeoftype 属性的不同:

import React from 'react';

const MyComponent = () => {
	return (
		<div>
			<span>Hello</span>
		</div>
	);
};

const MyFragment = () => {
	return (
		<>
			<span>Fragment</span>
		</>
	);
};

const element = <MyComponent />;
console.log(element.type); // 输出组件函数或类
console.log(element.$$typeof); // 输出 Symbol.for('react.element')

const fragmentElement = <MyFragment />;
console.log(fragmentElement.type); // 输出 Symbol.for('react.fragment')
console.log(fragmentElement.$$typeof); // 输出 Symbol.for('react.element')

接着我们来处理多节点 Diff 的情况,reconcileChildrenArray 方法调用了 updateFromMap 函数来遍历 newChild 数组,判断是否可复用,我们需要在其中增加逻辑,判断是否复用或者新建 Fragment:

如果 element.type === REACT_FRAGMENT_TYPE,当前节点为 Fragment 类型,调用 updateFragment 函数,判断旧节点中是否有 Fragment 类型的节点,若没有就新建一个 Fragment 节点,有的话就直接复用旧节点。

// packages/react-reconciler/src/childFiber.ts
function updateFromMap(
	returnFiber: FiberNode,
	existingChildren: ExistingChildren,
	index: number,
	element: any
): FiberNode | null {
	const keyToUse = element.key !== null ? element.key : index.toString();
	const before = existingChildren.get(keyToUse);

	// ReactElement
	if (typeof element === 'object' && element !== null) {
		switch (element.$$typeof) {
			case REACT_ELEMENT_TYPE:
				if (element.type === REACT_FRAGMENT_TYPE) {
					return updateFragment(
						returnFiber,
						before,
						element,
						keyToUse,
						existingChildren
					);
				}
			// ...
		}
	}
	// ...
}

// 复用或新建 Fragment
function updateFragment(
	returnFiber: FiberNode,
	current: FiberNode | undefined,
	elements: any[],
	key: Key,
	existingChildren: ExistingChildren
) {
	let fiber;
	if (!current || current.tag !== Fragment) {
		fiber = createFiberFromFragment(elements, key);
	} else {
		existingChildren.delete(key);
		fiber = useFiber(current, elements);
	}
	fiber.return = returnFiber;
	return fiber;
}













 
 
 
 
 
 
 
 
 
 






 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

3. 数组形式的 Fragment

ReactElement
const arr = [<li>1</li>, <li>2</li>];

const element = (
	<ul>
		{arr}
		<li>3</li>
		<li>4</li>
	</ul>
);

这种情况中,newChild.children 是一个数组,数组中的某一项也是一个数组。由于newChild.children 是数组,所以会进入 reconcileChildrenArray 函数进行处理,因此我们需要在其中增加数组类型的判断,看是否可以复用 Fragment 类型的 Fiber 节点,逻辑和上面一样:

// packages/react-reconciler/src/childFiber.ts
function updateFromMap(
	returnFiber: FiberNode,
	existingChildren: ExistingChildren,
	index: number,
	element: any
): FiberNode | null {
	const keyToUse = element.key !== null ? element.key : index.toString();
	const before = existingChildren.get(keyToUse);
	// ...

	// 数组类型的 ReactElement,如:<ul>{[<li/>, <li/>]}</ul>
	if (Array.isArray(element)) {
		return updateFragment(
			returnFiber,
			before,
			element,
			keyToUse,
			existingChildren
		);
	}

	return null;
}











 
 
 
 
 
 
 
 
 
 



2. 处理 Reconciliation 阶段

为了将 Fragment 类型的节点加入到 Reconciliation 阶段的更新和 Diff 流程中,我们需要在 beginWorkcompleteWork 两个函数加入对 Fragment 类型节点的处理逻辑。

1. 处理 beginWork

beginWork 函数中,我们需要增加对 Fragment 类型节点的处理:

// packages/react-reconciler/src/beginWork.ts
// 比较并返回子 FiberNode
export const beginWork = (workInProgress: FiberNode) => {
	switch (workInProgress.tag) {
		// ...
		case Fragment:
			return updateFragment(workInProgress);
		// ...
	}
};

function updateFragment(workInProgress: FiberNode) {
	const nextChildren = workInProgress.pendingProps;
	reconcileChildren(workInProgress, nextChildren);
	return workInProgress.child;
}





 
 




 
 
 
 
 

2. 处理 completeWork

completeWork 函数中,我们也需要增加对 Fragment 类型节点的处理,向上冒泡收集到的更新 flags:

// packages/react-reconciler/src/completeWork.ts
// 生成更新计划,计算和收集更新 flags
export const completeWork = (workInProgress: FiberNode) => {
	// ...
	switch (workInProgress.tag) {
		case HostRoot:
		case FunctionComponent:
		case Fragment:
			bubbleProperties(workInProgress);
			return null;
		// ...
	}
};







 
 
 



3. 处理 Commit 阶段

增加了 Fragment 组件之后,在 Commit 阶段也需要做一些边界情况处理。例如删除节点时,需要考虑到 Fragment 组件可能会包含多个子节点,因此在删除节点时需要遍历处理其子节点。

commitDeletion 函数负责在协调过程中删除 Fiber 节点及其关联子树的操作,下面我们就来改造 commitDeletion 函数:

  • 初始化:

    • 函数接受一个名为 childToDelete 的参数,表示要删除的子树的根节点。
    • 初始化一个数组 rootChildrenToDelete 用于跟踪需要移除的子树中的 Fiber 节点。
  • 递归遍历:

    • 调用 commitNestedUnmounts 函数,深度优先遍历 Fiber 树,执行 onCommitUnmount
    • commitNestedUnmounts 的回调函数中,调用 recordChildrenToDelete 函数,遍历所有兄弟节点,记录待删除的节点。
  • 遍历删除:

    • 遍历 rootChildrenToDelete,找到待删除子树的根节点的父级 DOM(hostParent),并通过 removeChild 移除每个节点的 DOM。
  • 清理关联:

    • childToDeletereturnchild 属性置为 null,断开与父节点和子节点的关联。
// 删除节点及其子树
const commitDeletion = (childToDelete: FiberNode) => {
	if (__DEV__) {
		console.log('执行 Deletion 操作', childToDelete);
	}

	// 子树的根节点
	let rootChildrenToDelete: FiberNode[] = [];

	// 递归遍历子树
	commitNestedUnmounts(childToDelete, (unmountFiber) => {
		switch (unmountFiber.tag) {
			case HostComponent:
				recordChildrenToDelete(rootChildrenToDelete, unmountFiber);
				// TODO 解绑ref
				return;
			case HostText:
				recordChildrenToDelete(rootChildrenToDelete, unmountFiber);
				return;
			case FunctionComponent:
				//  TODO useEffect unmount
				return;
			default:
				if (__DEV__) {
					console.warn('未实现的 delete 类型', unmountFiber);
				}
		}
	});

	// 移除 rootChildrenToDelete 的DOM
	if (rootChildrenToDelete.length !== 0) {
		// 找到待删除子树的根节点的 parent DOM
		const hostParent = getHostParent(childToDelete) as Container;
		rootChildrenToDelete.forEach((node) => {
			removeChild(node.stateNode, hostParent);
		});
	}

	childToDelete.return = null;
	childToDelete.child = null;
};

function recordChildrenToDelete(
	childrenToDelete: FiberNode[],
	unmountFiber: FiberNode
) {
	let lastOne = childrenToDelete[childrenToDelete.length - 1];
	if (!lastOne) {
		childrenToDelete.push(unmountFiber);
	} else {
		let node = lastOne.sibling;
		while (node !== null) {
			if (unmountFiber == node) {
				childrenToDelete.push(unmountFiber);
			}
			node = node.sibling;
		}
	}
}

4. 处理 React 导出

因为我们使用 Fragment 组件时,是从 react 包引入的,如:import { Fragment } from 'react'; 或直接使用 <React.Fragment> 标签,因此还需要在 react 包中导出 Fragment 组件。

jsx.ts 中导出 Fragment:

// packages/react/src/jsx.ts
import { REACT_FRAGMENT_TYPE } from 'shared/ReactSymbols';

export const Fragment = REACT_FRAGMENT_TYPE;

jsx-dev-runtime.ts 导出 Fragment:

// packages/react/jsx-dev-runtime.ts
export { jsxDEV, Fragment } from './src/jsx';

至此,我们就实现了 Fragment,并将 Fragment 类型的组件加入到了 React 更新流程中。

相关代码可在 git tag v1.13 查看,地址:https://github.com/2xiao/my-react/tree/v1.13open in new window