深入理解 React 中 state 和 props 的更新過程

語言: CN / TW / HK

好的文章就像 90 年代的港片讓人回味無窮。這篇文章雖然寫於 18 年,現在看來對理解 React Fiber 的工作流程依然有很大的幫助。有些 API 在最新版本的 React 中已經被廢棄,但絲毫不影響整體流程的理解。關注react原始碼系列一起踏踏實實學習react原始碼呀

深入理解 React 中 state 和 props 的更新

本文使用具有父元件和子元件的簡單案例來演示 Fiber 架構中 React 將 props 傳播到子元件的內部流程。

在我之前的文章 Fiber 內部:React 中新的協調演算法的深入概述中,我奠定了理解本文介紹的更新過程的技術細節所需要的基礎知識。

我已經概述了我將在本文中使用的主要資料結構和概念,特別是 Fiber 節點、current tree 和 workInProgress tree、副作用和副作用列表。我還高度概述了主要的演算法,並解釋了 render 和 commit 階段之間的區別。如果你還沒有讀過,我建議你從上一篇文章開始。

我還介紹了示例應用程式,該應用程式帶有一個按鈕,點選按鈕簡單地遞增螢幕上呈現的數字:

reconciler-04.gif

這是一個簡單的元件,render 方法返回 button 和 span 兩個子元素。單擊按鈕時,元件的狀態就會更新。這會導致 span 元素的文字更新:

```jsx class ClickCounter extends React.Component { constructor(props) { super(props); this.state = { count: 0 }; this.handleClick = this.handleClick.bind(this); }

handleClick() { this.setState((state) => { return { count: state.count + 1 }; }); }

componentDidUpdate() {}

render() { return [ , {this.state.count}, ]; } } ```

在這裡,我給元件添加了 componentDidUpdate 生命週期方法。這是為了演示 React 在 commit 階段是怎樣新增副作用並呼叫 componentDidUpdate 方法。

在本文中,我將介紹 React 如何處理狀態更新並構建副作用列表。我們將瞭解 render 和 commit 階段的主要函式都做了什麼事情。

特別是,我們將在 completeWork函式中看到,React 進行:

  • 更新 ClickCounter 元件中的 state.count 屬性
  • 呼叫 render 方法獲取子元素列表並進行比較
  • 更新 span 元素的 props 屬性

同時,在 commitRoot 函式中,React 會:

  • 更新 span 元素的 textContent 屬性
  • 呼叫 componentDidUpdate 生命週期方法

但在此之前,讓我們快速看一下在 click 事件中呼叫 setState 時,React 是如何排程的。

請注意,你無需瞭解任何內容即可使用 React。這篇文章是關於 React 工作原理的。

排程更新(Scheduling updates)

當我們點選按鈕時,click 事件被觸發,React 執行我們在按鈕中繫結的回撥。在我們的應用程式中,它只是增加計數器並更新狀態:

jsx class ClickCounter extends React.Component { ... handleClick() { this.setState((state) => { return {count: state.count + 1}; }); } }

每個 React 元件都有一個關聯的 updater,它充當元件和 React 核心之間的橋樑。這允許 ReactDOM、React Native、伺服器端渲染和測試實用程式以不同方式實現 setState。

在本文中,我們將探討 ReactDOM 中 updater 物件的實現,它使用 Fiber reconciler。對於 ClickCounter 元件,它是一個 classComponentUpdater。 它負責檢索 Fiber 例項、將更新新增到佇列中以及排程。

當新增更新時,它們只是簡單的新增到更新佇列中以便在 Fiber 節點上處理。在我們的例子中,ClickCounter 元件對應的 Fiber 節點 的結構如下:

```jsx { stateNode: new ClickCounter, type: ClickCounter, updateQueue: { baseState: {count: 0} firstUpdate: { next: { payload: (state) => { return {count: state.count + 1} } } }, ... }, ... }

```

