渐进式剖析React源码(4):细说beginWork

205 阅读7分钟

image.png

在上篇文章中有说到react16之后重构了ReactElement构成的树形结构,而beginWork、completeWork的核心目的就是做这样的一件事情,把ReactElement的结构转成fiber结构~

首先高度概括beginWork做的事:

1.从rootFiber开始向下构建子fiber

2.为fiber节点添加Placement or ChildDeletion 标记

老规矩还是先制定五个小目标:

  1. 更新流程起始阶段的工作
  2. React中的fiber类型
  3. beginWork针对不同fiber类型不同处理
  4. 单节点diff的流程
  5. 多节点的diff流程

react中唤起更新流程(不清楚如何唤起的参考上篇文章)后会执行很多的事情,这里我们略过关于调度层执行的事宜(后续文章会针对性讲解),着重关心update更新任务的创建构建缓存树(workInProgressFiber)

一、update更新任务的创建

唤起更新流程会有不同的场景,不同场景下都会去执行创建update任务和添加任务的过程

场景:

  1. mount阶段会执行react.createRoot.render()方法,方法的内部执行了createUpdate和enqueueUpdate
  2. 函数式组件中useState的dispatch也是会在内部执行createUpdate
  3. 类组件中setState同样会执行createUpdate

createUpdate函数做的事情很简单,那就是创建个任务对象,任务对象的结构如下:

createUpdate源码位置

简化后:

export const createUpdate = (
    	action,
    	lane
    ) => {
    	return {
                  lane,
                  action,
                  next: null
    	 };
    };

其中lane代表优先级我们暂不关心,update也是链表的结构,通过next指针进行相互串联构成了单向环状链表

fiber中是可以存在多个update任务的,比如函数式组件or类组件中可以多次使用setState触发更新,那这些产生的update对象会被串联成单向环状链表的结构存贮在fiber.updateQueue.shared.pending字段当中。

enqueueUpdate函数就是负责挂载update对象到fiber结构中(enqueUpdate源码)

简化后:

export const enqueueUpdate = <State>(
	updateQuene: UpdateQueue<State>,
	update: Update<State>
) => {
	const pending = updateQuene.shared.pending;
	if (pending === null) {
		// a -> a
		update.next = update;
	} else {
		// b->a->b
		update.next = pending.next;
		pending.next = update;
	}
	// pending 永远指向末尾的update 而pending.next指向链表首位 构成单向环状链表结构
	updateQuene.shared.pending = update;
};

update的环状结构最终形成updateA->updateB->updateC->updateA结构。

二、构建缓存树(createWorkInProgress)

依据当前的fiber结构,重新得到一份fiber结构(workInProgressFiber)用于后续的构建。

createWorkInProgress源码位置

简化后:

const createWorkInProgress = (
	current: FiberNode,
	pendingProps: Props
): FiberNode => {
	let fiber = current.alternate;
	if (fiber === null) {
		// 新建fiberNode节点
		fiber = new FiberNode(current.tag, pendingProps, current.key);
		fiber.stateNode = current.stateNode;
		fiber.alternate = current;
		current.alternate = fiber;
	} else {
		// 重复利用fiberNode节点
		fiber.pendingProps = pendingProps;
		fiber.flags = Noflags;
		fiber.subTreeFlags = Noflags;
		fiber.deletions = null;
	}
	fiber.type = current.type;
	fiber.updateQueue = current.updateQueue;
	fiber.child = current.child;
	fiber.memorizedProps = current.memorizedProps;
	fiber.memorizedState = current.memorizedState;
	return fiber;
};

react出于性能考虑重复去利用了currentFiber.alternate对象代替新建对象

简单解释下,在update更新阶段,我们是在currentFiber.alternate对象基础上,去修改清空某些属性然后再赋值currentFiber上的属性(如上面的代码),而不是直接去复制一份currentFiber再修改,后者明显是资源的浪费会导致每次更新都需要创建新对象,而前者在应用中始终只会存在两棵fiber树(两个对象),这也是享元模式在react中的应用。

二、react中的fiber类型

fiber类型源码位置

