點擊進入React源碼調試倉庫。
本篇是詳細解讀React DOM操作的第壹篇文章,文章所講的內容發生在commit階段。
Fiber架構使得React需要維護兩類樹結構,壹類是Fiber樹,另壹類是DOM樹。當刪除DOM節點時,Fiber樹也要同步變化。但請註意刪除操作執行的時機:在完成DOM節點的其他變化(增、改)前,要先刪除fiber節點,避免其他操作被幹擾。 這是因為進行其他DOM操作時需要循環fiber樹,此時如果有需要刪除的fiber節點卻還沒刪除的話,就會發生混亂。
function commitMutationEffects(
firstChild: Fiber,
root: FiberRoot,
renderPriorityLevel,
) {
let fiber = firstChild;
while (fiber !== null) {
// 首先進行刪除
const deletions = fiber.deletions;
if (deletions !== null) {
commitMutationEffectsDeletions(deletions, root, renderPriorityLevel);
}
// 如果刪除之後的fiber還有子節點,
// 遞歸調用commitMutationEffects來處理
if (fiber.child !== null) {
const primarySubtreeTag = fiber.subtreeTag & MutationSubtreeTag;
if (primarySubtreeTag !== NoSubtreeTag) {
commitMutationEffects(fiber.child, root, renderPriorityLevel);
}
}
if (__DEV__) {/*...*/} else {
// 執行其他DOM操作
try {
commitMutationEffectsImpl(fiber, root, renderPriorityLevel);
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
fiber = fiber.sibling;
}
}
fiber.deletions是render階段的diff過程檢測到fiber的子節點如果有需要被刪除的,就會被加到這裏來。
commitDeletion
函數是刪除節點的入口,它通過調用unmountHostComponents
實現刪除。搞懂刪除操作之前,先看看場景。
有如下的Fiber樹,Node(Node是壹個代號,並不指的某個具體節點)節點即將被刪除。
Fiber樹
div#root
|
<App/>
|
div
|
<Parent/>
|
Delation --> Node
| ↖
| ↖
P ——————> <Child>
|
a
通過這種場景可以推測出當刪除該節點時,它下面子樹中的所有節點都要被刪除。現在直接以這個場景為例,走壹下刪除過程。這個過程實際上也就是unmountHostComponents
函數的運行機制。
刪除Node節點需要父DOM節點的參與:
parentInstance.removeChild(child)
所以首先要定位到父級節點。過程是在Fiber樹中,以Node的父節點為起點往上找,找到的第壹個原生DOM節點即為父節點。在例子中,父節點就是div。此後以Node為起點,遍歷子樹,子樹也是fiber樹,因此遍歷是深度優先遍歷,將每個子節點都刪除。
需要特別註意的壹點是,對循環節點進行刪除,每個節點都會被刪除操作去處理,這裏的每個節點是fiber節點而不是DOM節點。DOM節點的刪除時機是從Node開始遍歷進行刪除的時候,遇到了第壹個原生DOM節點(HostComponent或HostText)這個時刻,在刪除了它子樹的所有fiber節點後,才會被刪除。
以上是完整過程的簡述,對於詳細過程要明確幾個關鍵函數的職責和調用關系才行。刪除fiber節點的是unmountHostComponents
函數,被刪除的節點稱為目標節點,它的職責為:
其中第3步的操作,是通過commitNestedUnmounts
完成的,它的職責很單壹也很明確,就是遍歷子樹卸載節點。
然後具體到每個節點的卸載過程,由commitUnmount
完成。它的職責是
unmountHostComponents
重復刪除過程下面來看壹下不同類型的組件它們的具體刪除過程是怎樣的。
Node節點的類型有多種可能性,我們以最典型的三種類型(HostComponent、ClassComponent、HostPortal
)為例分別說明壹下刪除過程。
首先執行unmountHostComponents
,會向上找到DOM層面的父節點,然後根據下面的三種組件類型分別處理,我們挨個來看。
Node 是HostComponent,調用commitNestedUnmounts
,以Node為起點,遍歷子樹,開始對所有子Fiber進行卸載操作,遍歷的過程是深度優先遍歷。
Delation --> Node(span)
| ↖
| ↖
P ——————> <Child>
|
a
對節點逐個執行commitUnmount
進行卸載,這個遍歷過程其實對於三種類型的節點,都是類似的,為了節省篇幅,這裏只表述壹次。
Node的fiber被卸載,然後向下,p的fiber被卸載,p沒有child,找到它的sibling<Child>
,<Child>
的fiber被卸載,向下找到a,a的fiber被卸載。此時到了整個子樹的葉子節點,開始向上return。由a 到 <Child>
,再回到Node,遍歷卸載的過程結束。
在子樹的所有fiber節點都被卸載之後,才可以安全地將Node的DOM節點從父節點中移除。
Delation --> Node(ClassComponent)
|
|
span
| ↖
| ↖
P ——————> <Child>
|
a
Node是ClassComponent,它沒有對應的DOM節點,要先調用commitUnmount
卸載它自己,之後會先往下找,找到第壹個原生DOM類型的節點span,以它為起點遍歷子樹,確保每壹個fiber節點都被卸載,之後再將span從父節點中刪除。
div2(Container Of Node)
↗
div containerInfo
| ↗
| ↗
Delation --> Node(HostPortal)
|
|
span
| ↖
| ↖
P ——————> <Child>
|
a
Node是HostPortal,它沒有對應的DOM節點,因此刪除過程和ClassComponent基本壹致,不同的是刪除它下面第壹個子fiber的DOM節點時不是從這個被刪除的HostPortal類型節點的DOM層面的父節點中刪除,而是從HostPortal的containerInfo中移除,圖示上為div2,因為HostPortal會將子節點渲染到父組件以外的DOM節點。
以上是三種類型節點的刪除過程,這裏值得註意的是,unmountHostComponents
函數執行到遍歷子樹卸載每個節點的時候,壹旦遇到HostPortal類型的子節點,會再次調用unmountHostComponents
,以它為目標節點再進行它以及它子樹的卸載刪除操作,相當於壹個遞歸過程。
HostComponent 和 ClassComponent的刪除都調用了commitUnmount,除此之外還有FunctionComponent也會調用它。它的作用對三種組件是不同的:
function commitUnmount(
finishedRoot: FiberRoot,
current: Fiber,
renderPriorityLevel: ReactPriorityLevel,
): void {
onCommitUnmount(current);
switch (current.tag) {
case FunctionComponent:
case ForwardRef:
case MemoComponent:
case SimpleMemoComponent:
case Block: {
const updateQueue: FunctionComponentUpdateQueue | null = (current.updateQueue: any);
if (updateQueue !== null) {
const lastEffect = updateQueue.lastEffect;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const {destroy, tag} = effect;
if (destroy !== undefined) {
if ((tag & HookPassive) !== NoHookEffect) {
// 向useEffect的銷毀函數隊列裏push effect
enqueuePendingPassiveHookEffectUnmount(current, effect);
} else {
// 嘗試使用try...catch調用destroy
safelyCallDestroy(current, destroy);
...
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
return;
}
case ClassComponent: {
safelyDetachRef(current);
const instance = current.stateNode;
// 調用componentWillUnmount
if (typeof instance.componentWillUnmount === 'function') {
safelyCallComponentWillUnmount(current, instance);
}
return;
}
case HostComponent: {
// 卸載ref
safelyDetachRef(current);
return;
}
...
}
}
我們來復盤壹下刪除過程中的重點:
mutation在基於Fiber節點對DOM做其他操作之前,需要先刪除節點,保證留給後續操作的fiber節點都是有效的。刪除的目標是Fiber節點及其子樹和Fiber節點對應的DOM節點,整個軌跡循著fiber樹,對目標節點和所有子節點都進行卸載,對目標節點對應的(或之下的第壹個)DOM節點進行刪除。對於原生DOM類型的節點,直接從其父DOM節點刪除,對於HostPortal節點,它會把子節點渲染到外部的DOM節點,所以會從這個DOM節點中刪除。明確以上三個點再結合上述梳理的過程,就可以逐漸理清刪除操作的脈絡。