更新!更新!实现react更新及diff流程

hey🖐! 我是小黄瓜😊😊。一枚小透明,期待关注➕ 点赞,共同成长~

写在前面

本系列会实现一个简单的react,包含最基础的首次渲染,更新,hooklane模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘

本文致力于实现一个最简单的fiber更新流程,代码均已上传至github,期待star!✨:

github.com/kongyich/ti...

本文是系列文章,阅读的联系性非常重要!!

手写mini-react!超万字实现mount首次渲染流程🎉🎉

进击的hooks(钩)!实现react(反应)中的hooks(钩)架构和useState 🚀🚀

期待点赞!😁😁

食用前指南!本文涉及到react的源码知识,需要对react有基础的知识功底,建议没有接触过react的同学先去官网学习一下基础知识,再看本系列最佳!

在上一篇hooks​中,我们简单实现了useState​,首先实现useState​是因为我们可以使用派发函数来触发react整个更新流程。

先来看一下调用setState​函数后是怎样开启更新流程的:

js 复制代码
export const useState = (initialState) => {
	const dispatcher = resolveDispatcher();
	return dispatcher.useStat(initialState);
};

dispatcher.useState​函数是在mount​阶段的mountState​函数中被定义的(详情参见上一篇hooks文章)。

js 复制代码
function mountState<State>(initialState) {
	// 找到当前useState对应的hook数据
	// 构建hooks链表
	const hook = mountWorkInprogressHook();

	// 处理初始化数据
	// ...省略代码

	const dispatch = dispatchSetState().bind(null, currentlyRenderingFiber, queue);
	queue.dispatch = dispatch;

	return [memoizedState, dispatch];
}

而在dispatchSetState​中先根据用户定义的更新函数action​创建了一个更新任务,保存到更新队列中,然后正式开启更新的调度流程。

js 复制代码
function dispatchSetState<State>(
	fiber,
	updateQueue,
	action
) {
	// 创建更新任务
	const update = createUpdate(action);
	enqueueUpdate(updateQueue, update);
	// 开启更新流程
	scheduleUpdateOnFiber(fiber);
}

一. 整体流程

更新的整体流程与mount​阶段大体一致,但是部分阶段需要对比新旧节点,然后处理差异部分。

更新阶段从scheduleUpdateOnFiber​ 函数开始调度,当一个函数组件调用setstate​时,首先会将该fiber​节点传入scheduleUpdateOnFiber​函数中:

js 复制代码
scheduleUpdateOnFiber(fiber);

此时该fiber​节点的updateQueue.shared.pending​ 属性中已经保存了用户定义的更新函数。

js 复制代码
export function scheduleUpdateOnFiber(fiber) {
	// 查找根节点
	const root = markUpdateFromFiberToRoot(fiber);
	// 开始调度
	renderRoot(root);
}

在调度流程开始之前,首先要寻找到整个应用的根节点,因为每次调度react都是从根节点开始,从上至下进行调度的。例如我们有以下jsx​:

js 复制代码
function Count() {
	const [num, setNum] = useState(0)
	function handleNum() {
		setNum(num + 1)
	}
	return (
		<p onClick={handleNum}>{num}</p>
	)
}

function App() {
	return (
		<div>
			<span>father</span>
			<Count />
		</div>
	)
}

如果在Count​函数组件中触发更新,那么会从Count​这个fiber​节点开始,不断的顺着return​属性往上查找到FiberRootNode​ 节点。开始构建整棵新的fiber​树,然后开始整个调度流程。

js 复制代码
function markUpdateFromFiberToRoot(fiber) {
	let node = fiber;
	let parent = node.return;
	while (parent !== null) {
		node = parent;
		parent = node.return;
	}
	// FiberRootNode 与 HostRootFiber是通过stateNode连接
	if (node.tag === HostRoot) {
		return node.stateNode;
	}
	return null;
}

接下来在renderRoot​ 开始时首先是创建一个头节点(HostRootfiber​)。在创建fiber​时,首先要判断alternate​ 属性是否有值,判断是mount​阶段还是update​阶段。

而在mount​阶段创建fiber​时,直接创建一个新的fiber​节点,在update​阶段,可以直接对alternate​属性关联的current​树中的原节点属性进行复用。

js 复制代码
export const createWorkInProgress = (
	current,
	pendingProps
) => {
	let wip = current.alternate;
	// 初始化阶段
	if (wip === null) {
		// mount
		wip = new FiberNode(current.tag, pendingProps, current.key);
		wip.stateNode = current.stateNode;
		wip.alternate = current;
		current.alternate = wip;
	} else {
		// 更新阶段
		// update
		wip.pendingProps = pendingProps;
		// 副作用标记
		wip.flags = NoFlags;
		// 子树副作用标记
		wip.subtreeFlags = NoFlags;
		// 删除标记
		wip.deletions = null;
	}
	wip.type = current.type;
	wip.updateQueue = current.updateQueue;
	wip.child = current.child;
	wip.memoizedState = current.memoizedState;
	wip.memoizedProps = current.memoizedProps;

	return wip;
};

更新阶段复用时,需要对相关的副作用标记重置。

js 复制代码
let workInProgress = null;

function renderRoot(root) {
	// 初始化
	preparereFreshStack(root);

	do {
		try {
			// 开始调度循环
			workLoop();
			break;
		} catch (e) {
			if (__DEV__) {
				console.warn('workLoop发生错误');
			}
			workInProgress = null;
		}
	} while (true);
}

function preparereFreshStack(root) {
	// 创建新的根节点fiber
	workInProgress = createWorkInProgress(root.current, {});
}

