博客 / 詳情

返回

400行mini-react,圖文解説React原理

引言:
在我學習React原理的時候,一上來看的非常全而細節的書/博客(頭大),或者是看的教你實現一個簡單mini-react(還是一知半解),最終學的痛苦又效果不好。所以,寫了這篇博客,希望能幫助您入門React原理。此外,在我看來,這篇文章幫助你應付面試完全足夠了。

説明:

  1. 本文章主要圍繞Zachary Lee的 400行實現mini-react 項目進行分析,結合圖文詳細分析,帶你弄懂React原理。
  2. 這個項目的一些API命名會和React有些出入,我會盡量對齊這些命名概念,同時本項目為了減少代碼量會弱化很多細節,我根據實際React的實現做補充。
  3. 本文很多圖都出自7kms大佬的 圖解React,對理解React非常有幫助,強烈推薦大家學習。(P.S. 7kms大佬是基於React17進行分析的,有些地方(比如EffectList)和最新的React 18/19是有出入的,所以另外再推薦一本書:卡頌的《React設計原理》)

通過本文你能收穫什麼:

  1. 理解並實現Zachary Lee的mini-react。
  2. 更深刻理解React的原理,包括渲染流程、Diff算法、bailout策略和hooks等。

1、React基本概念

a.常用對象:ReactElement,FiberNode,虛擬DOM

1.JSX編譯:

我們都知道React支持JSX語法,類似html標籤的寫法,如下:

<div id="root">
  <div>
      <h1>hello</h1>
      world
  </div>
</div>

那麼實際上它會被轉換為JS代碼來創建ReactElement,每一個標籤和文本都可以視為ReactElement

當我們import React from 'react'時就引入了React.createElementReact.render這些API,然後代碼會被babel編譯如下:

React.render(React.createElement('div', {}, 
    React.createElement('h1', {}, 'hello'), 
    'world'), 
    document.getElementById('root'));

P.S.為什麼早期react項目要引入import React from 'react'這句話,就是因為編譯後需要引入React.createElement

2.介紹ReactElement:

通過React.createElement創建出來的一個普通JS對象就是ReactElement類型(在本項目代碼中使用的VirtualElement,可認為是同一個東西)

// Class Component組件和Function組件組合定義
interface ComponentFunction {
  new (props: Record<string, unknown>): Component; //能new出Component實例
  (props: Record<string, unknown>): VirtualElement | string; //直接調用返回虛擬DOM VirtualElement
}
type VirtualElementType = ComponentFunction | string;

interface VirtualElementProps {
  children?: VirtualElement[];
  [propName: string]: unknown;
}
interface VirtualElement {
  type: VirtualElementType;
  props: VirtualElementProps;
}

// 判斷是否是VirtualElement(即ReactElement)
const isVirtualElement = (e: unknown): e is VirtualElement =>
  typeof e === 'object';

// Text elements require special handling.
const createTextElement = (text: string): VirtualElement => ({
  type: 'TEXT',
  props: {
    nodeValue: text,
  },
});

// 創建一個VirtualElement(即ReactElement)
const createElement = (
  type: VirtualElementType,
  props: Record<string, unknown> = {},
  ...child: (unknown | VirtualElement)[]
): VirtualElement => {
  const children = child.map((c) =>
    isVirtualElement(c) ? c : createTextElement(String(c)),
  );

  return {
    type,
    props: {
      ...props,
      children,
    },
  };
};

// Component組件定義 
//(class MyComp extends Component, 自定義class組件都要繼承這個Component)
abstract class Component {
  props: Record<string, unknown>;
  abstract state: unknown;
  abstract setState: (value: unknown) => void;
  abstract render: () => VirtualElement;

  constructor(props: Record<string, unknown>) {
    this.props = props;
  }

  // Identify Component.
  static REACT_COMPONENT = true;
}

簡單來看,VirtualElementReactElement)他包含了

  • type:實際React中type指ClassComponent/FunctionComponent/HostComponent(div/span/a這些原生標籤)/HostText/HostRoot(FiberTree根節點)等,本項目代碼的type做了簡化,並把ClassComponentFunctionComponent定義在一起了成ComponentFunction,然後按REACT_COMPONENT來區分。
  • props:就是使用React時傳入的props,包括children.

P.S.下文我提到ReactElement,你可以認為就是VirtualElement

3.介紹FiberNode:

// 真實DOM
type FiberNodeDOM = Element | Text | null | undefined;

