vue3源码学习(8) -- runtime-core(4):diff算法
前言
本文将继续学习children更新中最为重要的最为重要的部分,ARRAY TO ARRAY的更新,也就是大名鼎鼎的diff算法 ,其核心就是锁定中间的乱序部分
Array TO Array
diff算法也分为两种情况:有key
和key
两种情况
无key情况下的简易流程:
- 获取旧节点的长度
- 获取新节点的长度
- 取两个长度中较小的长度值
- 从0位置开始依次进行比较
for(i=0;i<commentLength;i++)
- 如果旧节点数 > 新节点数,移除多余的就节点
- 如果旧节点数 < 新节点数,增加节点
有key的情况下:进行双端diff算法。接下来我们着重学习双端diff算法 双端diff算法的流程和处理场景:
- ①左侧对比或右侧对比
- ②新的比来的长或新的比老的短
- ③中间对比
左侧对比
// (a b) c
// (a b) d e
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'C'},"C")
}
const newChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'D'},"D")
h("p",{key:'E'},"E")
}
当出现下图所示时候,范围锁定
所以可确定i的条件: i <= e1 && i <=e2
function patchChildren(n1,n2,container,preComponent){
/*其他代码*/
if(shapeFlag & shapeFlags.text_children){
/*其他代码*/
}else{
if(preShapeFlag & shapeFlag.text_children){
/*其他代码*/
}else{
patchKeyChildren(c1,c2)
}
}
}
function patchKeyChildren(c1,c2,container,parentComponent){
let i = 0
let e1 = c1.length -1
let e2 = c2.length -1
function isSameVnodeType(n1,n2){
//怎么判断n1 === n2
// type 和 key
return n1.type ===n2.type && n1.key ===n2.key // 将key挂载到vnode中
}
//左侧
while(i <= e1 && i <= e2){
const n1 = c1[i]
const n2 = c2[i]
//判断vnode 是否相同
if(isSameVnodeType(n1,n2)){
// key 或者 type相同时,递归调用patch进行props和下一层的children更新
patch(n1,n2,container,parentComponent)
}else{
break
}
i++
}
}
右侧对比
// (b c)
// d e (b c)
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'C'},"C")
}
const newChildren = {
h("p",{key:'D'},"D")
h("p",{key:'E'},"E")
h("p",{key:'B'},"B")
h("p",{key:'C'},"C")
}
当出现下图所示时候,范围锁定。
所以可确定i的条件: i <= e1 && i <=e2
patchKeyChildren(c1,c2,container,parentComponent){
/*其他代码*/
//左侧
while(i <= e1 && i<=e2){ /*其他代码*/ }
//右侧
while(i <= e1 && i<=e2){
const n1 = c1[e1]
const n2 = c2[e2]
//判断vnode 是否相同
if(isSameVnodeType(n1,n2)){
// key 或者 type相同时,递归调用patch进行props和下一层的children更新
patch(n1,n2,container,parentComponent)
}else{
break
}
e1--
e2--
}
}
通过左侧或者右侧对比会锁定一个范围
新的比老的长需要进行创建element
需要创建的element在右侧
// (a b)
// (a b) c
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
const newChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'C'},"C")
}
所以可确定i的条件: e1 < i <=e2
patchKeyChildren(c1,c2,container,parentComponent){
/*其他代码*/
//左侧
while(i <= e1 && i<=e2){ /*其他代码*/ }
//右侧
while(i <= e1 && i<=e2){/*其他代码*/}
//新的比旧的长
if(i > e1){
if( i <= e2){
patch(null,c2[i],container,parentComponent)
}
}
}
需要创建的element在左侧
// (a b)
// c (a b)
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
const newChildren = {
h("p",{key:'C'},"C")
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
当出现下图所示,范围确定
此时可以看到i的范围同样满足:e1 < i <= e2
所以之前实现的流程仍然适合,不过不能的是C
的创建位置不是在后面,而是插在A
之前,所以我们需要对patch
函数中的挂载element操作进行修改
//renderer.ts
function mountElement(vnode,container,parentComponent,anchor){
const el = vnode.el = document.createElement(vnode.type)
//props
const { props } = vnode
for (const key in props) {
const val = props[key];
hostPatchProp(el, key, null, val);
}
// children
const { children, shapeFlag } = vnode;
if (shapeFlag & shapeFlags.text_children) {
el.textContent = children;
} else if (shapeFlag & shapeFlags.array_children) {
mountChildren(vnode, el, parentComponent, anchor);
//修改挂载el的方式 不仅仅是appendChild
hostInsert(el, container, anchor);
}
function hostInsert(child,parent,anchor){
//添加到指定位置
parent.insertBefore(child,anchor || null)
}
此时修改之前patchKeyChildren
函数中的代码,获取到anchor
锚点
function patchKeyChildren(c1,c2,container,parentComponent){
//左侧对比
/*代码*/
//右侧对比
/*代码*/
// 新的比老的长
if(i > e1){
if( i <= e2){
const nextPos = e2 + 1
const anchor = nextPos < c2.length ? c2[nexPos].el :null // 如果e2 + 1 >c2.length 表示需要在后面添加
while ( i <= e2){
patch(null,c2[i],contianer,parentComponent,anchor)
i ++
}
}
}
老的比新的长
需要删除的元素在右侧
// (a b)
// (a b) c
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'C'},"C")
}
const newChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
如下图所示,锁定范围
可以得出i的范围 : e2 < i <= e1
代码实现
patchKeyChildren(c1,c2,container,parentComponent,anchor){
/*其他代码*/
//左侧
while(i <= e1 && i<=e2){ /*其他代码*/ }
//右侧
while(i <= e1 && i<=e2){/*其他代码*/}
//新的比旧的长
if(i > e1){
/*其他代码*/
}else if( i > e2 ){
while( i <= e1){
//删除节点
hostRemove(c2[i].el)
i++
}
}
}
function hostRemove(child) {
const parent = child.parentNode;
if (parent) {
parent.removeChild(child);
}
}
需要删除的元素在左侧
// c (a b)
// (a b)
const prevChildren = {
h("p",{key:'C'},"C")
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
const newChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
}
如下图所示,锁定范围
可以得出i的范围 : e2 < i <= e1,之前事件的代码仍然符合
中间对比
中间对比又分为三种情况
-
删除老的,在老的里面存在,新的里面不存在
-
创建新的, 在老的里面不存在,新的里面存在
-
移动,节点在新的老的里面都存在,只是位置发生该改变
删除
// a b ( c d ) f g
// a b ( e c ) f g
const prevChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'C',id="c-prev"},"C")
h("p",{key:'D'},"D")
h("p",{key:'F'},"F")
h("p",{key:'G'},"G")
}
const newChildren = {
h("p",{key:'A'},"A")
h("p",{key:'B'},"B")
h("p",{key:'E'},"E")
h("p",{key:'C',id="c-next"},"C")
h("p",{key:'F'},"F")
h("p",{key:'G'},"G")
}
上述实例代码中可以看到d
元素只存在于老节点,并不存在于新的节点,那么我们需要做的就是删除d
。
应该怎么删除呢?
通常我们会想到 遍历新节点队列中的(e,c)
列表(之前实现的功能能够将a、b、f、g渲染出来),判断是D
节点是否存在,此时时间复杂度为O(n)
但我们在渲染element之前,我们给element传递了一个props为Key,所以我们可以利用KEY
,我们可以将(e,c)
通过key映射一个,map,结构{E(key):i,C(key):i},接下来我们就可以通过旧节点的key来查找在新节点中是否存在,若存在就patch
,不存在就删除。此时事件复杂读为O(1)
代码实现
patchKeyChildren(c1,c2,container,parentComponent,anchor){
let i = 0
let e1 = n1.length - 1
let e2 = n2.length - 1
//左侧对比
while( i <= e1 && i <= e2){
/*其他代码*/
// 相同 patch i++
//不同 break
}
//右侧代码
while( i <= e1 && i <= e2){
/*其他代码*/
// 相同 patch e1--, e2--
//不同 break
}
// 若新的比老的长
if( i > e1){
if( i <= e2){
/*其他代码*/
// i<=e2 条件下 创建新的 i++
}
}
//新的比老的短
else if( i > e2){
/*其他代码*/
// i<= e1 情况下 删除老的 i++
}
//中间对比
else{
//记录新老节点的起始位置
let s1 = i //老
let s2 = i // 新
//建立新节点的映射表
cosnt keyToNewIndexMap = new Map()
let (let i = s2; i <=e1; i++){
const nextChid = c2[i]
keyToNewIndexMap.set(nextChid.key,i)
}
let( let i = s1; i<=e2 ; i++){
const prevChild = c1[i]
let newIndex
// 存在key
if(prevChild.key !== null){
newIndex = keyToNewIndexMap.get(prevChild.ley)
}else{
//不存在key,需要遍历新节点列表
for(let j = s2; j<= e1;j++){
if(isSameVnodeType(prevchild,c2[j])){
newIndex = j;
break
}
}
if(newIndex === undefined){
hostRemove(prevChild.el)
}else{
patch(prevChild,c2[newIndex],container,parentCpmonent,null)
}
}
}
}
此时 执行代码可以看到D
元素已经被删除
逻辑优化
// a b ( c e d ) f g
// a b ( e c ) f g
当新节点都已经比较完成后,老节点仍然存在没有对比过的,后续存在的老节点可以直接进行删除
- 记录新节点的数量 toBePatch
- 已经更新的数量
patchKeyChildren(c1,c2,container,parentComponent,anchor){
/*代码*/
else {
let s1 = i
let s2 = i
const toBePatched = e2 -s2 + 1 //需要进行更新节点的数量
const patched = 0 //当前已经更新的节点的数量
for( let i = s1; i<=e2 ; i++){
const prevChild = c1[i]
if(patched >= toBePatched){
hostRemove(preChild.el)
continue // 后面不用执行,跳出此次循环
}
/*其他代码*/
if(newIndex === undefined){
hostRemove(prevChild.el)
}else{
patch(prevChild,c2[newIndex],container,parentCpmonent,null)
patched ++
}
}
}
}
移动
下面例子中,我们需要移动e
节点
// a b ( c d e ) f g
// a b ( e c d ) f g
暴力解法
- 分别判断
c
,d
,e
是否在新的节点中,如果存在则插到指定位置,如果不存在就删除,这就相当于对(c d e)
模块进行了重新排列,十分消耗性能。
更好的提供性能的方法:不需要移动的节点就可以移动,尽可能减少dom
的移动。
我们对比新旧节点来看,仅仅需要把e
节点移动到c d
之前即可实现。这样减少dom
元素的操作,就可以降低性能损耗。
那么怎么做到只移动e
就可以实现需求呢?
我们可以得到需要移动的区域的vnode的节点索引值(c,d,e)=>(2,3,4)
,移动后的vnode节点索引值(e,c,d)=>(4,2,3)
,所以我们要做的只是移动(e:4) ,这时可以将(c d)=>(2,3)
看作一个稳定的序列。那么剩下的就是将不稳定节点(e)进行增删改查,重新排列位置,使其满足新节点队列的位置。所以我们通过新旧节点对比区域找到最长递增子序列,然后遍历旧节点的对比区域,判断节点是否在最长递增子序列中,如果在其中则不需要进行操作,不存在则进行操作。这样就可以尽可能地减少dom
操作
总结就是:根据新旧节点列表对比区域的映射关系找到最长递增子序列,对比旧节点对比区域中需要进行操作的区域,进行dom操作,达到最终效果
怎么找到最长递增子序列? 参考leetcode 300题
最长递增子序列
/*
* 求最长递增子序列在原数组的下标数组
* @param arr {number[]}
* @return {number[]}}
*/
function getSequence(arr:number[]):number[] {
//浅拷贝arr
const p = arr.slice();
const len = arr.length;
//存储最长递增子序列对应arr中下标的数组
const result = [0];
let i, j, u, v, c;
for (let i = 0; i < len; i++) {
const arrI = arr[i];
//排除等于0的情况
if (arrI !== 0) {
j = result[result.length - 1]; //获取当前reslut的最大值的下标
//如果当前val 大于当前递增子序列的最大值的时候,直接添加
if (arr[j] < arrI) {
p[i] = j; //保存上一次递增子序列最后一个值的索引
result.push(i);
continue;
}
/*二分查找*/
//定义二分查找区间[u,v]
u = 0;
v = result.length - 1;
while (u < v) {
//求中间值(向下取整)
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
// 当前递增子序列按顺序找到第一个大于 val 的值,将其替换
if (arrI < arr[result[u]]) {
if (u > 0) {
// 保存上一次递增子序列最后一个值的索引
p[i] = result[u - 1];
}
// 此时有可能导致结果不正确,即 result[left + 1] < result[left]
// 所以我们需要通过 _arr 来记录正常的结果
result[u] = i;
}
}
}
// 修正贪心算法可能造成最长递增子序列在原数组里不是正确的顺序
u = result.length;
v = result[u - 1];
// 倒序回溯,通过之前 _arr 记录的上一次递增子序列最后一个值的索引
// 进而找到最终正确的索引
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
getSequence([4,2,3]) // [1,2]为最长递增子序列
移动逻辑实现:
patchKeyChildren(c1,c2,container,parentComponent,anchor){
/*代码*/
else {
let s1 = i
let s2 = i
const toBePatched = e2 -s2 + 1 //需要进行更新节点的数量
const patched = 0 //当前已经更新的节点的数量
const newIndexToOldIndexMap = new Array(toBePatched) //建立新建节点的映射关系 定长的数组
newIndexToOldIndexMap.forEach((i) => {newIndexToOldMap[i] = 0})
for ( let i = s1; i<=e2 ; i++){
const prevChild = c1[i]
if(patched >= toBePatched){
hostRemove(preChild.el)
continue // 后面不用执行,跳出此次循环
}
let newIndex
/*其他代码*/
if(newIndex === undefined){
hostRemove(prevChild.el)
}else{
newIndexToOldIndexMap(newIndex - s2) = i + 1 //映射关系
patch(prevChild,c2[newIndex],container,parentCpmonent,null)
patched ++
}
}
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
let j
for(let i = toBePatch - 1; i >= 0; i--){
const nextIndex = i + s2
const nextChild = c2[nextIndex]
const anchor = nextIndex + 1 < c2.length ? c2[indexIndex + 1].el : null
if(j < 0 || i !== increasingNewIndexSequence[j])
hostInsert(nextChild.el, container, anchor);
console.log("移动位置")
}else{
j--
}
}
}
}
新增
之前我们实现中间对比中移动逻辑的时候,创建了新旧节点对比区域的映射关系数组,从中可以看到,如果旧节点中的数据新节点中没有,就不会出现在映射关系数组中,即newIndexToOldIndexMap[i] === 0,此时就需要创建数组
/*其他代码*/
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)
let j
for(let i = toBePatch - 1; i >= 0; i--){
const nextIndex = i + s2
const nextChild = c2[nextIndex]
const anchor = nextIndex + 1 < c2.length ? c2[indexIndex + 1].el : null
if (newIndexToOldIndexMap[i] === 0) {
patch(null, nextChild, container, parentComponent, anchor);
}else{
if(j < 0 || i !== increasingNewIndexSequence[j])
hostInsert(nextChild.el, container, anchor);
console.log("移动位置")
}else{
j--
}
}
}
到目前为止,我们的array to array的简单实现已经完成,接下来 我们通过一个中和案例来检验一下具体流程
综合案例
// 综合例子
// a,b,(c,d,e,z),f,g
// a,b,(d,c,aky,y,e),f,g
const prevChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "C" }, "C"),
h("p", { key: "D" }, "D"),
h("p", { key: "E" }, "E"),
h("p", { key: "Z" }, "Z"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];
const nextChildren = [
h("p", { key: "A" }, "A"),
h("p", { key: "B" }, "B"),
h("p", { key: "D" }, "D"),
h("p", { key: "C" }, "C"),
h("p", { key: "aky" }, "aky"),
h("p", { key: "Y" }, "Y"),
h("p", { key: "E" }, "E"),
h("p", { key: "F" }, "F"),
h("p", { key: "G" }, "G"),
];