你不知道的 React Virtual DOM

什麼是 Virtual DOM ?

在前端技術蓬勃發展的上古時代,前端開發主要是一些靜態頁面,使用 ajax、jQuery 等命令式的完成一些對 DOM 的操做,而伴隨着前端工程化的不斷髮展,涌現了諸如 angular、react 等一系列 MVVM 模式的前端框架,這些框架公有的特色就是再也不關心具體 DOM 的操做,而是把重點放在了基於數據狀態的操做,一旦數據更改,跟它綁定的那個地方的 DOM 也會跟着變化。這種聲明式的開發方式極大的增長了開發體驗,更好的幫助咱們完成組件複用、邏輯解耦等。html

藉助於上面提到的前端框架,咱們不用再主動的對 DOM 進行操做,框架在背後已經替咱們作了,咱們只須要關心應用的數據便可。而Virtual DOM(虛擬 DOM)的概念就是在此期間因爲其在React框架中的使用而變得流行起來。那麼到底什麼是Virtual DOM呢?前端

引用 react 官網上的介紹:node

Virtual DOM 是一種編程概念。在這個概念裏, UI 以一種理想化的,或者說「虛擬的」表現形式被保存於內存中,並經過如 ReactDOM 等類庫使之與「真實的」 DOM 同步。這一過程叫作協調。react

這種方式賦予了 React 聲明式的 API:您告訴 React 但願讓 UI 是什麼狀態,React 就確保 DOM 匹配該狀態。這使您能夠從屬性操做、事件處理和手動 DOM 更新這些在構建應用程序時必要的操做中解放出來。git

總結來講,理解 Virtual DOM 的含義主能夠從如下幾點出發:github

  1. 虛擬 DOM 並非真實的 DOM,它跟原生 DOM 本質上沒什麼關係。
  2. 本質上 Virtual DOM 對應的是一個 JavaScript 對象,它描述的是視圖和應用狀態之間的一種映射關係,是某一時刻真實 DOM 狀態的內存映射。
  3. 在視圖顯示方面,Virtual DOM 對象的節點跟真實 DOM Tree 每一個位置的屬性一一對應。
  4. 咱們再也不須要直接的操做 DOM,只須要關注應用的狀態便可,操做 DOM 的事情有框架替咱們作了。

爲何要用 Virtual DOM ?

咱們常常會說到真實的 DOM 操做代價昂貴,操做頻繁還會引發頁面卡頓影響用戶體驗,而虛擬 DOM 就是爲了解決這個瀏覽器性能問題才被創造出來。web

在介紹 Virtual DOM 有什麼好處以及爲何要使用它以前,咱們先來了解下爲何會說 DOM 操做是耗費性能的?ajax

操做 DOM 是耗費性能的

首先咱們要明白一點,DOM 並不屬於 JavaScript 語言的一部分,它是 JavaScript 的運行平臺(瀏覽器)提供的,好比在 nodejs 中就沒有 DOM。瀏覽器中的 DOM 對應的是 HTML 頁面中的元素節點,它自己和 JS 對象沒有什麼關聯,可是 webkit 渲染引擎和 JS 引擎之間經過 V8 Binding 在 V8 內部會把原生 DOM 對象映射爲 JS 對象,咱們稱之爲 Wrapper objects(包裝對象)。所以,咱們平時在寫代碼時,操做 DOM 對象就是操做的這種包裝對象,和操做 JS 對象是同樣的。下圖爲瀏覽器和 JS 引擎的關係(以 Chrome 和 V8 舉例,其餘瀏覽器也大同小異)。算法

因爲 JS 是可操縱 DOM 的,若是在修改這些元素屬性同時渲染界面(即 JS 線程和渲染線程同時運行),那麼渲染線程先後得到的元素數據就可能不一致了。所以爲了防止渲染出現不可預期的結果,瀏覽器設置 渲染線程JS 引擎線程 爲互斥的關係,當 JS 引擎執行時渲染線程會被掛起,GUI 更新則會被保存在一個隊列中等到 JS 引擎線程空閒時當即被執行。編程

