React排程器中工作迴圈的主要演算法
工作迴圈配圖,來自Lin Clark在ReactConf 2017精彩的演講為了教育我自己和社群,我花了很多時間在Web技術逆向工程和寫我的發現。在過去的一年裡,我主要專注在Angular的原始碼,釋出了網路上最大的Angular出版物—Angular-In-Depth。現在我已經把主要精力投入到React中。變化檢測已經成為我在Angular的專長的主要領域,通過一定的耐心和大量的除錯驗證,我希望能很快在React中達到這個水平。 在React中, 變化檢測機制通常稱為 "協調" 或 "渲染",而Fiber是其最新實現。歸功於它的底層架構,它提供能力去實現許多有趣的特性,比如執行非阻塞渲染,根據優先順序執行更新,在後臺預渲染內容等。這些特性在併發React哲學中被稱為時間分片。
除了解決應用程式開發者的實際問題之外,這些機制的內部實現從工程角度來看也具有廣泛的吸引力。原始碼中有如此豐富的知識可以幫助我們成長為更好的開發者。
如果你今天谷歌搜尋“React Fiber”,你會在搜尋結果中看到很多文章。但是除了Andrew Clark的筆記,所有文章都是相當高層次的解讀。在本文中,我將參考Andrew Clark的筆記,對Fiber中一些特別重要的概念進行詳細說明。一旦我們完成,你將有足夠的知識來理解Lin Clark在ReactConf 2017上的一次非常精彩的演講中的工作迴圈配圖。這是你需要去看的一次演講。但是,在你花了一點時間閱讀本文之後,它對你來說會更容易理解。
這篇文章開啟了一個React Fiber內部實現的系列文章。我大約有70%是通過內部實現瞭解的,此外還看了三篇關於協調和渲染機制的文章。
讓我們開始吧!
基礎
Fiber的架構有兩個主要階段:協調/渲染和提交。在原始碼中,協調階段通常被稱為“渲染階段”。這是React遍歷元件樹的階段,並且:
- 更新狀態和屬性
- 呼叫生命週期鉤子
- 獲取元件的
children
- 將它們與之前的
children
進行對比 - 並計算出需要執行的DOM更新
所有這些活動都被稱為Fiber內部的工作。 需要完成的工作型別取決於React Element的型別。 例如,對於
Class Component
React需要例項化一個類,然而對於Functional Component
卻不需要。如果有興趣,在這裡
你可以看到Fiber中的所有型別的工作目標。 這些活動正是Andrew在這裡談到的:
在處理UI時,問題是如果一次執行太多工作,可能會導致動畫丟幀...
具體什麼是一次執行太多?好吧,基本上,如果React要同步遍歷整個元件樹併為每個元件執行任務,它可能會執行超過16毫秒,以便應用程式程式碼執行其邏輯。這將導致幀丟失,導致不順暢的視覺效果。
那麼有好的辦法嗎?
較新的瀏覽器(和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
返回基於deadlineDidExpire和deadline變數的結果,這些變數在React為Fiber節點執行工作時不停的更新。
這裡深入介紹了peformUnitOfWork
函式。
我正在寫一系列深入探討React中Fiber變化檢測演算法實現細節的文章。
請繼續在Twitter和Medium上關注我,我會在文章準備好後立即發tweet。
謝謝閱讀!如果你喜歡這篇文章,請點選下面的點贊按鈕?。這對我來說意義重大,並且可以幫助其他人看到這篇文章。