// 定義FiberNode(Fiber節點)
interface FiberNode<S = any> extends VirtualElement {
  alternate: FiberNode<S> | null;  //指向當前Fiber節點的舊版本,用於Diff算法比較
  dom?: FiberNodeDOM; //指向真實DOM節點
  effectTag?: string; //用於標記Fiber節點的副作用,如添加、刪除、更新等,實際react中是flags
  child?: FiberNode; //指向第一個孩子Fiber節點
  return?: FiberNode; //指向父Fiber節點
  sibling?: FiberNode; //指向兄弟Fiber節點
  hooks?: {  //hooks數組(實際React是hooks鏈表)
    state: S;
    queue: S[];
  }[];
}
  • 一定程度上你可以認為ReactElement是虛擬DOM, 也可以認為FiberNode是虛擬DOM。FiberNode是在ReactElement基礎上進一步封裝,補充描述了狀態、dom、副作用標記、節點關係等等。
  • alternate:我們在下面的雙緩存-離屏FiberTree的概念中進一步説明作用,它指向對應的old FiberNode。
  • effectTag:(實際React是flags)在下面的「渲染流程」會進一步分析,它標記了這個Fiber是否存在副作用要執行。
  • dom:真實DOM。(實際React的FiberNode上有stateNode屬性 👈 與該 Fiber 對應的“實例”或 DOM 節點,本項目代碼這裏簡單用dom替代了。)
  • hooks:用來表示組件的hooks狀態。(實際React中Fiber用 memoizedState 屬性表示,這個屬性用來是用來保存組件的局部狀態的。memoizedState對於FunctionComponent來説是一個hooks鏈表,對於ClassComponent則是普通對象{}

下表展示了stateNode對應的內容:

Fiber tag stateNode 內容 示例
HostComponent 對應的 DOM 節點 ` → stateNode 指向 HTMLDivElement`
ClassComponent 對應的 類組件實例 new MyComponent()
FunctionComponent null 因為函數組件沒有實例
HostRoot 對應的 root 容器實例 ReactRoot(如 ReactDOM.createRoot(container)
HostText 對應的 文本節點 TextNode

下圖展示了ReactElement和FiberTree分別在內存中的樣子:
(圖片來自圖解React)
image.png

b.雙緩存-離屏FiberTree

(圖片來自圖解React)

  1. React中會同時存在兩棵FiberTree,如圖,中間的是內存中的FiberTree,也叫離屏FiberTree,是通過workInProgress指針(簡稱wip)進行移動來構建的;右邊的是頁面FiberTree,代表實際頁面(表示不會再發生變化,對應實際頁面DOM結構)。
  2. 為什麼要兩棵呢?頁面FiberTree代表舊Fibers, 離屏FiberTree代表新Fibers,需要根據ReactElement結構來創建新Fibers。創建過程中需要比較(Diff)新舊FiberTree進行「打標籤」來表示需要做哪些dom更新操作。 當我遍歷離屏FiberTree時,通過alternate指針找到舊Fiber,然後對它們的孩子節點進行Diff。
  3. FiberRoot是React應用的輔助節點,用來管理FiberTree。它的current指針指向的那個FiberTree代表頁面FiberTree。==當內存中的FiberTree構建完成後,FiberRoot.current切換到內存的FiberTree,表示新舊頁面切換,完成更新==。
  4. HostRootFiber就是FiberTree的根。掛載/更新都是從HostRootFiber開始DFS的。

c.宏觀理解React 運行原理

(圖片來自圖解React)

1.這個workLoop就是一個函數,會被反覆執行的一個函數,在React渲染流程中「render階段」和「commit階段」會做不同的事情。

2.當一次渲染任務開始(由renderRootSyncrenderRootConcurrent觸發):

  • render階段,從上到下DFS,遞歸時調用beginWork函數,回溯時調用completeWork。這階段核心工作是創建Fiber節點和打副作用標籤。(副作用的簡單理解:修改實際DOM就是副作用)
  • commit階段,從上到下DFS,根據Fiber上的副作用標籤(flags)和父Fiber上的deletions標記進行實際DOM操作:新增/移動、修改和刪除。

3.如何讓workLoop反覆不斷執行呢?,本項目代碼使用了requestIdleCallback(React很早期也是這個)來調用workLoop,當瀏覽器空閒時,就會分配一個時間片給workLoop執行。

4.當requestIdleCallback存在下面的問題:

  • 不可預測、觸發頻率太低。頁面在保持高幀率的時候(如動畫、滾動)時,瀏覽器幾乎沒有空閒時間,導致 requestIdleCallback 回調遲遲不能執行。
  • 優先級機制太弱。只提供了一個 “空閒時執行” 的概念,沒有優先級控制。
  • 這個API的兼容性

由於上述原因,React 自己實現了任務調度的算法(Scheduler):

  • MessageChannel(微任務方式,能精確控制調用時機);
  • setTimeout(作為後備機制);
  • 可控的時間切片(每個任務執行 5ms 左右後中斷);
  • 多級優先級(Immediate、UserBlocking、Normal、Low、Idle);
workLoop

當引入min-react時( import React from './mini-react';),就開始workLoop了,就開始工作循環了,按調度不斷執行workLoop這個函數

void (function main() { 
  window.requestIdleCallback(workLoop);
})();

workLoop內,通過deadline.timeRemaining()判斷剩餘時間來決定是否繼續執行「任務單元」。 一個任務單元(unitOfWork)是一個執行時間比較短的JS任務,一次requestIdleCallback給的空閒時間內一般能執行多個「任務單元」,即使超過時間了也影響不大,因為單個「任務單元」很短。這就是時間分片。

const workLoop: IdleRequestCallback = (deadline) => {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  if (!nextUnitOfWork && wipRoot) {
    commitRoot();
  }

  window.requestIdleCallback(workLoop);
};

2、performUnitOfWork對應 「render階段」

「render階段」會從離屏FiberTree的根節點(HostRootFiber)開始向下DFS,當然這種DFS是基於迭代方式的(而不是遞歸),這樣才能做到前面提到的時間分配,一個個執行短的「任務單元」。

這個階段的主要任務就是Diff比較和對Fiber打flags標籤。每次執行performUnitOfWork(wip:FiberNode)就是處理一個Fiber節點(這裏的wip指 離屏FiberTree中的工作指針workInProgress,代表當前要處理的Fiber)一開始wip指向HostRootFiber

在實際React中,「render」階段的流程可以分成兩種流程來看,一種是初次渲染流程,另一種是更新渲染流程

a.初次渲染流程

如下面這組圖片所示,此時頁面FiberTree是空的,會根據一步步根據render函數產生的ReactElement來構建離屏FiberTree
(下圖1,2,3,4 來自 圖解React)

image.png
image.png
image.png
image.png

1.向下遞歸beginWork的流程:

  • 圖1、此時wip指向HostRootFiber,執行performUnitOfWork(wip)時會調用updateHostRoot,拿HostRootFiber的children和HostRootFiber.alternate的children進行Diff比較,然後創建內存中的Fiber(App)HostRootFiber的children只有一個,就是React.render(App, container)時傳入的App)。最後wip指向子Fibers的第一個,即Fiber(App)
  • 圖2、此時wip指向Fiber(App),執行App組件的render方法,產生ReactElement('div')(這個就是App的children),執行performUnitOfWork(wip)時會那App的children和App.alternate的children進行Diff比較,然後創建內存中的Fiber(div)。最後wip指向子Fibers的第一個,即Fiber(div)
  • 圖3、此時wip指向Fiber(div),執行performUnitOfWork(wip)時拿Fiber(div)的children和Fiber(div).alternate的children進行Diff比較(初次渲染實際上不會比較,更新渲染才會真正的比較),然創建內存中的Fiber(header)Fiber(Content)。最後wip指向子Fibers的第一個,即Fiber(header)

2.向上回溯completeWork的流程:

  • 構建dom節點,掛在Fiber上,同時把子dom節點通過appendChild關聯上(注意:這個添加dom節點是從下往上的,所以此時回溯構建的真實DOM並沒有真的掛載到Document)

當掛載完成後,再經過「commit階段」後就變成下面這樣,此時原本離屏的FiberTree變成頁面FiberTree,而原來的頁面的FiberTree被清理成一棵空樹。

b.更新渲染流程

React有三種發起主動更新的方式:

  1. Class組件中調用setState.
  2. Function組件中調用hook對象暴露出的dispatchAction(就是setState).
  3. container節點上重複調用render

更新渲染初次掛載的區別:

  1. 有一個預處理的步驟——markUpdateLaneFromFiberToRoot(fiber, lane),標記優先級,等進入「render」階段DFS時通過bailout策略可能跳過一些子樹的協調。
  2. 更新渲染的beginWork是需要打副作用標籤flags的,completeWork是需要收集子Fiber的flags到父Fiber.subtreeFlags的,這是為了下一個階段「commit階段」準備的,這樣在commit時DFS可以跳過一些子樹。
markUpdateLaneFromFiberToRoot

初次掛載是React.render觸發,直接從根節點向下DFS了。而更新渲染流程中則有所不同,有一個預處理的步驟——markUpdateLaneFromFiberToRoot(fiber, lane),名字記不記無所謂,關鍵理解這一步做了什麼。

image.png

如上圖所示,在App組件內發生更新,先執行markUpdateLaneFromFiberToRoot動作:給Fiber(App)設置lanes,然後不斷回溯,父Fiber會收集所有子Fiber的lanes併入childLanesmarkUpdateLaneFromFiberToRoot結束後再安排一個調度任務(就是進入workLoop),等待執行。

  • childLanes在渲染流程的「render階段」的優化起到作用。在命中優化條件情況下,如果Fiber的childLanes不包含了當前更新優先級,將跳過Fiber和它的整個子樹的協調/Diff(這個將在下面的bailout優化策略中介紹)
  • 注意:childLanes是掛在old Fiber上的,在比較new FiberTree和old FiberTree後,發現某個Fiber命中優化才會去檢查對應old Fiber的childLanes
bailout策略

bailout策略 講的是如何在beginWork中比較新舊Fiber的優化問題(命中bailout策略能減少render工作量)。如何命中:

  1. oldProps全等於newProps(但對於Memo純組件,條件會變寬鬆,只需要淺比較oldProps和newprops)
  2. legacy context不變(對於新版Context,只要所處的context的value變化就意味着更新,那麼就不會命中bailout)
  3. 沒有更新操作(哪怕狀態不變,但只要做了更新動作,比如state:{}->setState({})也不會命中bailout)
  4. FiberNode.type不變

這裏必須貼一下React的源碼幫我們理解這個邏輯:

function updateMemoComponent(wip: FiberNode, renderLane: Lane) {
    // bailout四要素
    // props淺比較
    const current = wip.alternate;
    const nextProps = wip.pendingProps;
    const Component = wip.type.type;

    if (current !== null) {
        const prevProps = current.memoizedProps;

        // state context
        if (!checkScheduledUpdateOrContext(current, renderLane)) {
            // 淺比較props
            if (shallowEqual(prevProps, nextProps) && current.ref === wip.ref) {
                didReceiveUpdate = false;
                wip.pendingProps = prevProps;

                // 滿足四要素
                wip.lanes = current.lanes;
                return bailoutOnAlreadyFinishedWork(wip, renderLane);
            }
        }
    }
    return updateFunctionComponent(wip, Component, renderLane);
}

當執行bailoutOnAlreadyFinishedWork時,你就會發現childLanes起作用了,childLanes決定了命中bailout後的優化程度。

function bailoutOnAlreadyFinishedWork(wip: FiberNode, renderLane: Lane) {
    // 高程度優化,跳過子樹協調
    if (!includeSomeLanes(wip.childLanes, renderLane)) {
        return null;
    }
    //低程度優化,複用Fiber,繼續子樹協調
    cloneChildFibers(wip);
    return wip.child;
}

(圖片來自react性能優化|bailout策略)

image.png

  • 如果childLanes數組中包含本次更新優先級,則複用Fiber,繼續子樹的DFS/協調。
  • 如果不包含,則跳過子樹DFS/協調。
完整更新流程

遞歸打flags,回溯收集flags
遞歸執行beginWork的順序和「初次渲染」流程一樣,不過不同的是:

  1. 做新舊Fiber的比較,打flags
  2. 會遇到bailout策略,可能跳過子樹協調/Diff。

回溯執行completeWork的順序和「初次渲染」流程也一樣,不同的是:

  1. 收集子Fiber flags,併入父Fiber的subtreeFlags

beginWork
(下圖1,2,3,4 來自 圖解React)
image.png
image.png
image.png
image.png

其實這組圖已經很好説明了流程,我就不每一步説明了。重點關注:當wip指向Fiber(Header)時:

  • <Header>組件是PureComponent,滿足四要素(props淺比較相同,沒有更新,沒有context變化,type沒變),命中bailout
  • 然後Fiber(Header)上childLanes沒有包含本次更新優先級,所以高程度優化,直接跳過了子樹的比較,返回的wip就指向了兄弟節點Fiber(button)

completeWork
7kms大佬的繪的圖關於completeWork是使用EffectList(React v18已經不用了)收集副作用,下面是7kms大佬的圖:

然後我自己畫了個圖,表示收集subtreeFlags,一些細節就沒畫出來,重點關注subtreeFlagsdeletions數組(希望我的畫功沒讓你失望~)

image.png

這個subtreeFlags是收集子flags和子subtreeFlags合併得來的,在React中實際是一個二進制的數,但為了理解理論,這裏你可以把它當做是一個數組好了。

  • wip指向Fiber(div),遍歷所有子Fiber,收集有 subtreeFlags=[Placement, Deletion],繼續冒泡。
  • wip指向Fiber(App),遍歷所有子Fiber,因為Fiber(App),Fiber(button)沒有flags和subtreeFlags,只有Fiber(div)有subtreeFlags,故收集有subtreeFlags=[Placement, Deletion],繼續冒泡。
  • wip指向Fiber(HostRootFiber),收集有subtreeFlags=[Placement, Deletion]
結合項目代碼分析

項目代碼的performUnitOfWork比較簡化,沒有明顯區分初次掛載時和更新時兩個流程,都按更新流程來寫的,同時省略了completeWork回溯時該做的事(這個不影響功能,回溯收集subtreeFlags是為了跳過一些子Fiber的Diff,用於優化)。

// 執行「任務單元」,處理fiberNode(React中會把wip傳給fiberNode)
// 比較wip的props和oldFiberNode的props,記錄差異到effectTag
const performUnitOfWork = (fiberNode: FiberNode): FiberNode | null => {
  const { type } = fiberNode;
  switch (typeof type) {
    // 1.處理函數組件和類組件
    case 'function': {
      wipFiber = fiberNode; 
      wipFiber.hooks = [];
      hookIndex = 0;
      let children: ReturnType<ComponentFunction>;
      // 區分函數組件和類組件(實際React中是分成ClassComponent和FunctionComponent種類型)
      // 這裏通過REACT_COMPONENT(Component上的靜態變量來區分)
      if (Object.getPrototypeOf(type).REACT_COMPONENT) {
        const C = type;
        const component = new C(fiberNode.props);
        const [state, setState] = useState(component.state);
        component.props = fiberNode.props;
        component.state = state;
        component.setState = setState;
        children = component.render.bind(component)(); //對於類組件,調用render方法獲取children
      } else {
        children = type(fiberNode.props); //對於函數組件,直接調用函數組件並傳入props獲取children
      }
      reconcileChildren(fiberNode, [
        isVirtualElement(children)
          ? children
          : createTextElement(String(children)),
      ]);
      break;
    }
    // 2.處理文本節點和Fragment
    case 'number':
    case 'string':
      if (!fiberNode.dom) {
        fiberNode.dom = createDOM(fiberNode);
      }
      reconcileChildren(fiberNode, fiberNode.props.children);
      break;
    case 'symbol':
      if (type === Fragment) {
        reconcileChildren(fiberNode, fiberNode.props.children);
      }
      break;
    default:
      if (typeof fiberNode.props !== 'undefined') {
        reconcileChildren(fiberNode, fiberNode.props.children);
      }
      break;
  }

  // 處理完成當前節點(wip)後,返回下一個要處理的節點(nextUnitOfWork)
  // 找下一個待處理節點nextUnitOfWork,遵循DFS的順序
  if (fiberNode.child) {
    return fiberNode.child;
  }

  let nextFiberNode: FiberNode | undefined = fiberNode;

  while (typeof nextFiberNode !== 'undefined') {
    if (nextFiberNode.sibling) {
      return nextFiberNode.sibling;
    }

    nextFiberNode = nextFiberNode.return;
  }

  return null;  //null表示節點處理完畢
};

render階段又叫reconcile階段,原因就是因為這個階段的核心在於reconcileChildren函數,即Diff過程。下面就開始介紹React的Diff算法。

c.調和/Diff算法

Diff算法原理介紹

在React中Fiber節點的比較只做同層級的比較,按FiberTree從上到下的順序一級級比較。分為單節點和多節點比較。

  • 單節點比較(ReactElement序列只有1個或0個)
  • 多節點比較(ReactElement序列大於1個,構成數組)

單節點比較
image.png

多節點比較
初始的(新Fibers)ReactElement序列和 oldFibers序列如下:

第一次循環先遍歷公共序列,即新舊Fiber是一一對應,key和type相同,一旦key或type不同就中斷循環。前面這段公共序列的oldFibers是可以複用的(即複用dom)

第二次循環從第一次斷開的地方開始。

1) 先設置一個lastPlaceIndex的索引,為什麼叫lastPlaceIndex,因為它和最終的dom移動(是否打上Placement副作用標籤相關)。 初始設置lastPlaceIndex=0

