Fiber與循環diff

本文同步在我的博客shymean.com上,歡迎關注node

在上一篇文章VNode與遞歸diff中,咱們瞭解了VNode的做用,如何將VNode映射爲真實DOM,並經過遞歸實現了diff操做,最後研究了三種不一樣的diff方式帶來的性能差別。git

本文將緊接上文,研究如何經過循環的方式實現diff,並在此基礎上實現對應的調度系統,從而理解React中的一些核心原理。github

本文包含大量示例代碼,主要實現算法

  • createFiber,建立一個fiber節點
  • diff,經過循環的方式diff節點
  • scheduleWork,對於循環diff任務進行調度,從而避免遞歸diff長時間佔用線程的問題

本系列文章列表以下瀏覽器

排在後面文章內會大量採用前面文章中的一些概念和代碼實現,如createVNodediffChildrendoPatch等方法,所以建議逐篇閱讀,避免給讀者形成困惑。本文相關示例代碼均放在github上,若是發現問題,煩請指正。性能優化

fiber

爲了將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的基礎上增長了child(第一個子節點)和sibling(右側第一個兄弟節點)兩個屬性,此外$parent仍舊錶示對父節點的引用,基於這三個屬性,咱們能夠將vnode樹轉換成一個鏈表,從而就能夠將樹的遞歸遍歷修改成鏈表的循環遍歷app

diff

處於一樣的策略,咱們將整個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

對於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方法。

徹底相同的doPatch

在整個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過程!!!

回頭從新看一下diff方法中的核心代碼

while (cursor) {
    cursor = performUnitWork(cursor, patches)
}
// 咱們將該方法重命名爲diffSync,表示同步循環執行diff流程
複製代碼

爲了方便判斷循環在什麼時候暫停,咱們增長一個shouldYeild方法

while (cursor && shouldYeild()) {
    cursor = performUnitWork(cursor, patches)
}
複製代碼

在每次循環結束後中,都會從新調用shouldYeild方法判斷是否須要暫停循環,這樣,咱們就能夠將整個diff流程按時間進行切片,當單個切片循環用時到期,就暫停整個循環,並在合適的時候從cursor開始繼續循環,直到整個鏈表遍歷完畢。這裏咱們須要考慮以下幾個問題

  • 如何對整個diff過程按按時間切片,一個切片的佔用多少時間更合適?
  • 在什麼時機從新恢復循環繼續diff過程?
  • 在暫停期間,數據可能又發生了變更,致使咱們須要從新更新視圖,這種時候,咱們須要拋棄cursor,從新從根節點開始diff過程

咱們須要實現一個簡易的調度機制處理這些問題,

  • 調度器須要告訴diff流程是否應該暫停,這裏須要實現上面的shouldYeild方法
  • diff流程應該告訴調度器是否已經執行完畢,咱們在diff流程被中斷的時候同時傳輸當前狀態給調度器
  • 調度器須要在合適的時候繼續執行diff流程,可使用定時器或requestAnimationFramerequestIdleCallback等方案來實現

基於上面的整理,咱們開始編寫代碼,首先是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流程的意義:

  • diff新舊節點樹,找到整個視圖的變化,最後統一將變化更新到頁面上
  • 在長時間的diff流程下瀏覽器會卡死,所以咱們將整個流程進行切割,每次僅執行一部分diff工做,而後將控制權交給瀏覽器,那麼問題來了:
    • 對於某些變化,咱們但願儘快地diff完畢並更新到頁面上,如動畫、用戶交互等
    • 對於某些變化,咱們但願不會影響現有瀏覽器現有的動畫和交互等,所以但願在瀏覽器空閒時再執行diff工做

咱們暫且將變化分爲兩種類型:高優先級和低優先級。所幸的是,瀏覽器剛好提供了兩個API

基於這兩個接口,咱們能夠實現對應的調度策略。

requestAnimationFrame

首先來看看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來實現低優先級的調度策略

// 經過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是更合理的作法

重置diff

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方法,實現了暫停循環;
  • 經過對diff任務優先級考慮的需求,經過requestAnimationFramerequestIdleCallback實現了註冊和恢復diff任務
  • 經過workLoop的返回值,通知調度器當前diff是否執行完畢
  • 經過currentRoot保存當前diff節點,判斷是否須要取消上一次diff並從新重頭執行diff任務

上一篇文章VNode與遞歸diff是對於Vue源碼的一些理解,本文能夠算做是對於React源碼的一些理解。實際上diff過程性能優化很多,如提供shouldComponentUpdate等接口,在下一篇文章中,將介紹如何經過vnode構建組件及使用組件構建應用,到時候再進一步研究。

相關文章
相關標籤/搜索