本文同步在我的博客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构建组件及使用组件构建应用,到时候再进一步研究。