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构建组件及使用组件构建应用,到时候再进一步研究。

相关文章
相关标签/搜索