我们常见的类型其实是五种:

  1. HostRoot = 3, 代表的是rootFiber
  2. HostComponent = 5,代表的是div/span等等标签类对应的fiber
  3. HostText = 6, 代表的是文本节点对应的fiber
  4. FunctionComponent = 0,代表的是函数式组件对应的fiber
  5. Fragment = 7,代表的是fragment标签或者<>对应的fiber

对于不同类型的fiber,我们在构建过程中的操作是不同的,甚至相同字段名称在不同fiber类型下意义也是不一样,这块后续会反复强调

至此我们完成了第一个和第二个小目标,接下来是我们的重点内容~

三、beginWork的流程

beginWork函数核心目的就是通过当前的父级fiber向下构建子级的fiber。

beginWork源码位置

简化后

// 针对不同的fiber类型 处理方式不同
export const beginWork = (wip: FiberNode, renderLane: Lane) => {
	const tag = wip.tag;
	switch (tag) {
		case HostRoot:
			return updateHostRoot(wip, renderLane);
		case HostComponent:
			return updateHostComponent(wip);
		case HostText:
			return null;
		case FunctionComponent:
			return updateFunctionComponent(wip, renderLane);
		case Fragment:
			return updateFragment(wip);
		default:
			if (__Dev__) {
				console.warn(`${tag}当前类型不存在`);
			}
			break;
	}
	return null;
};

第一个参数我们称之为父级fiber,第二个参数这里我们暂且不讨论

这里针对不同的父级fiber类型处理的方法不相同,但是他们处理都是有共同点:

  1. 找到子级对应的ReactElement元素
  2. 调用reconcilChildren用于构建子级fiber
  3. 返回子级fiber

我们会分开讨论~

父级fiber是HostRoot,执行的updateHostRoot

updateHostRoot源码位置

简化后:

const updateHostRoot = (wip: FiberNode, renderLane: Lane) => {
	// 计算状态的最新值 此处的updateQueue.shared.pending中指向的update任务是 <App> ReactElement元素({action: ReactElement})
	const baseState = wip.memorizedState;
	const updateQueue = wip.updateQueue;
	const pending = updateQueue.shared.pending;
	updateQueue.shared.pending = null;
	const { memorizedState } = processUpdateQueue(baseState, pending, renderLane);
	wip.memorizedState = memorizedState;
	const nextChildren = wip.memorizedState;
	reconcilChildren(wip, nextChildren);
	return wip.child;
};

核心的逻辑就是获取到memorizedState并且将其传给reconcilChildren进行调用,而这个memorizedState就是上文提到的子级ReactElement元素

这里我们需要再详细讲解下涉及到的数据结构:

updateQueue是fiber的更新队列,存贮着后续需要更新的内容,内部的pending字段指向的就是上文提到的update更新对象。

{
  shared:{
    pending: update1->update2->update3->update1
  }
}

那么对于rootFiber节点,它的update是从哪里来?是什么?

mount阶段下,React.createRoot.render()内部会调用createUpdate(<App/>),从而创建出来update对象并挂载到fiberRoot.updateQueue.shared.pending当中,update对象中保存的action就是对应的ReactElement元素。

processUpdateQueue函数做的事情就是基于当前的状态消费update值得到最新的state值。

上文提到过不同fiber类型下相同字段含义是不一样的,对于rootFiber来说memorizedState对应的是根组件的ReactElement元素,而对于函数式组件、类组件的memorizedState它的概念就是useState/setState中的state。

父级fiber是HostComponent

    const updateHostComponent = (wip: FiberNode) => {
	const nextChildren = wip.pendingProps.children;
	reconcilChildren(wip, nextChildren);
	return wip.child;
};

依然遵循上面的3个步骤,这里的ReactElement元素是存贮在父级pendingProps.children当中

父级fiber是FunctionComponent

    const updateFunctionComponent = (wip: FiberNode, renderLane: Lane) => {
	const nextChildren = renderWithHooks(wip, renderLane);
	reconcilChildren(wip, nextChildren);
	return wip.child;
};