workInProgress​ 是一个全局变量,代表当前正在处理的fiber​节点。

workLoop​函数中只需要判断workInProgress​ 不为空则不断调用performUnitOfWork​ 。

js 复制代码
function workLoop() {
	// 循环处理workInProgress
	while (workInProgress !== null) {
		performUnitOfWork(workInProgress);
	}
}

在第一篇文章我们已经实现了整个mount​流程,这里就不再赘述了。这里简略的说一个整体的过程。

beginWork​ 处理当前fiber​节点的子树,返回其生成后的子fiber​,可以根据返回值next​判断是否还有子节点(当前子树是否已经到尽头)。如果还有子节点,则将子节点赋值给workInProgress​ ,继续处理子节点。

如果没有子节点,则对当前fiber​节点执行completeWork​ 函数,执行完毕后查找兄弟节点,如果有兄弟节点,则对兄弟节点执行beginWork​函数,如果没有兄弟节点,则向上查找父节点,并对父节点执行completeWork​函数,直到处理完根节点。

js 复制代码
function performUnitOfWork(fiber) {
	// 生成子节点fiber
	const next = beginWork(fiber);
	// 保存处理参数
	fiber.memoizedProps = fiber.pendingProps;
	// 是否拥有子节点
	if (next === null) {
		completeUnitOfWork(fiber);
	} else {
		workInProgress = next;
	}
}

接下来先看一下beginWork​函数执行过程中是怎么对更新阶段的fiber​节点是怎么处理的。

二. beginWork

beginWork​ 函数需要根据不同的tag​标记处理不同的节点类型:

js 复制代码
export const beginWork = (wip) => {
	// 返回子fiberNode
	switch (wip.tag) {
		// root类型(根节点)
		case HostRoot:
			return updateHostRoot(wip);
		// 原生节点类型(div / span / p)
		case HostComponent:
			return updateHostComponent(wip);
		// 文本类型
		case HostText:
			return null; 
		// 函数组件类型
		case FunctionComponent:
			return updateFunctionComponent(wip);
		default:
			if (__DEV__) {
				console.warn('beginWork未实现的类型');
			}
			break;
	}
	return null;
};

在提取子节点的逻辑中与mount​阶段是一致的:

  • HostRoot

由于在初始化时,jsx函数将<App />​函数组件当作整个element​节点保存至更新队列,所以最后执行完processUpdateQueue​ 函数后相当于将整个函数体作为memoizedState​ 属性,所以下次创建fiber​节点类型为FunctionComponent​。(详情见第一篇<首次渲染文章>)

js 复制代码
function updateHostRoot(wip) {
	const baseState = wip.memoizedState;
	const updateQueue = wip.updateQueue;
	const pending = updateQueue.shared.pending;
	updateQueue.shared.pending = null;
	const { memoizedState } = processUpdateQueue(baseState, pending);
	wip.memoizedState = memoizedState;

	const nextChildren = wip.memoizedState;
	reconcilerChildren(wip, nextChildren);
	return wip.child;
}

  • HostComponent

原生节点类型直接获取element​节点中props​的children​ 属性。

js 复制代码
function updateHostComponent(wip) {
	const nextProps = wip.pendingProps;
	const nextChildren = nextProps.children;

	reconcilerChildren(wip, nextChildren);
	return wip.child;
}

  • FunctionComponent

函数组件类型则调用element​属性的type​属性,整个函数组件的函数体被保存到了type​属性中。

js 复制代码
function updateFunctionComponent(wip) {
	// 执行函数组件返回子节点
	const nextChildren = renderWithHooks(wip);

	reconcilerChildren(wip, nextChildren);
	return wip.child;
}

殊途同归,最后这几种类型都会调用reconcilerChildren​ 开始生成子节点fiber​。

js 复制代码
function reconcileChildren(wip, children) {
	const current = wip.alternate;

	if (current !== null) {
		// update
		wip.child = reconcileChildFibers(wip, current?.child, children);
	} else {
		// mount
		wip.child = mountChildFibers(wip, null, children);
	}
}

mount​阶段不同的是,在update​阶段我们还需要传入current​树的子节点,也就是将新旧子节点进行对比。注意此时正在构建的workInProgress​树中新的子节点还未生成fiber​,此时还是element​节点,所以此时更新时传入的参数的类型:

  • wip :当前正在处理中的新的fiber节点
  • current.childcurrent树的对应子节点。fiber节点
  • children :即将要生成fiber的节点。wip的子级,此时还是element节点
js 复制代码
const reconcileChildFibers = ChildReconciler(true);

对于处理element​节点生成fiber​节点,主要有两种类型,如果element​为对象,说明当前节点为组件或者原生DOM类型。如果为string​或number​类型,说明当前节点为文本节点。

placeSingleChild​ 函数的主要作用的用于在生成的fiber​节点上标记新增DOM节点的标记,shouldTrackEffects​ 在更新阶段被标记为true,代表在更新阶段具有产生副作用操作。

js 复制代码
function placeSingleChild(fiber) {
	// shouldTrackEffects 为 true 并且当前为更新阶段
	if (shouldTrackEffects && fiber.alternate === null) {
		// 添加新增的标记
		fiber.flags |= Placement;
	}
	return fiber;
}

当前无论是mount​阶段还是update​阶段,都只处理了单节点的情况,也就是只有一个子节点的情况。

reconcileSingleElement​ 处理非文本节点,reconcileSingleTextNode​ 处理文本节点。

