「譯」如何以及爲何 React Fiber 使用鏈表遍歷組件樹

React調度器中工做循環的主要算法

工做循環配圖,來自Lin Clark在ReactConf 2017精彩的 演講

爲了教育我本身和社區,我花了不少時間在Web技術逆向工程和寫個人發現。在過去的一年裏,我主要專一在Angular的源碼,發佈了網路上最大的Angular出版物—Angular-In-Depth如今我已經把主要精力投入到React中變化檢測已經成爲我在Angular的專長的主要領域,經過必定的耐心和大量的調試驗證,我但願能很快在React中達到這個水平。 在React中, 變化檢測機制一般稱爲 "協調" 或 "渲染",而Fiber是其最新實現。歸功於它的底層架構,它提供能力去實現許多有趣的特性,好比執行非阻塞渲染,根據優先級執行更新,在後臺預渲染內容等。這些特性在併發React哲學中被稱爲時間分片html

除了解決應用程序開發者的實際問題以外,這些機制的內部實現從工程角度來看也具備普遍的吸引力。源碼中有如此豐富的知識能夠幫助咱們成長爲更好的開發者。node

若是你今天谷歌搜索「React Fiber」,你會在搜索結果中看到不少文章。可是除了Andrew Clark的筆記,全部文章都是至關高層次的解讀。在本文中,我將參考Andrew Clark的筆記,對Fiber中一些特別重要的概念進行詳細說明。一旦咱們完成,你將有足夠的知識來理解Lin Clark在ReactConf 2017上的一次很是精彩的演講中的工做循環配圖。這是你須要去看的一次演講。可是,在你花了一點時間閱讀本文以後,它對你來講會更容易理解。react

這篇文章開啓了一個React Fiber內部實現的系列文章。我大約有70%是經過內部實現瞭解的,此外還看了三篇關於協調和渲染機制的文章。git

讓咱們開始吧!github

基礎

Fiber的架構有兩個主要階段:協調/渲染和提交。在源碼中,協調階段一般被稱爲「渲染階段」。這是React遍歷組件樹的階段,而且:web

  • 更新狀態和屬性
  • 調用生命週期鉤子
  • 獲取組件的children
  • 將它們與以前的children進行對比
  • 並計算出須要執行的DOM更新

全部這些活動都被稱爲Fiber內部的工做。 須要完成的工做類型取決於React Element的類型。 例如,對於 Class Component React須要實例化一個類,然而對於Functional Component卻不須要。若是有興趣,在這裏 你能夠看到Fiber中的全部類型的工做目標。 這些活動正是Andrew在這裏談到的:算法

在處理UI時,問題是若是一次執行太多工做,可能會致使動畫丟幀...數組

具體什麼是一次執行太多?好吧,基本上,若是React要同步遍歷整個組件樹併爲每一個組件執行任務,它可能會運行超過16毫秒,以便應用程序代碼執行其邏輯。這將致使幀丟失,致使不暢的視覺效果。瀏覽器

那麼有好的辦法嗎?markdown

較新的瀏覽器(和React Native)實現了有助於解決這個問題的API ...

他提到的新API是requestIdleCallback 全局函數,可用於對函數進行排隊,這些函數會在瀏覽器空閒時被調用。如下是你將如何使用它:

requestIdleCallback((deadline)=>{
    console.log(deadline.timeRemaining(), deadline.didTimeout)
});
複製代碼

若是我如今打開控制檯並執行上面的代碼,Chrome會打印49.9 false。 它基本上告訴我,我有49.9ms去作我須要作的任何工做,而且我尚未用完全部分配的時間,不然deadline.didTimeout 將會是true。請記住timeRemaining可能在瀏覽器被分配某些工做後當即更改,所以應該不斷檢查。

requestIdleCallback 實際上有點過於嚴格,而且執行頻次不足以實現流暢的UI渲染,所以React團隊必須實現本身的版本

如今,若是咱們將React對組件執行的全部活動放入函數performWork, 並使用requestIdleCallback來安排工做,咱們的代碼可能以下所示:

requestIdleCallback((deadline) => {
    // 當咱們有時間時,爲組件樹的一部分執行工做 
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent);
    }
});
複製代碼

咱們對一個組件執行工做,而後返回要處理的下一個組件的引用。若是不是由於如前面的協調算法實現中所示,你不能同步地處理整個組件樹,這將有效。 這就是Andrew在這裏談到的問題:

爲了使用這些API,你須要一種方法將渲染工做分解爲增量單元