所以咱們在操做 DOM 時,任何 DOM API 調用都要先將 JS 數據結構轉爲 DOM 數據結構,再掛起 JS 引擎線程並啓動渲染引擎線程,執行事後再把可能的返回值反轉數據結構,重啓 JS 引擎繼續執行。這種兩個線程之間的上下文切換勢必會很耗性能。

另外不少 DOM API 的讀寫都涉及頁面佈局的 重繪(repaint)迴流(reflow),這會更加的耗費性能。

綜上所述,單次 DOM API 調用性能就不夠好,頻繁調用就會迅速積累上述損耗,但咱們又不可能不去操做 DOM,所以解決問題的本質是要 減小沒必要要的 DOM API 調用

Virtual DOM 有什麼優點?

不少人一談到 Virtual DOM 的優點就會說 「原生 DOM 操做太慢了,virtual DOM 更快些」,首先咱們要認識到一點:沒有任何框架能夠比純手動的優化 DOM 操做更快,由於框架的 DOM 操做層須要應對任何上層 API 可能產生的操做,它的實現必須是普適的。框架的意義在於爲你掩蓋底層的 DOM 操做,讓你用更聲明式的方式來描述你的目的,從而讓你的代碼更容易維護。

React 也歷來沒有說過 「React 比原生操做 DOM 快」。並非說 Virtual DOM 操做必定是比原生 DOM 操做快,這和具體的頁面模板大小和數據的變更量都有關係的 可是相比於操做 DOM,原生的 js 對象操做起來的確是會更快、更簡單。

React.js 相對於直接操做原生 DOM 最大的優點在於 batching 和 diff。爲了儘可能減小沒必要要的 DOM 操做, Virtual DOM 在執行 DOM 的更新操做後,不會直接操做真實 DOM,而是根據當前應用狀態的數據,生成一個全新的 Virtual DOM,而後跟上一次生成 的 Virtual DOM 去 diff,獲得一個 Patch,這樣就能夠找到變化了的 DOM 節點,只對變化的部分進行 DOM 更新,而不是從新渲染整個 DOM 樹,這個過程就是 diff。還有所謂的batching就是將屢次比較的結果合併後一次性更新到頁面,從而有效地減小頁面渲染的次數,提升渲染效率。batching 或者 diff, 說到底,都是爲了儘可能減小對 DOM 的調用。簡要的示意圖以下:

所以總結下關於 Virtual DOM 的優點有哪些:

  1. 爲函數式的 UI 編程方式打開了大門,咱們不須要再去考慮具體 DOM 的操做,框架已經替咱們作了,咱們就能夠用更加聲明式的方式書寫代碼。
  2. 減小頁面渲染的次數,提升渲染效率。
  3. 提供了更好的跨平臺的能力,由於 virtual DOM 是以 JavaScript 對象爲基礎而不依賴具體的平臺環境,所以能夠適用於其餘的平臺,如 node、weex、native 等。

附上知乎上尤雨溪 對於 Virtual DOM 的優點的回答

Virtual DOM 是如何實現的

引用 React 官網關於 Virtual DOM 的一段話:

與其將 「Virtual DOM」 視爲一種技術,不如說它是一種模式,人們提到它時常常是要表達不一樣的東西。在 React 的世界裏,術語 「Virtual DOM」 一般與React 元素關聯在一塊兒,由於它們都是表明了用戶界面的對象。而 React 也使用一個名爲 「fibers」 的內部對象來存放組件樹的附加信息。上述兩者也被認爲是 React 中 「Virtual DOM」 實現的一部分。

下面的部分咱們就來分別看看 ReactElementFiber 是什麼東西。

ReactElement

咱們前面說了本質上 Virtual DOM 對應的是一個 JavaScript 對象,那麼 React 是如何經過一個 js 對象將 Virtual DOM 和真實 DOM 對應起來的呢?這裏面的關鍵就是 ReactElement。

ReactElement 即 react 元素,描述了咱們在屏幕上所看到的內容,它是構成 React 應用的最小單元。好比下面的 jsx 代碼:

const element = <h1 id="hello">Hello, world</h1>
複製代碼

上面的代碼通過編譯後其實生成的代碼是這樣的:

React.createElement("h1", {
  id: "hello"
}, "Hello, world");
複製代碼