2) 把oldFibers剩餘的節點放入Map,方便後續通過key找oldFiber。即Fiber(C)Fiber(D)Fiber(E)會被放入Map。

3) 遍歷ReactElement序列的剩餘序列

  • 如果用當前ReactElement key能在Map中找到oldFiber,就複用Fiber(複用dom),oldFiber從Map中移除。
  • 複用後,還要比較lastPlaceIndex和oldFiber的index,如果index比lastPlaceIndex大or相等則只需要更新lastPlaceIndex=index,否則僅標記該新Fiber flags=PlacementlastPlaceIndex不動。
  • 如果用當前ReactElement找不到oldFiber,則標記flags=Placement (此時表示的是新增)

關於3)的第2點,以下圖為例説明: key=e找到Fiber(E)(oldFiber E的索引為4),此時lastPlaceIndex是0,只需更新lastPlaceIndex=4;: key=c找到Fiber(C)(oldFiber C的索引為2),此時對新Fiber C標記Placement(表示移動)lastPlaceIndex不變。
關於3)的第3點,就是 key=xkey=y找不到oldFiber,然後標記為Placement(表示新增)。

Diff算法思維導圖

image.png

結合項目代碼分析

這個項目代碼,沒有考慮key的設計(簡化了),主要考慮type的比較,也沒有區分開「單節點比較」和「多節點比較」,僅僅是簡單進行了「多節點比較」的粗略版比較。我們來一起看看吧,重點關注其中是如何打副作用標籤的(項目裏是effectTag字段,實際React中是flags字段)。

const reconcileChildren = (
  fiberNode: FiberNode,
  elements: VirtualElement[] = [],
) => {
  let index = 0;
  let oldFiberNode: FiberNode | undefined = void 0;
  let prevSibling: FiberNode | undefined = void 0;
  const virtualElements = elements.flat(Infinity);

  //這裏的fiberNode是內存中FiberTree的「父Fiber」 alternate指針指向它對應的old FiberNode(即離屏FiberTree上的)
  if (fiberNode.alternate?.child) {
    oldFiberNode = fiberNode.alternate.child; //  oldFiberNode表示了oldFibers序列
  }

  while (
    index < virtualElements.length ||
    typeof oldFiberNode !== 'undefined'
  ) {
    // ReactElement通過index自增移動來獲取,而oldFiberNode通過sibling移動來獲取
    const virtualElement = virtualElements[index];
    let newFiber: FiberNode | undefined = void 0;

    const isSameType = Boolean(
      oldFiberNode &&
        virtualElement &&
        oldFiberNode.type === virtualElement.type,
    );
    // 1.type相同,標記為UPDATE(複用真實dom,僅修改dom的屬性)
    if (isSameType && oldFiberNode) {
      newFiber = {
        type: oldFiberNode.type,
        dom: oldFiberNode.dom,
        alternate: oldFiberNode,
        props: virtualElement.props,
        return: fiberNode,
        effectTag: 'UPDATE',
      };
    }
    // 2.type不同並且有新的ReactElement,標記為REPLACEMENT,表示新增或移動dom
    // 其實這裏可分為兩種情況:1)oldFiberNode不存在——新增,2)oldFiberNode存在但type不同——移動
    if (!isSameType && Boolean(virtualElement)) {
      newFiber = {
        type: virtualElement.type,
        dom: null,
        alternate: null,
        props: virtualElement.props,
        return: fiberNode,
        effectTag: 'REPLACEMENT',
      };
    }
    // 3.type不同並且oldFiberNode存在(隱藏條件:ReactElement不存在),父FiberNode標記為DELETION,表示刪除dom
    // 除了標記為DELETION,還會把oldFiber放到deletions數組中,用於後續commitRoot時刪除dom  (實際React中這個deletions是掛在父fiberNode上的)
    if (!isSameType && oldFiberNode) {
      deletions.push(oldFiberNode);
    }

    if (oldFiberNode) { // oldFiberNode通過sibling移動來獲取
      oldFiberNode = oldFiberNode.sibling;
    }

    // 構建新的fiber樹(即內存中的Fiber Tree)
    if (index === 0) {
      fiberNode.child = newFiber;
    } else if (typeof prevSibling !== 'undefined') {
      prevSibling.sibling = newFiber;
    }

    prevSibling = newFiber;
    index += 1; // ReactElement通過index自增移動來獲取
  }
};

