设计动力
在某一时间节点调用 React 的 render() 方法,会创建一棵由 React 元素组成的树。在下一次 state 或props 更新时,相同的 render() 方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何有效率的更新 UI 以保证当前 UI 与最新的树保持同步。
这个算法问题有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作数。 然而,即使在最前沿的算法中,该算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。
如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个 开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:
- 两个不同类型的元素会产生出不同的树;
- 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;
在实践中,我们发现以上假设在几乎所有实用的场景下都成立。
什么是fiber
fiber是指组件上将要完成或者已经完成的任务,每个组件可以一个或者多个,数据格式是一个单链表;
结构图:
为什么需要fiber
- 对于大型项目,组件树会很大,这个时候递归遍历的成本就会很高,会造成主线程被持续占用,结果就是主线程上的布局、动画等周期性任务就无法立即得到处理,造成视觉上的卡顿,影响用户体验。
- 解决上面的问题。
- 增量渲染(把渲染任务拆分成块,匀到多帧)
- 更新时能够暂停,终止,复用渲染任务
- 给不同类型的更新赋予优先级;
- 并发方面新的基础能力;
- 更流畅;
fiber的调度
很多人模拟react源码都习惯使用window.requestIdleCallback(callback[, options]),实际上源码中没有使用这个API,而是自己实现了一个任务调度,名叫scheduler,现在只用于了React内部,但是据计划是要做成通用库的。现在开放的公共API还没有完成,还处于开发阶段。
在scheduler源码中unstable_scheduleCallback将任务区分成不同的优先级(优先级是由当前时间以及过期时间组成,越是接近当前时间,优先级越高),放入到两个数组中(taskQueue, timeQueue),之后由flushWork函数进行调度,在workLoop函数中调用时会判断当前浏览器时间片是否大于任务优先级调整的时候所加的时间片,如果大于则继续执行任务,如果小于则将浏览器主线程暂时交换给浏览器,等待下一轮继续执行。
依据以上原理,我们可以了解到为了实现可中断可执行的效果我们需要一个延时函数,促使我们的任务在不能执行的时候顺利的进入浏览器下一次的任务重,这就涉及到宏任务和微任务了,首先我们需要考虑使用宏任务,因为微任务优先级太高,所以当任务不能被继续执行的时候,并不会进入下一轮而是会随着微任务的加入继续执行。宏任务的中setTimeout要是不可以的,因为不稳定,setTimeout如果不设置延时时间则默认是4毫秒,并且4毫秒以后是否执行还需要查看当前进程是否阻塞,如果线程被阻塞还要继续等待,所以react中使用了MessageChannel来完成这个功能功能。
另外需要注意点,时间片的比较需要获取当前的准确时间,所以react中也是使用了performance.now(),精度更高,但是有兼容性问题,源码中也是用get Date()进行了兼容。
diff策略
节点可复用需要同时满足的条件:
- 同级比较,Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计;
- 节点类型必须一致,拥有不同类型的两个组件将会生成不同的树形结构。例如:div->p, CompA->CompB;
- 开发者可以通过 key prop 来暗示哪些子元素在不同的渲染下能保持稳定;
diff过程
vnode是现在的虚拟dom,newVnode是新虚拟dom
diff其实就是用新的newVnode树和老的vnode树比较,根据差异去修改页面上的DOM节点。以此降低重新渲染全部DOM带来的性能问题。
比对两个虚拟dom时会有三种操作:删除、替换和更新;
- 删除:newVnode不存在时;
- 替换:vnode和newVnode类型不同或key不同时;
- 更新:有相同类型和key但vnode和newVnode不同时;
在实践中也证明这三个前提策略是合理且准确的,它保证了整体界面构建的性能;
fiber的比较是
newVnode节点的props(类型是数组),和vnode的节点上的alternate(类型单链表)相比较。
源码的处理逻辑:
从头遍历newVnode节点找到第一个不可复用的节点, 并记录当前停止的节点;
那么及拥有以下三种情况:
- 如果
newVnode全都遍历完成,并且没有发现不可复用的节点,那么直接删除当前vnode后续所有的sibling;
节点排布类似于:
那么567节点将直接删除,放到新节点的deletions中;
- 如果
newVnode还未遍历完,但是vnode的sibling就已经不存在了,那说明newVnode多于vnode;直接进行新节点追加; 注意:代码初次渲染的时候vnode是不存在的,所以走的也是这部分逻辑;
节点排布类似于:
456在vnode中不存在,直接createFiber即可;
- 最后一种情况
newVnode未遍历完就出现了不可复用的情况
节点排布类似于:
当遍历到第四个节点的时候,出现不可复用的情况,这个是后我们要声明一个
existingChildren变量(map),将剩余的vnode打成一个map,继续遍历newVnode剩余的节点,每次找到一个可复用的节点就从existingChildren中删除可复用的节点,遍历中找不到的节点直接重复第二情况直接追加新的节点即可,当newVnode遍历完成时existingChildren中还有剩余的数据,那么就需要和第一种情况后续一样直接将剩余节点放到新节点的deletions中;
主题核心代码复现:
源码中对children的类型进行了判断,对是否为数组的情况进行了拆解,这里为方便我直接强行转为数据了,为了方便测试整体代码,我将我写的代码放到了码云上,有兴趣的小伙伴可以移步尝试
/**
*
* @param {*} returnFiber 为父fiber,
* @param {*} children 为newVnode
* @returns
*/
export function reconcileChildren(returnFiber, children) {
if (isStringOrNumber(children)) {
return;
}
const newChildren = isArray(children) ? children : [children];
// 判断是渲染(false)还是更新(true)
const shouldTrackSideEffects = !!returnFiber.alternate
let previosNewFiber = null;
// 添加oldFiber节点更新
let oldFiber = returnFiber.alternate && returnFiber.alternate.child;
// 记录上次节点插入的位置
let lastPlacedIndex = 0;
let newIdx = 0;
// 记录下一个oldFiber节点
let nextOldFiber = null
// 从头遍历新节点找到第一个不可复用的节点(替换)
for (; oldFiber != null && newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
if (newChild == null) {
continue;
}
if (oldFiber.index > newIdx) {
// 说明节点有位置变化
nextOldFiber = oldFiber;
oldFiber = null
} else {
nextOldFiber = oldFiber.sibling
}
const same = sameNode(newChild, oldFiber)
if (!same) {
if (oldFiber == null) {
oldFiber = nextOldFiber
console.log(newChild);
console.log(oldFiber);
}
break;
}
// 可复用
const newFiber = createFiber(newChild, returnFiber)
Object.assign(newFiber, {
alternate: oldFiber,
stateNode: oldFiber.stateNode,
flags: Update
})
lastPlacedIndex = placeChild(
newFiber,
lastPlacedIndex,
newIdx,
shouldTrackSideEffects
)
if (previosNewFiber === null) {
returnFiber.child = newFiber;
} else {
previosNewFiber.sibling = newFiber;
}
previosNewFiber = newFiber;
// !切换
oldFiber = nextOldFiber;
}
// old 1 2 3 4 5 6 7
// new 0 1 2 3
// 如果新节点全都遍历完成 这直接删除老节点中未遍历到节点
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return;
}
// old 1 2 3 4
// new 1 2 3 4 5 6 7
// 初次渲染或者追加节点
if (oldFiber == null) {
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
if (newChild === null) {
continue
}
const newFiber = createFiber(newChild, returnFiber);
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx, shouldTrackSideEffects)
if (previosNewFiber === null) {
returnFiber.child = newFiber;
} else {
previosNewFiber.sibling = newFiber;
}
previosNewFiber = newFiber;
}
}
// old 0 1 2 | 3 4 fiber ->map{key: value}
// new 0 1 2 | 5 4 3 8 newChildren[]
// 现将剩余的oldFiber打成一个map
const existingChildren = mapRemainingChildren(oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
if (newChild === null) {
continue
}
const newFiber = createFiber(newChild, returnFiber);
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx, shouldTrackSideEffects)
// existingChildren查询可复用节点
const matchedFiber = existingChildren.get(newFiber.key || newFiber.index)
if (matchedFiber) {
// 找到一个就删除一个那么最后剩下的直接删除就可以了
existingChildren.delete(newFiber.key || newFiber.index)
Object.assign(newFiber, {
alternate: matchedFiber,
stateNode: matchedFiber.stateNode,
flags: Update
})
}
if (previosNewFiber === null) {
returnFiber.child = newFiber
} else {
previosNewFiber.sibling = newFiber
}
previosNewFiber = newFiber;
}
// 将剩余的oldFiber节点删除
if (shouldTrackSideEffects) {
existingChildren.forEach(child => deleteChild(returnFiber, child))
}
}
后续在代码commit阶段节点查找deletions即可进行DOM的删除以及更新,并且按照插入的key进行节点的位置调整。
总结
还差一个知识点没有提那就提一个关键词吧,executionContext,提示下和setState的同步异步有关,懂的都懂(笑。
好了,进行到这里react篇基本就可以收尾了,总体来说还是收获挺多的,写了算是日记的博客吧,哪里写的不对还望海涵,谢谢。