執行 React.createElement 函數,會返回相似於下面的一個 js 對象,這個對象就是咱們所說的 React 元素:

const element = {
  type: 'h1',
  props: {
    id: 'hello',
    children: 'hello world'
  }
}
複製代碼

React 元素也能夠是用戶自定義的組件:

function Button(props) {
  return <button style={{ color }}>{props.children}</button>;
}

const buttonComp = <Button color="red">點擊我</Button>
複製代碼

編譯後的代碼以下:

React.createElement("Button", {
  color: "red"
}, "點擊我");
複製代碼

所以咱們就能夠說 React 元素其實就是一個普通的 js 對象(plain object),這個對象用來描述一個 DOM 節點及其屬性 或者組件的實例,當咱們在 JSX 中使用 Button 組件時,就至關於調用了React.createElement()方法對組件進行了實例化。因爲組件能夠在其輸出中引用其餘組件,當咱們在構建複雜邏輯的組件時,會造成一個樹形結構的組件樹,React 便會一層層的遞歸的將其轉化爲 React 元素,當碰見 type 爲大寫的類型時,react 就會知道這是一個自定義的組件元素,而後執行組件的 render 方法或者執行該組件函數(根據是類組件或者函數組件的不一樣),最終返回 描述 DOM 的元素進行渲染。

咱們來看下 React 源碼中關於 ReactElement 和 createElement 方法的實現:

var ReactElement = function (type, key, ref, self, source, owner, props) {
  var element = {
    // This tag allows us to uniquely identify this as a React Element
    $typeof: REACT_ELEMENT_TYPE,
    // Built-in properties that belong on the element
    type: type,
    key: key,
    ref: ref,
    props: props,
    // Record the component responsible for creating this element.
    _owner: owner
  };

  // do somethings ....

  return element;
 }

 function createElement(type, config, children) {
  var propName; // Reserved names are extracted
  var props = {};
  var key = null;
  var ref = null;
  var self = null;
  var source = null;


  if (config != null) {
    if (hasValidRef(config)) {
      ref = config.ref;
      {
        warnIfStringRefCannotBeAutoConverted(config);
      }
    }
    if (hasValidKey(config)) {
      key = '' + config.key;
    }

    self = config.__self === undefined ? null : config.__self;
    source = config.__source === undefined ? null : config.__source; // Remaining properties are added to a new props object

    for (propName in config) {
      if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
        props[propName] = config[propName];
      }
    }

  } // Children can be more than one argument, and those are transferred onto

  // the newly allocated props object.


  //....


  return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
複製代碼

從上面的源碼中能夠看出:

  1. ReactElement 是經過 createElement 函數建立的。
  2. createElement 函數接收 3 個參數,分別是 type, config, children
  • type 指代這個 ReactElement 的類型,它能夠是 DOM 元素類型,也能夠是 React 組件類型。
  • config 便是傳入的 元素上的屬性組成的對象。
  • children 是一個數組,表明該元素的子元素。

爲了更加清楚的表示,咱們經過在控制檯打印出整個 ReactElement 對象來看看它的真實的結構:

<div className="box" id="name" key="uniqueKey" ref="boxRef">
    <h1>header</h1>
    <div className="content">content</div>
    <div>footer</div>
</div>
複製代碼

它最終會生成下面這樣的一個對象:

  1. $$typeof 是一個常量 REACT_ELEMENT_TYPE,全部經過 React.createElement 生成的元素都有這個值,用來表示這是一個 React 元素。它還有一個取值,經過 createPortals 函數生成的 $$typeof 值就是 REACT_PORTAL_TYPE。
  2. key 和 ref 從 config 對象中做爲一個特殊的配置,被單獨抽取出來,放在 ReactElement 下。
  3. props 包含了兩部分,第一部分是去除了 key 和 ref 的 config,第二部分是 children 數組,數組的成員也是經過 React.createElement 生成的對象。
  4. _owner 就是 Fiber,這個咱們後面會講到。

經過上面這些屬性,React 就能夠用 js 對象把 DOM 樹上的結構信息、屬性信息輕易的表達出來了。

React Fiber

Stack reconciler

