Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《自己动手写 React》——【13】实现 Fragment #13

Open
2xiao opened this issue Mar 30, 2024 · 0 comments
Open

《自己动手写 React》——【13】实现 Fragment #13

2xiao opened this issue Mar 30, 2024 · 0 comments

Comments

@2xiao
Copy link
Owner

2xiao commented Mar 30, 2024

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

1. Diff 算法支持 Fragment

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

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

1. Fragment 包裹其他组件

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

// 对应 DOM
<div>1</div>
<div>2</div>

// JSX 转换结果
var element = jsxs(Fragment, {
    children: [
        jsx("div", {children: "1"})
        jsx("div", {children: "2"})
    ]
})

其中,<></> 是 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>
);

// 对应 DOM
<ul>
	<li>1</li>
	<li>2</li>
	<li>3</li>
	<li>4</li>
</ul>

// JSX 转换结果
var element = jsxs("ul", {
    children: [
        jsxs(Fragment, {
            children: [
                jsx("li", {children: "1"})
                jsx("li", {children: "2"})
            ]
        }),
        jsx("li", {children: "3"})
        jsx("li", {children: "4"})
    ]
})

这种情况中,若 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>
);

// 对应 DOM
<ul>
	<li>1</li>
	<li>2</li>
	<li>3</li>
	<li>4</li>
</ul>

// JSX 转换结果
var arr = [
    jsx("li", {children: "1"}),
    jsx("li", {children: "2"})
]

var element = jsxs("ul", {
    children: [
        arr,
        jsx("li", {children: "3"})
        jsx("li", {children: "4"})
    ]
})

这种情况中,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.13

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant