【react】什麼是fiber?fiber解決了什麼問題?從源碼角度深入了解fiber運行機制與diff執行

壹 ❀ 引
我在[react] 什麼是虛擬dom?虛擬dom比操作原生dom要快嗎?虛擬dom是如何轉變成真實dom並渲染到頁面的?一文中,介紹了虛擬dom的概念,以及react中虛擬dom的使用場景。那麼按照之前的約定,本文來聊聊react中另一個非常重要的概念,也就是fiber。那麼通過閱讀本文,你將了解到如下幾個知識點:
react在使用fiber之前為什麼會出現丟幀(卡頓)?- 如何理解瀏覽器中的幀?
- 什麼是
fiber?它解決了什麼問題? fiber有哪些優勢?- 了解
requestIdleCallback react中的fiber是如何運轉的(fiber的兩個階段)diff源碼分析(基於react 17.0.2)
同樣,若文中涉及到的源碼部分,我依然會使用17.0.2的版本,保證文章的結論不會過於老舊;其次,fiber的概念理解起來其實比較枯燥,但我會盡量描述的通俗易懂一點,那麼本文開始。
貳 ❀ 在fiber之前
我們學習任何東西,一定會經歷兩個階段,一是這個東西是什麼?二是這個東西有什麼用(解決了什麼問題)?所以在介紹fiber之前,我們還是先說說在fiber之前react遇到了什麼問題,而這個問題,我們可以通過自己手寫一個簡單的render來模擬react 15之前的渲染過程。
通過虛擬dom一文,我們已經知道所謂虛擬dom其實就是一個包含了dom節點類型type,以及dom屬性props的對象,我們假定有如下一段dom資訊,現在需要通過自定義方法render將其渲染到頁面:
const vDom = {
type: "div",
props: {
id: "0",
children: [
{
type: "span",
children: 111,
},
],
},
};
其實一共就三步,創建dom,加工屬性,以及遞歸處理子元素,直接上程式碼:
const render = (element, container) => {
// 創建dom節點
let dom = document.createElement(element.type);
// 添加屬性
const props = Object.keys(element.props);
props.forEach((e) => {
if (e !== "children") {
dom[e] = element.props[e];
}
});
// 處理子元素
if (Array.isArray(element.props.children)) {
// 是數組,那就繼續遞歸
element.props.children.forEach((c) => render(c, dom));
} else {
// 是文本節點就設置文本
dom.innerHTML = element.props.children;
}
// 將當前加工好的dom節點添加到父容器節點中
container.appendChild(dom);
};
render(vDom, document.getElementById("root"));
通過這段程式碼,你應該想到了一個問題,假設我們的dom結果非常複雜,react在遞歸進行渲染時一定會非常耗時;而這段程式碼又是同步執行,遞歸一旦開始就不能停止。
大家都知道瀏覽器是單執行緒,JS執行緒與UI執行緒互斥,假設這段程式碼運行的時間足夠久,那麼瀏覽器就必須一直等待,嚴重情況下瀏覽器還可能失去響應。
當然,react團隊大佬雲集,不至於說react會在渲染上嚴重卡頓,但在極端情況下,react在渲染大量dom節點時還是會出現丟幀問題,這個現象大家可以對比react 15(棧實現)與react引入fiber之後的渲染差異Fiber vs Stack Demo:


很顯然,在引入fiber概念以及Reconcilation(diff相關)重構後,react在渲染上可以說跟德芙一樣縱享絲滑了。
即便現在我們還未了解fiber,但通過了解傳統的遞歸渲染,我們知道了同步渲染會佔用線層,既然fiber能解決這個問題,我們可以猜測到fiber一定會有類似執行緒控制的操作,不過在介紹fiber之前,我們還是得介紹瀏覽器幀的概念,以及為啥react 15會有掉幀的情況,這對於後續理解fiber也會有一定的幫助,我們接著聊。
叄 ❀ 幀的概念
如何理解幀?很直觀的解釋可以借用動畫製作製程,傳統的動畫製作其實都是逐幀拍攝,動畫作者需要將一個連貫的畫面一張一張的畫出來,然後再結合畫面的高速切換以達到動畫的效果,我相信不少人在讀書時代應該也做過在課本每一頁畫畫然後玩翻頁動畫的事情。

所以如果一個連貫動作我們用100個畫面去呈現,那麼你會發現這個畫面看起來非常流暢,但如果我們抽幀到只有10幀,人物的動作就會顯得不連貫且卡頓,這時候大家就說開啟眨眼補幀模式。不過在影片混剪上,也有人還會故意用抽幀來達到王家衛電影的拖影效果,但這都是藝術表現層面的話術了。
所以回到瀏覽器渲染,我們其實也可以將瀏覽器的動畫理解成一張張的圖,而主流的顯示器刷新率其實都是60幀/S,也就是一秒畫面會高速的刷新60次,按照電腦1S等於1000ms的設定,那麼一幀的預算時間其實是1000ms/60幀也就是16.66ms。
在實現動畫效果時,我們有時候會使用到window.requestAnimationFrame方法,關於其解釋可見requestAnimationFrame MDN:
window.requestAnimationFrame()告訴瀏覽器——你希望執行一個動畫,並且要求瀏覽器在下次重繪之前調用指定的回調函數更新動畫。該方法需要傳入一個回調函數作為參數,該回調函數會在瀏覽器下一次重繪之前執行。
而16.66ms也不是我們隨口一說,我們可以通過一個簡單的例子來驗證這個結論:
<div id="some-element-you-want-to-animate"></div>
const element = document.getElementById('some-element-you-want-to-animate');
let start;
// callback接受一個由瀏覽器提供的,當函數開始執行的時間timestamp
function step(timestamp) {
if (start === undefined) {
start = timestamp;
}
// 計算每一幀刷新時的類增時間
const elapsed = timestamp - start;
console.log(elapsed);
//這裡使用`Math.min()`確保元素剛好停在 200px 的位置。
element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
if (elapsed < 2000) { // 在兩秒後停止動畫
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);

大家有興趣可以在本地運行下這個例子,可以看到當每一幀中執行step方法時,所接受的開始時間的時間差都是16.66ms。如果你的時間差要低於16.66ms,那說明你使用的電腦顯示器刷新率要高於60幀/S。
我們人眼在舒適放鬆時可視幀數是24幀/S,也就是說1S起碼得得有24幀我們才會覺得畫面流程,但前文也說了,react 15之前的版本實現,渲染任務只要過長就會一直佔用執行緒導致瀏覽器渲染任務推遲,如果這個渲染之間夾雜了多次推遲,瀏覽器1S都不夠渲染60幀甚至更低,那瀏覽器渲染的整體幀率自然就會下降,我們在視覺上的直觀感受就是掉幀了。
那麼到這裡,我們解釋了react 15掉幀的根本原因,傳統的遞歸調用棧的實現,在長任務面前會造成執行緒佔用的情況,嚴重的話就會掉幀,react急需另一種策略來解決這個問題,接下來我們就來好好聊聊fiber。
肆 ❀ fiber是什麼?
那麼如何理解react中的fiber呢,兩個層面來解釋:
- 從運行機制上來解釋,
fiber是一種流程讓出機制,它能讓react中的同步渲染進行中斷,並將渲染的控制權讓回瀏覽器,從而達到不阻塞瀏覽器渲染的目的。 - 從數據角度來解釋,
fiber能細化成一種數據結構,或者一個執行單元。
我們可以結合這兩點來理解,react會在跑完一個執行單元後檢測自己還剩多少時間(這個所剩時間下文會解釋),如果還有時間就繼續運行,反之就終止任務並記錄任務,同時將控制權還給瀏覽器,直到下次瀏覽器自身工作做完,又有了空閑時間,便再將控制權交給react,以此反覆。
傳統遞歸,一條路走到黑

react fiber,靈活讓出控制權保證渲染與瀏覽器響應

而關於fiber數據結構,我在虛擬dom一文其實也簡單提到過,每一個被創建的真實dom都會被包裝成一個fiber節點,它具備如下結構:
const fiber = {
stateNode,// dom節點實例
child,// 當前節點所關聯的子節點
sibling,// 當前節點所關聯的兄弟節點
return// 當前節點所關聯的父節點
}

這樣設計的好處就是在數據層已經在不同節點的關係給描述了出來,即便某一次任務被終止,當下次恢復任務時,這種結構也利於react恢復任務現場,知道自己接下來應該處理哪些節點。
當然,上面也抽象只是解釋fiber是個什麼東西,結合react的角度,綜合來講react中的fiber其實具備如下幾點核心特點:
- 支援增量渲染,
fiber將react中的渲染任務拆分到每一幀。(不是一口氣全部渲染完,走走停停,有時間就繼續渲染,沒時間就先暫停) - 支援暫停,終止以及恢復之前的渲染任務。(沒渲染時間了就將控制權讓回瀏覽器)
- 通過
fiber賦予了不同任務的優先順序。(讓優先順序高的運行,比如事件交互響應,頁面渲染等,像網路請求之類的往後排) - 支援並發處理(結合第3點理解,面對可變的一堆任務,
react始終處理最高優先順序,靈活調整處理順序,保證重要的任務都會在允許的最快時間內響應,而不是死腦筋按順序來)
到這裡,我相信大家腦中應該有了一個模糊的理解了,可能有同學就好奇了,那這個fiber是怎麼做到讓出控制權的呢?react又是怎麼知道接下來自己可以執行的呢?那接下里,我們就不得不介紹另一個API requestIdleCallback。
伍 ❀ 關於requestIdleCallback
關於requestIdleCallback詳情大家可以查看requestIdleCallback mdn介紹,這裡普及下概念:
window.requestIdleCallback()方法插入一個函數,這個函數將在瀏覽器空閑時期被調用。這使開發者能夠在主事件循環上執行後台和低優先順序工作,而不會影響延遲關鍵事件,如動畫和輸入響應。
與requestAnimationFrame類似,requestIdleCallback也能接受一個callback,而這個callback又能接收一個由瀏覽器告知你執行剩餘時間的參數IdleDeadline,我們來看個簡單的例子:
const process = (deadline) => {
// 通過deadline.timeRemaining可獲取剩餘時間
console.log('deadline', deadline.timeRemaining());
}
window.requestIdleCallback(process);

簡單點來說,這個方法其實是瀏覽器在有空閑時間時會自動調用,而且瀏覽器會告訴你剩餘時間還剩多少。
因此,我們可以將一些不太重要的,或者優先順序較低的事情丟在requestIdleCallback裡面,然後判斷有沒有剩餘時間,再決定要不要做。當有時間時我們可以去做需要做的事情,而我們決定不做時,控制權也會自然回到瀏覽器手裡,畢竟瀏覽器也不會因為JS沒事幹而自己閑著。那麼這個剩餘時間是怎麼算的呢?
通過上文我們知道,所謂掉幀就是,正常來說瀏覽器1S本來是可以渲染60幀,但由於執行緒一直被JS占著,導致瀏覽器響應時的時間已經不夠渲染這麼多次了,所以整體上1S能渲染的幀數比較低,這就是我們所謂的掉幀。而一般情況下,1幀的時間是16.66ms,那是不是表示剩餘時間 = 16.66ms - (瀏覽器處理完自己的事情的時間) 呢?
確實是這樣,但需要注意的是,在一些極端情況下,瀏覽器會最多給出50ms的空閑時間給我們處理想做的事情,比如我們一些任務非常耗時,瀏覽器知道我們會耗時,但為了讓頁面呈現儘可能不要太卡頓,同時又要照顧JS執行緒,所以它會主動將一幀的用時從16.66ms提升到50ms,也就是說此時1S瀏覽器至多能渲染20幀。
我們可以通過如下程式碼來故意造成耗時的場景,然後再來查看剩餘時間:
// 用於造成耗時情況的函數
const delay = (time) => {
let now = Date.now();
// 這段邏輯會佔用time時長,所以執行完它需要time時間
while (time + now > Date.now()) {};
}
// 待辦事項
let work = [
() => {
console.log('任務1')
// 故意佔用1S時間
delay(1000);
},
() => {
console.log('任務2')
delay(1000);
},
() => {
console.log('任務3')
},
() => {
console.log('任務4')
},
];
const process = (deadline) => {
// 通過deadline.timeRemaining可獲取剩餘時間
console.log('deadline', deadline.timeRemaining());
// 還有剩餘時間嗎?還有剩餘工作嗎?如果都滿足,那就再做一個任務吧
if (deadline.timeRemaining() > 0 && work.length > 0) {
work.shift()();
}
// 如果還有任務,繼續調用requestIdleCallback
if (work.length) {
window.requestIdleCallback(process);
}
}
window.requestIdleCallback(process);

可以看到,第一個輸出的剩餘時間還是很少的,但第一個任務結尾處有一個耗時的邏輯,所以瀏覽器直接將1幀的剩餘時間提到了50ms,而為什麼偏偏是50ms呢,其實還是跟性能相關,如下:
| 延遲時間 | 用戶感知 |
|---|---|
| 0-16ms | 非常流暢 |
| 0-100ms | 基本流暢 |
| 100-1000ms | 能感覺到有一些延遲 |
| 1000ms或更多 | 失去耐心 |
| 10000ms以上 | 拜拜,再也不來了 |
在沒有辦法的情況下,又要保持瀏覽器響應,又要盡量保證刷新看起來流程,50ms也算瀏覽器的一種折中方案了。
那麼在了解了requestIdleCallback之後,我們知道了fiber是如何實現控制權讓出的,這很重要。
但需要注意的是,react在最終實現上並未直接採用requestIdleCallback,一方面是requestIdleCallback目前還是實驗中的api,兼容性不是非常好,其次考慮到剩餘時間提升到50ms也就20幀左右,體驗依舊不是很好。於是react通過MessageChannel + requestAnimationFrame 自己模擬實現了requestIdleCallback。
上文我們已經介紹了requestAnimationFrame會在每一幀繪製前被瀏覽器調用,所以react將想要做的事放在requestAnimationFrame的callback中,而callback能接受到瀏覽器傳遞過來的幀的起始時間timestamp,所以react自己動手計算幀與幀的時間差,以此判斷是否超出預期時間。這部分知識我個人感覺有些超綱,大家如果自己感興趣,可以直接搜下react 中 requestIdleCallback 的實現原理這個關鍵詞,這裡就不模擬這個實現過程了。
陸 ❀ react中的fiber是如何運轉的?
fiber在渲染中每次都會經歷協調Reconciliation與提交Commit兩個階段。
協調階段:這個階段做的事情很多,比如fiber的創建diff對比等等都在這個階段。在對比完成之後即等待下次提交,需要注意的是這個階段可以被暫停。
提交階段:將協調階段計算出來的變更一次性提交,此階段同步進行且不可中斷(優先保證渲染)。
那麼接下來我將從源碼角度,給大家展示下react是如何創建fiber節點,Reconciliation(diff)是如何對比,以及前文提到的剩餘時間是如何運轉的。
為了更好理解下面的源碼,我以下面這個組件為模板:
const P = () => {
const [state, setState] = useState({ a: 1, b: 2 });
const handleState = useCallback(() => {
setState({ a: 2, b: 3 });
}, []);
return (
<div>
<span id="span1">{state.a}</span>
<span id="span2">{state.b}</span>
<button onClick={handleState}>點我</button>
</div>
);
};
陸 ❀ 壹 fiber的創建與節點關係的建立
react會在準備好虛擬dom之後再基於虛擬dom創建fiber節點,那麼這裡我們就來闡述fiber是如何創建,以及如何建立兄弟父級關係的。
需要注意的是,這次的源碼分析我不會再從render方法開始,上面的組件P中的div有三個子元素,因為是個數組,這裡我們直接關注到reconcileChildrenArray方法,如果大家也想跟這個過程,可以在本地項目啟動後,然後在react-dom.development文件搜索此方法再斷點,如果只是想看源碼,可以直接跳轉github地址,具體程式碼如下:
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// 刪除部分邏輯
// ...
if (oldFiber === null) {
// 這裡的newChildren其實就是虛擬dom節點的數據,遍歷依次根據虛擬dom創建fiber階段
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber;
} else {
// 在這裡,我們建立了同層級fiber節點兄弟關係
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
// 遍歷生成結束後,返回第一個child,這樣父節點就知道自己的第一個孩子是誰了
return resultingFirstChild;
} // Add all children to a key map for quick lookups.
}

