1. 程式人生 > 實用技巧 >手寫React的Fiber架構,深入理解其原理

手寫React的Fiber架構,深入理解其原理

熟悉React的朋友都知道,React支援jsx語法,我們可以直接將HTML程式碼寫到JS中間,然後渲染到頁面上,我們寫的HTML如果有更新的話,React還有虛擬DOM的對比,只更新變化的部分,而不重新渲染整個頁面,大大提高渲染效率。到了16.x,React更是使用了一個被稱為Fiber的架構,提升了使用者體驗,同時還引入了hooks等特性。那隱藏在React背後的原理是怎樣的呢,Fiberhooks又是怎麼實現的呢?本文會從jsx入手,手寫一個簡易版的React,從而深入理解React的原理。

本文主要實現了這些功能:

簡易版Fiber架構

簡易版DIFF演演算法

簡易版函式元件

簡易版Hook: useState

娛樂版Class元件

本文程式碼地址:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/fiber-and-hooks

本文程式跑起來效果如下:

JSX和creatElement

以前我們寫React要支援JSX還需要一個庫叫JSXTransformer.js,後來JSX的轉換工作都整合到了babel裡面了,babel還提供了線上預覽的功能,可以看到轉換後的效果,比如下面這段簡單的程式碼:

const App =
(
<div>
<h1 id="title">Title</h1>
<a href="xxx">Jump</a>
<section>
<p>
Article
</p>
</section>
</div>
);

經過babel轉換後就變成了這樣:

上面的截圖可以看出我們寫的HTML被轉換成了React.createElement,我們將上面程式碼稍微格式化來看下:

var App = React.createElement(
'div',
null,
React.createElement(
'h1',
{
id: 'title',
},
'Title',
),
React.createElement(
'a',
{
href: 'xxx',
},
'Jump',
),
React.createElement(
'section',
null,
React.createElement('p', null, 'Article'),
),
);

從轉換後的程式碼我們可以看出React.createElement支援多個引數:

  1. type,也就是節點型別
  2. config, 這是節點上的屬性,比如idhref
  3. children, 從第三個引數開始就全部是children也就是子元素了,子元素可以有多個,型別可以是簡單的文字,也可以還是React.createElement,如果是React.createElement,其實就是子節點了,子節點下面還可以有子節點。這樣就用React.createElement的巢狀關係實現了HTML節點的樹形結構。

讓我們來完整看下這個簡單的React頁面程式碼:

渲染在頁面上是這樣:

這裡面用到了React的地方其實就兩個,一個是JSX,也就是React.createElement,另一個就是ReactDOM.render,所以我們手寫的第一個目標就有了,就是createElementrender這兩個方法。

手寫createElement

對於<h1 id="title">Title</h1>這樣一個簡單的節點,原生DOM也會附加一大堆屬性和方法在上面,所以我們在createElement的時候最好能將它轉換為一種比較簡單的資料結構,只包含我們需要的元素,比如這樣:

{
type: 'h1',
props: {
id: 'title',
children: 'Title'
}
}

有了這個資料結構後,我們對於DOM的操作其實可以轉化為對這個資料結構的操作,新老DOM的對比其實也可以轉化為這個資料結構的對比,這樣我們就不需要每次操作都去渲染頁面,而是等到需要渲染的時候才將這個資料結構渲染到頁面上。這其實就是虛擬DOM!而我們createElement就是負責來構建這個虛擬DOM的方法,下面我們來實現下:

function createElement(type, props, ...children) {
// 核心邏輯不復雜,將引數都塞到一個物件上返回就行
// children也要放到props裡面去,這樣我們在元件裡面就能通過this.props.children拿到子元素
return {
type,
props: {
...props,
children
}
}
}

上述程式碼是React的createElement簡化版,對原始碼感興趣的朋友可以看這裡:https://github.com/facebook/react/blob/60016c448bb7d19fc989acd05dda5aca2e124381/packages/react/src/ReactElement.js#L348

手寫render

上述程式碼我們用createElement將JSX程式碼轉換成了虛擬DOM,那真正將它渲染到頁面的函式是render,所以我們還需要實現下這個方法,通過我們一般的用法ReactDOM.render( <App />,document.getElementById('root'));可以知道他接收兩個引數:

  1. 根元件,其實是一個JSX元件,也就是一個createElement返回的虛擬DOM
  2. 父節點,也就是我們要將這個虛擬DOM渲染的位置

