本文同步在我的博客shymean.com上,歡迎關注node
在上一篇文章VNode與遞歸diff中,咱們瞭解了VNode的做用,如何將VNode映射爲真實DOM,並經過遞歸實現了diff操做,最後研究了三種不一樣的diff方式帶來的性能差別。git
本文將緊接上文,研究如何經過循環的方式實現diff,並在此基礎上實現對應的調度系統,從而理解React
中的一些核心原理。github
本文包含大量示例代碼,主要實現算法
createFiber
,建立一個fiber節點diff
,經過循環的方式diff節點scheduleWork
,對於循環diff任務進行調度,從而避免遞歸diff長時間佔用線程的問題本系列文章列表以下瀏覽器
排在後面文章內會大量採用前面文章中的一些概念和代碼實現,如createVNode
、diffChildren
、doPatch
等方法,所以建議逐篇閱讀,避免給讀者形成困惑。本文相關示例代碼均放在github上,若是發現問題,煩請指正。性能優化
爲了將vnode樹從遞歸遍歷修改成循環遍歷,咱們須要再次修改一下vnode的結構,爲了向React表示敬意,咱們使用fiber
這個名稱閉包
function createFiber(type, props, children) {
let vnode = {
type,
props,
key: props.key,
$el: null,
}
let firstChild
vnode.children = children.map((child, index) => {
// ...處理文本節點
child.$parent = vnode // 每一個子節點保存對父節點的引用
if (!firstChild) {
vnode.$child = child // 父節點保存對於第一個子節點的引用
} else {
firstChild.$sibling = child // 保存對於下一個兄弟節點的引用
}
firstChild = child
return child
})
return vnode
}
複製代碼
咱們在vnode的基礎上增長了sibling(右側第一個兄弟節點)兩個屬性,此外$parent仍舊錶示對父節點的引用,基於這三個屬性,咱們能夠將vnode樹轉換成一個鏈表,從而就能夠將樹的遞歸遍歷修改成鏈表的循環遍歷。app
處於一樣的策略,咱們將整個diff過程分爲了diff
遍歷節點收集變化和doPatch
將變化更新到視圖上兩個過程。異步
有了$child
、$sibling
和$sibling
,咱們就能夠經過循環的方式來遍歷鏈表。爲了簡化代碼,咱們爲fiber增長一個oldFiber
的屬性,保持對上一次舊節點的引用。函數
function diff(oldFiber, newFiber) {
newFiber.oldFiber = oldFiber
let cursor = newFiber // 當前正在進行diff操做的節點
let patches = []
while (cursor) {
cursor = performUnitWork(cursor, patches)
}
return patches
}
複製代碼
須要注意的是,在單次performUnitWork
中僅會完成一個節點的diff工做
function performUnitWork(fiber, patches) {
let oldFiber = fiber.oldFiber
// 任務一:對比當前新舊節點,收集變化
diffFiber(oldFiber, fiber, patches)
// 任務二:爲新節點中children的每一個元素找到須要對比的舊節點,設置oldFiber屬性,方便下個循環繼續執行performUnitWork
diffChildren(oldFiber && oldFiber.children || [], fiber.children, patches)
// 將遊標移動至新vnode樹中的下一個節點,以
// (div, {}, [
// (h1, {}, [text]),
// (ul, {}, [
// li1, li2,li3
// ])]
// ]) 爲例,整個應用的的遍歷流程是
// div -> h1 -> h1(text) -> h1 -> ul ->li1 -> li2 -> li3 -> ul -> div
// 上面的diffFiber就是遍歷當前節點
// 有子節點繼續遍歷子節點
if (fiber.$child) return fiber.$child
while (fiber) {
// 無子節點可是有兄弟節點,繼續遍歷兄弟節點
if (fiber.$sibling) return fiber.$sibling
// 子節點和兄弟節點都遍歷完畢,返回父節點,開始遍歷父節點的兄弟節點,重複該過程
fiber = fiber.$parent;
if (!fiber) return null
}
return null
}
複製代碼
diffFiber
與遞歸diff的diff
方法很類似,用於對比兩個節點
function diffFiber(oldNode, newNode, patches) {
if (!oldNode) {
// 當前節點與其子節點都將插入
patches.push({ type: INSERT, newNode })
} else {
// 若是存在有變化的屬性,則使用新節點的屬性更新舊節點
let attrs = diffAttr(oldNode.props, newNode.props) // 發生變化的屬性
if (Object.keys(attrs).length > 0) {
patches.push({ type: UPDATE, oldNode, newNode, attrs })
}
// 節點須要移動位置
if (oldNode.index !== newNode.index) {
patches.push({ type: MOVE, oldNode, newNode })
}
newNode.$el = oldNode.$el // 直接複用舊節點
// 繼續比較子節點
}
}
複製代碼
對於diffChildren
而言,咱們在遞歸diff中已經研究了一些不一樣的diff策略,最終選擇告終合key與type的方式儘量地複用DOM節點,此處的diff邏輯不須要改動。
惟一的區別在於,咱們如今不須要在diffChildren
中遞歸調用diff
方法,僅僅爲新節點找到並綁定須要diff的舊節點便可,至於具體的diff,咱們將在下一次performUnitWork
中執行
// 根據type和key來進行判斷,避免同類型元素順序變化致使的沒必要要更新
function diffChildren(oldChildren, newChildren, patches) {
newChildren = newChildren.slice() // 複製一份children,避免影響父節點的children屬性
// 找到新節點列表中帶key的節點
let keyMap = {}
newChildren.forEach((child, index) => {
let { key } = child
// 只有攜帶key屬性的會參與同key節點的比較
if (key !== undefined) {
if (keyMap[key]) {
console.warn(`請保證${key}的惟一`, child)
} else {
keyMap[key] = {
vnode: child,
index
}
}
}
})
// 在遍歷舊列表時,先比較類型與key均相同的節點,若是新節點中不存在key相同的節點,纔會將舊節點保存起來
let typeMap = {}
oldChildren.forEach(child => {
let { type, key } = child
// 先比較類型與key均相同的節點
let { vnode, index } = (keyMap[key] || {})
if (vnode && vnode.type === type) {
newChildren[index] = null // 該節點已被比較,須要彈出
delete keyMap[key]
vnode.oldFiber = child
} else {
// 將剩餘的節點保存起來,與剩餘的新節點進行比較
if (!typeMap[type]) typeMap[type] = []
typeMap[type].push(child)
}
})
// 此時key相同的節點已被比較
for (let i = 0; i < newChildren.length; ++i) {
let cur = newChildren[i]
if (!cur) continue; // 已在在前面與此時key相同的節點進行比較
if (typeMap[cur.type] && typeMap[cur.type].length) {
let old = typeMap[cur.type].shift()
cur.oldFiber = old
} else {
cur.oldFiber = null
}
}
// 剩餘未被使用的舊節點,將其移除
Object.keys(typeMap).forEach(type => {
let arr = typeMap[type]
arr.forEach(old => {
patches.push({ type: REMOVE, oldNode: old, })
})
})
}
複製代碼
能夠看見在上面的過程當中,與遞歸diff的diffChildren
基本相同,區別自安於咱們只是僞新節點找到並賦值了oldFiber
,並無遞歸調用diffFiber
方法。
在整個diff過程結束後,咱們一樣須要將收集的patches
更新到視圖上,因爲path
自己與diff或者是循環無關,咱們甚至不須要修改遞歸diff算法中doPatch
的任何代碼。
下面是利用循環diff實現的頁面初始化和視圖更新的測試代碼,從使用方的角度看起來跟遞歸diff並無什麼差異。
let root = createRoot({
title: 'hello fiber',
list: [1, 2, 3]
})
root.$parent = {
$el: app
}
// 初始化
let patches = diff(null, root)
doPatch(patches)
btn.onclick = function () {
let root2 = createRoot({
title: 'title change',
list: [1, 2, 3]
})
let patches = diff(root, root2)
console.log(patches) // title text標籤發生了變化
doPatch(patches) // 更新視圖
}
複製代碼
在此以前,咱們接觸到的diff流程都是同步進行的。循環diff比遞歸diff最大的就是:能夠在某個時刻暫停diff過程!!!
回頭從新看一下diff
方法中的核心代碼
while (cursor) {
cursor = performUnitWork(cursor, patches)
}
// 咱們將該方法重命名爲diffSync,表示同步循環執行diff流程
複製代碼
爲了方便判斷循環在什麼時候暫停,咱們增長一個shouldYeild
方法
while (cursor && shouldYeild()) {
cursor = performUnitWork(cursor, patches)
}
複製代碼
在每次循環結束後中,都會從新調用shouldYeild
方法判斷是否須要暫停循環,這樣,咱們就能夠將整個diff流程按時間進行切片,當單個切片循環用時到期,就暫停整個循環,並在合適的時候從cursor
開始繼續循環,直到整個鏈表遍歷完畢。這裏咱們須要考慮以下幾個問題
cursor
,從新從根節點開始diff過程咱們須要實現一個簡易的調度機制處理這些問題,
shouldYeild
方法requestAnimationFrame
、requestIdleCallback
等方案來實現基於上面的整理,咱們開始編寫代碼,首先是shouldYield
方法
// 默認1秒30幀運行,即一個切片最多運行的時間
const frameLength = 1000 / 30
let frameDeadline // 當前切片執行時間deadline,在每次執行新的切片時會更新爲getCurrentTime() + frameLength
function getCurrentTime() {
return +new Date()
}
function shouldYield() {
return getCurrentTime() > frameDeadline
}
複製代碼
這樣咱們就解決了上面的第一個問題:調度器須要告訴diff流程是否應該暫停。
而後咱們爲調度器暴露一個註冊diff任務的接口scheduleWork
,該方法接受一個diff任務的切片,並根據該切片的返回值判斷diff任務是否指向完畢,爲true則表示有未完成的diff任務,須要調度器繼續管理並在合適的時候繼續執行下一個diff切片。
let scheduledCallback
function scheduleWork(workLoop) {
scheduledCallback = workLoop
// TODO 咱們須要在這裏實現任務調度的相關邏輯,在每一個切片內,更新當前切片的frameDeadline,執行切片的diff任務
// 大體僞代碼以下
// frameDeadline = getCurrentTime() + frameLength
// if(scheduledCallback()){
// // 繼續註冊下一個執行切片的任務
// }
}
複製代碼
能夠看見,咱們在這裏約定了scheduleWork
註冊的任務workLoop
會返回一個bool值,這樣就可以解決上面的第二個問題:diff流程應該告訴調度器是否已經執行完畢。
最後,咱們重寫diff
,在內部scheduleWork
註冊diff任務,完成整個邏輯
// 如今diff過程變成了異步的流程,所以只能在回調函數中等待
function diff(oldFiber, newFiber, cb) {
newFiber.oldFiber = oldFiber
// 當前正在進行diff操做的節點
let cursor = newFiber
let patches = []
// workLoop能夠理解每一個切片須要執行的diff任務
const workLoop = () => {
while (cursor) {
// shouldYield在每diff完一個節點以後都會調用該方法,用於判斷是否須要暫時中斷diff過程
if (shouldYield()) {
// workLoop返回true表示當前diff流程還未執行完畢
return true
} else {
cursor = performUnitWork(cursor, patches)
}
}
// diff流程執行完畢,咱們可使用收集到的patches了
cb(patches)
return false
}
// 將diff流程進行切片,如今只能異步等待patches收集完畢了
scheduleWork(workLoop)
}
複製代碼
能夠看見,任務切片workLoop
經過閉包維持了對cursor
節點的引用,所以下次diff能夠直接從上一次的暫停點繼續執行。因爲如今diff流程可能被中斷,所以咱們須要經過回調的方式在diff流程結束以後再使用整個流程收集到的patches,因此diff函數簽名變成了diff(oldFiber, newFiber, cb)
接下來咱們回到scheduleWork
中,研究實現調度任務切片的方法,解決第三個問題。在這裏,咱們能夠回頭思考一下diff
流程的意義:
咱們暫且將變化分爲兩種類型:高優先級和低優先級。所幸的是,瀏覽器剛好提供了兩個API
基於這兩個接口,咱們能夠實現對應的調度策略。
首先來看看requestAnimationFrame
function onAnimationFrame() {
if (!scheduledCallback) return;
// 更新當前diff流程的deadline,保證任務最多執行frameLength的時間
frameDeadline = getCurrentTime() + frameLength;
// 在每一幀中執行註冊的任務workLoop
// 在workLoop的每次循環中都會調用shouldYield,若是當前時間超過frameDeadline時,就會暫停循環並返回true
const hasMoreWork = scheduledCallback();
// 根據workLoop返回值判斷當前diff是否已經執行完畢
if (hasMoreWork) {
// 註冊回調,方便下一幀更新frameDeadline,繼續執行diff任務,直至整個任務執行完畢
// 因爲workLoop經過閉包維持了對於cursor當前遍歷的節點的引用,所以下次diff能夠直接從上一次的暫停點繼續執行
requestAnimationFrame(nextRAFTime => {
onAnimationFrame();
});
} else {
// 若是已經執行完畢,則清空
scheduledCallback = null;
}
}
複製代碼
須要注意的是,因爲標籤頁在後臺時會暫停requestAnimationFrame
,會致使整個diff過程暫停,顯然這並非咱們想要的,這種狀況下咱們可使用定時器setTimeout
來預防一下
// 因爲標籤頁在後臺時會暫停`requestAnimationFrame`,這也會致使整個diff過程暫停,可使用定時器來處理這個問題
// 若是過了在某段時間內沒有執行requestAnimationFrame,則會經過定時器繼續註冊回調
let rAFTimeoutID = setTimeout(() => {
onAnimationFrame()
}, frameLength * 2);
requestAnimationFrame(nextRAFTime => {
clearTimeout(rAFTimeoutID) // 當requestAnimationFrame繼續執行時,移除
onAnimationFrame();
});
複製代碼
咱們也可使用requestIdleCallback
來實現低優先級的調度策略
// 經過requestIdleCallback註冊,在瀏覽器的空閒時間時執行低優先級工做,這樣不會影響延遲關鍵事件,
// 經過timeout參數能夠控制每次佔用的調用的時長
function onIdleFrame(deadline) {
if (!scheduledCallback) return;
let remain = deadline.timeRemaining()// 當前幀剩餘可用的空閒時間
frameDeadline = getCurrentTime() + Math.min(remain, frameLength) // 限制當前任務切片的執行deadline
const hasMoreWork = scheduledCallback();
if (hasMoreWork) {
requestIdleCallback(onIdleFrame, { timeout: frameLength })
} else {
// 若是已經執行完畢,則清空
scheduledCallback = null
}
}
複製代碼
因爲deadline.timeRemaining
能夠很方便地得到當前幀的剩餘時間,用來更新frameDeadline
貌似很不錯。最後,咱們來補全scheduleWork
方法
function scheduleWork(workLoop) {
scheduledCallback = workLoop
// 註冊diff任務,咱們能夠採用下面這兩種策略來進行進行調度
// requestAnimationFrame(onAnimationFrame)
requestIdleCallback(onIdleFrame)
}
複製代碼
因爲篇幅和主題的關係,本文不會涉及如何區分變化的優先級,只研究在這高低優先級兩種變化下如何調度diff任務。
貌似已經大功告成了~咱們寫點代碼來測試下。
function testSchedule() {
// ...爲了節省篇幅,下面代碼省略了root和root2節點的初始化
// 如今須要在回調函數中等待diff執行完畢
let isInit = false
diff(null, root, (patches) => {
doPatch(patches)
isInit = true
})
btn.onclick = function () {
if (!isInit) {
console.log('應用暫未初始化完畢,請稍後重試...')
return
}
diff(root, root2, (patches) => {
console.log(patches)
doPatch(patches)
root = root2
})
}
}
複製代碼
在整個過程當中,diff
方法是異步的,但doPatch
並無任何改動(基於這個緣由,我更傾向於將diff和doPatch過程分開),當收集到所有變化以後,咱們會同步地將整個變化更新到視圖上。
當舊節點root和新節點root2進行diff時,當結構比較簡單時,在一幀內就完成了整個diff,效果就會與diffSync
相同;當結構比較複雜或單個diff節點的耗時很長時,咱們就能看見明顯的差異,爲了便於測試,編寫一個阻塞JS運行的同步函數,用於增長單個diff節點的耗時
function sleepSync(time) {
let now = + new Date()
while (now + time > +new Date()) { }
}
複製代碼
因爲咱們默認設置的1秒30幀,表示一個切片能夠執行的時間是33.33ms
,所以咱們使用sleepSync
控制一個切片只能執行一個節點的diff
// diffSync
while (cursor) {
sleepSync(33.33)
cursor = performUnitWork(cursor, patches)
}
// diff
const workLoop = () => {
while (cursor) {
if (shouldYield()) {
return true
} else {
sleepSync(33.33) // 保證每一幀只能diff一個節點
cursor = performUnitWork(cursor, patches)
}
}
複製代碼
能夠看見在diffSync
中,在整個diff過程當中,瀏覽器會處於卡死的狀態,但在diff
中,就不會存在這個問題。
此外,雖然更新任務存在高優先級可低優先級之分,但初始化任務咱們每每但願用戶可以儘快看見頁面,所以在初始化時,調用diffSync
是更合理的作法
在diffSync
中,整個diff和doPatch過程都是同步的,所以數據的變化將在該過程結束後直接更新到視圖上。
在調度器介入後,狀況變得不同了。若是在diff暫停時數據從新發生了變化,咱們應該如何處理呢?
假設原始節點root
,還未完成diff過程的新節點root2
,此時數據又發生了變化對應的新節點root3
,咱們有下面兩種處理方案
diff(root, root2)
執行完畢,而後再對比diff(root2, root3)
root2
,從頭開始進行diff(root, root3)
很顯然,當新數據對應的節點樹變成了root3
,那麼root2
做爲中間狀態,自己就沒有再展現的必要性了,若是從新執行diff(root2, root3)
,性能上來講並非一件十分划算的事情,所以我以爲最好的辦法仍是直接使用方案二:從新重頭從新diff整個節點,儘管這看起來也是一件效率比較低下的事情。
咱們經過currentRoot
保存本地diff新的根節點,若是上一個diff任務還沒有完成,可是又調用了新的diff,則會取消上一次在調度器中註冊的任務,從新執行本次的diff
let currentRoot // 保存當前diff過程新的根節點,判斷是否須要重置diff流程
function diff(oldFiber, newFiber, cb) {
// 表示前一個diff任務還沒有結束,但又調用了新的diff與原來的oldFiber進行對比
if (currentRoot && currentRoot !== newFiber) {
cancleWork()
}
currentRoot = newFiber // 記錄
// ...
const workLoop = () => {
// ...
currentRoot = null // 重置
}
scheduleWork(workLoop)
}
// 取消以前註冊的diff任務
function cancleWork() {
scheduledCallback = null
}
複製代碼
下面是測試思路
// diff1
diff(root, root2, (patches) => {
console.log('normal patches', patches)
root = root2
})
// 在上面diff還未結束時,數據又發生了變化
setTimeout(() => {
diff(root, root3, (patches) => {
console.log('timer patches', patches)
doPatch(patches)
root = root3
})
}, 1)
// diff(root, root2)還沒有結束,又調用了diff(root, root3),就會拋棄上一次diff,從根節點從新執行diff
複製代碼
當應用比較龐大時,咱們不得不考慮diff過程帶來的性能損耗。本文首先在createVNode
方法上,爲vnode擴展了一些引用屬性,包括$child
、$sibling
,從而將vnode樹轉換爲鏈表,經過循環鏈表的方式遍歷整個應用。
對於整個循環diff的流程
shouldYeild
方法,實現了暫停循環;requestAnimationFrame
和requestIdleCallback
實現了註冊和恢復diff任務workLoop
的返回值,通知調度器當前diff是否執行完畢currentRoot
保存當前diff節點,判斷是否須要取消上一次diff並從新重頭執行diff任務上一篇文章VNode與遞歸diff是對於Vue源碼的一些理解,本文能夠算做是對於React源碼的一些理解。實際上diff過程性能優化很多,如提供shouldComponentUpdate
等接口,在下一篇文章中,將介紹如何經過vnode構建組件及使用組件構建應用,到時候再進一步研究。