js 复制代码
function ChildReconciler(shouldTrackEffects) {
	return function reconcileChildFibers(
		returnFiber,
		currentFiber,
		newChild
	) {
		// 判断当前fiber的类型
		if (typeof newChild === 'object' && newChild !== null) {
			switch (newChild.$$typeof) {
				case REACT_ELEMENT_TYPE:
					return placeSingleChild(
						reconcileSingleElement(returnFiber, currentFiber, newChild)
					);
				default:
					if (__DEV__) {
						console.warn('未实现的reconcile类型', newChild);
					}
					break;
			}
		}
		// HostText 文本节点
		if (typeof newChild === 'string' || typeof newChild === 'number') {
			return placeSingleChild(
				reconcileSingleTextNode(returnFiber, currentFiber, newChild)
			);
		}

		if (currentFiber !== null) {
			// 删除current树节点
			deleteChild(returnFiber, currentFiber);
		}

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

reconcileSingleElement​的主要任务就是根据element​来创建fiber​节点,接收的参数同样也是当前正在处理中的fiber​节点、current​树中对应的子fiber​节点(旧节点)、新的element​子节点(即将被处理)。

mount​流程中直接调用createFiberFromElement​ 函数进行创建。但是再update​流程中还需要对比新旧节点,当前我们只处理了单节点的情况:

  • 对比key

    • 新旧节点的key​不同:直接删除旧fiber​节点,根据element​对象创建新fiber

    • 新旧节点的key​相同

      • 对比​type

        • 新旧节点的type不同:直接删除旧fiber节点,根据element对象创建新fiber
        • 新旧节点的type相同:复用原fiber节点,创建新fiber节点

js 复制代码
function reconcileSingleElement(
		returnFiber,
		currentFiber,
		element
	) {
		const key = element.key;
		// 是否为更新流程
		if (currentFiber !== null) {
			if (currentFiber.key === key) {
				// key相同
				if (element.$$typeof === REACT_ELEMENT_TYPE) {
					if (currentFiber.type === element.type) {
						// type相同,复用fiber
						const existing = useFiber(currentFiber, element.props);
						existing.return = returnFiber;
						return existing;
					}

					// 标记删掉旧的fiber节点
					deleteChild(returnFiber, currentFiber);
				} else {
					if (__DEV__) {
						console.warn('还未实现的react类型', element);
					}
				}
			} else {
				// 标记删掉旧的fiber节点
				deleteChild(returnFiber, currentFiber);
			}
		}
		// 根据element创建fiber
		const fiber = createFiberFromElement(element);
		// 更新链接
		fiber.return = returnFiber;
		return fiber;
	}

复用旧的fiber​节点可以直接使用createWorkInProgress​ 函数进行复用,并重置节点连接。

js 复制代码
function useFiber(fiber, pendingProps): FiberNode {
	// 创建一个fiber,createWorkInProgress函数内部会对alternate不为null的fiber节点复用
	const clone = createWorkInProgress(fiber, pendingProps);
	// 重置
	clone.index = 0;
	clone.sibling = null;
	return clone;
}

createWorkInProgress​ 函数在复用旧节点时,主要是重置flags​ ,subtreeFlags​,deletions​属性,其中deletions​属性保存待删除节点。

js 复制代码
// createWorkInProgress函数中update更新逻辑
wip.pendingProps = pendingProps;
wip.flags = NoFlags;
wip.subtreeFlags = NoFlags;
wip.deletions = null;

针对文本节点的处理也是类似,但是对于props​的处理直接更新content​属性即可:

js 复制代码
	function reconcileSingleTextNode(
		returnFiber,
		currentFiber,
		content
	) {
		if (currentFiber !== null) {
			// update
			if (currentFiber.tag === HostText) {
				// 类型没变,可以复用
				const existing = useFiber(currentFiber, { content });
				existing.return = returnFiber;
				return existing;
			}
			// 不可复用,标记删除
			deleteChild(returnFiber, currentFiber);
		}
		// 直接创建fiber
		const fiber = new FiberNode(HostText, { content }, null);
		fiber.return = returnFiber;
		return fiber;
	}

对于发生更新的节点,也就是完全不能复用的节点,在创建新的fiber​之前,还要先删除掉之前的旧节点,由于在render​阶段只是标记和处理fiber​节点,不涉及到真实DOM的操作,所以需要在需要删除的节点上打上标记,并且维护一个列表,将需要被删除的子节点记录下来。

commit​阶段根据ChildDeletion​ 标记和deletions​ 集合处理真实DOM。

js 复制代码
export const ChildDeletion = 0b0000100;

function deleteChild(returnFiber, childToDelete) {
		// 不触发副作用
		if (!shouldTrackEffects) {
			return;
		}
		// 获取fiber节点的待删除集合
		const deletions = returnFiber.deletions;
		if (deletions === null) {
			returnFiber.deletions = [childToDelete];
			// 标记删除
			returnFiber.flags |= ChildDeletion;
		} else {
			deletions.push(childToDelete);
		}
	}

三. completeWork

beginWork​流程处理完毕后就会进入completeUnitOfWork​处理流程。completeUnitOfWork​在更新只需要完成一件事:标记更新。

js 复制代码
function completeUnitOfWork(fiber) {
	let node = fiber;

	do {
		completeWork(node);
		const sibling = node.sibling;

		if (sibling !== null) {
			workInProgress = sibling;
			return;
		}
		node = node.return;
		workInProgress = node;
	} while (node !== null);
}

completeWork​ 也需要对不同的节点类型分别处理。HostComponent​ 类型需要确定是否存在alternate​ 和stateNode​ ,而对于文本节点,则直接判断他们的值是否一致。

js 复制代码
export const completeWork = (wip) => {
	const newProps = wip.pendingProps;
	const current = wip.alternate;

	switch (wip.tag) {
		case HostComponent:
			if (current !== null && wip.stateNode) {
				// update
				markUpdate(wip);
			} else {
				// mount阶段 构建DOM
				// 。。。省略
			}
			bubbleProperties(wip);
			return null;
		case HostText:
			if (current !== null && wip.stateNode) {
				// update
				// 获取文本节点的内容
				const oldText = current.memoizedProps.content;
				const newText = newProps.content;
				if (oldText !== newText) {
					// 	标记update
					markUpdate(wip);
				}
			} else {
				// mount阶段 构建DOM
				// 。。。省略
			}
			bubbleProperties(wip);
			return null;
		case HostRoot:
			// ...省略
			return null;
		case FunctionComponent:
			// ...省略
			return null;
		default:
			if (__DEV__) {
				console.warn('未处理的completeWork情况', wip);
			}
			break;
	}
};

发生更新则标记update​:

js 复制代码
function markUpdate(fiber) {
	fiber.flags |= Update;
}

四. commitWork

render​阶段执行完毕后,我们已经得到了一棵新的完整的workInProgress​树,接下来commit​阶段就开始根据fiber​树构建DOM树。

commitRoot​ 函数为commit​阶段的入口,传整个workInProgress树​。在处理之前,首先判断在根fiber​与子fiber​中是否存在副作用标记(增加/更新/删除),如果存在需要处理的副作用标记,会根据fiber​树对真实DOM进行变更。

commit​ 细分可以分为三个阶段,会在不同阶段执行在DOM变更前后不同的逻辑,例如双缓存树的切换,执行生命周期等等。双缓存树的切换是在layout​ 阶段之前。

  • Before mutation 阶段:执行 DOM 操作前

没修改真实的 DOM ,是获取 DOM 快照的最佳时期,如果是类组件有 getSnapshotBeforeUpdate​,会在这里执行。

  • mutation 阶段:执行 DOM 操作

对新增元素,更新元素,删除元素。进行真实的 DOM 操作。

  • layout 阶段:执行 DOM 操作后

DOM 已经更新完毕。

目前只有单节点的更新,所以只实现mutation​阶段:

js 复制代码
function commitRoot(root) {
	const finishedWork = root.finishedWork;

	if (finishedWork === null) {
		return;
	}

	// 重置
	root.finishedWork = null;

	// 判断是否存在三个子阶段需要执行的操作
	const subtreeHasEffect =
		(finishedWork.subtreeFlags & MutationMask) !== NoFlags;
	const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;

	if (subtreeHasEffect || rootHasEffect) {
		// beforeMutation

		// mutation
		commitMutationEffects(finishedWork);
		// 切换fiber树
		root.current = finishedWork;

		// layout
	} else {
		// 切换fiber树
		root.current = finishedWork;
	}
}

commitMutationEffects​ 会在整棵fiber​树中不断向下查找最后具有副作用标识的节点,然后向上回溯处理节点(具体过程可查看第一篇mount​阶段执行过程的文章)。

js 复制代码
export const commitMutationEffects = (finishedWork) => {
	nextEffect = finishedWork;

	while (nextEffect !== null) {
		// 向下遍历
		const child = nextEffect.child;

		if (
			(nextEffect.subtreeFlags & MutationMask) !== NoFlags &&
			child !== null
		) {
			nextEffect = child;
		} else {
			// 向上遍历
			up: while (nextEffect !== null) {
				commitMutationEffectsOnFiber(nextEffect);
				// 处理兄弟节点
				const sibling = nextEffect.sibling;

				if (sibling !== null) {
					nextEffect = sibling;
					break up;
				}
				// 兄弟节点为null,则继续向上遍历
				nextEffect = nextEffect.return;
			}
		}
	}
};

commitMutationEffectsOnFiber​ 针对具体的fiber​节点处理,在mount​流程中我们已经处理了标记为Placement​(新增)的节点。

本次主要是处理flags​ 标记为Update​ (更新)和ChildDeletion​(删除)的节点。

deletions​ 保存的是该fiber​节点下所有待删除的子节点,遍历deletions​,执行commitDeletion​ 函数对每个子节点执行删除逻辑。

js 复制代码
const commitMutaitonEffectsOnFiber = (finishedWork: FiberNode) => {
	const flags = finishedWork.flags;

	if ((flags & Placement) !== NoFlags) {
		// ...省略
	}
	if ((flags & Update) !== NoFlags) {
		// 处理更新标记节点
		commitUpdate(finishedWork);
		// 去掉Update标记
		finishedWork.flags &= ~Update;
	}
	if ((flags & ChildDeletion) !== NoFlags) {
		const deletions = finishedWork.deletions;
		// 处理删除标记节点
		if (deletions !== null) {
			deletions.forEach((childToDelete) => {
				commitDeletion(childToDelete);
			});
		}
		// 去掉ChildDeletion标记
		finishedWork.flags &= ~ChildDeletion;
	}
};

commitUpdate​ 函数的更新对于不同类型的处理逻辑不同。HostText​ 主要更新文本内容,直接替换其textContent​ 属性中的文本内容。

Update

HostComponent​ 类型主要更新属性,利用memoizedProps​ 属性中保存的最新的props​值对真实DOM更新。updateFiberProps​ 涉及到各种事件的处理,将于后续的逻辑实现。

js 复制代码
export function commitUpdate(fiber) {
	switch (fiber.tag) {
		case HostText:
			// 获取最新的文本内容
			const text = fiber.memoizedProps?.content;
			return commitTextUpdate(fiber.stateNode, text);
		case HostComponent:
			return updateFiberProps(fiber.stateNode, fiber.memoizedProps);
		default:
			if (__DEV__) {
				console.warn('未实现的Update类型', fiber);
			}
			break;
	}
}

export function commitTextUpdate(textInstance, content) {
	textInstance.textContent = content;
}

ChildDeletion

关于节点删除的逻辑就相对复杂一点了,虽然我们已经在render​阶段将所有需要删除的子节点都保存在列表中,但是我们的DOM层级可能是深层次的,在待删除列表中的每个节点都可能有更多的子节点,而且在这些子节点中可能存在很多的时间绑定,ref绑定,和副作用相关的回调函数等等。

所以在删除每个子节点之前都需要对其进行子级的深层次的遍历,针对子级不同类型的节点进行不同的处理(useEffect​, unmount​、解绑ref)。

js 复制代码
function commitDeletion(childToDelete) {
	let rootHostNode = null;

	// 递归子树
	commitNestedComponent(childToDelete, (unmountFiber) => {
		switch (unmountFiber.tag) {
			case HostComponent:
				if (rootHostNode === null) {
					rootHostNode = unmountFiber;
				}
				// 解绑ref
				return;
			case HostText:
				if (rootHostNode === null) {
					rootHostNode = unmountFiber;
				}
				return;
			case FunctionComponent:
				// useEffect unmount 、解绑ref
				return;
			default:
				if (__DEV__) {
					console.warn('未处理的unmount类型', unmountFiber);
				}
		}
	});

	// 移除rootHostComponent的DOM
	if (rootHostNode !== null) {
		const hostParent = getHostParent(childToDelete);
		if (hostParent !== null) {
			removeChild((rootHostNode).stateNode, hostParent);
		}
	}
	childToDelete.return = null;
	childToDelete.child = null;
}

这段执行逻辑可能不大好理解,在commitNestedComponent​ 函数中接受一个fiber​节点,第一个参数就是在当前正在处理fiber​节点的deletions​ 列表中的每一项子节点,第二个参数是一个匿名函数,是在遍历深层节点是对每个子级节点执行的处理函数,对每种不同类型的节点执行不同的卸载逻辑。

令人疑惑的是rootHostNode​ 这个变量,咋一看他好像既代表当前进入commitDeletion​ 函数的每个根节点,也代表后续遍历的子节点。

其实看后面的commitNestedComponent​ 函数的实现可以看出来,整个遍历过程与render​阶段生成fiber​节点的逻辑非常类似:先深入再回溯。 所以commitNestedComponent​ 函数执行完毕后,在执行删除DOM的逻辑之前,rootHostNode​ 变量中保存的依然是最开始传入commitDeletion​ 函数的fiber​节点。

‍ 所以最后执行removeChild​ 逻辑的依然是在具有ChildDeletion​ 标记的fiber​节点与其deletions​ 列表中的子节点进行的。

js 复制代码
function commitNestedComponent(
	root,
	onCommitUnmount
) {
	let node = root;
	while (true) {
		// 执行节点卸载
		onCommitUnmount(node);

		if (node.child !== null) {
			// 向下遍历
			node.child.return = node;
			node = node.child;
			continue;
		}
		// 已经回溯到根节点 --> 退出循环
		if (node === root) {
			// 终止条件
			return;
		}
		while (node.sibling === null) {
			if (node.return === null || node.return === root) {
				return;
			}
			// 向上归并
			node = node.return;
		}
		node.sibling.return = node.return;
		node = node.sibling;
	}
}

至此,单节点更新的处理就完成了。

五. 多节点diff

在前面的mount​以及update​流程中我们已经根据多节点fiber​树提前实现了不少逻辑,但是我们的主流程目前依然只支持单节点的处理:

js 复制代码
function App() {
	return <div>
		<span>hi, react</span>
	</div>;
}

现在我们需要提供在一个节点存在多个平级节点的情况:

js 复制代码
function App() {
	return <div>
		<span>hi, react</span>
		<span>hi, gua</span>
	</div>;
}

这两者在编译时保存子节点的方式不同:

可以看到在多节点时,将多个平级的子节点保存在数组中,所以在处理<App />​函数组件的子节点时,待处理的children​属性中保存的也是element​数组:

然而在beginWork​流程生成fiber​节点的逻辑中我们并没有处理这种情况:

children​在拥有多节点的情况下表现为数组的形式,显然是没有$$typeof​ 属性的。

所以在reconcileChildFibers​ 函数中加入处理多节点的逻辑,当children​为数组时,调用reconcileChildrenArray​ 函数。

js 复制代码
function reconcileChildFibers(
		returnFiber,
		currentFiber,
		newChild
	) {
		// 判断当前fiber的类型
		if (typeof newChild === 'object' && newChild !== null) {
			switch (newChild.$$typeof) {
				// ...省略
				// 单节点
			}
			// 多节点的情况 ul> li*3
			if (Array.isArray(newChild)) {
				// 多节点执行reconcileChildrenArray函数
				return reconcileChildrenArray(returnFiber, currentFiber, newChild);
			}
		}

		// HostText
		if (typeof newChild === 'string' || typeof newChild === 'number') {
			// ...省略
			// 文本节点
		}

		if (currentFiber !== null) {
			// 兜底删除
			deleteChild(returnFiber, currentFiber);
		}

		return null;
	};

由于我们已经在前面同时实现了mount​以及update​流程,所以在处理多节点时,同样也需要同时处理这两种情况。

对于mount​阶段,主要任务依然是构建,不过对比单节点,本次的处理需要增加sibling​兄弟节点的创建。

我们使用firstNewFiber​ 和lastNewFiber​ 指针来记录第一个子节点和最后一个子节点,当第一个子节点在生成的时候,此时firstNewFiber​ 和lastNewFiber​ 指针都指向第一个节点。

如果不是第一个节点,使用sibling​连接兄弟节点。

updateFromMap​ 创建每个fiber​节点。

js 复制代码
function reconcileChildrenArray(
		// 父节点
		returnFiber,
		// 旧fiber第一个子节点
		currentFirstChild,
		// 新element节点 --> 数组
		newChild
	) {
		// 最后一个可复用fiber在current中的index
		let lastPlacedIndex = 0;
		// 创建的最后一个fiber
		let lastNewFiber = null;
		// 创建的第一个fiber
		let firstNewFiber = null;

		for (let i = 0; i < newChild.length; i++) {
			// 2.遍历newChild,寻找是否可复用
			const after = newChild[i];
			// 创建fiber节点
			const newFiber = updateFromMap(returnFiber, '', i, after);

			if (newFiber === null) {
				continue;
			}

			newFiber.index = i;
			newFiber.return = returnFiber;
			// 第一个子节点
			if (lastNewFiber === null) {
				lastNewFiber = newFiber;
				firstNewFiber = newFiber;
			} else {
			// 非第一个节点,使用sibling连接兄弟节点
				lastNewFiber.sibling = newFiber;
				lastNewFiber = lastNewFiber.sibling;
			}

			if (!shouldTrackEffects) {
				continue;
			}

			const current = newFiber.alternate;
			// mount
			// 添加新增的标识
			newFiber.flags |= Placement;
		}
		return firstNewFiber;
	}

最后分别根据不同的节点类型,生成对应的fiber​​节点。

js 复制代码
	function updateFromMap(
		returnFiber,
		existingChildren,
		index,
		element
	) {
		// HostText
		if (typeof element === 'string' || typeof element === 'number') {
			return new FiberNode(HostText, { content: element + '' }, null);
		}

		// ReactElement
		if (typeof element === 'object' && element !== null) {
			switch (element.$$typeof) {
				case REACT_ELEMENT_TYPE:
					return createFiberFromElement(element);
			}

			// TODO 数组类型
			if (Array.isArray(element) && __DEV__) {
				console.warn('还未实现数组类型的child');
			}
		}
		return null;
	}

但是对于多节点的更新逻辑,就会复杂一点,因为更新时节点的关系既有可能是多个节点变更为一个节点,一个节点变更为多个节点,多个节点变更为多个节点,节点之间还会存在移动和删除的情况。

单节点需要支持的情况:

  • 插入 Placement
  • 删除 ChildDeletion

多节点需要支持的情况:

  • 插入 Placement
  • 删除 ChildDeletion
  • 移动 Placement

下面将使用不同的字母来表示对不同节点数量的处理:

ABC --> AB || AB --> ABC || A --> ABC

对于新的子节点列表数量与旧节点不同情况,可以认为是某些子节点被删除。这种情况可以维护一个Map​列表,在构建新的fiber​节点时使用key​进行提取,等待新的节点列表都被构建完成后,再将Map​列表剩余的旧节点全部删除。

js 复制代码
function reconcileChildrenArray(
	// 父节点
	returnFiber,
	// 旧fiber第一个子节点
	currentFirstChild,
	// 新element节点 --> 数组
	newChild
) {
	// 1.将current保存在map中
	const existingChildren = new Map();
	let current = currentFirstChild;
	while (current !== null) {
		// 根据key值构建Map列表
		const keyToUse = current.key !== null ? current.key : current.index;
		existingChildren.set(keyToUse, current);
		current = current.sibling;
	}

	for (let i = 0; i < newChild.length; i++) {
		// 2.遍历newChild,寻找是否可复用
		const after = newChild[i];
		// 根据existingChildren查找可复用节点
		const newFiber = updateFromMap(returnFiber, existingChildren, i, after);

		// ...省略

		// 4. 将Map中剩下的标记为删除
		existingChildren.forEach((fiber) => {
			deleteChild(returnFiber, fiber);
		});
	}

}

‍ 在updateFromMap​ 构建新节点时,如果旧的fiber​节点列表中,有具有相同key​的fiber​节点,则进行复用,随后删除旧复用的节点,如果没有提取到相同key​的节点,则创建一个新的fiber​节点。

js 复制代码
function updateFromMap(
		returnFiber,
		existingChildren,
		index,
		element
	) {
		const keyToUse = element.key !== null ? element.key : index;
		// 根据key获取旧节点
		const before = existingChildren.get(keyToUse);

		// HostText
		if (typeof element === 'string' || typeof element === 'number') {
			if (before) {
				if (before.tag === HostText) {
					existingChildren.delete(keyToUse);
					return useFiber(before, { content: element + '' });
				}
			}
			return new FiberNode(HostText, { content: element + '' }, null);
		}

		// ReactElement
		if (typeof element === 'object' && element !== null) {
			switch (element.$$typeof) {
				case REACT_ELEMENT_TYPE:
					if (before) {
						// 复用,删除旧节点
						if (before.type === element.type) {
							existingChildren.delete(keyToUse);
							return useFiber(before, element.props);
						}
					}
					return createFiberFromElement(element);
			}

			// TODO 数组类型
			if (Array.isArray(element)) {
				console.warn('还未实现数组类型的child');
			}
		}
		return null;
	}

ABC --> A

针对旧节点为多节点,而更新后变更为单节点,其实这条逻辑不会进入reconcileChildrenArray​ 函数,而是会进入单节点的处理函数reconcileSingleElement​ ,进入reconcileSingleElement​函数说明此时需要处理的fiber​为单节点,将旧节点的所有兄弟节点删除。

js 复制代码
	function reconcileSingleElement(
		returnFiber,
		currentFiber,
		element
	) {
		const key = element.key;
		while (currentFiber !== null) {
			// update
			if (currentFiber.key === key) {
				// key相同
				if (element.$$typeof === REACT_ELEMENT_TYPE) {
					if (currentFiber.type === element.type) {
						// type相同
						const existing = useFiber(currentFiber, element.props);
						existing.return = returnFiber;
						// 当前节点可复用,标记剩下的节点删除
						deleteRemainingChildren(returnFiber, currentFiber.sibling);
						return existing;
					}

					// key相同,type不同 删掉所有旧的
					deleteRemainingChildren(returnFiber, currentFiber);
					break;
				} else {
					// ...省略
				}
			} else {
				// key不同,删掉旧的
				// ...省略
			}
		}
		// 根据element创建fiber
		// ...省略
	}

reconcileSingleTextNode​ 也需要做同样的处理。

js 复制代码
function reconcileSingleTextNode(
		returnFiber,
		currentFiber,
		content
	) {
		while (currentFiber !== null) {
			// update
			if (currentFiber.tag === HostText) {
				// 类型没变,可以复用
				const existing = useFiber(currentFiber, { content });
				existing.return = returnFiber;
				// 删除兄弟节点
				deleteRemainingChildren(returnFiber, currentFiber.sibling);
				return existing;
			}
			// ...省略
		}
		// ...省略
		// 创建新fiber
	}

deleteRemainingChildren​ 函数主要就是使用每个节点的sibling​ 指针,查找每个兄弟节点,然后调用deleteChild​ 函数删除。

js 复制代码
function deleteRemainingChildren(
		returnFiber,
		currentFirstChild
	) {
		if (!shouldTrackEffects) {
			return;
		}
		let childToDelete = currentFirstChild;
		while (childToDelete !== null) {
			// 调用deleteChild删除
			deleteChild(returnFiber, childToDelete);
			childToDelete = childToDelete.sibling;
		}
	}

ABCD --> BCAD

对于节点列表的每一项都相同,但是只是其中的位置发生了变化,此时对于节点的移动操作显然比重新创建更加划算。

例如我们有以下节点列表:(是用字母表示)

js 复制代码
旧:A B C D E
       |
新:A C E D B

可以看到上述的5个节点只是位置发生了变化,此时只需要移动两个节点就可以达成更新的目标。

那么怎么找到需要移动的节点呢,其实答案就在旧节点对应的位置中。

「移动」 具体是指 「向右移动」。

移动的判断依据:记录当前遍历到的新节点在旧节点列表中对应的index​,当遍历element​时, 「当前遍历到的element」 一定是 「所有已遍历的element」 中最靠右那个。

拿着新的fiber​寻找新的fiber​在旧fiber​列表中的位置,以上一次找到的位置为坐标。

所以只需要记录 「最后一个可复用fiber」current​中的index​(lastPlacedIndex​),在接下来的遍历中:

  • 如果接下来遍历到的 「可复用fiber」index < lastPlacedIndex,则标记Placement
  • 否则,不标记

如果光看文字可能非常不好理解,下面结合图例来还原查找过程:

第一次查找:

A节点查找到对应旧fiber​节点的坐标为1,并不满足移动条件,将A节点的坐标值记录下来。

lastPlacedIndex = 1

第二次查找:

C节点查找到对应旧fiber​节点的坐标为3, **lastPlacedIndex**​ < 3 ,所以并不满足移动条件,将C节点的坐标值记录下来。

lastPlacedIndex = 3

第三次查找:

E节点查找到对应旧fiber​节点的坐标为5, **lastPlacedIndex**​ < 5 ,所以并不满足移动条件,将E节点的坐标值记录下来。

lastPlacedIndex= 5

第四次查找:

D节点查找到对应旧fiber​节点的坐标为4,4 < **lastPlacedIndex**​ ,所以满足移动条件,标记D节点移动。

lastPlacedIndex = 5

‍ 第五次查找:

B节点查找到对应旧fiber​节点的坐标为2,2 < **lastPlacedIndex**​ ,所以满足移动条件,标记B节点移动。

lastPlacedIndex = 5

移动完成。

js 复制代码
	function reconcileChildrenArray(
		// 父节点
		returnFiber,
		// 旧fiber第一个子节点
		currentFirstChild,
		// 新element节点 --> 数组
		newChild
	) {
		// 最后一个可复用fiber在current中的index
		let lastPlacedIndex = 0;
		// 创建的最后一个fiber
		let lastNewFiber = null;
		// 创建的第一个fiber
		let firstNewFiber = null;

		// 1.将current保存在map中
		const existingChildren = new Map();
		let current = currentFirstChild;
		while (current !== null) {
			const keyToUse = current.key !== null ? current.key : current.index;
			existingChildren.set(keyToUse, current);
			current = current.sibling;
		}

		for (let i = 0; i < newChild.length; i++) {
			// 2.遍历newChild,寻找是否可复用
			const after = newChild[i];
			const newFiber = updateFromMap(returnFiber, existingChildren, i, after);

			if (newFiber === null) {
				continue;
			}

			// 3. 标记移动还是插入
			newFiber.index = i;
			newFiber.return = returnFiber;

			if (lastNewFiber === null) {
				lastNewFiber = newFiber;
				firstNewFiber = newFiber;
			} else {
				lastNewFiber.sibling = newFiber;
				lastNewFiber = lastNewFiber.sibling;
			}

			if (!shouldTrackEffects) {
				continue;
			}

			const current = newFiber.alternate;
			if (current !== null) {
				// 旧fiber的index
				// 拿着新的fiber寻找新的fiber在旧fiber的位置,以上一次找到的位置为坐标,这一次如果比上一次小,则移动
				const oldIndex = current.index;
				if (oldIndex < lastPlacedIndex) {
					// 移动
					newFiber.flags |= Placement;
					continue;
				} else {
					// 不移动
					lastPlacedIndex = oldIndex;
				}
			} else {
				// mount
				newFiber.flags |= Placement;
			}
		}
		// 4. 将Map中剩下的标记为删除
		existingChildren.forEach((fiber) => {
			deleteChild(returnFiber, fiber);
		});
		return firstNewFiber;
	}

由于现在多了移动的逻辑,那么在commit​阶段处理真实DOM时,也要处理"插入"这种场景。

由于我们在节点移动时为需要移动的节点标记了Placement​ ,所以会在commitPlacement​ 处理插入的逻辑:

js 复制代码
const commitPlacement = (finishedWork) => {
	// parent DOM
	const hostParent = getHostParent(finishedWork);

	// 获取兄弟节点
	const sibling = getHostSibling(finishedWork);

	if (hostParent !== null) {
		insertOrAppendPlacementNodeIntoContainer(finishedWork, hostParent, sibling);
	}
};

由于在DOM中插入节点还需要传入后一个节点来定位插入DOM节点的位置,所以还需要实现一个获取下一个兄弟节点的方法。

但是获取兄弟节点还有几种需要考虑的情况,因为当前节点的兄弟节点可能是函数组件生成的fiber​节点,我们需要寻找真正代表DOM的fiber​节点:

  • 情况一:当前fiber节点的兄弟节点为函数组件

查找兄弟节点的子节点,直到查找到第一个DOM节点。

js 复制代码
<div/><App/>
function App() {
  return <p/>;
}
  • 情况二:当前fiber节点的父节点为函数组件

查找父级的兄弟节点,查找父级的兄弟节点的子节点,直到查找到第一个DOM节点。

js 复制代码
<A/><B/>
function A() {
  return <div/>;
}
  • 情况三:不稳定的Host节点不能作为 「目标的兄弟Host节点」
js 复制代码
if ((node.flags & Placement) !== NoFlags) {
	continue
}

js 复制代码
function getHostSibling(fiber) {
	let node = fiber;

	findSibling: while (true) {
		while (node.sibling === null) {
			const parent = node.return;
			// 如果是DOM节点不继续寻找
			// 说明当前无兄弟节点
			if (
				parent === null ||
				parent.tag === HostComponent ||
				parent.tag === HostRoot
			) {
				return null;
			}
			node = parent;
		}
		// 寻找兄弟节点
		node.sibling.return = node.return;
		node = node.sibling;

		while (node.tag !== HostText && node.tag !== HostComponent) {
			// 向下遍历
			// 不稳定节点跳过
			if ((node.flags & Placement) !== NoFlags) {
				continue findSibling;
			}
			if (node.child === null) {
				continue findSibling;
			} else {
				node.child.return = node;
				node = node.child;
			}
		}
		// 返回稳定DOM节点
		if ((node.flags & Placement) === NoFlags) {
			return node.stateNode;
		}
	}
}

此外还需要实现插入方法:

js 复制代码
function insertOrAppendPlacementNodeIntoContainer(
	finishedWork,
	hostParent,
	before
) {
	// fiber host
	if (finishedWork.tag === HostComponent || finishedWork.tag === HostText) {
		// 如果存在兄弟节点,则调用插入方法
		if (before) {
			insertChildToContainer(finishedWork.stateNode, hostParent, before);
		} else {
			appendChildToContainer(hostParent, finishedWork.stateNode);
		}

		return;
	}
	// ...省略
}
js 复制代码
export function insertChildToContainer(
	child,
	container,
	before
) {
	container.insertBefore(child, before);
}

写在最后

未来可能会更新实现mini-reactantd源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳

相关推荐
JUNAI_Strive_ving8 分钟前
番茄小说逆向爬取
javascript·python
看到请催我学习17 分钟前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5
twins352037 分钟前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
qiyi.sky1 小时前
JavaWeb——Vue组件库Element(3/6):常见组件:Dialog对话框、Form表单(介绍、使用、实际效果)
前端·javascript·vue.js
煸橙干儿~~1 小时前
分析JS Crash(进程崩溃)
java·前端·javascript
哪 吒1 小时前
华为OD机试 - 几何平均值最大子数(Python/JS/C/C++ 2024 E卷 200分)
javascript·python·华为od
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_027】3.4 让 D3 数据适应屏幕(下)—— D3 分段比例尺的用法
前端·javascript·信息可视化·数据可视化·d3.js·d3比例尺·分段比例尺
l1x1n02 小时前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
Q_w77422 小时前
一个真实可用的登录界面!
javascript·mysql·php·html5·网站登录
昨天;明天。今天。2 小时前
案例-任务清单
前端·javascript·css