有了這兩個引數,我們來實現下render方法:

function render(vDom, container) {
let dom;
// 檢查當前節點是文字還是物件
if(typeof vDom !== 'object') {
dom = document.createTextNode(vDom)
} else {
dom = document.createElement(vDom.type);
} // 將vDom上除了children外的屬性都掛載到真正的DOM上去
if(vDom.props) {
Object.keys(vDom.props)
.filter(key => key != 'children')
.forEach(item => {
dom[item] = vDom.props[item];
})
} // 如果還有子元素,遞迴呼叫
if(vDom.props && vDom.props.children && vDom.props.children.length) {
vDom.props.children.forEach(child => render(child, dom));
} container.appendChild(dom);
}

上述程式碼是簡化版的render方法,對原始碼感興趣的朋友可以看這裡:https://github.com/facebook/react/blob/3e94bce765d355d74f6a60feb4addb6d196e3482/packages/react-dom/src/client/ReactDOMLegacy.js#L287

現在我們可以用自己寫的createElementrender來替換原生的方法了:

可以得到一樣的渲染結果:

為什麼需要Fiber

上面我們簡單的實現了虛擬DOM渲染到頁面上的程式碼,這部分工作被React官方稱為renderer,renderer是第三方可以自己實現的一個模組,還有個核心模組叫做reconsiler,reconsiler的一大功能就是大家熟知的diff,他會計算出應該更新哪些頁面節點,然後將需要更新的節點虛擬DOM傳遞給renderer,renderer負責將這些節點渲染到頁面上。但是這個流程有個問題,雖然React的diff演演算法是經過優化的,但是他卻是同步的,renderer負責操作DOM的appendChild等API也是同步的,也就是說如果有大量節點需要更新,JS執行緒的執行時間可能會比較長,在這段時間瀏覽器是不會響應其他事件的,因為JS執行緒和GUI執行緒是互斥的,JS執行時頁面就不會響應,這個時間太長了,使用者就可能看到卡頓,特別是動畫的卡頓會很明顯。在React的官方演講中有個例子,可以很明顯的看到這種同步計算造成的卡頓:

而Fiber就是用來解決這個問題的,Fiber可以將長時間的同步任務拆分成多個小任務,從而讓瀏覽器能夠抽身去響應其他事件,等他空了再回來繼續計算,這樣整個計算流程就顯得平滑很多。下面是使用Fiber後的效果:

怎麼來拆分

上面我們自己實現的render方法直接遞迴遍歷了整個vDom樹,如果我們在中途某一步停下來,下次再呼叫時其實並不知道上次在哪裡停下來的,不知道從哪裡開始,即使你將上次的結束節點記下來了,你也不知道下一個該執行哪個,所以vDom的樹形結構並不滿足中途暫停,下次繼續的需求,需要改造資料結構。另一個需要解決的問題是,拆分下來的小任務什麼時候執行?我們的目的是讓使用者有更流暢的體驗,所以我們最好不要阻塞高優先順序的任務,比如使用者輸入,動畫之類,等他們執行完了我們再計算。那我怎麼知道現在有沒有高優先順序任務,瀏覽器是不是空閒呢?總結下來,Fiber要想達到目的,需要解決兩個問題:

  1. 新的任務排程,有高優先順序任務的時候將瀏覽器讓出來,等瀏覽器空了再繼續執行
  2. 新的資料結構,可以隨時中斷,下次進來可以接著執行

requestIdleCallback

requestIdleCallback是一個實驗中的新API,這個API呼叫方式如下:

// 開啟呼叫
var handle = window.requestIdleCallback(callback[, options]) // 結束呼叫
Window.cancelIdleCallback(handle)

requestIdleCallback接收一個回撥,這個回撥會在瀏覽器空閒時呼叫,每次呼叫會傳入一個IdleDeadline,可以拿到當前還空餘多久,options可以傳入引數最多等多久,等到了時間瀏覽器還不空就強制執行了。使用這個API可以解決任務排程的問題,讓瀏覽器在空閒時才計算diff並渲染。更多關於requestIdleCallback的使用可以檢視MDN的檔案。但是這個API還在實驗中,相容性不好,所以React官方自己實現了一套。本文會繼續使用requestIdleCallback來進行任務排程,我們進行任務排程的思想是將任務拆分成多個小任務,requestIdleCallback裡面不斷的把小任務拿出來執行,當所有任務都執行完或者超時了就結束本次執行,同時要註冊下次執行,程式碼架子就是這樣:

function workLoop(deadline) {
while(nextUnitOfWork && deadline.timeRemaining() > 1) {
// 這個while迴圈會在任務執行完或者時間到了的時候結束
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
} // 如果任務還沒完,但是時間到了,我們需要繼續註冊requestIdleCallback
requestIdleCallback(workLoop);
} // performUnitOfWork用來執行任務,引數是我們的當前fiber任務,返回值是下一個任務
function performUnitOfWork(fiber) { }
requestIdleCallback(workLoop);

上述workLoop對應React原始碼看這裡。

Fiber可中斷資料結構

上面我們的performUnitOfWork並沒有實現,但是從上面的結構可以看出來,他接收的引數是一個小任務,同時通過這個小任務還可以找到他的下一個小任務,Fiber構建的就是這樣一個資料結構。Fiber之前的資料結構是一棵樹,父節點的children指向了子節點,但是隻有這一個指標是不能實現中斷繼續的。比如我現在有一個父節點A,A有三個子節點B,C,D,當我遍歷到C的時候中斷了,重新開始的時候,其實我是不知道C下面該執行哪個的,因為只知道C,並沒有指標指向他的父節點,也沒有指標指向他的兄弟。Fiber就是改造了這樣一個結構,加上了指向父節點和兄弟節點的指標:

上面的圖片還是來自於官方的演講,可以看到和之前父節點指向所有子節點不同,這裡有三個指標:

  1. child: 父節點指向第一個子元素的指標。
  2. sibling:從第一個子元素往後,指向下一個兄弟元素。
  3. return:所有子元素都有的指向父元素的指標。

有了這幾個指標後,我們可以在任意一個元素中斷遍歷並恢復,比如在上圖List處中斷了,恢復的時候可以通過child找到他的子元素,也可以通過return找到他的父元素,如果他還有兄弟節點也可以用sibling找到。Fiber這個結構外形看著還是棵樹,但是沒有了指向所有子元素的指標,父節點只指向第一個子節點,然後子節點有指向其他子節點的指標,這其實是個連結串列。

實現Fiber

現在我們可以自己來實現一下Fiber了,我們需要將之前的vDom結構轉換為Fiber的資料結構,同時需要能夠通過其中任意一個節點返回下一個節點,其實就是遍歷這個連結串列。遍歷的時候從根節點出發,先找子元素,如果子元素存在,直接返回,如果沒有子元素了就找兄弟元素,找完所有的兄弟元素後再返回父元素,然後再找這個父元素的兄弟元素。整個遍歷過程其實是個深度優先遍歷,從上到下,然後最後一行開始從左到右遍歷。比如下圖從div1開始遍歷的話,遍歷的順序就應該是div1 -> div2 -> h1 -> a -> div2 -> p -> div1。可以看到這個序列中,當我們return父節點時,這些父節點會被第二次遍歷,所以我們寫程式碼時,return的父節點不會作為下一個任務返回,只有siblingchild才會作為下一個任務返回。

// performUnitOfWork用來執行任務,引數是我們的當前fiber任務,返回值是下一個任務
function performUnitOfWork(fiber) {
// 根節點的dom就是container,如果沒有這個屬性,說明當前fiber不是根節點
if(!fiber.dom) {
fiber.dom = createDom(fiber); // 建立一個DOM掛載上去
} // 如果有父節點,將當前節點掛載到父節點上
if(fiber.return) {
fiber.return.dom.appendChild(fiber.dom);
} // 將我們前面的vDom結構轉換為fiber結構
const elements = fiber.children;
let prevSibling = null;
if(elements && elements.length) {
for(let i = 0; i < elements.length; i++) {
const element = elements[i];
const newFiber = {
type: element.type,
props: element.props,
return: fiber,
dom: null
} // 父級的child指向第一個子元素
if(i === 0) {
fiber.child = newFiber;
} else {
// 每個子元素擁有指向下一個子元素的指標
prevSibling.sibling = newFiber;
} prevSibling = newFiber;
}
} // 這個函式的返回值是下一個任務,這其實是一個深度優先遍歷
// 先找子元素,沒有子元素了就找兄弟元素
// 兄弟元素也沒有了就返回父元素
// 然後再找這個父元素的兄弟元素
// 最後到根節點結束
// 這個遍歷的順序其實就是從上到下,從左到右
if(fiber.child) {
return fiber.child;
} let nextFiber = fiber;
while(nextFiber) {
if(nextFiber.sibling) {
return nextFiber.sibling;
} nextFiber = nextFiber.return;
}
}

