點擊進入React源碼調試倉庫。
本篇是詳細解讀React DOM操作的第二篇文章,文章所講的內容發生在commit階段。
插入DOM節點操作的是fiber節點上的stateNode,對於原生DOM類型的fiber節點來說stateNode存儲著DOM節點。commit階段插入節點的操作就是循著fiber樹把DOM節點插入到真實的DOM樹中。
commitPlacement
是插入節點的入口,
function commitMutationEffectsImpl(
fiber: Fiber,
root: FiberRoot,
renderPriorityLevel,
) {
...
switch (primaryEffectTag) {
case Placement: {
// 插入操作
commitPlacement(fiber);
fiber.effectTag &= ~Placement;
break;
}
...
}
}
我們將需要被執行插入操作的fiber節點稱為目標節點,commitPlacement
函數的功能如下:
<div>hello</div>
,並且持有ContentReset(內容重置)的effectTag,那麽插入節點之前先設置壹下文字內容function commitPlacement(finishedWork: Fiber): void {
...
// 找到目標節點DOM層面的父節點(parent)
const parentFiber = getHostParentFiber(finishedWork);
// 根據目標節點類型,改變parent
let parent;
let isContainer;
const parentStateNode = parentFiber.stateNode;
switch (parentFiber.tag) {
case HostComponent:
parent = parentStateNode;
isContainer = false;
break;
case HostRoot:
parent = parentStateNode.containerInfo;
isContainer = true;
break;
case HostPortal:
parent = parentStateNode.containerInfo;
isContainer = true;
break;
case FundamentalComponent:
if (enableFundamentalAPI) {
parent = parentStateNode.instance;
isContainer = false;
}
}
if (parentFiber.effectTag & ContentReset) {
// 插入之前重設文字內容
resetTextContent(parent);
// 刪除ContentReset的effectTag
parentFiber.effectTag &= ~ContentReset;
}
// 找到基準節點
const before = getHostSibling(finishedWork);
// 執行插入操作
if (isContainer) {
// 在外部DOM節點上插入
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
// 直接在父節點插入
insertOrAppendPlacementNode(finishedWork, before, parent);
}
}
這裏要明確的壹點是DOM節點插入到哪,也就是要根據目標節點類型,找到對應的parent。
如果是HostRoot
或者HostPortal
類型的節點,第壹它們都沒有對應的DOM節點,第二實際渲染時它們會將DOM子節點渲染到對應的外部節點上(containerInfo)。所以當fiber節點類型為這兩個時,就將節點插入到這個外部節點上,即:
// 將parent賦值為fiber上的containerInfo
parent = parentStateNode.containerInfo
...
// 插入到外部節點(containerInfo)中
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent)
如果是HostComponent
,則直接向它的父級DOM節點中插入,即
// 直接在父節點插入
insertOrAppendPlacementNode(finishedWork, before, parent);
我們看到,在實際執行插入的時候,都有壹個before
參與,那它是幹什麽的呢?
React插入節點的時候,分兩種情況,新插入的DOM節點在它插入的位置是否已經有兄弟節點,沒有,執行parentInstance.appendChild(child)
,有,調用parentInstance.insertBefore(child, beforeChild)
。這個beforeChild
就是上文提到的before
,它是新插入的DOM節點的基準節點,有了它才可以在父級DOM節點已經存在子節點的情況下,將新節點插入到正確的位置。試想如果已經有子節點還用parentInstance.appendChild(child)
去插入,那是不是就把新節點插入到最末尾了?這顯然是不對的。所以找到before的位置十分重要,before
(基準節點)通過getHostSibling
函數來定位到。
我們用壹個例子來說明壹下getHostSibling
的原理:
p為新生成的DOM節點。a為已存在且無變化的DOM節點。它們在fiber樹中的位置如下,p需要插入到DOM樹中,我們可以根據這棵fiber樹來推斷出最終的DOM樹形態。
Fiber樹 DOM樹
div#root div#root
| |
<App/> div
| / \
div p a
/ ↖
/ ↖
Placement --> p ----> <Child/>
|
a
可以看到,在Fiber樹中,a是p的父節點的兄弟節點,而在DOM樹中,p和a是兄弟節點的關系,p最後要插入到a之前。
按照以上的圖示我們來推導壹下過程:
p有兄弟節點<Child/>
,它有子節點a,a是壹個原生DOM節點,並且a已存在於DOM樹,那麽a作為結果返回,p插入到a之前。
再來壹個例子,p同樣也是新插入的節點,h1作為已有節點存在於DOM樹中。
Fiber樹 DOM樹
div#root div#root
| |
<App/> div
| / \
div p h1
/ ↖
/ ↖
<Child1/>--><Child2/>
| |
| |
Placement ---> p h1
p沒有兄弟節點,往上找到<Child1/>
,它有兄弟節點<Child2/>
,<Child2/>
不是原生DOM節點,找<Child2/>
的子節點,發現了h1,h1是原生DOM節點並且h1已存在於DOM樹,那麽h1作為結果返回,p插入到h1之前。
經過兩個例子,getHostSibling尋找到新插入節點的兄弟DOM節點的過程可以總結為:
before
(基準節點),新節點需要插入到它的前面。這其中有如下規律:
需要插入的節點
如果有同級fiber節點且是原生DOM節點,那麽它壹定是插入到這個節點之前的。如果同級節點不是原生DOM節點,那麽它和同級節點的子節點在DOM層面是兄弟節點
的關系。
需要插入的節點
如果沒有同級節點,那麽它和父節點的兄弟節點的子節點在DOM層面是兄弟節點
的關系。
基準節點和目標節點在DOM樹中是兄弟關系,且它的位置壹定在目標節點之後
接下來按照上面總結的過程看壹下它源碼:
function getHostSibling(fiber: Fiber): ?Instance {
let node: Fiber = fiber;
siblings: while (true) {
while (node.sibling === null) {
if (node.return === null || isHostParent(node.return)) {
// 代碼執行到這裏說明沒有兄弟節點,並且新節點的父節點為DOM節點,
// 那麽它將作為唯壹的節點,插入父節點
return null;
}
// 如果父節點不為null且不是原生DOM節點,那麽繼續往上找
node = node.return;
}
// 首先從兄弟節點裏找基準節點
node.sibling.return = node.return;
node = node.sibling;
// 如果node不是以下三種類型的節點,說明肯定不是基準節點,
// 因為基準節點的要求是DOM節點
// 會壹直循環到node為dom類型的fiber為止。
// 壹旦進入循環,此時的node必然不是最開始是傳進來的fiber
while (
node.tag !== HostComponent &&
node.tag !== HostText &&
node.tag !== DehydratedFragment
) {
if (node.effectTag & Placement) {
// 如果這個節點也要被插入,繼續siblings循環,找它的基準節點
continue siblings;
}
if (node.child === null || node.tag === HostPortal) {
// node無子節點,或者遇到了HostPortal節點,繼續siblings循環,
// 找它的基準節點。
// 註意,此時會再進入siblings循環,循環的開始,也就是上邊的代碼
// 會判斷這個節點有沒有siblings,沒有就向上找,有就從siblings裏找。
continue siblings;
} else {
// 過濾不出來原生DOM節點,但它有子節點,就繼續往下找。
node.child.return = node;
node = node.child;
}
}
if (!(node.effectTag & Placement)) {
// 過濾出原生DOM節點了,並且這個節點不需要動,
// stateNode就作為基準節點返回
return node.stateNode;
}
}
}
此時基準節點已經找到,接下來執行插入操作。
插入節點操作的是DOM樹,除了插入目標節點,還需要遍歷它的fiber子樹,保證所有子DOM節點都被插入,遍歷的過程是深度優先。
我們以將節點插入父節點的insertOrAppendPlacementNode
函數為主,來梳理壹下插入的過程。
Fiber樹
div#root
|
<App/>
|
div#App
|
Placement --> <Child/>
/
/
p ------> span ----- h1
|
a
現在要將<Child/>
插入到div#App
中,真實的插入過程是先找到div#App作為parent,此後再找<Child/>
是否有sibling節點,然後調用insertOrAppendPlacementNode
來執行插入操作。
來看壹下它的調用方式:
insertOrAppendPlacementNode(finishedWork, before, parent);
壹共有三個參數:
<Child/>
<Child/>
的sibling節點,該場景下為null<Child/>
的parent節點,也就是div#App
進入函數,它的任務是將DOM節點插入到parent之下或before之前,如果finishedWork是原生DOM節點,那麽依據有無before來決定節點的插入方式,無論哪種方式都會將DOM實實在在地插入到正確的位置上。
如果不是原生DOM節點,就是<Child/>
這種,不能對它進行插入操作,那麽怎麽辦呢?向下,從它的child切入,再次調用insertOrAppendPlacementNode
,也就是遞歸地調用自己,將child壹個不剩地全插入到parent中。在例子中,會把p插入到parent。
此時<Child/>
的子節點已經全部完成插入,這時會再找到p的兄弟節點span,對它進行插入,然後發現span還有兄弟節點h1,將h1也插入。
這就是節點插入的完整過程。有壹個特點與completeWork中的插入節點類似,也就是只將目標節點的第壹層子DOM節點插入到正確的位置,因為子DOM節點的再下層的DOM節點已經在處理該層的時候插入過了。
來對照著上面的過程看壹下insertOrAppendPlacementNode
的源碼
function insertOrAppendPlacementNode(
node: Fiber,
before: ?Instance,
parent: Instance,
): void {
const {tag} = node;
const isHost = tag === HostComponent || tag === HostText;
if (isHost || (enableFundamentalAPI && tag === FundamentalComponent)) {
// 如果是原生DOM節點,直接進行插入操作
const stateNode = isHost ? node.stateNode : node.stateNode.instance;
if (before) {
// 插入到基準節點之前
insertBefore(parent, stateNode, before);
} else {
// 插入到父節點之下
appendChild(parent, stateNode);
}
} else if (tag === HostPortal) {
// HostPortal節點什麽都不做
} else {
// 不是原生DOM節點,找它的子節點
const child = node.child;
if (child !== null) {
// 對子節點進行插入操作
insertOrAppendPlacementNode(child, before, parent);
// 然後找兄弟節點
let sibling = child.sibling;
while (sibling !== null) {
// 插入兄弟節點
insertOrAppendPlacementNode(sibling, before, parent);
// 繼續檢查兄弟節點
sibling = sibling.sibling;
}
}
}
}
在遞歸調用insertOrAppendPlacementNode插入節點的時候也傳入了before,這個before是最開始那個待插入的目標節點的基準節點。我們來用源碼中的兩個場景看壹下這樣做的意義。
假設目標節點不是原生DOM節點,且有已存在DOM的兄弟節點(就是基準節點before,span):
Fiber樹 DOM樹
div div
| / \
Placement --> <Child/>----> span / \
| p span
|
p
Fiber樹 DOM樹
div div
| /|\
Placement --> <Child/>----> span / | \
| p a span
|
p ----> a
結合實際插入節點產生的問題不難總結出commit階段插入節點過程的特點:
找到基準節點before是第1點的關鍵,有了基準節點就能知道即將插入的父級節點上是否有已經存在,並且位置在目標節點之後的子節點。根據有無基準節點來決定執行哪種插入策略。
如何避免DOM節點的多余插入呢?上面分析插入過程的時候已經講過,只會將目標節點的第壹層子DOM節點插入到正確的位置,因為子DOM節點的插入工作已經完成了。這和effectList中收集的fiber節點的順序有關,因為是自下而上收集的,所以fiber的順序也是自下而上,導致DOM節點的插入也是自下而上的,可以類比壹下累加的過程。
如下,可以看到最終的effectList中,最下層的節點排在最前面:
以上,是依據Fiber樹插入DOM節點的過程。