可以看到,updateQueue.firstUpdate.next.payload 裡面的函式就是我們在 ClickCounter 元件中傳遞給 setState 的回撥。它代表了 render 階段中需要處理的第一個更新

處理 ClickCounter Fiber 節點的更新(Processing updates for the ClickCounter Fiber node)

我之前的文章中關於工作迴圈的章節解釋了全域性變數 nextUnitOfWork 的作用。特別是,它說明了這個變數儲存的是 workInProgress 樹中需要處理的 fiber 節點的引用。當 React 遍歷 Fibers 樹時,它使用這個變數來了解是否有尚未完成工作的 fiber 節點。

假設我們已經呼叫了 setState 方法。React 將 setState 中的回撥新增到 ClickCounter Fiber 節點的 updateQueue 中並開始排程。React 進入 render 階段。它在 renderRoot 函式裡面從最頂層的 HostRoot Fiber 節點開始遍歷。但是,它會退出(跳過)已處理的 Fiber 節點,直到找到未完成工作的節點。此時只有一個 Fiber 節點需要處理。它是 ClickCounter Fiber 節點。

所有工作都在這個 Fiber 節點的克隆副本上執行,(副本)儲存在 Fiber 節點的 alternate 欄位中。如果尚未建立 alternate 節點,那麼在處理更新前,React 會在函式 createWorkInProgress 中建立副本。讓我們假設變數 nextUnitOfWork 指向 ClickCounter Fiber 節點的 alternate 節點。

開始工作(beginWork)

我們的 Fiber 節點首先經過 beginWork 函式處理。

因為 Fiber 樹中每個 Fiber 節點都會經過 beginWork 函式處理,所以如果你想除錯 render 階段,這是一個打斷點的好地方。我經常這樣做並根據 Fiber 節點的 type 新增條件斷點

beginWork 函式就是一個大 switch 語句,它通過 tag 確定一個 Fiber 節點需要完成的工作型別,然後執行相應的函式來執行工作。在本例中, CountClicks 是一個類元件,因此採用此分支:

jsx function beginWork(current$$1, workInProgress, ...) { ... switch (workInProgress.tag) { ... case FunctionalComponent: {...} case ClassComponent: { ... return updateClassComponent(current$$1, workInProgress, ...); } case HostComponent: {...} case ... }

我們進入 updateClassComponent 函式。根據元件是第一次渲染還是更新,React 會建立一個例項或者掛載元件並更新

jsx function updateClassComponent(current, workInProgress, Component, ...) { ... const instance = workInProgress.stateNode; let shouldUpdate; if (instance === null) { ... // In the initial pass we might need to construct the instance. constructClassInstance(workInProgress, Component, ...); mountClassInstance(workInProgress, Component, ...); shouldUpdate = true; } else if (current === null) { // In a resume, we'll already have an instance we can reuse. shouldUpdate = resumeMountClassInstance(workInProgress, Component, ...); } else { shouldUpdate = updateClassInstance(current, workInProgress, ...); } return finishClassComponent(current, workInProgress, Component, shouldUpdate, ...); }

處理 ClickCounter Fiber 的更新(Processing updates for the ClickCounter Fiber)

我們已經有了 ClickCounter 元件的例項,所以我們進入 updateClassInstance這是 React 為類元件執行大部分工作的地方。以下是函式中按執行順序執行的最重要的操作:

  • 呼叫 UNSAFE_componentWillReceiveProps()鉤子(已棄用)
  • 處理 updateQueue 中的更新並生成新狀態
  • 使用新狀態呼叫 getDerivedStateFromProps 並得到結果
  • 呼叫 shouldComponentUpdate 判斷元件是否需要更新:
  • 如果是 false,跳過整個渲染過程,不再繼續呼叫這個元件及其子元件的 render 方法
  • 呼叫 UNSAFE_componentWillUpdate(已棄用)
  • 新增一個 effect 以便後續觸發 componentDidUpdate 生命週期鉤子

    雖然在 render 階段添加了觸發 componentDidUpdate 呼叫的 effect,但 componentDidUpdate 方法在接下來的 commit 階段才會被執行

  • 更新元件例項上的 state 和 props

元件例項上的 state 和 props 必須在呼叫 render 方法前更新。因為 render 方法的輸出依賴於 state 和 props。如果我們不這樣做,它將每次返回相同的結果。

這是該函式的簡化版本:

```jsx function updateClassInstance(current, workInProgress, ctor, newProps, ...) { const instance = workInProgress.stateNode;

const oldProps = workInProgress.memoizedProps;
instance.props = oldProps;
if (oldProps !== newProps) {
    callComponentWillReceiveProps(workInProgress, instance, newProps, ...);
}

let updateQueue = workInProgress.updateQueue;
if (updateQueue !== null) {
    processUpdateQueue(workInProgress, updateQueue, ...);
    newState = workInProgress.memoizedState;
}

applyDerivedStateFromProps(workInProgress, ...);
newState = workInProgress.memoizedState;

const shouldUpdate = checkShouldComponentUpdate(workInProgress, ctor, ...);
if (shouldUpdate) {
    instance.componentWillUpdate(newProps, newState, nextContext);
    workInProgress.effectTag |= Update;
    workInProgress.effectTag |= Snapshot;
}

instance.props = newProps;
instance.state = newState;

return shouldUpdate;

} ```

我在上面的程式碼片段中刪除了一些輔助程式碼。例如,在呼叫生命週期方法或者新增觸發生命週期方法執行的 effect 之前,React 會使用 typeof 操作符檢查元件是否實現了對應的生命週期方法。例如,在新增 effect 之前,React 會檢查元件例項是否存在 componentDidUpdate 方法。

jsx if (typeof instance.componentDidUpdate === "function") { workInProgress.effectTag |= Update; }

好的,現在我們知道在 render 階段 ClickCounter Fiber 節點都執行了哪些操作。現在讓我們看看這些操作如何改變 Fiber 節點上的值。當 React 開始工作時,ClickCounter 元件的 Fiber 節點看起來像這樣:

jsx { effectTag: 0, elementType: class ClickCounter, firstEffect: null, memoizedState: {count: 0}, type: class ClickCounter, stateNode: { state: {count: 0} }, updateQueue: { baseState: {count: 0}, firstUpdate: { next: { payload: (state, props) => {…} } }, ... } }

工作完成後,我們最終得到一個如下所示的 Fiber 節點:

jsx { effectTag: 4, elementType: class ClickCounter, firstEffect: null, memoizedState: {count: 1}, type: class ClickCounter, stateNode: { state: {count: 1} }, updateQueue: { baseState: {count: 1}, firstUpdate: null, ... } }

花點時間觀察屬性值的差異。

更新完成後,fiber.memoizedState 以及 fiber.updateQueue.baseState 中的 count 屬性都變成了 1。React 還更新了 ClickCounter 元件例項中的 state。

此時,佇列中不再有更新,因此 firstUpdate 被設定成 null。重要的是,我們的 effectTag 屬性發生了變化。它不再是 0,它變成了 4。在二進位制中就是 100,這意味著第三位設定成了 1,這正是 Update 副作用標籤的位:

jsx export const Update = 0b00000000100;

總而言之,當在 ClickCounter Fiber (父)節點上工作時,React 會呼叫 pre-mutation 生命週期方法,更新狀態並定義相關的副作用。

協調 ClickCounter Fiber 的子元素(Reconciling children for the ClickCounter Fiber)

一旦完成,React 就會進入 finishClassComponent函式。這是 React 呼叫元件例項上的 render 方法並將 dom diff 演算法應用於元件返回的子元素的地方。文件中有高度概括。這是相關部分:

當比較兩個相同型別的 React DOM 元素時,React 會檢視兩者的屬性,複用相同的底層 DOM 節點,並且只更新變化的屬性。

然而,如果我們深入挖掘,我們可以瞭解到它實際上是將 Fiber 節點與 React element 進行了比較。但我現在不會詳細介紹,因為這個過程非常複雜。我將寫一篇單獨的文章,重點介紹子元素協調的過程。

如果你急於瞭解詳細資訊,請檢視 reconcileChildrenArray 函式,因為在我們的應用程式中,render 方法返回的是一個 React element 陣列。

在這一點上,有兩件重要的事需要理解。首先,當 React 處理子元素協調過程時,它會為 render 方法返回的子 React 元素建立或更新 Fiber 節點。finishClassComponent 函式返回當前 Fiber 節點的第一個子節點的引用。它將分配給 nextUnitOfWork 並在稍後的工作迴圈中處理。其次,React 將更新子元素的 props 作為父元件工作的一部分(即子元素的 props 更新是在父元件中完成的)。為此,它使用 render 方法返回的 React 元素中的資料。

例如,在 React 開始協調 ClickCounter Fiber 的子元素前,span 元素對應的 Fiber 節點如下所示:

jsx { stateNode: new HTMLSpanElement, type: "span", key: "2", memoizedProps: {children: 0}, pendingProps: {children: 0}, ... }

正如你所看到的,memoizedProps 以及 pendingProps 中的 children 屬性都是 0。下面是 render 方法返回的 span 元素的結構:

jsx { $$typeof: Symbol(react.element); key: "2"; props: { children: 1; } ref: null; type: "span"; }

如你所見,Fiber 節點和返回的 React element 之間的 props 存在差異。 createWorkInProgress函式用於建立 alternate Fiber 節點,在函式內部 React 會將更新後的屬性從 React 元素複製到 Fiber 節點。

因此,在 React 完成 ClickCounter 元件的子元素協調之後,span 的 Fiber 節點的 pendingProps 屬性更新完成。它們與 span element 的值匹配。

jsx { stateNode: new HTMLSpanElement, type: "span", key: "2", memoizedProps: {children: 0}, pendingProps: {children: 1}, ... }

稍後,當 React 為 span Fiber 節點執行工作時,它會將 pendingProps 複製到 memoizedProps 並新增 effects 以更新 DOM。

好吧,這就是 React 在 render 階段為 ClickCounter Fiber 節點執行的所有工作。由於按鈕是 ClickCounter 元件的第一個子節點,它將被分配給 nextUnitOfWork 變數。(按鈕節點)沒有什麼可做的,所以 React 將移動到它的兄弟節點,即 span Fiber 節點。根據這裡描述的演算法,它發生在 completeUnitOfWork 函式中

處理 Span fiber 的更新(Processing updates for the Span fiber)

變數 nextUnitOfWork 現在指向 span fiber 的備用(alternate)節點,React 開始處理它。與為 ClickCounter 執行的步驟類似,我們從 beginWork 函式開始。

由於我們的 span 節點是 HostComponent 型別的,所以這次在 switch 語句中 React 採用了這個分支:

jsx function beginWork(current$$1, workInProgress, ...) { ... switch (workInProgress.tag) { case FunctionalComponent: {...} case ClassComponent: {...} case HostComponent: return updateHostComponent(current, workInProgress, ...); case ... }

並在 updateHostComponent 函式中結束。同時,你還可以看到為 ClassComponent 呼叫的 updateClassComponent 函式。對於 FunctionalComponent,它將是 updateFunctionComponent 等等。你可以在ReactFiberBeginWork.js 檔案中找到所有這些函式

協調 span fiber 的子元素(Reconciling children for the span fiber)

在我們的例子中,updateHostComponent 函式並沒有對 span 節點做任何重要的事情。因此可以簡單略過

完成 Span Fiber 節點的工作(Completing work for the Span Fiber node)

beginWork 完成後,節點進入 completeWork 函式。但在此之前,React 需要更新 span fiber 上的 memoizedProps 屬性。你可能還記得在為 ClickCounter 元件協調子元素時,React 更新了 span fiber 節點上的 pendingProps 屬性

jsx { stateNode: new HTMLSpanElement, type: "span", key: "2", memoizedProps: {children: 0}, pendingProps: {children: 1}, ... }

因此,一旦 span fiber 節點的 beginWork 完成了,React 就會更新 memoizedProps:

jsx function performUnitOfWork(workInProgress) { ... next = beginWork(current$$1, workInProgress, nextRenderExpirationTime); workInProgress.memoizedProps = workInProgress.pendingProps; ... }

然後它呼叫 completeWork 函式,和 beginWork 函式一樣,completeWork 函式也只是一個大的 switch 語句:

jsx function completeWork(current, workInProgress, ...) { ... switch (workInProgress.tag) { case FunctionComponent: {...} case ClassComponent: {...} case HostComponent: { ... updateHostComponent(current, workInProgress, ...); } case ... } }

由於我們的 span Fiber 節點是 HostComponent,它呼叫updateHostComponent 函式。在這個函式中,React 基本上做了以下事情:

  • 準備 DOM 更新
  • 將它們新增到 span fiber 的 updateQueue 中。
  • 新增更新 DOM 的 effect

在執行這些操作之前,span fiber 節點如下所示:

jsx { stateNode: new HTMLSpanElement, type: "span", effectTag: 0 updateQueue: null ... }

當工作完成後,它看起來像這樣:

jsx { stateNode: new HTMLSpanElement, type: "span", effectTag: 4, updateQueue: ["children", "1"], ... }

注意 effectTag 和 updateQueue 欄位的差異。effectTag 從 0 變成 4。在二進位制中這是 100,這意味著第三位設定成了 1,這正是 update 副作用對應的 tag 型別。這是 React 在接下來的 commit 階段需要為這個節點做的唯一工作。updateQueue 欄位儲存了將用於更新的資料(payload)。

一旦 React 處理完成 ClickCounter 及其子元素,render 階段就完成了。它現在可以將完成的 alternate 樹分配給 FiberRoot 的 finishedWork 屬性。這是需要重新整理到螢幕上的新樹。它可以在 render 階段之後立即處理,也可以在瀏覽器空閒時間處理。

副作用列表(Effects list)

在我們的例子中,由於 span 節點和 ClickCounter 元件都有副作用,React 會將 span fiber 節點的連結新增到 HostFiber 的 firstEffect 屬性.

React 在 completeUnitOfWork 函式中構建副作用列表。這是具有更新 span 節點文字和呼叫 ClickCounter 鉤子 副作用的 Fiber 樹的樣子:

update-01.png

這是具有副作用的節點的線性列表:

update-02.png

提交階段(Commit phase)

這個階段從completeRoot 函式開始。在它開始做任何工作之前,它將 FiberRoot 的 finishedWork 屬性重置為 null:

jsx root.finishedWork = null;

與 render 階段不同,commit 階段始終是同步的,因此它可以安全地更新 HostRoot 以指示 commit 工作已經開始。

在 commit 階段, React 更新 DOM 並呼叫 post mutation 生命週期方法,如 componentDidUpdate。為此,它會遍歷在 render 階段構建的副作用列表並應用它們。

我們在 render 階段中為我們的 span 和 ClickCounter 節點定義了以下 effects:

jsx { type: ClickCounter, effectTag: 5 } { type: 'span', effectTag: 4 }

ClickCounter 的 effect tag 是 5 或 二進位制的 101,這意味著需要呼叫 componentDidUpdate 方法。最低有效位也設定為表示該 Fiber 節點在 render 階段的所有工作都已完成。

span 的 effect tag 是 4 或 二進位制的 100,定義了需要更新 host component 的 dom 節點的更新工作。對於 span 元素,React 需要更新元素的 textContent 屬性。

應用效果(Applying effects)

讓我們看看 React 如何應用這些 effects。用於應用 effects 的commitRoot函式由 3 個子函式組成:

jsx function commitRoot(root, finishedWork) { commitBeforeMutationLifecycles(); commitAllHostEffects(); root.current = finishedWork; commitAllLifeCycles(); }

這些子函式中的每一個都實現了一個迴圈,這些迴圈遍歷副作用列表並檢查 effect 的型別。當它找到與函式功能相關的 effect 時,它會應用它。在我們的例子中,它將呼叫 ClickCounter 元件的 componentDidUpdate 生命週期方法並更新 span 元素的文字。

第一個函式 commitBeforeMutationLifeCycles 查詢 Snapshot effect 並呼叫 getSnapshotBeforeUpdate 方法。但是,由於我們沒有在 ClickCounter 元件上實現這個方法,所以 React 沒有在 render 階段新增對應的 Snapshot effect。所以在我們的例子中,這個函式什麼都不做。

DOM 更新(DOM updates)

下一步,React 執行commitAllHostEffects 函式。這裡 React 將 span 元素的文字從 0 更改為 1。由於類元件對應的節點沒有任何 DOM 更新,因此這裡不需要處理 ClickCounter fiber。

這個函式的目的是選擇正確的 effect 型別並執行相應的操作。在我們的例子中,我們需要更新 span 元素上的文字,所以我們在這裡使用 Update 分支:

jsx function updateHostEffects() { switch (primaryEffectTag) { case Placement: {...} case PlacementAndUpdate: {...} case Update: { var current = nextEffect.alternate; commitWork(current, nextEffect); break; } case Deletion: {...} } }

繼續往下執行 commitWork 函式,我們最終進入updateDOMProperties 函式。它使用 render 階段新增的 updateQueue 資料更新 span 元素的 textContent 屬性。

jsx function updateDOMProperties(domElement, updatePayload, ...) { for (let i = 0; i < updatePayload.length; i += 2) { const propKey = updatePayload[i]; const propValue = updatePayload[i + 1]; if (propKey === STYLE) { ...} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {...} else if (propKey === CHILDREN) { setTextContent(domElement, propValue); } else {...} } }

在應用 DOM 更新後,React 將 finishedWork 樹分配給 HostRoot。它將 alternate 樹設定為 current 樹:

jsx root.current = finishedWork;

呼叫 post mutation 生命週期鉤子(Calling post mutation lifecycle hooks)

最後剩下的函式是 commitAllLifecycles。這裡 React 呼叫 post mutational 生命週期方法。在 render 階段,React 將 Update effect 新增到 ClickCounter 元件中。這是 commitAllLifecycles 函式尋找並呼叫 componentDidUpdate 方法的效果之一:

```jsx function commitAllLifeCycles(finishedRoot, ...) { while (nextEffect !== null) { const effectTag = nextEffect.effectTag;

    if (effectTag & (Update | Callback)) {
        const current = nextEffect.alternate;
        commitLifeCycles(finishedRoot, current, nextEffect, ...);
    }

    if (effectTag & Ref) {
        commitAttachRef(nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
}

} ```

該函式還會更新 refs,但由於我們沒有任何此功能,因此不會使用。componentDidUpdate 方法在 commitLifeCycles 函式中被呼叫:

jsx function commitLifeCycles(finishedRoot, current, ...) { ... switch (finishedWork.tag) { case FunctionComponent: {...} case ClassComponent: { const instance = finishedWork.stateNode; if (finishedWork.effectTag & Update) { if (current === null) { instance.componentDidMount(); } else { ... instance.componentDidUpdate(prevProps, prevState, ...); } } } case HostComponent: {...} case ... }

你還可以看到,這是 React 為第一次渲染的元件呼叫 componentDidMount 方法的地方

原文連結