React原始碼中的performUnitOfWork看這裡,當然比我們這個複雜很多。

統一commit DOM操作

上面我們的performUnitOfWork一邊構建Fiber結構一邊操作DOMappendChild,這樣如果某次更新好幾個節點,操作了第一個節點之後就中斷了,那我們可能只看到第一個節點渲染到了頁面,後續幾個節點等瀏覽器空了才陸續渲染。為了避免這種情況,我們應該將DOM操作都蒐集起來,最後統一執行,這就是commit。為了能夠記錄位置,我們還需要一個全域性變數workInProgressRoot來記錄根節點,然後在workLoop檢測如果任務執行完了,就commit:

function workLoop(deadline) {
while(nextUnitOfWork && deadline.timeRemaining() > 1) {
// 這個while迴圈會在任務執行完或者時間到了的時候結束
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
} // 任務做完後統一渲染
if(!nextUnitOfWork && workInProgressRoot) {
commitRoot();
} // 如果任務還沒完,但是時間到了,我們需要繼續註冊requestIdleCallback
requestIdleCallback(workLoop);
}

因為我們是在Fiber樹完全構建後再執行的commit,而且有一個變數workInProgressRoot指向了Fiber的根節點,所以我們可以直接把workInProgressRoot拿過來遞迴渲染就行了:

// 統一操作DOM
function commitRoot() {
commitRootImpl(workInProgressRoot.child); // 開啟遞迴
workInProgressRoot = null; // 操作完後將workInProgressRoot重置
} function commitRootImpl(fiber) {
if(!fiber) {
return;
} const parentDom = fiber.return.dom;
parentDom.appendChild(fiber.dom); // 遞迴操作子元素和兄弟元素
commitRootImpl(fiber.child);
commitRootImpl(fiber.sibling);
}

reconcile調和

reconcile其實就是虛擬DOM樹的diff操作,需要刪除不需要的節點,更新修改過的節點,新增新的節點。為了在中斷後能回到工作位置,我們還需要一個變數currentRoot,然後在fiber節點裡面新增一個屬性alternate,這個屬性指向上一次執行的根節點,也就是currentRootcurrentRoot會在第一次render後的commit階段賦值,也就是每次計算完後都會把當次狀態記錄在alternate上,後面更新了就可以把alternate拿出來跟新的狀態做diff。然後performUnitOfWork裡面需要新增調和子元素的程式碼,可以新增一個函式reconcileChildren。這個函式裡面不能簡單的建立新節點了,而是要將老節點跟新節點拿來對比,對比邏輯如下:

  1. 如果新老節點型別一樣,複用老節點DOM,更新props
  2. 如果型別不一樣,而且新的節點存在,建立新節點替換老節點
  3. 如果型別不一樣,沒有新節點,有老節點,刪除老節點

注意刪除老節點的操作是直接將oldFiber加上一個刪除標記就行,同時用一個全域性變數deletions記錄所有需要刪除的節點:

      // 對比oldFiber和當前element