3、commitRoot對應 「commit階段」

1.籠統的説,「commit階段」要做兩件事:

  • 負責DOM節點的插入/移動、更新屬性和刪除,注意這裏説的是DOM,是對真實DOM操作。
  • 執行副作用,useEffect/useLayoutEffect的 destory和create函數。

2.「commit階段」的流程如下圖所示
(圖來自《React設計原理》)
image.png

這個“判斷是否有副作用”指判斷subtreeFlags是否有標記(不是noFlags)。有副作用標記,則DFS時對於每個Fiber節點,需要執行BeforeMutationMutationLayout階段。

  • BeforeMutation:執行ClassComponent的getSnapshotBeforeUpdate方法,異步調用flushPassiveEffects(和useEffect有關).
  • Mutation:插入/移動、更新屬性或刪除DOM。
  • Layout:執行componentDidMount/UpdateuseLayoutEffect鈎子。

3.等上述的同步代碼執行完成後,我們看到的頁面就更新了! 到這裏我相信你也理解了為什麼 useLayout能獲取到更新的dom並且能在頁面繪製前操作dom了。

  • 這裏有個有意思的問題:大家普遍的理解是react(v18版本後)的setState是異步的。那麼為什麼useLayoutEffect中setState可以在頁面繪製前完成狀態更新呢?
  • 解釋:如果 layout effect 裏有 setState,React 立即標記這是一個 同步更新(SyncLane);並且立刻重新 render + commit
useLayoutEffect(() => {
    setXxState(); // 引起的更新優先級是同步的,優先級最高,更新在頁面繪製前執行。
  }, []);

其實這裏會涉及到一個經典面試題“setState是同步還是異步?”,這個問題留到下一篇博客(React八股和場景題)討論吧,歡迎大家關注和訂閲專欄(😏)。

4.這裏你是不是好奇useEffect的執行時機又是怎麼樣的呢? 別急,後面的「hooks原理」小節會講到這個問題。

5.補充:
Fiber早期架構(v16)中還沒有subtreeFlags,使用的是Effects List,如下圖,HostRootFiber中保存了effectsList,通過遍歷這個鏈表來更新Fiber,就不用重新DFS整棵Fiber Tree了。
image.png

最新的Fiber架構,則採用了subtreeFlags(v17過渡版本就開始有這個字段了,需要開啓併發模式才會用到),大概原因是為了Suspense這個API的一些特性,採用了收集flags的方式。這樣就需要DFS 整棵Fiber Tree,通過subtreeFlags判斷是否需要繼續向下DFS。

結合項目代碼分析

本項目代碼沒有考慮副作用的處理了,重點關注DOM的更新。

// 根據Fiber節點的effectTag更新真實DOM
// 在commitRoot之前,已經完成了所有Fiber節點的比較
// 之前的Fiber比較流程是可以中斷的,但commitRoot不能中斷
const commitRoot = () => {
  // 找到帶dom的父Fiber
  const findParentFiber = (fiberNode?: FiberNode) => {
    if (fiberNode) {
      let parentFiber = fiberNode.return;
      while (parentFiber && !parentFiber.dom) {
        parentFiber = parentFiber.return;
      }
      return parentFiber;
    }

    return null;
  };

  const commitDeletion = (
    parentDOM: FiberNodeDOM,
    DOM: NonNullable<FiberNodeDOM>,
  ) => {
    if (isDef(parentDOM)) {
      parentDOM.removeChild(DOM);
    }
  };

  const commitReplacement = (
    parentDOM: FiberNodeDOM,
    DOM: NonNullable<FiberNodeDOM>,
  ) => {
    if (isDef(parentDOM)) {
      parentDOM.appendChild(DOM);
    }
  };

  const commitWork = (fiberNode?: FiberNode) => {
    if (fiberNode) {
      if (fiberNode.dom) {
        const parentFiber = findParentFiber(fiberNode);
        const parentDOM = parentFiber?.dom;
        //根據副作用標籤,更新真實DOM(注意:這裏的effectTag和實際React的flags有差異,表示方式不同罷了)
        switch (fiberNode.effectTag) {
          case 'REPLACEMENT':
            commitReplacement(parentDOM, fiberNode.dom);
            break;
          case 'UPDATE':
            updateDOM(
              fiberNode.dom,
              fiberNode.alternate ? fiberNode.alternate.props : {},
              fiberNode.props,
            );
            break;
          default:
            break;
        }
      }
      //遞歸,先第一個孩子,再處理兄弟節點
      commitWork(fiberNode.child);
      commitWork(fiberNode.sibling);
    }
  };

  // 這裏處理了所有的刪除工作。(實際React中是在commitWork(fiber)DFS時,遍歷父節點的deletions數組做刪除的)
  for (const deletion of deletions) {
    if (deletion.dom) {
      const parentFiber = findParentFiber(deletion);
      commitDeletion(parentFiber?.dom, deletion.dom);
    }
  }
  // 執行插入/移動、更新工作。
  if (wipRoot !== null) {
    commitWork(wipRoot.child);
    currentRoot = wipRoot;
  }

  wipRoot = null;
};

