React和DOM的那些事-節(jié)點刪除算法
本篇是詳細解讀React DOM操作的第一篇文章,文章所講的內(nèi)容發(fā)生在commit階段。
Fiber架構(gòu)使得React需要維護兩類樹結(jié)構(gòu),一類是Fiber樹,另一類是DOM樹。當刪除DOM節(jié)點時,F(xiàn)iber樹也要同步變化。但請注意刪除操作執(zhí)行的時機:在完成DOM節(jié)點的其他變化(增、改)前,要先刪除fiber節(jié)點,避免其他操作被干擾。 這是因為進行其他DOM操作時需要循環(huán)fiber樹,此時如果有需要刪除的fiber節(jié)點卻還沒刪除的話,就會發(fā)生混亂。
- 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還有子節(jié)點,
- // 遞歸調(diào)用commitMutationEffects來處理
- if (fiber.child !== null) {
- const primarySubtreeTag = fiber.subtreeTag & MutationSubtreeTag;
- if (primarySubtreeTag !== NoSubtreeTag) {
- commitMutationEffects(fiber.child, root, renderPriorityLevel);
- }
- }
- if (__DEV__) {/*...*/} else {
- // 執(zhí)行其他DOM操作
- try {
- commitMutationEffectsImpl(fiber, root, renderPriorityLevel);
- } catch (error) {
- captureCommitPhaseError(fiber, error);
- }
- }
- fiberfiber = fiber.sibling;
- }
- }
fiber.deletions是render階段的diff過程檢測到fiber的子節(jié)點如果有需要被刪除的,就會被加到這里來。
commitDeletion函數(shù)是刪除節(jié)點的入口,它通過調(diào)用unmountHostComponents實現(xiàn)刪除。搞懂刪除操作之前,先看看場景。
有如下的Fiber樹,Node(Node是一個代號,并不指的某個具體節(jié)點)節(jié)點即將被刪除。
- Fiber樹
- div#root
- |
- <App/>
- |
- div
- |
- <Parent/>
- |
- Node
- | ↖
- | ↖
- P ——————> <Child>
- |
- a
通過這種場景可以推測出當刪除該節(jié)點時,它下面子樹中的所有節(jié)點都要被刪除。現(xiàn)在直接以這個場景為例,走一下刪除過程。這個過程實際上也就是unmountHostComponents函數(shù)的運行機制。
刪除過程
刪除Node節(jié)點需要父DOM節(jié)點的參與:
- parentInstance.removeChild(child)
所以首先要定位到父級節(jié)點。過程是在Fiber樹中,以Node的父節(jié)點為起點往上找,找到的第一個原生DOM節(jié)點即為父節(jié)點。在例子中,父節(jié)點就是div。此后以Node為起點,遍歷子樹,子樹也是fiber樹,因此遍歷是深度優(yōu)先遍歷,將每個子節(jié)點都刪除。
需要特別注意的一點是,對循環(huán)節(jié)點進行刪除,每個節(jié)點都會被刪除操作去處理,這里的每個節(jié)點是fiber節(jié)點而不是DOM節(jié)點。DOM節(jié)點的刪除時機是從Node開始遍歷進行刪除的時候,遇到了第一個原生DOM節(jié)點(HostComponent或HostText)這個時刻,在刪除了它子樹的所有fiber節(jié)點后,才會被刪除。
以上是完整過程的簡述,對于詳細過程要明確幾個關(guān)鍵函數(shù)的職責(zé)和調(diào)用關(guān)系才行。刪除fiber節(jié)點的是unmountHostComponents函數(shù),被刪除的節(jié)點稱為目標節(jié)點,它的職責(zé)為:
- 找到目標節(jié)點的DOM層面的父節(jié)點
- 判斷目標節(jié)點如果是原生DOM類型的節(jié)點,那么執(zhí)行3、4,否則先卸載自己之后再往下找到原生DOM類型的節(jié)點之后再執(zhí)行3、4
- 遍歷子樹執(zhí)行fiber節(jié)點的卸載
- 刪除目標節(jié)點的DOM節(jié)點
其中第3步的操作,是通過commitNestedUnmounts完成的,它的職責(zé)很單一也很明確,就是遍歷子樹卸載節(jié)點。
然后具體到每個節(jié)點的卸載過程,由commitUnmount完成。它的職責(zé)是
- Ref的卸載
- 類組件生命周期的調(diào)用
- HostPortal類型的fiber節(jié)點遞歸調(diào)用unmountHostComponents重復(fù)刪除過程
下面來看一下不同類型的組件它們的具體刪除過程是怎樣的。
區(qū)分被刪除組件的類別
Node節(jié)點的類型有多種可能性,我們以最典型的三種類型(HostComponent、ClassComponent、HostPortal)為例分別說明一下刪除過程。
首先執(zhí)行unmountHostComponents,會向上找到DOM層面的父節(jié)點,然后根據(jù)下面的三種組件類型分別處理,我們挨個來看。
HostComponent
Node 是HostComponent,調(diào)用commitNestedUnmounts,以Node為起點,遍歷子樹,開始對所有子Fiber進行卸載操作,遍歷的過程是深度優(yōu)先遍歷。
- Delation --> Node(span)
- | ↖
- | ↖
- P ——————> <Child>
- |
- a
對節(jié)點逐個執(zhí)行commitUnmount進行卸載,這個遍歷過程其實對于三種類型的節(jié)點,都是類似的,為了節(jié)省篇幅,這里只表述一次。
Node的fiber被卸載,然后向下,p的fiber被卸載,p沒有child,找到它的sibling<Child>,<Child>的fiber被卸載,向下找到a,a的fiber被卸載。此時到了整個子樹的葉子節(jié)點,開始向上return。由a 到 <Child>,再回到Node,遍歷卸載的過程結(jié)束。
在子樹的所有fiber節(jié)點都被卸載之后,才可以安全地將Node的DOM節(jié)點從父節(jié)點中移除。
ClassComponent
- Delation --> Node(ClassComponent)
- |
- |
- span
- | ↖
- | ↖
- P ——————> <Child>
- |
- a
Node是ClassComponent,它沒有對應(yīng)的DOM節(jié)點,要先調(diào)用commitUnmount卸載它自己,之后會先往下找,找到第一個原生DOM類型的節(jié)點span,以它為起點遍歷子樹,確保每一個fiber節(jié)點都被卸載,之后再將span從父節(jié)點中刪除。
HostPortal
- div2(Container Of Node)
- ↗
- div containerInfo
- | ↗
- | ↗
- Node(HostPortal)
- |
- |
- span
- | ↖
- | ↖
- P ——————> <Child>
- |
- a
Node是HostPortal,它沒有對應(yīng)的DOM節(jié)點,因此刪除過程和ClassComponent基本一致,不同的是刪除它下面第一個子fiber的DOM節(jié)點時不是從這個被刪除的HostPortal類型節(jié)點的DOM層面的父節(jié)點中刪除,而是從HostPortal的containerInfo中移除,圖示上為div2,因為HostPortal會將子節(jié)點渲染到父組件以外的DOM節(jié)點。
以上是三種類型節(jié)點的刪除過程,這里值得注意的是,unmountHostComponents函數(shù)執(zhí)行到遍歷子樹卸載每個節(jié)點的時候,一旦遇到HostPortal類型的子節(jié)點,會再次調(diào)用unmountHostComponents,以它為目標節(jié)點再進行它以及它子樹的卸載刪除操作,相當于一個遞歸過程。
commitUnmount
HostComponent 和 ClassComponent的刪除都調(diào)用了commitUnmount,除此之外還有FunctionComponent也會調(diào)用它。它的作用對三種組件是不同的:
- FunctionComponent 函數(shù)組件中一旦調(diào)用了useEffect,那么它卸載的時候要去調(diào)用useEffect的銷毀函數(shù)。(useLayoutEffect的銷毀函數(shù)是調(diào)用commitHookEffectListUnmount執(zhí)行的)
- ClassComponent 類組件要調(diào)用componentWillUnmount
- HostComponent 要卸載ref
- 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的銷毀函數(shù)隊列里push effect
- enqueuePendingPassiveHookEffectUnmount(current, effect);
- } else {
- // 嘗試使用try...catch調(diào)用destroy
- safelyCallDestroy(current, destroy);
- ...
- }
- }
- effecteffect = effect.next;
- } while (effect !== firstEffect);
- }
- }
- return;
- }
- case ClassComponent: {
- safelyDetachRef(current);
- const instance = current.stateNode;
- // 調(diào)用componentWillUnmount
- if (typeof instance.componentWillUnmount === 'function') {
- safelyCallComponentWillUnmount(current, instance);
- }
- return;
- }
- case HostComponent: {
- // 卸載ref
- safelyDetachRef(current);
- return;
- }
- ...
- }
- }
總結(jié)
我們來復(fù)盤一下刪除過程中的重點:
- 刪除操作執(zhí)行的時機
- 刪除的目標是誰
- 從哪里刪除
mutation在基于Fiber節(jié)點對DOM做其他操作之前,需要先刪除節(jié)點,保證留給后續(xù)操作的fiber節(jié)點都是有效的。刪除的目標是Fiber節(jié)點及其子樹和Fiber節(jié)點對應(yīng)的DOM節(jié)點,整個軌跡循著fiber樹,對目標節(jié)點和所有子節(jié)點都進行卸載,對目標節(jié)點對應(yīng)的(或之下的第一個)DOM節(jié)點進行刪除。對于原生DOM類型的節(jié)點,直接從其父DOM節(jié)點刪除,對于HostPortal節(jié)點,它會把子節(jié)點渲染到外部的DOM節(jié)點,所以會從這個DOM節(jié)點中刪除。明確以上三個點再結(jié)合上述梳理的過程,就可以逐漸理清刪除操作的脈絡(luò)。