FunctionComponent情况下,会先执行renderWithHooks这里其实是针对hooks的一些处理,这块内容会在后续hooks讲解中专门解析,这里只需要知道renderWithHooks内部会调用**fiber.type()**方法,type存贮的就是函数式组件本身,所以执行这个函数就得到它子元素的ReactElment,同样也是遵循上面3个步骤。

我们发现以上几种情况下都会走到reconcilChildren方法,而此方法就是具体创建子fiber的地方。

reconcilChildren源码位置

简化后:

    const reconcilChildren = (wip: FiberNode, children?: ReactElementType) => {
	// current Fiber与reactElement进行diff
	const current = wip.alternate;
	if (current) {
		// update
		wip.child = reconcileChildFibers(wip, current?.child, children);
	} else {
		// mount
		wip.child = mountChildFibers(wip, null, children);
	}
};

简单概括下,current意味着当前存在fiber节点,若存在那我们肯定是需要先去执行对比diff后再判断是否是新建还是复用子节点,如果不存在fiber节点那意味着直接就是新建子节点。

对比diff的节点需要重点关注下,是当前的fiber节点与ReactElment元素(virtual dom)之间的对比。而这个ReactElment元素的来源,针对不同fiber类型是不一样的。

我们具体看看mountChildFibers和reconcileChildFibers做了什么事情(源码位置

我们发现这两个方法最终都是调用createChildReconciler,只不过入参的布尔值shouldTrackSideEffects不同,mount是false,reconciler是true。

React中的flags

  • Placement(增加、移动):0b0000001
  • Update(更新):0b0000010
  • ChildDeletion(删除):0b0000100
  • PassiveEffect(副作用):0b0001000

flags是二进制数据,具体类型可以参考flags类型

标记flags的目的是在commit阶段的时候可以通过不同的flags做不同的处理(譬如dom的增删操作、钩子函数的调度等),在beginWork阶段只会处理Placement/ChildDeletion。

shouldTrackSideEffects的意义

shouldTrackSideEffects 表示是否追踪副作用,意味着是否为fiber节点标记flags。

在首次渲染的时候是不需要为每个新建的fiber节点都打上placement标记的,这其实是一种性能优化。

这样的设计概念就类比于文档碎片DocumentFragment,当我们需要频繁增加dom节点的时候,是不会一次又一次的的访问真实dom去添加,而是内存中创建一个文档碎片,在文档碎片内部完成一颗离屏dom结构,再一次性挂载到父级容器。

React在此处也是这样的设计思想,譬如首次渲染的时候,只需要在根组件对应的fiber节点标记Placement,等到commit阶段只需要做一次dom的挂载就完成渲染。

四、单节点diff

单节点顾名思义就是更新后只有一个节点

单节点diff源码位置

简化后:

    const reconcileSingleElement = (
		returnFiber: FiberNode,
		currentFiber: FiberNode | null,
		element: ReactElementType
	) => {
		const key = element.key;
		// update
		while (currentFiber !== null) {
			// key 相同
			if (key === currentFiber.key) {
				if (element.$$typeof === REACT_ELEMENT_TYPE) {
					if (element.type === currentFiber.type) {
						let props = element.props;
						// 复用
						const existing = userFiber(currentFiber, props);
						existing.return = returnFiber;
						// 找到复用节点删除其他同级节点
						deleteRemainingChildren(returnFiber, currentFiber.sibling);
						return existing;
					}
					// key 相同 type不相同,都没有可复用的节点,全部标记删除
					deleteRemainingChildren(returnFiber, currentFiber);
					break;
				} else {
					if (__Dev__) {
						console.warn(`还未实现的react类型`);
					}
				}
			} else {
				// key不相同 删除当前的fiber 继续遍历下个节点
				deleteChild(returnFiber, currentFiber);
				currentFiber = currentFiber.sibling;
			}
		}
		// 根据element创建fiberNode并且返回
		let fiber;
		fiber = createFiberNodeFromElement(element);
		fiber.return = returnFiber;
		return fiber;
	}

核心逻辑

  1. 只有key相同、type相同才可以被复用
  2. 当找到key相同而type却不相同的时候就停止遍历,全部标记删除,因为key具有唯一性,唯一的那个元素type都不一样,所以后续不会有复用元素。
  3. 将current赋值为current.sibling继续走前面第一步、第二步,直到current为null 或者找到复用节点。

下图帮助大家更好理解单节点diff流程:

image.png

五、多节点diff

更新后同级节点为多个

多节点diff源码位置 简化后:

const reconcileChildrenArray = (
		returnFiber: FiberNode,
		currentFirstChild: FiberNode | null,
		newChild: any[]
	) => {
		// 最后一位可复用的fiber在current节点中的位置
		let lastPlacedIndex = 0;
		// 创建的最后一个fiber
		let lastNewFiber: FiberNode | null = null;
		// 创建的第一个fiber
		let firstNewFiber: FiberNode | null = null;
		// 1.将current保存在map中
		const existingChildren: 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];
			// 返回的newFiber要么是复用的fiber 要么是新建的fiber
			const newFiber = updateFromMap(returnFiber, existingChildren, i, after);
			// 更新后的值为false null
			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) {
				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;
	};
    