所以,爲了解決這個問題,React必須從新實現遍歷樹的算法,從依賴於內置堆棧的同步遞歸模型,變爲具備鏈表和指針的異步模型。這就是Andrew在這裏寫的:

若是你只依賴於[內置]調用堆棧,它將繼續工做直到堆棧爲空。。。 若是咱們能夠隨意中斷調用堆棧並手動操做堆棧幀,那不是很好嗎?這就是React Fiber的目的。 Fiber是堆棧的從新實現,專門用於React組件。 你能夠將單個Fiber視爲一個虛擬堆棧幀。

這就是我如今將要講解的內容。

關於堆棧想說的

我假設大家都熟悉調用堆棧的概念。若是你在斷點處暫停代碼,則能夠在瀏覽器的調試工具中看到這一點。如下是維基百科的一些相關引用和圖表:

在計算機科學中,調用堆棧是一種堆棧數據結構,它存儲有關計算機程序的活躍子程序的信息...調用堆棧存在的主要緣由是跟蹤每一個活躍子程序在完成執行時應該返回控制的位置...調用堆棧由堆棧幀組成...每一個堆棧幀對應於一個還沒有返回終止的子例程的調用。例如,若是由子程序DrawSquare調用的一個名爲DrawLine的子程序當前正在運行,則調用堆棧的頂部可能會像在下面的圖片中同樣。

爲何堆棧與React相關?

正如咱們在本文的第一部分中所定義的,React在協調/渲染階段遍歷組件樹,併爲組件執行一些工做。協調器的先前實現使用依賴於內置堆棧的同步遞歸模型來遍歷樹。關於協調的官方文檔描述了這個過程,並談了不少關於遞歸的內容:

默認狀況下,當對DOM節點的子節點進行遞歸時,React會同時迭代兩個子節點列表,並在出現差別時生成突變。

若是你考慮一下,每一個遞歸調用都會向堆棧添加一個幀。而且是同步的。假設咱們有如下組件樹:

render函數表示爲對象。你能夠把它們想象成組件實例:

const a1 = {name: 'a1'};
const b1 = {name: 'b1'};
const b2 = {name: 'b2'};
const b3 = {name: 'b3'};
const c1 = {name: 'c1'};
const c2 = {name: 'c2'};
const d1 = {name: 'd1'};
const d2 = {name: 'd2'};

a1.render = () => [b1, b2, b3];
b1.render = () => [];
b2.render = () => [c1];
b3.render = () => [c2];
c1.render = () => [d1, d2];
c2.render = () => [];
d1.render = () => [];
d2.render = () => [];
複製代碼

React須要迭代樹併爲每一個組件執行工做。爲了簡化,要作的工做是打印當前組件的名字和獲取它的children。下面是咱們如何經過遞歸來完成它。

遞歸遍歷

循環遍歷樹的主要函數稱爲walk,實現以下:

walk(a1);

function walk(instance) {
    doWork(instance);
    const children = instance.render();
    children.forEach(walk);
}

function doWork(o) {
    console.log(o.name);
}
複製代碼

這裏是個人獲得的輸出:

a1, b1, b2, c1, d1, d2, b3, c2

若是你對遞歸沒有信心,請查看我關於遞歸的深刻文章

遞歸方法直觀,很是適合遍歷樹。可是正如咱們發現的,它有侷限性。最大的一點就是咱們沒法分解工做爲增量單元。咱們不能暫停特定組件的工做並在稍後恢復。經過這種方法,React只能不斷迭代直到它處理完全部組件,而且堆棧爲空。

那麼React如何實現算法在沒有遞歸的狀況下遍歷樹?它使用單鏈表樹遍歷算法。它使暫停遍歷並阻止堆棧增加成爲可能。

鏈表遍歷

我很幸運能找到SebastianMarkbåge在這裏概述的算法要點。 要實現該算法,咱們須要一個包含3個字段的數據結構:

  • child — 第一個子節點的引用
  • sibling — 第一個兄弟節點的引用
  • return — 父節點的引用

在React新的協調算法的上下文中,包含這些字段的數據結構稱爲Fiber。在底層它是一個表明保持工做隊列的React Element。更多內容見個人下一篇文章。

下圖展現了經過鏈表連接的對象的層級結構和它們之間的鏈接類型:

咱們首先定義咱們的自定義節點的構造函數:

class Node {
    constructor(instance) {
        this.instance = instance;
        this.child = null;
        this.sibling = null;
        this.return = null;
    }
}
複製代碼

以及獲取節點數組並將它們連接在一塊兒的函數。咱們將它用於連接render方法返回的子節點:

