React | 2020-08-12 20:36:25 582次 1次
经过了 scheduleWork、render 的阶段处理,生成了一条 effectList 链表,和一些更新链表,接下来进入 commit 阶段的处理这个阶段是不可以被打断的。回顾进入这里的流程:
performSyncWorkOnRoot | | workLoopSync ------ performUnitOfWork | | beginWork | | --------------completeUnitOfWork | | finishSyncRender | commitRoot
commit 的过程分为三个步骤:
before mutation阶段(执行DOM操作前):commitBeforeMutationEffects
mutation阶段(执行DOM操作) :commitMutationEffects
layout阶段(执行DOM操作后) :commitLayoutEffects
一、commitRoot概览
function commitRootImpl(root, renderPriorityLevel) { if (firstEffect !== null) { nextEffect = firstEffect; do { try{ commitBeforeMutationEffects(); }catch(e){ captureCommitPhaseError(nextEffect, error); nextEffect = nextEffect.nextEffect; } } while (nextEffect !== null); nextEffect = firstEffect; do { commitMutationEffects(root, renderPriorityLevel); } while (nextEffect !== null); nextEffect = firstEffect; do { commitLayoutEffects(root, expirationTime); } while (nextEffect !== null); } // 在离开commitRoot函数前调用,确保任何附加的任务被调度 ensureRootIsScheduled(root); // 在commit阶段有同步任务被调度,在这里率先执行他们,不需要等到浏览器下一个macroTask // 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行 flushSyncCallbackQueue(); return null; }
这里面通过三个大的遍历分别执行上述的三个过程,nextEffect 是一个全局变量,每次循环前都会重新取第一个 effectList 值,从头开始。其实是每个子过程中都会有一个遍历的过程(这里代码中没有体现),为什么这部分还有循环呢?因为这里的循环是为了出错时通过 nextEffect(这个不是全局变量,是上一篇文章中最后画的那张图中的指针) 继续进入对应的过程遍历。
二、before mutation阶段
function commitBeforeMutationEffects() { while (nextEffect !== null) { const effectTag = nextEffect.effectTag; if ((effectTag & Snapshot) !== NoEffect) { const current = nextEffect.alternate; commitBeforeMutationEffectOnFiber(current, nextEffect); } if ((effectTag & Passive) !== NoEffect) { // useEffect 相关 暂时忽略 if (!rootDoesHavePassiveEffects) {...} } nextEffect = nextEffect.nextEffect; } }
这里面针对 class 组件执行的是 getSnapshotBeforeUpdate 钩子,针对 function 组件执行的是 useEffect,这里暂时忽略,后面 hooks 再分析。
在 beginWork 中 updateClassComponent 时判断如果有 gSBU 钩子就会打上 workInProgress.effectTag |= Snapshot 标记,到这里才会调用,因为 commit 阶段(不会打断)和 render 阶段(会打断)执行不同,只会执行一次,所以 componentWillXXX 等不安全的方法通过 gSBU 代替。
三、mutation阶段
此阶段执行 commitMutationEffects 方法:
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) { // 遍历effectList while (nextEffect !== null) { ... // 更新ref if (effectTag & Ref) { ... } //选中其中一种类型 const primaryEffectTag = effectTag & (Placement | Update | Deletion | Hydrating); switch (primaryEffectTag) { // 插入DOM case Placement: { commitPlacement(nextEffect); nextEffect.effectTag &= ~Placement; break; } // 插入DOM 并 更新DOM case PlacementAndUpdate: { // 插入 commitPlacement(nextEffect); nextEffect.effectTag &= ~Placement; // 更新 const current = nextEffect.alternate; commitWork(current, nextEffect); break; } ... // 更新DOM case Update: { const current = nextEffect.alternate; commitWork(current, nextEffect); break; } // 删除DOM case Deletion: { commitDeletion(root, nextEffect, renderPriorityLevel); break; } } nextEffect = nextEffect.nextEffect; }}
四、mutation阶段--插入 commitPlacement
看这个方法之前回顾下第三篇笔记中提到的节点关系 关系图 。其中 FiberRoot 是整个应用的起点,记录整个应用更新过程中各种信息,只能有一个。RootFiber 则是具体的 Fiber 节点的根,可以有多个。
function commitPlacement(finishedWork: Fiber): void { // 找到父级 const parentFiber = getHostParentFiber(finishedWork); let parent; let isContainer; // 拿到真实节点 rootFiber.stateNode === fiberRoot const parentStateNode = parentFiber.stateNode; switch (parentFiber.tag) { case HostComponent: // 普通节点直接拿到真实dom parent = parentStateNode; isContainer = false; break; case HostRoot: // 对于 fiberRoot 要取containerInfo,即 ReactDom.render中第二个参数 parent = parentStateNode.containerInfo; isContainer = true; break; ... } // 没有找到对应场景,但是这里是判断如果存在标记则执行,通过 ~清除标记 if (parentFiber.effectTag & ContentReset) { // 清除标记 按位取反 parentFiber.effectTag &= ~ContentReset; } // 获取Fiber节点的DOM兄弟节点 const before = getHostSibling(finishedWork); //根据DOM兄弟节点是否存在决定insertBefore 或者 appendChild 执行DOM插入操作。 // 下面这两个 方法做的事情是一样的,只不过一个是挂载到 container 另一个是父节点 if (isContainer) { insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent); } else { insertOrAppendPlacementNode(finishedWork, before, parent); } }
上述过程为:找到父级节点并拿到真实 DOM---获取兄弟节点---根据是否有兄弟 before 执行不同方法。
getHostSibling
这个方法的代码暂时没看懂为什么如此设计,它的目的就是找到需要插入的位置,如果有在它之前插入 insertBefore,如果没有直接 appendChild。现列举一些案例辅助理解过程:
// 在 p111 之前插入 // 这种操作最好加上key 不加key这里不会全部复用旧节点,加上key就是复用 // 如果 不加入Com节点,加不加key都无所谓,都会复用 因为 null==null // 这里的effectlist为:p11 -> Com -> p111 -> p222 // 因为旧节点 p11 并没有直接移除 而是打上了删除标记 deleteChild方法中 // 旧节点p22被新节点p111复用(残留的statenode所以为p22) return !flag ? ( <div id="A1"> <p>11</p> <p>22</p> </div> ): ( <div id="A1"> <Com /> <p>111</p> <p>222</p> </div> )
// 当这种情况给 Com 加上key 时和不加key getHostSibling的算法是不一样的 // 不加key进入第二个while中第一个判断就继续跳出到最外层 sibling tag // 加上key进入第二个while第二个判断继续取child return !flag ? ( <div id="A1"> <Com /> </div> ): ( <div id="A1"> <p>111</p> <Com /> </div> )
function getHostSibling(fiber: Fiber): ?Instance { let node: Fiber = fiber; siblings: while (true) { // If we didn't find anything, let's try the next sibling. while (node.sibling === null) { if (node.return === null || isHostParent(node.return)) { return null; } node = node.return; } node.sibling.return = node.return; node = node.sibling; while ( node.tag !== HostComponent && node.tag !== HostText && node.tag !== DehydratedFragment ) { // 按照上述示例,如果为一个 class 组件,有插入标识,直接跳出本轮 继续大循环 if (node.effectTag & Placement) { continue siblings; } // 如果被复用的类组件 继续找子节点 if (node.child === null || node.tag === HostPortal) { continue siblings; } else { node.child.return = node; node = node.child; } } // 返回最终的需要被插入的真实dom if (!(node.effectTag & Placement)) { // Found it! return node.stateNode; } } }
因为 fiber 树和真实的 dom 树是不一致的,比如组件 Com,在 fiber 上是占一个位置的,它的 child 才是真的需要渲染的节点,真实 dom 树上是不存在 Com 这个东西的。
如果待插入节点存在兄弟节点,并且兄弟节点不是特殊组件,比如(class、function)类型,并且兄弟节点并不是新插入的,则找到的插入位置就是兄弟节点之前。
如果待插入节点存在兄弟节点,兄弟节点是特殊组件,比如(class、function)类型,并且兄弟节点并不是新插入的,那么需要继续找组件的第一个子节点真实 dom,在这之前插入。
其他情况返回 null,意味着后续操作是直接 append 操作。
insertOrAppendPlacementNode
这个方法会判断如果是组件类型,则递归调用插入或者追加节点,因为要获取它的 child,普通 dom 节点直接根据 getHostSibling 的结果执行插入或者追加节点。
如果组件类型不可以复用,那么它下面的节点是全部重新插入,effectList 上只有组件,不会有它的孩子节点。如果组件类型可以复用,那么产生的 effectList 会包含它的子节点对比后的结果,而不会全部重新插入,所以在做一些节点的移动业务时,对于多个子节点的情景最好的情况就是别忘了给组件加上 key,因为这种插入的情况不加 key 会导致 fiber 不可复用。
const isHost = tag === HostComponent || tag === HostText;
五、mutation阶段 --更新commitWork
这个方法做的事情就很简单了,就是更新节点属性和节点内容,最终会调用这个方法,其中对于 hooks 的内容暂时忽略:
function updateDOMProperties( domElement: Element, updatePayload: Array<any>, wasCustomComponentTag: boolean, isCustomComponentTag: boolean, ): void { // 这个是在completeWork中生成的 updateQueue // 数组形式,奇数索引存储属性,偶数索引存储对应的值 for (let i = 0; i < updatePayload.length; i += 2) { // 属性 const propKey = updatePayload[i]; // 值 const propValue = updatePayload[i + 1]; if (propKey === STYLE) { setValueForStyles(domElement, propValue); } else if (propKey === DANGEROUSLY_SET_INNER_HTML) { setInnerHTML(domElement, propValue); } else if (propKey === CHILDREN) { setTextContent(domElement, propValue); } else { setValueForProperty(domElement, propKey, propValue, isCustomComponentTag); } } }
六、mutation阶段 --删除commitDeletion
删除 dom 节点,并且还会执行对应的组件中的对应的卸载钩子,重置 current fiber 节点,因为卸载了不需要复用了。
调用 unmountHostComponents ,看下主要逻辑:
function unmountHostComponents(...) { ... // 大循环 最后看下作用 while (true) { // 内部这个循环为了找到 currentParent 父节点 if (!currentParentIsValid) { let parent = node.return; findParent: while (true) { const parentStateNode = parent.stateNode; switch (parent.tag) { case HostComponent: currentParent = parentStateNode; currentParentIsContainer = false; break findParent; ... } parent = parent.return; } currentParentIsValid = true; } // dom节点 if (node.tag === HostComponent || node.tag === HostText) { commitNestedUnmounts(...); // 就是单纯的删除 removeChild(...); } else { // 组件 执行卸载钩子 和 ref卸载 commitUnmount(finishedRoot, node, renderPriorityLevel); // 组件内部的dom节点继续遍历 if (node.child !== null) { node.child.return = node; node = node.child; continue; } } // 子树遍历结束 if (node === current) { return; } // 没有兄弟 while (node.sibling === null) { // 回到顶点或者子节点也遍历完成 if (node.return === null || node.return === current) { return; } node = node.return; if (node.tag === HostPortal) { // When we go out of the portal, we need to restore the parent. // Since we don't keep a stack of them, we will search for it. currentParentIsValid = false; } } // 比如组件 return [1,2,3] 需要继续遍历兄弟,所以外部是一个大循环 node.sibling.return = node.return; node = node.sibling; } }
这里面主要做的事情是先找到父节点,然后根据节点类型执行相关逻辑,下面就是不停的遍历进行处理。
对于 dom 节点执行 commitNestedUnmounts 和 removeChild 移除,
对于组件执行 commitUnmount 执行一些卸载钩子,类组件就是 componentWillUnmount,hooks 组件就是 useEffect 的返回回调。
commitNestedUnmounts 方法做的事情和上面的逻辑差不多,深度优先遍历,因为有可能 dom 中包含了组件,所以需要深度执行所有的组件卸载钩子 commitUnmount,unmountHostComponents 的遍历逻辑因为判单进入 dom 节点不会再深度执行,所以需要这个方法辅助继续深度执行,逻辑其实是一样的,只不过这里不再用做节点类型判断:
function commitNestedUnmounts(...){ let node: Fiber = root; while (true) { // 卸载钩子执行 commitUnmount(finishedRoot, node, renderPriorityLevel); if ( node.child !== null && (!supportsMutation || node.tag !== HostPortal) ) { 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; } }
七、layout阶段
进入 commit 的最后一个阶段--layout,在上个阶段 dom 已经渲染完成,那么这个阶段执行的一些事情就是可以让开发者访问真实 dom,比如一些钩子调用、setState 回调(所以回调里能立即取到最新值)、render 回调、ref 的挂载等。
function commitLayoutEffects(...) { while (nextEffect !== null) { const effectTag = nextEffect.effectTag; if (effectTag & (Update | Callback)) { // 只看下类组件 commitLayoutEffectOnFiber(...); } if (effectTag & Ref) { // ref相关 后面分析 commitAttachRef(nextEffect); } nextEffect = nextEffect.nextEffect; } }
function commitLayoutEffectOnFiber(){ ... const instance = finishedWork.stateNode; // 钩子执行 instance.componentDidUpdate(...); // 更新渲染则执行的是 componentDidUpdate,初次执行 didmount const updateQueue = finishedWork.updateQueue; // setState回调执行 commitUpdateQueue(finishedWork, updateQueue, instance); ... }
最后还要执行实现树的切换,将 wip 树转为 current 树,之前笔记中提到了双缓冲机制:
root.current = finishedWork;
到此,一个 class 组件的更新渲染流程大致就完成了,接下来进行 hooks、context、ref、事件系统等源码学习!
1人赞