核心逻辑:

  1. 将同级的currentFiber收集成一个map结构
  2. 遍历element数组,从currentFiber中查询符合条件的key用以复用。
  3. 标记当前的fiber节点是移动还是新建
  4. 将其他元素标记为删除

关于移动这里需要阐述下,在React中可以复用的元素只有两种情况,要么不移动,要么右移(添加placement标记)。

这里代码中的实现也很简单,会记录上一位可复用的fiber在current节点中的位置,如果当前复用元素在current节点中的位置下标小于它,则说明当前元素是需要右移,进行标记即可~

下图帮助大家更好理解多节点diff流程:

image.png

以上介绍了单节点和多节点是如何diff的流程,接下来我们看下如何标记删除以及如何根据ReactElement创建fiberNode的方法:

标记删除(deleteRemainingChildren)

deleteRemainingChildren源码位置 简化后:

    const deleteRemainingChildren = (
		returnFiber: FiberNode,
		currentFirstChild: FiberNode | null
	) => {
		if (!shouldTrackEffects) {
			return;
		}
		let childToDelete = currentFirstChild;
		while (childToDelete !== null) {
      	const deletions = returnFiber.deletions;
				if (deletions === null) {
  				returnFiber.deletions = [childToDelete];
  				returnFiber.flags |= ChildDeletion;
				} else {
					returnFiber.deletions?.push(childToDelete);
				}
			childToDelete = childToDelete.sibling;
		}
	};

概括来说就是在父级中创建个deletions数组存贮需要删除的子节点,并给父级打上flags为ChildDeletion的标记

根据ReactElement创建fiber(createFiberNodeFromElement)

 const createFiberNodeFromElement = (element: ReactElementType) => {
	const { key, props, type } = element;
	let fiberTag: workTags = FunctionComponent;
	if (typeof type === 'string') {
		fiberTag = HostComponent;
	} else if (typeof type !== 'function' && __Dev__) {
		console.warn('当前类型未定义', element);
	}
	const fiber = new FiberNode(fiberTag, props, key);
	fiber.type = type;
	return fiber;
};

从ReactElement中拿到key/props/type,调用FiberNode实例化即可,FiberNode类可以查看第二篇文章fiber数据结构。

beginWork整体的流程图如下:

image.png

综上,我们完成了beginWork的五个小目标~

总结

  1. 更新流程的唤起一定会创建update更新对象以及重新创建一颗缓存fiber树(workInProgressFiber)
  2. beginWork会从rootFiber开始向下构建子fiber,核心目的就是构建子fiber并添加flags标记
  3. beginWork针对不同类型的父级fiber会有不同操作,但是都是需要拿到对应子节点的ReactElement元素再进行构建
  4. 单节点和多节点的diff会对type/key相同的fiber进行复用,复用的节点有可能是向右移动或者是不移动,其余的要么是新增,要么就是删除。

此篇文章内容比较多,若有疑问欢迎私信或者评论~