React 15 及更早的 reconciler 架構能夠分爲兩層:

  • Reconciler(協調器): 負責找出變化的組件,一般將這時候 的 reconciler 稱爲 stack reconciler。
  • Renderer(渲染器): 負責將變化的組件渲染到頁面上。

每當有狀態更新時,Reconciler會作以下工做:

  1. 調用函數組件、或 class 組件的 render 方法,將返回的 JSX 轉化爲 Virtual DOM。
  2. 將 Virtual DOM 和上次更新時的 Virtual DOM 對比。
  3. 經過對比找出本次更新中變化的 Virtual DOM
  4. 通知 Renderer 將變化的 Virtual DOM 渲染到頁面上,因爲 React 支持跨平臺,因此不一樣平臺有不一樣的 Renderer

它的工做流程很像是函數調用的方式,一旦 setState 以後,便開始從父節點開始遞歸的進行遍歷,找出 Virtual DOM 的不一樣。在將全部的 Virtual DOM 遍歷完成以後,React 才能給出當前須要更新的 DOM 信息。這個過程是個同步的過程。對於一些特別龐大的組件來講,js 執行會佔據很長的主線程時間,這樣會致使頁面響應速度變慢,出現卡頓等現象,尤爲是在動畫顯示上,極可能會出現丟幀的現象。

那麼爲何 Stack reconsiler 會致使丟幀呢?咱們來看一下一幀都作了什麼。在上面的圖中,咱們能夠看出一幀包括了用戶的交互行爲的處理、js 的執行、requestAnimationFrame 的調用、layout 佈局、paint 頁面重繪等工做,假如某一幀裏面要執行的任務很少,在不到 16ms(1000/60=16)的時間內就完成了上述任務的話,頁面就會正常顯示不會出現卡頓的現象,可是若是一旦 js 執行時間過長,超過了 16ms,這一幀的刷新就沒有時間執 layout 和 paint 部分了,就可能會出現頁面卡頓的現象。

Fiber reconciler

咱們仔細考慮,其實對於視圖來講,同步的改變並非一種好的解決方案,主要有如下幾點考慮:

  1. 並非全部的狀態更新都須要當即同步顯示,好比可視範圍以外的部分的更新。
  2. 不一樣類型的更新的優先級是不同的,好比對用戶輸入的響應通常是要比 ajax 請求的響應優先級高的。
  3. 理想狀況下,對於某些高優先級的操做,應該是能夠打斷低優先級的操做執行的,好比用戶輸入時,頁面的某個評論還在 reconciliation,應該優先響應用戶輸入。

爲了解決上面的 stack reconciler 中固有的問題,react 團隊重寫了核心算法 --reconciliation,即 fiber reconciler(二者之間效果對比更直觀的感覺能夠看下這個demo)。fiber reconciler 的架構在原來的基礎上增長了 Scheduler(調度器)的概念:

  • Scheduler(調度器): 調度任務的優先級,高優任務優先進入Reconciler。

上面咱們在講一幀的過程的時候提到,假如某一幀裏面要執行的任務很少,在不到 16 ms 的時間內就完成了任務,那麼這一幀就有空閒時間,咱們就能夠利用這個空閒時間用來執行低優先級的任務,瀏覽器有個 api 叫requestIdleCallback,就是指在瀏覽器的空閒時段內調用的一些函數的回調。React 實現了功能更完備的 requestIdleCallbackpolyfill,這就是Scheduler。除了在空閒時觸發回調的功能外,Scheduler還提供了多種調度優先級供任務設置。Scheduler 主要決定應該在什麼時候作什麼,它在接收到更新後,首先看看有沒有其它高優先級的更新須要先執行,若是有就先執行高優先級的任務,等到空閒期再執行這次更新;若是沒有則將這次任務交給 reconciler 。

Fiber Nodes

還記得前面在講 ReactElement 時在控制檯打印出的對象裏面有個 _owner 對象嗎,它就是咱們說到的 Fiber 節點。當一個 React Element 第一次被轉換爲 fiber 節點的時候, React 將會從 React Element 中提取數據並在在createFiberFromTypeAndProps函數中建立一個新的 fiber 節點。Fiber 的主要目標是使 React 可以利用調度。具體來講,咱們須要可以

  • 暫停工做,稍後回來
  • 給不一樣類型的工做分配優先級
  • 重用以前已經完成的工做
  • 當工做再也不須要時取消

爲了作到這一點,咱們首先須要一種將工做分解爲單元的方法。從某種意義上說,這就是 Fiber。Fiber 表明一種工做單位。React 會爲每一個獲得的 React Element 建立 fiber,這些 fiber 節點被鏈接起來組成 fiber tree。每一個 fiber 對應一個 React Element,保存了該元素的類型、對應的 DOM 節點、本次更新中的該元素改變的狀態、要執行的任務(刪除、插入、更新)等信息。咱們看一下 React 源碼中 FiberNode 構造函數的部分:

  • type 和 key 與 React 元素的用途相同,React 經過它們來判斷 Fiber 是否能夠重複使用。

  • stateNode 是 Fiber 對應的真實 DOM 節點。

  • 多個 fiber 節點中是怎麼鏈接造成 fiber tree 的呢?主要靠如下三個屬性:

    • return:指向父級 Fiber 節點;
    • child:指向子級 fiber 節點;
    • sibling:指向右邊第一個兄弟 fiber 節點;

在 React Fiber 中,一次更新過程會分紅多個分片完成,因此徹底有可能一個更新任務尚未完成,就被另外一個更高優先級的更新過程打斷,這時候,優先級高的更新任務會優先處理完,而低優先級更新任務所作的工做則會徹底做廢,而後等待機會重頭再來。由於一個更新過程可能被打斷,因此 React Fiber 一個更新過程被分爲兩個階段(Phase):第一個階段Reconciliation Phase****和第二階段Commit Phase。在第一階段 Reconciliation Phase,React Fiber 會找出須要更新哪些 DOM,這個階段是能夠被打斷的;可是到了第二階段 Commit Phase,那就一氣呵成把 DOM 更新完,毫不會被打斷。

雙緩衝 Fiber tree

在 React 中最多會同時存在兩棵fiber tree。當前屏幕上顯示內容對應的fiber tree稱爲current fiber tree,正在內存中構建的fiber tree稱爲workInProgress fiber tree。current fiber tree 中的 Fiber 節點被稱爲 current fiber,workInProgress fiber tree 中的 Fiber 節點被稱爲 workInProgress fiber,他們經過 alternate 屬性鏈接。

currentFiber.alternate === workInProgressFiber;

workInProgressFiber.alternate === currentFiber;
複製代碼

React 應用的根節點經過current指針在不一樣fiber treerootFiber間切換來實現fiber tree的切換。雙緩衝具體指的是當workInProgress fiber tree構建完成交給Renderer渲染在頁面上後,應用根節點的current指針指向workInProgress fiber tree,此時workInProgress fiber tree就變爲current fiber tree。每次狀態更新都會產生新的workInProgress fiber tree,經過currentworkInProgress的替換,完成 DOM 更新。這樣作的好處是:

  • 可以複用內部對象(fiber)
  • 節省內存分配、GC 的時間開銷

總結

前面咱們瞭解了 ReactElement 和 React Fiber,如今總結一下整個 Virtual DOM 的工做流程。

  1. 初始化渲染,調用函數組件、或 class 組件的 render 方法,將 JSX 代碼編譯成 ReactELement 對象,它描述當前組件內容的數據結構。
  2. 根據生產的 ReactELement 對象構建 Fiber tree,它包含了組件 schedulereconcilerrender 所需的相關信息。
  3. 一旦有狀態變化,觸發更新,Scheduler 在接收到更新後,根據任務的優先級高低來進行調度,決定要執行的任務是什麼。
  4. 接下來的工做交給 Reconciler 處理,Reconciler 經過對比找出變化了的 Virtual DOM ,爲其打上表明增/刪/更新的標記,當全部組件都完成 Reconciler 的工做,纔會統一交給Renderer
  5. Renderer 根據 Reconciler 爲 Virtual DOM 打的標記,同步執行對應的 DOM 更新操做。

Diff 算法

在調用 React 的 render() 方法,會建立一棵由 React 元素組成的樹。在下一次 state 或 props 更新時,相同的 render() 方法會返回一棵不一樣的樹。React 須要基於這兩棵樹之間的差異來進行比較,這個比較的過程就是俗稱的 diff 算法,換成前面咱們講的 React Fiber 的概念來講,就是將當前組件與該組件在上次更新時對應的 Fiber node 比較,將比較的結果生成新的 Fiber 節點。爲了方便理解,咱們列舉下這個更新的 DOM 節點在某一時刻會有這麼幾個概念與其相關:

  1. current Fiber:若是該 DOM 節點已在頁面中,current Fiber 表明該 DOM 節點對應的 Fiber node。
  2. workInProgress Fiber: workInProgress Fiber 表明了正在內存中構建的 Fiber 節點,若是該 DOM 節點即將在本次更新中渲染到頁面中,則 workInProgress Fiber 表明該 DOM 節點對應的 Fiber 節點,就是最後更新的結果。
  3. ReactElement 對象:即前面講到的 Class 組件 render 方法或者調用函數組件的結果通過 React.createElement 生成的對象。

Diff 算法的本質就是對比 1 和 3,生成 2。

Diff 策略

React 文檔中提到,即便在最前沿的算法中,將先後兩棵樹徹底比對的算法的複雜程度爲 O(n 3 ),其中 n 是樹中元素的數量。若是在 React 中使用了該算法,那麼展現 1000 個元素所須要執行的計算量將在十億的量級範圍。這個開銷實在是太太高昂,顯然沒法知足性能要求,因而 React 在如下兩個假設的基礎之上提出了一套 O(n) 的啓發式算法:

  1. 只對同級元素進行 Diff,若是某一個節點在一次更新中跨域了層級,React 不會複用該節點,而是從新建立生成新的節點。
  2. 兩個不一樣類型的元素會產生出不一樣的樹,若是元素由 div 變爲 p,React 會銷燬 div 及其子孫節節點,並新建 p 及其子孫節點。
  3. 開發者能夠經過 key prop 來暗示哪些子元素在不一樣的渲染下能保持穩定;

如上圖所示,React 只會對相同顏色框內的 DOM 節點進行比較,即同一個父節點下的全部子節點。當發現節點已經不存在,則該節點及其子節點會被徹底刪除掉,不會用於進一步的比較。這樣只須要對樹進行一次遍歷,便能完成整個 DOM 樹的比較。當有下面的狀況時(A 節點直接被整個移動到 D 節點下):由於 React 只會對同級節點進行比較,這時候 React 發現的是 A 節點不見了,就會直接銷燬 A 節點,在 D 節點那裏發現多了一個新的子節點 A,則會建立一個新的 A 節點做爲子節點。

上面的例子是對於在不一樣層級的節點的比較,對於同一層級的節點,React 引入了 key 屬性來來給每個節點添加惟一標識,這樣 React 就能匹配到原有的節點,提升轉換效率,以下面的例子:

// 更新前
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

// 更新後
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
複製代碼

若是沒有 key 值,React 會從新建立每個子元素,由於在比較 ul 的第一個子元素時發現二者不一樣,即開始重建,但當子元素擁有 key 時,React 使用 key 來匹配原有樹上的子元素以及最新樹上的子元素,如今 React 知道只有帶着 '2014' key 的元素是新元素,帶着 '2015' 以及 '2016' key 的元素僅僅移動了。

因此咱們在寫代碼時遇到列表渲染的時候,必定要記得給列表的每一項加上 key 屬性,這個 key 不須要全局惟一,但在列表中須要保持惟一。

Diff 算法的實現

咱們從 Diff 的入口函數 reconcileChildFibers 出發,該函數會根據 newChild(即 ReactElement 對象)類型調用不一樣的處理函數。其中幾個參數的含義以下:

  • newChild:即當前更新新生成的 ReactElement 對象。
  • returnFiber:表明當前 Diff 的 節點的父級 Fiber 節點,也就是上次更新時的 Fiber 節點。
  • currentFirstChild:即與 newChild 進行 diff 的節點,也是 returnFiber 的第一個子節點。

咱們能夠從同級的節點數量將 Diff 分爲兩類:

  1. 當 newChild 類型爲 object、number、string,表明同級只有一個節點
  2. 當 newChild 類型爲 Array,同級有多個節點