function link(parent, elements) {
    if (elements === null) elements = [];

    parent.child = elements.reduceRight((previous, current) => {
        const node = new Node(current);
        node.return = parent;
        node.sibling = previous;
        return node;
    }, null);

    return parent.child;
}
複製代碼

該函數從最後一個節點開始往前遍歷節點數組,將它們連接在一個單獨的鏈表中。它返回第一個兄弟節點的引用。 這是一個如何工做的簡單演示:

const children = [{name: 'b1'}, {name: 'b2'}];
const parent = new Node({name: 'a1'});
const child = link(parent, children);

// 下面兩行代碼的執行結果爲true
console.log(child.instance.name === 'b1');
console.log(child.sibling.instance === children[1]);
複製代碼

咱們還將實現一個輔助函數,爲節點執行一些工做。在咱們的狀況是,它將打印組件的名字。但除此以外,它也獲取組件的children並將它們連接在一塊兒:

function doWork(node) {
    console.log(node.instance.name);
    const children = node.instance.render();
    return link(node, children);
}
複製代碼

好的,如今咱們已經準備好實現主要遍歷算法了。這是父節點優先,深度優先的實現。這是包含有用註釋的代碼:

function walk(o) {
    let root = o;
    let current = o;

    while (true) {
        // 爲節點執行工做,獲取並鏈接它的children
        let child = doWork(current);

        // 若是child不爲空, 將它設置爲當前活躍節點
        if (child) {
            current = child;
            continue;
        }

        // 若是咱們回到了根節點,退出函數
        if (current === root) {
            return;
        }

        // 遍歷直到咱們發現兄弟節點
        while (!current.sibling) {

            // 若是咱們回到了根節點,退出函數
            if (!current.return || current.return === root) {
                return;
            }

            // 設置父節點爲當前活躍節點
            current = current.return;
        }

        // 若是發現兄弟節點,設置兄弟節點爲當前活躍節點
        current = current.sibling;
    }
}
複製代碼

雖然代碼實現並非特別難以理解,但你可能須要稍微運行一下代碼才能理解它。在這裏作。 思路是保持對當前節點的引用,並在向下遍歷樹時從新給它賦值,直到咱們到達分支的末尾。而後咱們使用return指針返回根節點。

若是咱們如今檢查這個實現的調用堆棧,下圖是咱們將會看到的:

正如你所看到的,當咱們向下遍歷樹時,堆棧不會增加。但若是如今放調試器到doWork函數並打印節點名稱,咱們將看到下圖:

**它看起來像瀏覽器中的一個調用堆棧。**因此使用這個算法,咱們就是用咱們的實現有效地替換瀏覽器的調用堆棧的實現。這就是Andrew在這裏所描述的:

Fiber是堆棧的從新實現,專門用於React組件。你能夠將單個Fiber視爲一個虛擬堆棧幀。

所以咱們如今經過保持對充當頂部堆棧幀的節點的引用來控制堆棧:

function walk(o) {
    let root = o;
    let current = o;

    while (true) {
            ...

            current = child;
            ...

            current = current.return;
            ...

            current = current.sibling;
    }
}
複製代碼

咱們能夠隨時中止遍歷並稍後恢復。這正是咱們想要實現的可以使用新的requestIdleCallback API的狀況。

React中的工做循環

這是在React中實現工做循環的代碼

function workLoop(isYieldy) {
    if (!isYieldy) {
        // Flush work without yielding
        while (nextUnitOfWork !== null) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }
    } else {
        // Flush asynchronous work until the deadline runs out of time.
        while (nextUnitOfWork !== null && !shouldYield()) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
        }
    }
}
複製代碼

如你所見,它很好地映射到我上面提到的算法。nextUnitOfWork變量做爲頂部幀,保留對當前Fiber節點的引用。

該算法能夠同步地遍歷組件樹,併爲樹中的每一個Fiber點執行工做(nextUnitOfWork)。 這一般是由UI事件(點擊,輸入等)引發的所謂交互式更新的狀況。或者它能夠異步地遍歷組件樹,檢查在執行Fiber節點工做後是否還剩下時間。 函數shouldYield返回基於deadlineDidExpiredeadline變量的結果,這些變量在React爲Fiber節點執行工做時不停的更新。

這裏深刻介紹了peformUnitOfWork函數。


我正在寫一系列深刻探討React中Fiber變化檢測算法實現細節的文章。

請繼續在TwitterMedium上關注我,我會在文章準備好後當即發tweet。

謝謝閱讀!若是你喜歡這篇文章,請點擊下面的點贊按鈕👏。這對我來講意義重大,而且能夠幫助其餘人看到這篇文章。

相關文章
相關標籤/搜索