根據上圖其實可以發現,這裡的newChildren其實就是遍歷到某一層級時的所有子元素的集合,然後遍歷子元素依次調用createChild方法從而得到fiber節點,在下層通過previousNewFiber.sibling = _newFiber讓子元素建立兄弟關係。
在方法結尾可以看到返回了resultingFirstChild(第一個子元素),目的是讓父節點知道自己的第一個孩子是誰從而建立父子關係。所以到這我們就知道了兄弟關係,以及父節點的第一個子節點的關係是如何建立的。
那麼如何創建的fiber呢?我們繼續跟蹤createChild方法:
function createChild(returnFiber, newChild, lanes) {
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
{
// 關注點,這裡又調用了createFiberFromElement方法
var _created = createFiberFromElement(newChild, returnFiber.mode, lanes);
_created.ref = coerceRef(returnFiber, null, newChild);
// 在這裡為創建出來的fiber節點綁定父節點,也就是前文說的return
_created.return = returnFiber;
return _created;
}
// 刪除部分多餘邏輯
}
}
在createChild中核心就兩點,調用createFiberFromElement方法,顧名思義,根據element節點(虛擬element節點)來創建fiber節點。其次,在生成fiber後為通過return為其設置父節點。
我們在上個方法提到了fiber是如何建立兄弟節點(sibling欄位),以及如何為父節點綁定第一個孩子(child欄位)。說通俗點,站在父節點角度,我的child只用來綁定第一個子節點,而子節點自己呢都會通過return來建立與父節點的關係,所以到這裡,child、sibling、return三個欄位我們都解釋清楚了,我們接著跟調用過程:
function createFiberFromElement(element, mode, lanes) {
var owner = null;
{
owner = element._owner;
}
// 獲取虛擬dom的類型,key,props等相關資訊
var type = element.type;
var key = element.key;
var pendingProps = element.props;
// 關注點在這裡
var fiber = createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes);
{
fiber._debugSource = element._source;
fiber._debugOwner = element._owner;
}
return fiber;
}
這個方法其實也沒做什麼具體的事情,只是從虛擬dom上提取了元素類型,元素props相關屬性,然後調用了createFiberFromTypeAndProps方法(根據type與props創建fiber):
function createFiberFromTypeAndProps(type, key, pendingProps, owner, mode, lanes) {
var fiberTag = IndeterminateComponent;
var resolvedType = type;
// 刪除部分特殊預處理邏輯
// ....
// 關注點
var fiber = createFiber(fiberTag, pendingProps, key, mode);
fiber.elementType = type;
fiber.type = resolvedType;
fiber.lanes = lanes;
{
fiber._debugOwner = owner;
}
return fiber;
}
此處會根據type類型(比如是函數或者類型)做部分預處理,這裡我們的虛擬dom已經能具體到div或者span,所以默認走string類型的處理,所以關注點又到了createFiber方法:
var createFiber = function (tag, pendingProps, key, mode) {
return new FiberNode(tag, pendingProps, key, mode);
};
function FiberNode(tag, pendingProps, key, mode) {
// Instance
this.tag = tag;
this.key = key;
this.elementType = null;
this.type = null;
this.stateNode = null; // Fiber
// 節點關係網初始化,兄弟節點,子節點,父節點等等。
this.return = null;
this.child = null;
this.sibling = null;
this.index = 0;
this.ref = null;
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode; // Effects
this.flags = NoFlags;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
this.lanes = NoLanes;
this.childLanes = NoLanes;
this.alternate = null;
// 時間相關初始化,用於後續剩餘時間計算
{
this.actualDuration = Number.NaN;
this.actualStartTime = Number.NaN;
this.selfBaseDuration = Number.NaN;
this.treeBaseDuration = Number.NaN;
this.actualDuration = 0;
this.actualStartTime = -1;// 真正的開始時間
this.selfBaseDuration = 0;
this.treeBaseDuration = 0;
}
if (!hasBadMapPolyfill && typeof Object.preventExtensions === 'function') {
// 讓fiber節點變的不可擴展,也就是永遠不能再添加新的屬性。
Object.preventExtensions(this);
}
}
可以看到最終來到了FiberNode構造函數,通過new調用我們得到了一個fiber實例。那麼到這裡,我們清晰的了解了fiber節點的創建過程,以及fiber節點的關係網是如何建立的。
事實上,react使用fiber節點的另一個原因就是為了通過這種關係網(鏈表),來模擬傳統的js調用棧。為啥這樣說呢?前文也說了傳統的調用棧一旦開始就不能停止,而鏈表好的好處是,我即便暫停了,也能通過next提前設置好下次要恢復的節點單元,一旦瀏覽器有了空閑時間,我們還是能快速恢復之前的工作,而fiber與fiber之間又存在了父子兄弟的關係,上下文能很自然的再度形成,可想而知fiber節點對於恢復先前的工作具有極大的意義。
陸 ❀ 貳 diff階段的對比過程
之前一直想將fiber和diff做兩篇文章寫,結果在閱讀源碼後發現,diff本身就是fiber協調階段的一部分,當組件更新時在會根據現有的fiber節點與新的虛擬dom進行對比,若有不同則更新fiber節點,所以這裡我就站在源碼角度,來看看diff是如何進行的。
為了方便理解如下過程,這裡我提前將fiber結構列出來,它其實是這樣的。

所以一開始更新的起點,其實是一個代表了組件P的fiber節點,它的child指向了我們組件內部的div。而對比過程其實也是在通過鏈表進行遞歸,遞歸的過程依賴了如下兩個方法:
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
// 只要還有節點單元,一直進行對比
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function performUnitOfWork(unitOfWork) {
// 獲取當前fiber節點
var current = unitOfWork.alternate;
setCurrentFiber(unitOfWork);
// 創建next節點,等會會設置next為下一個要對比的fiber節點
var next;
if ( (unitOfWork.mode & ProfileMode) !== NoMode) {
// 設置fiber節點的開始時間
startProfilerTimer(unitOfWork);
// 獲取當前fiber節點的child,將其設置為next
next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork$1(current, unitOfWork, subtreeRenderLanes);
}
resetCurrentFiber();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
// 將next賦予給workInProgress,於是while循環會持續進行
workInProgress = next;
}
}
在workLoopSync方法中可以看到while (workInProgress !== null)的判斷,只要fiber節點不為空,就一直遞歸調用performUnitOfWork方法。
而在performUnitOfWork中可以看到前文我們說的鏈表的概念,react通過next = 當前節點child的操作,只要子節點仍存在,就不斷更新next並賦予給workInProgress,所以也驗證了前文所說,即便任務被暫停,react也能通過next繼續先前的工作。
現在我們點擊的P組件的更新按鈕按鈕修改狀態,react會以當前組件為根節點依次向下進行重新渲染,所以此時的起點,就是上圖的fiber P,我們跳過多餘的遞歸部分,最終會來到beginWork方法的return updateFunctionComponent這一句,這裡就是P組件真正開始更新的起點。
接下來,因為要重新渲染P組件,所以又會通過調用P組件得到其child,也就是虛擬dom節點資訊:
var children = Component(props, secondArg);

