hey🖐! 我是小黄瓜😊😊。一枚小透明,期待关注➕ 点赞,共同成长~
写在前面
本系列会实现一个简单的react
,包含最基础的首次渲染,更新,hook
,lane
模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘
本文致力于实现一个最简单的fiber
更新流程,代码均已上传至github
,期待star!✨:
本文是系列文章,阅读的联系性非常重要!!
手写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.child
:current
树的对应子节点。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-react
和antd
源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