更新DOM和創建DOM代碼多一點,單獨寫成函數,如下:

// 更新DOM(屬性)
// 簡單起見,這裏是刪除之前所有屬性,添加新屬性
const updateDOM = (
  DOM: NonNullable<FiberNodeDOM>,
  prevProps: VirtualElementProps,
  nextProps: VirtualElementProps,
) => {
  const defaultPropKeys = 'children';

  for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
    if (removePropKey.startsWith('on')) {
      DOM.removeEventListener(
        removePropKey.slice(2).toLowerCase(),
        removePropValue as EventListener,
      );
    } else if (removePropKey !== defaultPropKeys) {
      // @ts-expect-error: Unreachable code error
      DOM[removePropKey] = '';
    }
  }

  for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
    if (addPropKey.startsWith('on')) {
      DOM.addEventListener(
        addPropKey.slice(2).toLowerCase(),
        addPropValue as EventListener,
      );
    } else if (addPropKey !== defaultPropKeys) {
      // @ts-expect-error: Unreachable code error
      DOM[addPropKey] = addPropValue;
    }
  }
};
// 基於Fiber的type創建 dom 
const createDOM = (fiberNode: FiberNode): FiberNodeDOM => {
  const { type, props } = fiberNode;
  let DOM: FiberNodeDOM = null;

  if (type === 'TEXT') {
    DOM = document.createTextNode('');
  } else if (typeof type === 'string') {
    DOM = document.createElement(type);
  }

  // Update properties based on props after creation.
  if (DOM !== null) {
    updateDOM(DOM, {}, props);
  }

  return DOM;
};

4、Hooks原理

React官方將hooks分為兩類,一類是狀態hooks,另一類是副作用hooks。

  1. 狀態hooks: useState, useReducer, (廣義上還有)useContext, useRef, useCallback, useMemo
  2. 副作用hooks: useEffect, useLayoutEffect

a.狀態hook

1.下面我們通過一個Fiber節點來看看hooks是如何工作的。
Fiber(App)節點為例,對應的JSX代碼如下:

function App() {
  const [count, setCount] = useState(0);
  useEffect(()=>{
    console.log('didMount')
  },[])
  const [show, setShow] = useState(true)
  useEffect(()=>{
    console.log(show, count)
  },[show, count])
  return (
    <div>
      <p>You clicked {show ? count : '*'} times</p>
      <button onClick={() => setCount(count + 1)}>increase</button>
      <button onClick={() => setCount(count - 1)}>decrease</button>
    </div>
  );
}

Fiber(App)上用了4個hook,那麼Fiber的結構如下,Fiber上的屬性memoizedState保存了一個鏈表結構:

注意:你寫的hooks順序和memoizedState保存的順序是一致的。當App Function的hooks按順序執行的同時,會通過一個全局變量currentHook移動來指向當前的hook。==如果hooks是條件裏執行的話,那麼hooks鏈表節點的查找實際是不可預測的,這也是為什麼hooks不能條件裏執行==。

2.下面我們來分析下hook節點(鏈表節點)上的queuememoizedState

  • 當我們調用setState方法,實際會生成一個Update對象放入queue隊列,如下圖所示。
  • 當到了「Render階段」處理對應新Fiber時,會從舊Fiber把hooks鏈表copy一份過來,然後一個個執行hook,執行currentHook時會從queue隊列中遍歷所有Update計算出最終的狀態,這個狀態是放在新Fiber的currentHookmemoizedState
  • 這個memoizedState就是 const [state, setState] = useState()中的state。

結合項目代碼分析

本項目中的變量命名和數據結構有些差異,下面我先説明“映射”關係。(本項目->實際React):

  • Fiber的hook數組 -> Fiber的memoizedState鏈表
  • hook節點的state -> hook節點的memoizedState
  • hook節點的queue數組 -> hook節點的queue鏈表
  • 全局變量hookIndex -> 全局變量currentHook指針

本項目代碼如下:

// hooks: 找到當前hook節點,計算狀態。
function useState<S>(initState: S): [S, (value: S) => void] {
  const fiberNode: FiberNode<S> = wipFiber;
  // 每次更新需要重新構建Fiber, 運行useState時需要從alternate(old Fiber)中獲取上一次的hook
  const hook: {
    state: S;
    queue: S[];
  } = fiberNode?.alternate?.hooks
    // 從hooks數組(實際是React是hooks鏈表)中獲取當前的hook節點! 這解釋了為什麼要按順序執行hooks
    ? fiberNode.alternate.hooks[hookIndex] 
    : {
        state: initState,
        queue: [],
      };

  // 從更新隊列(實際React中更新隊列是一個鏈表,這裏簡化為數組)中取出所有更新,合併到state中
  while (hook.queue.length) {
    let newState = hook.queue.shift();
    if (isPlainObject(hook.state) && isPlainObject(newState)) {
      newState = { ...hook.state, ...newState };
    }
    if (isDef(newState)) {
      hook.state = newState; //這就是該hook的新狀態,根據這個新狀態渲染UI
    }
  }

  if (typeof fiberNode.hooks === 'undefined') {
    fiberNode.hooks = [];  
  }

  // 組件內可能多次調用useState,每個useState對應一個hook節點(實際React中就是一個鏈表節點)
  fiberNode.hooks.push(hook);
  hookIndex += 1; //使用索引保證能按順序處理hooks數組

  // setState就是一個閉包,裏面訪問了當前hook節點。
  const setState = (value: S) => {
    hook.queue.push(value);
    if (currentRoot) { //注意這個currentRoot 指向舊Fiber Tree的根節點(即實際React中的FiberRoot.current)
      // 創建新Fiber Tree 的 HostRootFiber
      wipRoot = { 
        type: currentRoot.type,
        dom: currentRoot.dom,
        props: currentRoot.props,
        alternate: currentRoot,
      };
      // Fiber工作指針有值了意味着新的render任務,requestIdleCallback會調用workLoop時會處理nextUnitOfWork
      // 接下來就會進入performUnitOfWork,從根HostRootFiber往下DFS,構建新的Fiber Tree
      nextUnitOfWork = wipRoot;  
      deletions = [];
      currentRoot = null;
    }
  };

  return [hook.state, setState];
}

b.副作用hook

我們先記住下面這個Effect對象定義

export type Effect = {
  tag: HookFlags, // 副作用hook的類型
  create: () => (() => void) | void, //useXxxEffect的創建,即第一個參數。
  destroy: (() => void) | void, //useXxxEffect的銷燬函數,第一個參數的返回函數
  deps: Array<mixed> | null, //useXxxEffect的依賴
  next: Effect, //下一個useXxxEffect保存的Effect對象
};

一圖勝千言,對於副作用hook而言,hook節點上memoizedState保存的是Effect對象

初次調用

1.如上圖所示,初次調用useEffect,會創建Effect對象並形成effects鏈表。Effect.tag標記了Effect是Layout還是Passive(對於useLayoutEffect的就標記Layout,對於useEffect的就標記Passive

2.在Commit的「BeforeMutation子階段」, 異步調用了flushPassiveEffects(宏任務)。在這期間帶有Passive標記的effect已經被添加到全局數組中。
接下來flushPassiveEffects就可以脱離fiber節點,遍歷全局數組,直接訪問effect,先執行effect.destroy,後執行effect.create函數。

3.解釋異步調用了flushPassiveEffects:這裏的異步調用,是React調度時給了NormalSchedulerPriority優先級,此時flushPassiveEffects被當做一個宏任務來執行。(到這裏咱就明白了useEffect和useLayoutEffect的區別:==useEffect是一個宏任務,在頁面繪製後執行;useLayoutEffect是微任務,在頁面繪製前執行==)

更新調用

1.當組件更新,就會重新執行useEffect/useLayoutEffect,創建新hook節點,然後對新hook會和舊hook的effect依賴deps比較:

  • 如果有依賴項引用變化,創建新Effect並打上tag |= HasEffect標記
  • 如果沒有變化,僅創建新Effect(沒有HasEffect標記)

2.新的hook以及新的effect創建完成之後, 餘下邏輯與初次渲染完全一致。處理 Effect 回調時也會根據effect.tag進行判斷: 只有effect.tag包含HookHasEffect時才會調用effect.destroyeffect.create()

3.此時,你應該明白了==如果useXxEffect沒有正確依賴,會導致Effect回調不會被觸發==。

4.這部分,本項目沒有代碼哦~

5、總結

學數學我們講究數形結合,那麼學框架原理,我們也要「碼形結合」,這個“碼”就是指代碼,本文很好的碼形結合解釋了React原理。

渲染階段-思維導圖

image.png

更新流程-思維導圖

image.png
image.png

Diff算法-思維導圖

image.png

參考

mini-react github倉庫
圖解React
《React設計原理》- big-react github倉庫
react性能優化|bailout策略

user avatar _raymond 頭像 buxia97 頭像 yilezhiming 頭像 waweb 頭像 frontoldman 頭像 warn 頭像 cipchk 頭像 layouwen 頭像 niaonao 頭像 shellingfordly 頭像 user_kim 頭像 hooray 頭像
42 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.