前情提要
上一篇我們提到如果 setState 之後,虛擬 dom diff 比較耗時,那麼導致瀏覽器 FPS 降低,使得用戶覺得頁面卡頓。那麼 react 新的調度算法就是把原本一次 diff 的過程切分到各個幀去執行,使得瀏覽器在 diff 過程中也能響應用戶事件。接下來我們具體分析下新的調度算法是怎麼回事。
原虛擬DOM問題
假設我們有一個 react 應用如下:
class App extends React.Component {
render() {
return (
<div>
<div>{this.props.name}</div>
<ul>
<li>{this.props.items[0]}</li>
<li>{this.props.items[1]}</li>
</ul>
</div>
);
}
}
整個 app 的虛擬 dom 大致是這樣的:
var rootHost = {
type: 'div',
children: [ {
type: 'div',
children: [ {type: 'text'} ]
}. {
type: 'ul',
children: [
{ type: 'li', children:[ {type: 'text'} ] },
{ type: 'li', children:[ {type: 'text'} ] }
]
} ]
}
當更新發生 diff 兩棵新老虛擬 dom 樹的時候是遞歸的逐層比較(如下圖)。這個過程是一次完成的,如果要按上一篇我們說的把 diff 過程切割成好多時間片來執行,難度是如何記住狀態且恢復現場。譬如說你 diff 到一半函數返回了,等下一個時間片繼續 diff。如果只記住上次遞歸到哪個節點,那麼你只能順着他的 children 繼續 diff,而它的兄弟節點就丟失了。如果要完美恢復現場保存的結構估計得挺複雜。所以 react16 改造了虛擬dom的結構,引入了 fiber 的鏈表結構。
現在解決方案 - fiber
fiber 節點相當於以前的虛擬 dom 節點,結構如下:
const Fiber = {
tag: HOST_COMPONENT,
type: "div",
return: parentFiber,
child: childFiber,
sibling: null,
alternate: currentFiber,
stateNode: document.createElement("div")| instance,
props: { children: [], className: "foo"},
partialState: null,
effectTag: PLACEMENT,
effects: []
};
先講重要的幾個屬性: return 存儲的是當前節點的父節點(元素),child 存儲的是第一個子節點(元素),sibling 存儲的是他右邊第一個的兄弟節點(元素)。alternate 保存是當更新發生時候同一個節點帶有新的 props 和 state 生成的新 fiber 節點。 那麼虛擬 dom 的存儲結構用鏈表的形式描述了整棵樹。
從頂層開始左序深度優先遍歷如下圖所示:
我們在遍歷 dom 樹 diff 的時候,即使中斷了,我們只需要記住中斷時候的那麼一個節點,就可以在下個時間片恢復繼續遍歷並 diff。這就是 fiber 數據結構選用鏈表的一大好處。我先用文字大致描述下 fiber diff 算法的過程再來看代碼。從跟節點開始遍歷,碰到一個節點和 alternate 比較並記錄下需要更新的東西,並把這些更新提交到當前節點的父親。當遍歷完這顆樹的時候,再通過 return 回溯到根節點。這個過程中把所有的更新全部帶到根節點,再一次更新到真實的 dom 中去。
從根節點開始:
- div1 通過 child 到 div2。
- div2 和自己的 alternate 比較完把更新 commit1 通過 return 提交到 div1。
- div2 通過 sibling 到 ul1。
- ul1 和自己的 alternate 比較完把更新 commit2 通過 return 提交到 div1。
- ul1 通過 child 到 li1。
- li1 和自己的 alternate 比較完把更新 commit3 通過 return 提交到 ul1。
- li1 通過 sibling 到 li2。
- li2 和自己的 alternate 比較完把更新 commit4 通過 return 提交到 ul1。
- 遍歷完整棵樹開始回溯,li2 通過 return 回到 ul1。
- 把 commit3 和 commit4 通過 return 提交到 div1。
- ul1 通過 return 回到 div1。
- 獲取到所有更新 commit1-4,一次更新到真是的 dom 中去。
使用fiber算法更新的代碼實現
React.Component.prototype.setState = function( partialState, callback ) {
updateQueue.pus( {
stateNode: this,
partialState: partialState
} );
requestIdleCallback(performWork); // 這裏就開始幹活了
}
function performWork(deadline) {
workLoop(deadline)
if (nextUnitOfWork || updateQueue.length > 0) {
requestIdleCallback(performWork) //繼續幹
}
}
setState 先把此次更新放到更新隊列 updateQueue 裏面,然後調用調度器開始做更新任務。performWork 先調用 workLoop 對 fiber 樹進行遍歷比較,就是我們上面提到的遍歷過程。當此次時間片時間不夠遍歷完整個 fiber 樹,或者遍歷並比較完之後 workLoop 函數結束。接下來我們判斷下 fiber 樹是否遍歷完或者更新隊列 updateQueue 是否還有待更新的任務。如果有則調用 requestIdleCallback 在下個時間片繼續幹活。nextUnitOfWork 是個全局變量,記錄 workLoop 遍歷 fiber 樹中斷在哪個節點。
function workLoop(deadline) {
if (!nextUnitOfWork) {
//一個週期內只創建一次
nextUnitOfWork = createWorkInProgress(updateQueue)
}
while (nextUnitOfWork && deadline.timeRemaining() > EXPIRATION_TIME) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
if (pendingCommit) {
//當全局 pendingCommit 變量被負值
commitAllwork(pendingCommit)
}
}
剛開始遍歷的時候判斷全局變量 nextUnitOfWork 是否存在?如果存在表示上次任務中斷了,我們繼續,如果不存在我們就從更新隊列裏面取第一個任務,並生成對應的 fiber 根節點。接下來我們就是正式的工作了,用循環從某個節點開始遍歷 fiber 樹。performUnitOfWork 根據我們上面提到的遍歷規則,在對當前節點處理完之後,返回下一個需要遍歷的節點。循環除了要判斷是否有下一個節點(是否遍歷完),還要判斷當前給你的時間是否用完,如果用完了則需要返回,讓瀏覽器響應用戶的交互事件,然後再在下個時間片繼續。workLoop 最後一步判斷全局變量 pendingCommit 是否存在,如果存在則把這次遍歷 fiber 樹產生的所有更新一次更新到真實的 dom 上去。注意 pendingCommit 在完成一次完整的遍歷過程之前是不會有值的。
function createWorkInProgress(updateQueue) {
const updateTask = updateQueue.shift()
if (!updateTask) return
if (updateTask.partialState) {
// 證明這是一個setState操作
updateTask.stateNode._internalfiber.partialState = updateTask.partialState
}
const rootFiber =
updateTask.fromTag === tag.HostRoot
? updateTask.stateNode._rootContainerFiber
: getRoot(updateTask.stateNode._internalfiber)
return {
tag: tag.HostRoot,
stateNode: updateTask.stateNode,
props: updateTask.props || rootFiber.props,
alternate: rootFiber // 用於鏈接新舊的 VDOM
}
}
function getRoot(fiber) {
let _fiber = fiber
while (_fiber.return) {
_fiber = _fiber.return
}
return _fiber
}
createWorkInProgress 拿出更新隊列 updateQueue 第一個任務,然後看觸發這個任務的節點是什麼類型。如果不是根節點,則通過循環迭代節點的 return 找到最上層的根節點。最後生成一個新的 fiber 節點,這個節點就是當前 fiber 節點的 alternate 指向的,也就是說下面會在當前節點和這個新生成的節點直接進行 diff。
function performUnitOfWork(workInProgress) {
const nextChild = beginWork(workInProgress)
if (nextChild) return nextChild
// 沒有 nextChild, 我們看看這個節點有沒有 sibling
let current = workInProgress
while (current) {
//收集當前節點的effect,然後向上傳遞
completeWork(current)
if (current.sibling) return current.sibling
//沒有 sibling,回到這個節點的父親,看看有沒有sibling
current = current.return
}
}
performUnitOfWork 做的工作是 diff 當前節點,diff 完看看有沒有子節點,如果沒有子節點則把更新先提交到父節點。然後再看有沒有兄弟節點,如果有則返回出去當作下次遍歷的節點。如果還是沒有,說明整個 fiber 樹已經遍歷完了,則進入到回溯過程,把所有的更新都集中到根節點進行更新真實 dom。
function completeWork(currentFiber) {
if (currentFiber.tag === tag.classComponent) {
// 用於回溯最高點的 root
currentFiber.stateNode._internalfiber = currentFiber
}
if (currentFiber.return) {
const currentEffect = currentFiber.effects || [] //收集當前節點的 effect list
const currentEffectTag = currentFiber.effectTag ? [currentFiber] : []
const parentEffects = currentFiber.return.effects || []
currentFiber.return.effects = parentEffects.concat(currentEffect, currentEffectTag)
} else {
// 到達最頂端了
pendingCommit = currentFiber
}
}
我們看到 completeWork 中當判斷到當前節點是根節點的時候才賦值 pendingCommit 整個全局變量。
function commitAllwork(topFiber) {
topFiber.effects.forEach(f => {
commitWork(f)
})
topFiber.stateNode._rootContainerFiber = topFiber
topFiber.effects = []
nextUnitOfWork = null
pendingCommit = null
}
當回溯完,有了 pendingCommit,則 commitAllwork 會被調用。它做的工作就是循環遍歷根節點的 effets 數據,裏面保存着所有要更新的內容。commitWork 就是執行具體更新的函數,這裏就不展開了(因爲這篇主要想講的是 fiber 更新的調度算法)。
所以你們看遍歷 dom 數 diff 的過程是可以被打斷並且在後續的時間片上接着幹,只是最後一步 commitAllwork 是同步的不能打斷的。這樣 react 使用新的調度算法優化了更新過程中執行時間過長導致的頁面卡頓現象。
參考文獻
- 爲 Luy 實現 React Fiber 架構 - 更詳細的代碼實現可以看這片文章。