在上篇文章中有说到react16之后重构了ReactElement构成的树形结构,而beginWork、completeWork的核心目的就是做这样的一件事情,把ReactElement的结构转成fiber结构~
首先高度概括beginWork做的事:
1.从rootFiber开始向下构建子fiber
2.为fiber节点添加Placement or ChildDeletion 标记
老规矩还是先制定五个小目标:
- 更新流程起始阶段的工作
- React中的fiber类型
- beginWork针对不同fiber类型不同处理
- 单节点diff的流程
- 多节点的diff流程
react中唤起更新流程(不清楚如何唤起的参考上篇文章)后会执行很多的事情,这里我们略过关于调度层执行的事宜(后续文章会针对性讲解),着重关心update更新任务的创建和构建缓存树(workInProgressFiber)
一、update更新任务的创建
唤起更新流程会有不同的场景,不同场景下都会去执行创建update任务和添加任务的过程
场景:
- mount阶段会执行react.createRoot.render()方法,方法的内部执行了createUpdate和enqueueUpdate
- 函数式组件中useState的dispatch也是会在内部执行createUpdate
- 类组件中setState同样会执行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)用于后续的构建。
简化后:
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类型
我们常见的类型其实是五种:
- HostRoot = 3, 代表的是rootFiber
- HostComponent = 5,代表的是div/span等等标签类对应的fiber
- HostText = 6, 代表的是文本节点对应的fiber
- FunctionComponent = 0,代表的是函数式组件对应的fiber
- Fragment = 7,代表的是fragment标签或者<>对应的fiber
对于不同类型的fiber,我们在构建过程中的操作是不同的,甚至相同字段名称在不同fiber类型下意义也是不一样,这块后续会反复强调
至此我们完成了第一个和第二个小目标,接下来是我们的重点内容~
三、beginWork的流程
beginWork函数核心目的就是通过当前的父级fiber向下构建子级的fiber。
简化后
// 针对不同的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类型处理的方法不相同,但是他们处理都是有共同点:
- 找到子级对应的ReactElement元素
- 调用reconcilChildren用于构建子级fiber
- 返回子级fiber
我们会分开讨论~
父级fiber是HostRoot,执行的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的地方。
简化后:
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
单节点顾名思义就是更新后只有一个节点
简化后:
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;
}
核心逻辑
- 只有key相同、type相同才可以被复用
- 当找到key相同而type却不相同的时候就停止遍历,全部标记删除,因为key具有唯一性,唯一的那个元素type都不一样,所以后续不会有复用元素。
- 将current赋值为current.sibling继续走前面第一步、第二步,直到current为null 或者找到复用节点。
下图帮助大家更好理解单节点diff流程:
五、多节点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;
};
核心逻辑:
- 将同级的currentFiber收集成一个map结构
- 遍历element数组,从currentFiber中查询符合条件的key用以复用。
- 标记当前的fiber节点是移动还是新建
- 将其他元素标记为删除
关于移动这里需要阐述下,在React中可以复用的元素只有两种情况,要么不移动,要么右移(添加placement标记)。
这里代码中的实现也很简单,会记录上一位可复用的fiber在current节点中的位置,如果当前复用元素在current节点中的位置下标小于它,则说明当前元素是需要右移,进行标记即可~
下图帮助大家更好理解多节点diff流程:
以上介绍了单节点和多节点是如何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整体的流程图如下:
综上,我们完成了beginWork的五个小目标~
总结
- 更新流程的唤起一定会创建update更新对象以及重新创建一颗缓存fiber树(workInProgressFiber)
- beginWork会从rootFiber开始向下构建子fiber,核心目的就是构建子fiber并添加flags标记
- beginWork针对不同类型的父级fiber会有不同操作,但是都是需要拿到对应子节点的ReactElement元素再进行构建
- 单节点和多节点的diff会对type/key相同的fiber进行复用,复用的节点有可能是向右移动或者是不移动,其余的要么是新增,要么就是删除。
此篇文章内容比较多,若有疑问欢迎私信或者评论~