單節點 Diff

對於單個節點,咱們以類型 object 爲例,會進入 reconcileSingleElement 函數裏,這個函數主要作了如下事情:reconcileSingleElement 方法的部分代碼以下:

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {

  const key = element.key;

  let child = currentFirstChild;

  // 首先判斷是否存在對應DOM節點

  while (child !== null) {
    // 上一次更新存在DOM節點,接下來判斷是否可複用

    // 首先比較key是否相同
    if (child.key === key) {

      // key相同,接下來比較type是否相同
      switch (child.tag) {
        // ...省略case

        default: {
          if (child.elementType === element.type) {
            // type相同則表示能夠複用
            // 返回複用的fiber
            return existing;
          }
          // type不一樣則跳出循環
          break;
        }
      }

      // 代碼執行到這裏表明:key相同可是type不一樣
      // 將該fiber及其兄弟fiber標記爲刪除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不一樣,將該fiber標記爲刪除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }


  // 建立新Fiber,並返回 ...省略

}
複製代碼

多節點 Diff

當 ReactElement 的 children 屬性不是單一節點的話,以下面結構:

<ul>
    <li key="0">0</li>
    <li key="1">1</li>
    <li key="2">2</li>
    <li key="3">3</li>
 </ul>
複製代碼

此時它返回的對象的 children 是包含 4 個對象的數組:

{

  $typeof: Symbol(react.element),
  key: null,
  props: {
    children: [
      {$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {…}, …}
      {$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {…}, …}
      {$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {…}, …}
      {$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {…}, …}
    ]
  },
  ref: null,
  type: "ul"
}
複製代碼

這種狀況下,reconcileChildFibersnewChild參數類型爲Array,對應的處理函數是reconcileChildrenArray裏的newChildren,在比較時,和newChildren裏的每個child比較的是current fiber,即newChildren[0]fiber比較,newChildren[1]fiber.sibling比較。

多節點 diff 的狀況比較多比較複雜,大體能夠分爲如下幾個方面:

  1. 節點更新
  • 節點屬性變化
  • 節點類型變化
  1. 節點增長或減小
  2. 節點位置發生變化

React 團隊發現,在平常開發中,相較於新增和刪除,更新組件發生的頻率更高。因此 Diff 會優先判斷當前節點是否屬於更新。基於以上緣由,Diff 算法的總體邏輯會經歷兩輪:

  • 第一輪遍歷:處理更新的節點。
  • 第二輪遍歷:處理剩下的不屬於更新的節點。

第一輪遍歷的步驟以下:

  1. 遍歷 newChildren,將 newChildren[i] 與 oldFiber 比較,判斷 DOM 節點是否可複用。
  2. 若是可複用,i++,繼續比較 newChildren[i] 與 oldFiber.sibling,能夠複用則繼續遍歷。
  3. 若是不可複用,當即跳出整個遍歷,第一輪遍歷結束。
  4. 若是 newChildren 遍歷完(即 i === newChildren.length - 1)或者 oldFiber 遍歷完(即 oldFiber.sibling === null),跳出遍歷,第一輪遍歷結束。

第一輪遍歷結束後,有如下幾種結果:

  1. newChildren 與 oldFiber 同時遍歷完:這說明新舊節點數量同樣,只是組件發生了更新。此時 Diff 結束。
  2. newChildren 沒遍歷完,oldFiber 遍歷完:這說明舊的節點遍歷完了,可是還有新加入的節點,咱們只須要遍歷剩下的 newChildren 爲生成的 workInProgress fiber 依次標記上 Placement。
  3. newChildren 遍歷完,oldFiber 沒遍歷完:這說明本次更新比以前的節點數量變少了,有節點被刪除了,因此要遍歷剩下的 oldFiber,依次標記 Deletion。
  4. newChildren 與 oldFiber 都沒遍歷完:這意味着有節點在此次更新中改變了位置,這時候須要經過 key 來標記節點是否移動。

等上面全部的節點都遍歷完成後,都已經打上了增/刪/更新的標記,此時就生成了 workInProgress Fiber,剩下的工做就是交個 renderer 處理了。

相關文章
相關標籤/搜索