拿到了虛擬dom就可以準備開始diff對比了,這裡展示下updateFunctionComponent需要關注的程式碼:
function updateFunctionComponent(current, workInProgress, Component, nextProps, renderLanes) {
// 刪除多餘的程式碼
var nextChildren;
{
// 獲取函數組件P的子節點,也就是上面的Component(props, secondArg)
nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
if ( workInProgress.mode & StrictMode) {
try {
nextChildren = renderWithHooks(current, workInProgress, Component, nextProps, context, renderLanes);
} finally {
reenableLogs();
}
}
}
workInProgress.flags |= PerformedWork;
// 根據新的虛擬dom節點,更新舊有的fiber節點
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
// 更新完當前節點後,繼續遞歸更新child節點
return workInProgress.child;
}
緊接著我們來到reconcileChildren方法(fiber的第一個階段,Reconciliation協調階段):
// current -- 舊有的fiber節點資訊
// workInProgress -- 也是舊有的fiber節點資訊,結構與current有少許不同
// nextChildren -- 之前調用Component(props, secondArg)得到的虛擬dom子節點
function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
// 通過current我們能知道此時是初次渲染,還是更新
if (current === null) {
// 掛載fiber節點
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
} else {
// diff fiber節點
workInProgress.child = reconcileChildFibers(workInProgress, current.child, nextChildren, renderLanes);
}
}
reconcileChildren做的事情很簡單,就是看current(舊fiber節點)存不存在,初次渲染肯定不存在,所以會走掛載路線mountChildFibers,我們前面分析fiber的創建過程其實就是走的mountChildFibers。
由於此時我們是更新state,所以current肯定是存在的,緊接著我們將舊節點以及新的虛擬dom節點傳遞下去,可以看到此時的nextChildren中props已經是更新後的了:

那麼接下來我想大家也猜得到,肯定得根據新的虛擬dom來更新fiber節點了,我們將關注點放在reconcileChildFibers上:
// returnFiber -- 當前fiber節點的父節點,此時就是P組件
// currentFirstChild -- returnFiber節點的子節點,也就是舊的div fiber節點
// newChild -- 新的div 虛擬dom節點
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
// 判斷傳遞的新虛擬dom是不是對象
var isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 是對象,且是虛擬dom類型,繼續調用
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
}
}
// 刪除部分無用程式碼
if (isArray$1(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
}
(注意參數註解,便於你理解當前在幹啥)
reconcileChildFibers方法會判斷新的節點是什麼類型,比如當前我們傳遞的是虛擬dom div,它是個對象,所以會繼續調用placeSingleChild方法,根據遞歸的特性,等會還會對比div的props,也就是包含了2個span一個button的數組,因此下一輪會調用reconcileChildrenArray方法,這裡提前打聲招呼,那我們先看placeSingleChild方法:
// 參數與上個方法的參數註解相同,按值傳遞
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
// 獲取新虛擬dom的key
var key = element.key;
// 舊有的div fiber節點
var child = currentFirstChild;
// 判斷舊有fiber存不存在,一定是存在才能diff,否則就是走fiber創建初始化了
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
switch (child.tag) {
// 刪除部分無用邏輯
default:
{
if (child.elementType === element.type || (
isCompatibleFamilyForHotReloading(child, element) )) {
deleteRemainingChildren(returnFiber, child.sibling);
// 根據新的虛擬dom的props來更新舊有div fiber節點
var _existing3 = useFiber(child, element.props);
// 更新完成後重新設置ref以及父節點
_existing3.ref = coerceRef(returnFiber, child, element);
_existing3.return = returnFiber;
return _existing3;
}
break;
}
}
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 如果key不相等,直接在父節點上把自己整個都刪掉
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 如果不存在舊的fiber節點,那說明是掛載,因此否則走fiber的初始化
// 這裡的初始化我刪掉了
}
placeSingleChild其實就是diff了,大家可以看看我添加的注釋,這裡我簡單描述這個過程:
- 判斷是否存在舊有的
fiber節點,如果不存在說明沒必要diff,直接走fiber新建掛載邏輯。 - 有
child說明有舊有fiber,那就對比key,如果不相等,直接運行deleteChild(returnFiber, child),也就是從div節點的舊有父節點上,將整個div都刪除掉,div的子節點都不需要比了,這也驗證了react的逐級比較,父不同,子一律都不比較視為不同。 - 若
key相同,那就比較新舊fiber的type(標籤類型),如果type不相同,跟key不相同一樣,調用了deleteRemainingChildren(returnFiber, child)方法,直接從div的舊有父節點上將自己整個刪除。 - 若
key type都相同,那隻能說明是props變了,因此調用var _existing3 = useFiber(child, element.props)方法,根據新的props來更新舊有的div fiber節點。
我們將關注點放到useFiber上,程式碼如下:
function useFiber(fiber, pendingProps) {
// 使用舊有的fiber節點以及新的props來創建一個新的clone fiber
var clone = createWorkInProgress(fiber, pendingProps);
clone.index = 0;
clone.sibling = null;
return clone;
}
做的事情很清晰,使用舊有的fiber div節點以及新的虛擬dom div的props創建了一個全新的div fiber,創建過程的程式碼跟前面fiber一樣,這裡就不展示了。
創建完成之後返回,然後為新的fiber設置ref,父節點等相關資訊,那麼到這裡div這個fiber就更新完成了。程式碼會一層層返回,直到updateFunctionComponent的return workInProgress.child這一句,一直返回到next的賦值。啥意思呢?
前面的對比,其實是站在fiber P的角度把fiber div更新完了,而fiber div還有自己的孩子呢,所以接下來又以div為父節點依次更新它的三個子節點,還記得前文我們提前打的招呼嗎?接下來它就會執行下面這段:
// 可在本文搜索程式碼,回顧上文劇情
if (isArray$1(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
reconcileChildrenArray方法在fiber創建階段已經給大家分析了部分源碼,當時執行的邏輯是if (oldFiber === null),因為不存在舊有fiber,所以直接重新創建,而此時因為咱們有,所以就不是重新創建,而是執行下面這段程式碼:
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// for循環,依次更新兩個span以及button
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 建立兄弟關係
nextOldFiber = oldFiber.sibling;
}
// 調用updateSlot,使用新的props來更新舊有fiber節點
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
// 刪除多餘程式碼....
}
而updateSlot又是一次diff,源碼如下:
// returnFiber -- 當前節點的父級,此時是div
// oldFiber -- 舊span1節點
// newChild -- 新的span1的虛擬dom
function updateSlot(returnFiber, oldFiber, newChild, lanes) {
// 獲取舊有fiber的key
var key = oldFiber !== null ? oldFiber.key : null;
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
// 是react node類型嗎?
case REACT_ELEMENT_TYPE:
{
// 判斷舊fiber與信虛擬dom的key
if (newChild.key === key) {
// 判斷是不是fragment節點
if (newChild.type === REACT_FRAGMENT_TYPE) {
return updateFragment(returnFiber, oldFiber, newChild.props.children, lanes, key);
}
// 利用新的虛擬dom來更新舊fiber span
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
}
}
// 刪除部分無用程式碼
}
}
return null;
}
這一段邏輯與之前div的對比大同小異,同樣是對比type與key,因為都相等,所以我們來到了updateElement方法,顧名思義,根據新虛擬dom的屬性來更新舊fiber節點:
function updateElement(returnFiber, current, element, lanes) {
// 有舊fiber就單純的更新
if (current !== null) {
if (current.elementType === element.type || ( // Keep this check inline so it only runs on the false path:
isCompatibleFamilyForHotReloading(current, element) )) {
// 與前面更新div的邏輯一模一樣
var existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
{
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
}
// 沒有就重新創建
var created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
updateElement做的事情很簡單,判斷存不存在舊fiber節點,存在就同樣調用useFiber,以舊fiber clone一個新fiber出來,沒有就重新創建。
不知道大家發現沒,react雖然明確做了很多的條件判斷,即便如此,依舊會在某個地方底層內部再做一次兜底的處理,所以程式碼看著挺多,其實大部分是為了邏輯的健壯性。
之後做的事情相比大家也清晰了,更新span2以及button,以及考慮span1 span2 button有沒有child,很明顯他們都沒有,於是程式碼最終又來到了workLoopSync,可見此時已經沒有可執行的任務單元了,於是協調階段完整結束。

由於協調階段結束,緊接著來到commit階段,我們直接關注到performSyncWorkOnRoot方法:
function performSyncWorkOnRoot(root){
// 刪除意義不大的程式碼
var finishedWork = root.current.alternate;
root.finishedWork = finishedWork;
root.finishedLanes = lanes;
// 提交root節點
commitRoot(root); // Before exiting, make sure there's a callback scheduled for the next
// pending level.
ensureRootIsScheduled(root, now());
return null;
}
我們再關注到commitRoot方法,這裡會對當前任務進行優先順序判斷,再決定後續處理:
function commitRoot(root) {
// 斷點發現這裡的優先順序是99,最高優先順序
var renderPriorityLevel = getCurrentPriorityLevel();
runWithPriority$1(ImmediatePriority$1, commitRootImpl.bind(null, root, renderPriorityLevel));
return null;
}
由於是優先順序最高的render,因此後續react會在瀏覽器允許的情況下將最終創建的真實dom重新更新到頁面,這裡我就不再展示程式碼過程了。
柒 ❀ 總結
那麼到這裡,我們闡述了react 15以及之前的大量dom渲染時卡頓的原因,從而介紹了幀的概念。
緊接著我們引出了fiber,那麼什麼是fiber呢?往小了說它就是一種數據結構,包含了任務開始時間,節點關係資訊(return,child這些),我們把視角往上抬一點,我們也可以說fiber是一種模擬調用棧的特殊鏈表,目的是為了解決傳統調用棧無法暫停的問題。
而站在宏觀角度fiber又是一種調度讓出機制,它讓react達到了增量渲染的目的,在保證幀數流暢的同時,fiber總是在瀏覽器有剩餘時間的情況下去完成目前目前最高優先順序的任務。
所以如果讓我來提煉fiber的關鍵詞,我大概給出如下幾點:
fiber是一種數據結構。fiber使用父子關係以及next的妙用,以鏈表形式模擬了傳統調用棧。fiber是一種調度讓出機制,只在有剩餘時間的情況下運行。fiber實現了增量渲染,在瀏覽器允許的情況下一點點拼湊出最終渲染效果。fiber實現了並發,為任務賦予不同優先順序,保證了一有時間總是做最高優先順序的事,而不是先來先佔位死板的去執行。fiber有協調與提交兩個階段,協調包含了fiber創建與diff更新,此過程可暫停。而提交必須同步執行,保證渲染不卡頓。
而通過fiber的協調階段,我們了解了diff的對比過程,如果將fiber的結構理解成一棵樹,那麼這個過程本質上還是深度遍歷,其順序為父---父的第一個孩子---孩子的每一個兄弟。
通過源碼,我們了解到react的diff是同層比較,最先比較key,如果key不相同,那麼不用比較剩餘節點直接刪除,這也強調了key的重要性,其次會比較元素的type以及props。而且這個比較過程其實是拿舊的fiber與新的虛擬dom在比,而不是fiber與fiber或者虛擬dom與虛擬dom比較,其實也不難理解,如果key與type都相同,那說明這個fiber只用做簡單的替換,而不是完整重新創建,站在性能角度這確實更有優勢。
最後,附上fiber更新調度的執行過程:

那麼到這裡,本文結束。
捌 ❀ 參考
這可能是最通俗的 React Fiber(時間分片) 打開方式

