本文爲意譯和整理,若有誤導,請放棄閱讀。原文html
這篇文章主要是探索React reconciler的新實現-Fiber中的work loop。在文本中,咱們對比和解釋了【瀏覽器的call stack】和【React Fiber架構本身實現的stack】之間的不一樣。node
爲了自學和回饋社區,我花費了大量的時間去作web技術的逆向工程方面的實踐,並寫文章記錄個人發現。在上一年,我主要是聚焦在Angular的源碼上,在網上發表關於Angular方面最大數量的文章-Angular-In-Depth。如今,是時候要深刻到React中來了。Change detection 是我在Angular那邊專攻而且有專業水準的領域。但願在足夠的耐心和大量的debugging下面,我可以早日在React這邊達到一樣的專業水平。react
在React中,change detection機制經常被稱爲「reconciliation」或者「rendering」。而Fiber就是reconciliation的最新實現。在Fiber架構的底層,它爲咱們提供了實現各類有趣特性的能力。好比說:「非阻塞型的渲染(non-blocking rendering)」,「基於priority的更新策略」和「在後臺作內容預渲染(pre-rendeing)」。在這些特性在Concurrent React philosophy中被統稱爲時間切片(time slicing)。git
除了爲應用開發者解決實際的問題外,從(軟件)工程學的角度來看,這些機制的內部實現一樣是有着強大的吸引力。在源碼裏面,有着大量的知識可以幫助你成爲更加優秀的開發者。github
今天,若是你去google「React Fiber」的話,你將會看到大量的關於這方面的文章。它們之中,除了一篇高質量的notes by Andrew Clark ,其餘文章的質量嘛......你懂的。本文中,我將會引用這篇筆記裏面的某些論述。對Fiber架構中一些挺特別重要的概念,我會給出更加詳盡的解釋。一旦你看完並理解了這篇文章,你就能很好地看懂來自於ReactConf 2017 Lin Clark的一個很好的演講。 這是一個你須要好好瞧瞧,好好聽聽的演講。可是若是你可以花點時間去研究一下源碼,而後回來再看這個演講,效果更佳。web
這篇文章了開闢了個人【深刻xxx】系列的先河。我相信,我已經大概弄懂了Fiber內部實現細節的70%了。於此同時,我準備要寫三篇關於reconciliation和rendering的文章。算法
下面,讓咱們開始咱們的探索之旅吧。數組
Fiber架構有兩個主要的階段:瀏覽器
reconciliation/render階段和commit階段。在源碼中,大部分地方都會把「reconciliation階段」稱爲「render階段」。就是在render階段裏面,React遍歷了組件樹,而且作了如下的這些事情:bash
在Fiber架構中,全部的這些activity都被稱爲「work」。一個fiberr node須要作什麼樣的work取決於其對應的react element是什麼的類型。打比方說,對於一個class component而言,React須要作的work就是實例化這個組件。而對於functional component來講,它沒有這樣的work須要去完成。若是你感興趣的話,這裏有你想看的全部的work的類型。這些activity就是Andrew在他的筆記中所說的東西:
When dealing with UIs, the problem is that if too much work is executed all at once, it can cause animations to drop frames…
那上面的「all at once」該如何理解呢?好,基本上,React會以同步的方式去遍歷整一顆組件樹,對每一個組件進行具體的操做。這樣會有什麼樣的問題呢?實際上,這種方式可能會致使應用邏輯代碼的執行時間超過可用的16毫秒。這就會引發界面渲染的掉幀,產生卡頓的視覺效果。
那麼,這個問題能被解決嗎?
Newer browsers (and React Native) implement APIs that help address this exact problem…
他說的新API就是全局函數requestIdleCallback 。這個全局函數能將一個函數入隊起來,而後在瀏覽器空閒時間裏面再去調用它。下面是一個使用的例子:
requestIdleCallback((deadline)=>{
console.log(deadline.timeRemaining(), deadline.didTimeout)
});
複製代碼
若是我在Chrome瀏覽器的控制檯上輸入以上代碼,並執行它。那麼,咱們會看到控制檯打印出49.9 false
。這個運行結果基本上是在告訴我,你有49.9毫秒的私人時間去作你本身的事情,false
是在告訴我,你沒有用完我(指瀏覽器)分配給你的時間。若是我用完了這個時間,deadline.didTimeout
的值將會是true
。須要時刻提醒本身的是,隨着瀏覽器的運行,timeRemaining
的字段值會改變的。因此,咱們應該常常性地去檢查這個字段的值。
requestIdleCallback
實際上的限制比較多和它的執行頻率也不夠高,致使了沒法創造一個流暢的界面渲染體驗。因此,React團隊不得不實現本身的版本。
如今,假設咱們把React在組件上須要執行的全部的work都放進了performWork
函數裏面,而後用requestIdleCallback
去調度這個函數,那麼咱們的實現代碼應該差很少是這樣子的:
requestIdleCallback((deadline) => {
// while we have time, perform work for a part of the components tree
while ((deadline.timeRemaining() > 0 || !deadline.didTimeout) && nextComponent) {
nextComponent = performWork(nextComponent);
}
});
複製代碼
咱們一個接一個地在組件上執行work,而後相繼地在處理完當前組件以後把下一個組件的引入返回出去,再接着處理。按理說,這種實現應該是可行的。可是這裏有一個問題。你不能像reconciliation算法之前的實現那樣,採用同步的方式去遍歷整一顆組件樹。這就是Andrew筆記中所提到的問題:
in order to use those APIs, you need a way to break rendering work into incremental units
所以,爲了解決這個問題,React不得不將遍歷組件樹所用到的算法從新實現一遍。把它從原來的,依賴於瀏覽器原生call stack的【同步遞歸模式】改成【用鏈表和指針實現的異步模式】。Andrew對於這一點,如是說:
若是你僅僅依賴於瀏覽器原生的call stack的話,那麼call stack會一直執行咱們的代碼,直到本身被清空爲止.....若是咱們能手動地,任意地中斷call stack和操做call stack的每個幀豈不是很棒嗎?而這就是React Fiber的目的。React Fiber是對stack的從新實現,特別是爲了React組件而做的實現。你能夠把單獨的一個fiber node理解爲一個virtual stack frame。
而Andrew說的這些話也正是我打算深刻解釋的東西。
我假設大家都熟悉「call stack」這個概念。它是你在瀏覽器開發工具打斷點的時候調試面板所看到的東西。下面是來自於維基百科的引用和圖示:
在計算機科學中,call stack是一種存儲計算機程序當前正在執行的子程序(subroutine)信息的棧結構......使用call stack的主要緣由是保存一個用於追蹤【當前子程序執行完畢後,程序控制權應該歸還給誰】的指針.....一個call stack是由多個stack frame組成。每一個stack frame對應於一個子程序調用。做爲stack frame,這個子程序此時應該尚未被return語句所終結。舉個例子,咱們有一個叫
DrawLine
的子程序正在運行。這個子程序又被另一個叫作DrawSquare
的子程序所調用,那麼call stack中頂部的佈局應該長成下面那樣:
在上面【背景交代】一小節中,咱們提到,React會在reconciliation/render階段遍歷整一顆組件樹,而後針對每一組件去執行具體的work。在reconciler的先前的實現中,React使用了【同步遞歸模式】。這種模式依賴於瀏覽器原生的call stack。這篇官方文檔對這個處理流程進行了闡述,並大量地談到遞歸:
By default, when recursing on the children of a DOM node, React just iterates over both lists of children at the same time and generates a mutation whenever there’s a difference.
若是你可以對此進行思考的話,你就會知道,每遞歸調用一次就是往call stack上增長一個stack frame。這樣子的話,整個遞歸流程表現得是如此的同步(太過同步,某種程度下就表明着阻塞call stack。想深刻call stack,請查閱我整理的:Event Loop究竟是什麼鬼?)。假設咱們有如下的一顆組件樹:
咱們用一個帶有render
方法的object去表明每一個節點。你也能夠把這個object當作是組件的實例;
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須要迭代整一顆樹,對每個組件執行某些work。爲了簡單起見,咱們把組件須要執行的work定義爲「打印組件的名字,並返回children」。下面一小節就是講述咱們是如何用遞歸的方式去實現它的。
負責對組件樹迭代的函數叫作walk
。它的具體實現以下:
function walk(instance) {
doWork(instance);
const children = instance.render();
children.forEach(walk);
}
function doWork(o) {
console.log(o.name);
}
walk(a1);
複製代碼
執行以上代碼,你將會看到如下的輸出:
a1, b1, b2, c1, d1, d2, b3, c2
複製代碼
若是你以爲本身對遞歸的理解不夠深刻的話,歡迎去閱讀個人深刻講解遞歸的文章。
在這裏使用遞歸是一種很好的直覺,而且也很適合組件樹遍歷。可是,咱們也發現了它的侷限性。其中最大的一點是【咱們不能把work拆分爲增量單元(incremental units)】。咱們不能暫停一個特定組件work的執行,而後稍後再恢復執行它。遞歸模式下,React只能一直迭代下去,直到全部的組件都被處理一遍,call stack清空了才停下來(此謂之「one pass」)。
那麼問題就來了。在不使用遞歸模式的狀況下,React是如何實現遍歷組件樹的算法呢?答案是,它使用了單鏈表(singly-linked-list)式的樹遍歷算法。這使得遍歷暫停和防止stack高度增加成爲了可能(stop the stack from growing)。
我慶幸本身在這裏找到Sebastian Markbåge總結的算法概述。爲了實現這個算法,咱們須要由三個字段連接起來的數據結構:
在React新的reconciliation算法這個語境下,具有這三個字段的數據結構被稱爲「Fiber node」。在底層,它表明着具備work須要執行的react element。想看更多展開的闡述,請看我下一篇文章。
下面這個圖演示了linked-list中節點的層級和它們之間存在的關係:
下面,讓咱們一塊兒來定義一下咱們本身的fiber node的構造函數吧:
class Node {
constructor(instance) {
this.instance = instance;
this.child = null;
this.sibling = null;
this.return = null;
}
}
複製代碼
下面再實現一個將從組件實例的render方法返回的children連接在一塊,使它們成爲linked-list的函數。這個函數接收一個【parent fiber node】和【由組件實例組成的數組】做爲輸入,最後返回parent fiber node的第一個child的引用:
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;
}
複製代碼
這個函數從倒數第一個開始(注意看,這裏是用了reduceRight方法),遍歷數組裏面的每個元素,把它們連接成一個linked-list。最後,把parent fiber node的第一個child fiber node的引用返回出去。下面這個代碼演示一下這個函數的使用:
const children = [{name: 'b1'}, {name: 'b2'}];
const parent = new Node({name: 'a1'});
const child = link(parent, children);
// the following two statements are true
console.log(child.instance.name === 'b1');
console.log(child.sibling.instance === children[1]);
複製代碼
同時,咱們也須要實現一個helper函數來幫助咱們在fiber node身上執行具體的work。在本示例中,這個work就是簡單地打印出組件實例的名字。這個helper函數除了執行work以外,還獲取到了組件最新的children list,而後將他們連接到一塊了:
function doWork(node) {
console.log(node.instance.name);
const children = node.instance.render();
return link(node, children);
}
複製代碼
okay,萬事俱備只欠東風。下面,咱們去實現具體的遍歷算法。這是一個深度優先的算法實現。下面是加上了點註釋的實現代碼:
// 參數o你能夠說它是一個fiber node也能夠說它是一顆fiber node tree
function walk(o) {
let root = o;
let current = o;
while (true) {
// perform work for a node, retrieve & link the children
let child = doWork(current);
// if there is a child, set it as the current active node
if (child) {
current = child;
continue;
}
// if we have returned to the top, exit the function
if (current === root) {
return;
}
// keep going up until we find the sibling
while (!current.sibling) {
// if we have returned to the top, exit the function
if (!current.return || current.return === root) {
return;
}
// set the parent as the current active node
current = current.return;
}
// if found, set the sibling as the current active node
current = current.sibling;
}
}
複製代碼
儘管上面的實現代碼不是特別難以理解。可是,我以爲你最好好好把玩一下它,這樣你才能理解得更透徹。Do it here。這個實現的中心思想是:保留一個指向當前被處理fiber node的引用,隨着深度優先的向下遍歷,不斷地修正這個引用,直到遍歷觸及到這個樹分支的葉子節點。一旦到底了,咱們就經過return
字段,層層地返回到上一層的parent fiber node上去。
若是此時咱們去看看這個實現的call stack的話,那麼咱們將會看到這樣的畫面:
正如你所看到的那樣,隨着咱們的遍歷,這個stack的高度並無增長。可是若是咱們在doWork函數裏面打個斷點的話,並把組件實例節點的名字打印出來的話,咱們將會看到這樣的結果:
這個結果的動畫跟瀏覽器call stack的表現很像(不一樣點在於,call stack的棧底是在下面,而這裏是在上面)。有了這個算法實現,咱們就可以很好地把瀏覽器的call stack替換爲咱們本身的stack。這就是Andrew在他的筆記中所講到的一點:
Fiber is re-implementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.
如今咱們可以保存一個fiber node的引用(這個fiber node充當着stack的top frame),並經過不斷地切換它的指向
某種狀況下,指向它的child fiber node,某種狀況下指向它的sibling fiber node,某種狀況下指向它的return/parent fiber node
來控制咱們的「call stack」了:
function walk(o) {
let root = o;
let current = o;
while (true) {
...
current = child;
...
current = current.return;
...
current = current.sibling;
}
}
複製代碼
所以,咱們可以在遍歷過程當中隨意地暫停和恢復執行。而這也是可以使用新的requestIdleCallback
API的先決條件。
下面是React中實現work loop的代碼:
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);
}
}
}
複製代碼
正如你所看到的那樣,React中的實現跟咱們上面提到的算法實現十分類似。它也是經過nextUnitOfWork
變量來保存一個【表明top frame的】fiber node引用。
React實現的walk loop算法能以同步的方式去遍歷組件樹,並在每一個fiber node上(nextUnitOfWork)執行某些 work。同步的方式每每是發生在所謂的【因爲UI事件(好比,click,input等)的發生而致使的】「交互式更新」場景下。除了同步方式,walk loop也可以以異步的方式去進行。在遍歷過程當中,在每執行一個fiber node相關work以前,該算法會去檢查當前是否還有可用時間。shouldYield
函數會基於deadlineDidExpire和deadline的變量值去返回結果。這兩個變量的值會隨着React對fiber node的work執行的推動而隨時被更新的。
想要了解peformUnitOfWork
的更多細節,請查閱這篇文章:深刻React Fiber架構的reconciliation 算法。