const sameType = oldFiber && element && oldFiber.type === element.type; //檢測型別是不是一樣
// 先比較元素型別
if(sameType) {
// 如果型別一樣,複用節點,更新props
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
return: workInProgressFiber,
alternate: oldFiber, // 記錄下上次狀態
effectTag: 'UPDATE' // 新增一個操作標記
}
} else if(!sameType && element) {
// 如果型別不一樣,有新的節點,建立新節點替換老節點
newFiber = {
type: element.type,
props: element.props,
dom: null, // 構建fiber時沒有dom,下次perform這個節點是才建立dom
return: workInProgressFiber,
alternate: null, // 新增的沒有老狀態
effectTag: 'REPLACEMENT' // 新增一個操作標記
}
} else if(!sameType && oldFiber) {
// 如果型別不一樣,沒有新節點,有老節點,刪除老節點
oldFiber.effectTag = 'DELETION'; // 新增刪除標記
deletions.push(oldFiber); // 一個陣列收集所有需要刪除的節點
}

然後就是在commit階段處理真正的DOM操作,具體的操作是根據我們的effectTag來判斷的:

function commitRootImpl(fiber) {
if(!fiber) {
return;
} const parentDom = fiber.return.dom;
if(fiber.effectTag === 'REPLACEMENT' && fiber.dom) {
parentDom.appendChild(fiber.dom);
} else if(fiber.effectTag === 'DELETION') {
parentDom.removeChild(fiber.dom);
} else if(fiber.effectTag === 'UPDATE' && fiber.dom) {
// 更新DOM屬性
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} // 遞迴操作子元素和兄弟元素
commitRootImpl(fiber.child);
commitRootImpl(fiber.sibling);
}

替換和刪除的DOM操作都比較簡單,更新屬性的會稍微麻煩點,需要再寫一個輔助函式updateDom來實現:

// 更新DOM的操作
function updateDom(dom, prevProps, nextProps) {
// 1. 過濾children屬性
// 2. 老的存在,新的沒了,取消
// 3. 新的存在,老的沒有,新增
Object.keys(prevProps)
.filter(name => name !== 'children')
.filter(name => !(name in nextProps))
.forEach(name => {
if(name.indexOf('on') === 0) {
dom.removeEventListener(name.substr(2).toLowerCase(), prevProps[name], false);
} else {
dom[name] = '';
}
}); Object.keys(nextProps)
.filter(name => name !== 'children')
.forEach(name => {
if(name.indexOf('on') === 0) {
dom.addEventListener(name.substr(2).toLowerCase(), nextProps[name], false);
} else {
dom[name] = nextProps[name];
}
});
}

updateDom的程式碼寫的比較簡單,事件只處理了簡單的on開頭的,相容性也有問題,prevPropsnextProps可能會遍歷到相同的屬性,有重複賦值,但是總體原理還是沒錯的。要想把這個處理寫全,程式碼量還是不少的。

函式元件

函式元件是React裡面很常見的一種元件,我們前面的React架構其實已經寫好了,我們這裡來支援下函式元件。我們之前的fiber節點上的type都是DOM節點的型別,比如h1什麼的,但是函式元件的節點type其實就是一個函式了,我們需要對這種節點進行單獨處理。

首先需要在更新的時候檢測當前節點是不是函式元件,如果是,children的處理邏輯會稍微不一樣:

// performUnitOfWork裡面
// 檢測函式元件
function performUnitOfWork(fiber) {
const isFunctionComponent = fiber.type instanceof Function;
if(isFunctionComponent) {
updateFunctionComponent(fiber);
} else {
updateHostComponent(fiber);
} // ...下面省略n行程式碼...
} function updateFunctionComponent(fiber) {
// 函式元件的type就是個函式,直接拿來執行可以獲得DOM元素
const children = [fiber.type(fiber.props)]; reconcileChildren(fiber, children);
} // updateHostComponent就是之前的操作,只是單獨抽取了一個方法
function updateHostComponent(fiber) {
if(!fiber.dom) {
fiber.dom = createDom(fiber); // 建立一個DOM掛載上去
} // 將我們前面的vDom結構轉換為fiber結構
const elements = fiber.props.children; // 調和子元素
reconcileChildren(fiber, elements);
}

然後在我們提交DOM操作的時候因為函式元件沒有DOM元素,所以需要注意兩點:

  1. 獲取父級DOM元素的時候需要遞迴網上找真正的DOM
  2. 刪除節點的時候需要遞迴往下找真正的節點

我們來修改下commitRootImpl:

function commitRootImpl() {
// const parentDom = fiber.return.dom;
// 向上查詢真正的DOM
let parentFiber = fiber.return;
while(!parentFiber.dom) {
parentFiber = parentFiber.return;
}
const parentDom = parentFiber.dom; // ...這裡省略n行程式碼... if{fiber.effectTag === 'DELETION'} {
commitDeletion(fiber, parentDom);
}
} function commitDeletion(fiber, domParent) {
if(fiber.dom) {
// dom存在,是普通節點
domParent.removeChild(fiber.dom);
} else {
// dom不存在,是函式元件,向下遞迴查詢真實DOM
commitDeletion(fiber.child, domParent);
}
}

現在我們可以傳入函式元件了:

import React from './myReact';
const ReactDOM = React; function App(props) {
return (
<div>
<h1 id="title">{props.title}</h1>
<a href="xxx">Jump</a>
<section>
<p>
Article
</p>
</section>
</div>
);
} ReactDOM.render(
<App title="Fiber Demo"/>,
document.getElementById('root')
);

實現useState

useState是React Hooks裡面的一個API,相當於之前Class Component裡面的state,用來管理元件內部狀態,現在我們已經有一個簡化版的React了,我們也可以嘗試下來實現這個API。

簡單版

我們還是從用法入手來實現最簡單的功能,我們一般使用useState是這樣的:

function App(props) {
const [count, setCount] = React.useState(1);
const onClickHandler = () => {
setCount(count + 1);
}
return (
<div>
<h1>Count: {count}</h1>
<button onClick={onClickHandler}>Count+1</button>
</div>
);
} ReactDOM.render(
<App title="Fiber Demo"/>,
document.getElementById('root')
);

上述程式碼可以看出,我們的useState接收一個初始值,返回一個陣列,裡面有這個state的當前值和改變state的方法,需要注意的是App作為一個函式元件,每次render的時候都會執行,也就是說裡面的區域性變數每次render的時候都會重置,那我們的state就不能作為一個區域性變數,而是應該作為一個全部變數儲存:

let state = null;
function useState(init) { state = state === null ? init : state; // 修改state的方法
const setState = value => {
state = value; // 只要修改了state,我們就需要重新處理節點
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
} // 修改nextUnitOfWork指向workInProgressRoot,這樣下次就會處理這個節點了
nextUnitOfWork = workInProgressRoot;
deletions = [];
} return [state, setState]
}

這樣其實我們就可以使用了:

支援多個state

上面的程式碼只有一個state變數,如果我們有多個useState怎麼辦呢?為了能支援多個useState,我們的state就不能是一個簡單的值了,我們可以考慮把他改成一個陣列,多個useState按照呼叫順序放進這個陣列裡面,訪問的時候通過下標來訪問:

let state = [];
let hookIndex = 0;
function useState(init) {
const currentIndex = hookIndex;
state[currentIndex] = state[currentIndex] === undefined ? init : state[currentIndex]; // 修改state的方法
const setState = value => {
state[currentIndex] = value; // 只要修改了state,我們就需要重新處理這個節點
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
} // 修改nextUnitOfWork指向workInProgressRoot,這樣下次就會處理這個節點了
nextUnitOfWork = workInProgressRoot;
deletions = [];
} hookIndex++; return [state[currentIndex], setState]
}

來看看多個useState的效果:

支援多個元件

上面的程式碼雖然我們支援了多個useState,但是仍然只有一套全域性變數,如果有多個函式元件,每個元件都來操作這個全域性變數,那相互之間不就是汙染了資料了嗎?所以我們資料還不能都存在全域性變數上面,而是應該存在每個fiber節點上,處理這個節點的時候再將狀態放到全域性變數用來通訊:

// 申明兩個全域性變數,用來處理useState
// wipFiber是當前的函式元件fiber節點
// hookIndex是當前函式元件內部useState狀態計數
let wipFiber = null;
let hookIndex = null;

因為useState只在函式元件裡面可以用,所以我們之前的updateFunctionComponent裡面需要初始化處理useState變數:

function updateFunctionComponent(fiber) {
// 支援useState,初始化變數
wipFiber = fiber;
hookIndex = 0;
wipFiber.hooks = []; // hooks用來儲存具體的state序列 // ......下面程式碼省略......
}

因為hooks佇列放到fiber節點上去了,所以我們在useState取之前的值時需要從fiber.alternate上取,完整程式碼如下:

function useState(init) {
// 取出上次的Hook
const oldHook = wipFiber.alternate && wipFiber.alternate.hooks && wipFiber.alternate.hooks[hookIndex]; // hook資料結構
const hook = {
state: oldHook ? oldHook.state : init // state是每個具體的值
} // 將所有useState呼叫按照順序存到fiber節點上
wipFiber.hooks.push(hook);
hookIndex++; // 修改state的方法
const setState = value => {
hook.state = value; // 只要修改了state,我們就需要重新處理這個節點
workInProgressRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
} // 修改nextUnitOfWork指向workInProgressRoot,這樣下次requestIdleCallback就會處理這個節點了
nextUnitOfWork = workInProgressRoot;
deletions = [];
} return [hook.state, setState]
}

上面程式碼可以看出我們在將useState和儲存的state進行匹配的時候是用的useState的呼叫順序匹配state的下標,如果這個下標匹配不上了,state就錯了,所以React裡面不能出現這樣的程式碼:

if (something) {
const [state, setState] = useState(1);
}

上述程式碼不能保證每次something都滿足,可能導致useState這次render執行了,下次又沒執行,這樣新老節點的下標就匹配不上了,對於這種程式碼,React會直接報錯:

用Hooks模擬Class元件

這個功能純粹是娛樂性功能,通過前面實現的Hooks來模擬實現Class元件,這個並不是React官方的實現方式哈~我們可以寫一個方法將Class元件轉化為前面的函式元件:

function transfer(Component) {
return function(props) {
const component = new Component(props);
let [state, setState] = useState(component.state);
component.props = props;
component.state = state;
component.setState = setState; return component.render();
}
}

然後就可以寫Class了,這個Class長得很像我們在React裡面寫的Class,有state,setStaterender

import React from './myReact';

class Count4 {
constructor(props) {
this.props = props;
this.state = {
count: 1
}
} onClickHandler = () => {
this.setState({
count: this.state.count + 1
})
} render() {
return (
<div>
<h3>Class component Count: {this.state.count}</h3>
<button onClick={this.onClickHandler}>Count+1</button>
</div>
);
}
} // export的時候用transfer包裝下
export default React.transfer(Count4);

然後使用的時候直接:

<div>
<Count4></Count4>
</div>

當然你也可以在React裡面建一個空的class Component,讓Count4繼承他,這樣就更像了。

好了,到這裡我們程式碼就寫完了,完整程式碼可以看我GitHub。

總結

  1. 我們寫的JSX程式碼被babel轉化成了React.createElement
  2. React.createElement返回的其實就是虛擬DOM結構。
  3. ReactDOM.render方法是將虛擬DOM渲染到頁面的。
  4. 虛擬DOM的調和和渲染可以簡單粗暴的遞迴,但是這個過程是同步的,如果需要處理的節點過多,可能會阻塞使用者輸入和動畫播放,造成卡頓。
  5. Fiber是16.x引入的新特性,用處是將同步的調和變成非同步的。
  6. Fiber改造了虛擬DOM的結構,具有父 -> 第一個子子 -> 兄子 -> 父這幾個指標,有了這幾個指標,可以從任意一個Fiber節點找到其他節點。
  7. Fiber將整棵樹的同步任務拆分成了每個節點可以單獨執行的非同步執行結構。
  8. Fiber可以從任意一個節點開始遍歷,遍歷是深度優先遍歷,順序是父 -> 子 -> 兄 -> 父,也就是從上往下,從左往右。
  9. Fiber的調和階段可以是非同步的小任務,但是提交階段(commit)必須是同步的。因為非同步的commit可能讓使用者看到節點一個一個接連出現,體驗不好。
  10. 函式元件其實就是這個節點的type是個函式,直接將type拿來執行就可以得到虛擬DOM。
  11. useState是在Fiber節點上添加了一個陣列,陣列裡面的每個值對應了一個useStateuseState呼叫順序必須和這個陣列下標匹配,不然會報錯。

參考資料

A Cartoon Intro to Fiber

妙味課堂大聖老師:手寫react的fiber和hooks架構

React Fiber

這可能是最通俗的 React Fiber(時間分片) 開啟方式

淺析 React Fiber

React Fiber架構

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd