1.引入
虚拟节点vnode的children有两种类型,一种就是vnode数组 (可以根据数组来patch递归产生子节点),一种就是string类型(就比如 <div>文本</div> 中,div是vnode,"文本"是children )
在进行更新的时候,children的变化有四种可能
- vnode[] -> string,这种情况就把原本的children节点全部移除,然后添加text节点(调用DOM的API)
- string -> string,这种情况就判断string是否有变化,有变化再调用DOM API设置Text即可
- string -> vnode[] ,这种情况,把Text设置为空字符串,然后挂载新的children vnode节点
- vnode[] -> vnode[] ,这种情况就比较复杂了,出于性能考虑, 我们不能把所有节点移除,然后再挂载,这时候就需要使用DIff算法,来判断 哪些节点可以复用 ,哪些节点需要移除,哪些节点需要添加
2.我的理解
· 双端Diff ?
在vue中使用的是双端Diff算法。
什么是双端Diff算法呢? 就是从两端去分别对比新旧vnode数组
为什么要双端对比呢?
我的理解是, 因为在子节点中,很可能只有中间一部分发生了变化,前后是不变的,使用单端对比,必然会造成节点浪费 (如下图,ab和fg不变,如果使用单端对比,那么除了ab,其它都会被重新渲染,造成性能浪费)
从两端去遍历children,相同的就跳过,直到遇到不同的,从而缩小待处理范围
· 基本过程
先从左往右遍历,找到变化区间的左边界 。 let i 从0开始,遍历结束后,最终的i就是左边界
然后从右往左遍历,找到变化区间的右边界 。 let e1 = oldChildren.length -1 和 let e2 = newChildren.length -1。 然后在从右往左遍历,结束后(也就是遇到不相同的节点了),说明 e1 和 e2 分别是新旧children的右边界
然后锁定好变化范围之后,又会发现有以下几种情况
- 新节点的数量大于旧节点的数量(单端)。 (即 i > e1 && i <= e2 ) 那么就去创建这些新增的节点
- 新节点的数量小于旧节点的数量(单端)。 (即 i > e1 && i <= e2 ) 那么我们就需要把多余的删除
- 其它情况就是中间有各种变动的。(else) 这里的逻辑下面再看
· 新节点数量>旧节点(单端)
在这里,新节点的位置又分为两种可能,第一种就是在头部,第二种是在尾部
注,这只是简单的单端情况,如果是 [ b,c] --> [a , b, c, d] 就不适用了
· 新节点数量<旧节点(单端)
在这里一样是有两种可能
注,这只是简单的单端情况,如果是 [ a,b,c,d] --> [b, c] 就不适用了
· 中间对比
剩下的就是最复杂的情况了:
3.简单实现
· 准备
- 给出部分ts类型定义(是我自己写的简单的定义,与官方源码不同,但是能对读写时提供帮助)
TypeScript
/**虚拟节点 */
export interface vnode {
/**这个vnode的根容器的DOM元素 */
el: Element | null
/**vnode的类型。如果是组件就是一个用户编写的对象(包含setup等),如果是标签就是string(比如 div span) */
type: string | Symbol | 用户编写的对象 // 这里该对象比较复杂,本代码也用不上,不写了
/**虚拟节点的key值,可以用于Diff算法。 */
key: number | string | undefined
/**标识符。有关位运算的知识可以看 src\shared\shapeFlags.ts */
shapeFlag: ShapeFlags
/**props属性 */
props?: {
/**虚拟节点的key值,可以用于Diff算法。非必填 */
key?: string | number
[key: string]: any //其它键值对
}
/**孩子。插槽孩子,或者普通vnode孩子 */
children?: string | vnode[] // 插槽孩子的类型在这里用不上,不展示了
}
/**组件实例 */
export interface componentInstance { //只给出了本算法可能需要的类型
/**该组件的虚拟节点 */
vnode: vnode,
/**vnode中的type,放在这里方便使用 */
type: vnode['type']
/**该组件的props */
props: vnode['props']
/**父组件的实例。为undefined的话说明是根组件了 */
parent: componentInstance | undefined
/**当前的节点树 */
subTree: vnode
}
/**虚拟节点的类型标识符 */
export const enum ShapeFlags {
/**element 类型 (HTML标签) */
ELEMENT = 1, // 0001
/**有状态的组件类型 */
STATEFUL_COMPONENT = 1 << 1, // 0010
/**vnode.children 为 string 类型 */
TEXT_CHILDREN = 1 << 2, // 0100
/**vnode.children 为数组类型 */
ARRAY_CHILDREN = 1 << 3, // 1000
/**vnode.children 为 slots 类型 */
SLOTS_CHILDREN = 1 << 4 // 10000
}
- 代码中还会频繁用到 patch 函数 ,这个函数的作用是,递归vnode和它的孩子,判断是 组件 还是 element标签,然后去 创建 或 更新 节点
TypeScript
/**patch函数,最重要的函数,会对比n1和n2,来判断是需要更新还是创建,操作的是Element类型还是Component类型,然后进行递归创建/更新节点
* @param n1 旧Vnode (如果是初始化,这个就是null)
* @param n2 新Vnode
* @param container 容器
* @param parentComponent 父组件实例
* @param anchor 插入的锚点, 如果是需要插入的话,将要插在这个节点之前
*/
function patch(n1: vnode | null, n2: vnode, container: Element, parentComponent: componentInstance, anchor: Element) {
const { shapeFlag, type } = n2
switch (type) {
case Fragment:
processFragment(n1, n2, container, parentComponent, anchor) //去处理 <Fragment></Fragment> 标签的
break;
case Text:
processText(n1, n2, container) //去处理文本类型标签的
break;
default://没有命中的。说明不是特殊标签,进行正常的element或component渲染
// 有关位运算的知识可以看 src\shared\shapeFlags.ts
if (shapeFlag & ShapeFlags.ELEMENT) {//位运算判断是否是element
processElement(n1, n2, container, parentComponent, anchor)//创建/更新 element 节点
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {//位运算判断是否是组件
processComponent(n1, n2, container, parentComponent, anchor)//创建/更新 组件
}
break;
}
}
//在 processElement 和 processComponent 这俩函数的某个过程中,还会调用patch,形成递归子节点
- 然后就正式开始diff算法:
TypeScript
/**对比两个 children 数组 。diff算法就在这里
* @param c1 旧children数组
* @param c2 新children数组
* @param container 容器DOM元素
* @param parentAnchor 父组件的插入的锚点, 如果是需要插入的话,将要插在这个节点之前
* @param parentComponent 父组件实例
*/
function patchKeyedChildren(c1: vnode[], c2: vnode[], container: Element, parentComponent: componentInstance, parentAnchor: Element) {
/**左边界索引 */
let i = 0;
/**新children数组的长度 */
const l2 = c2.length;
/**旧children数组的右边界索引 */
let e1 = c1.length - 1;
/**新children数组的右边界索引 */
let e2 = l2 - 1;
/**判断两个VNode是否相同 (基于 type 和 key 判断是否相等) */
const isSameVNodeType = (n1: vnode, n2: vnode) => {
return n1.type === n2.type && n1.key === n2.key;
};
// .... 内容在下面完善
// .... 内容在下面完善
// .... 内容在下面完善
// .... 内容在下面完善
// .... 内容在下面完善
// .... 内容在下面完善
}
· 从左到右 寻找左边界
根据前面理解中的图片和描述,我们可以知道, 左边界索引 i 需要满足 i <= e1 && i <= e2 ,在while循环中,如果发现两个节点相同,就i++ ,继续遍历; 一旦发现有哪个节点不同了,就立刻break,就能得到左边界 (相同的时候要调用patch进行递归)
TypeScript
// 先从左到右,找到左边界。 判断 n1 和 n2 是否相等,遇到不相等就跳出。当前的相等,就进行patch递归
while (i <= e1 && i <= e2) {
/**当前索引对应的旧孩子 */
const n1 = c1[i]
/**当前索引对应的新孩子 */
const n2 = c2[i]
if (!isSameVNodeType(n1, n2)) { //如果不相同
console.log("两个 child 不相等(从左往右比对),左边界为", i, `\n旧孩子: `, n1, `\n新孩子: `, n2);
break
}
patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
i++
}
· 从右到左 寻找右边界
由于新旧数组的长度可能不相同,所以需要用两个变量作为索引。 循环条件同样是 i <= e1 && i <= e2
如果发现两个节点相同,就 e1--;e2--; 继续遍历; 一旦发现有哪个节点不同了,就立刻break,就得到右边界 (相同的时候要调用patch进行递归)
TypeScript
//此时的i不再开始变化,而是 e1 和 e2 从右往左进行对比 (从后往前判断,看看是否屁股后也有相同的元素,免得插入了中间一个,后面的没变却要重新渲染)
//遇到不相等就跳出。当前的相等,就把这两个child节点进行patch递归
while (i <= e1 && i <= e2) {
/**当前索引对应的旧孩子 */
const n1 = c1[e1]
/**当前索引对应的新孩子 */
const n2 = c2[e2]
if (!isSameVNodeType(n1, n2)) { //如果不相同
console.log(`两个 child 不相等(从右往左比对),右边界分别为 旧 ${e1} 新 ${e2}`, `\n旧孩子: `, n1, `\n新孩子: `, n2);
break
}
patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
e1--
e2--
}
console.log('边界确定', `i = ${i}, e1 = ${e1}`, `e2 = ${e2}`);
· 新节点>旧节点数量 (单端)
画图分析可知,这种情况就是 i 在 e1 和 e2 之间
1图情况是,新节点要添加在左侧 、 2图情况是,新节点要添加在右侧
注,这只是简单的单端情况,如果是 [ b,c] --> [a , b, c, d] 就不适用了
TypeScript
if (i > e1 && i <= e2) {
// 如果是这种情况的话就说明, 新节点的数量大于旧节点的数量 ==> 也就是说新增了 vnode (画图可以就理解为什么i在这俩中间)
// 锚点的计算:新的节点有可能需要添加到尾部,也可能添加到头部,所以需要指定添加的问题
/**判断要插入锚点的位置 */
const nextPos = e2 + 1 //应该使用e2 + 1 (而不是 i + 1,因为对于往左侧添加的话,当插入的数量≥2的时候,应该获取到 c2 的第一个元素,i+1就不准确了,可以画图看)
/**获得要插入的锚点 */
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor; // 如果比新children长度短,就正常以这个为锚点。 否则就越界了,应该使用父亲的Anchor
while (i <= e2) {
console.log(`需要新创建一个 vnode:`, c2[i]);
patch(null, c2[i], container, parentComponent, anchor as container) //第一个参数为null,代表是创建新节点
i++; // 光标移动,支持一次新建多个节点
}
}
· 新节点<旧节点数量(单端)
画图分析可知,此时的i应该满足 i > e2 && i <= e1
注,这只是简单的单端情况,如果是 [ a,b,c,d] --> [b, c] 就不适用了
TypeScript
else if (i <= e1 && i > e2) {
// 这种情况的话说明新节点的数量是小于旧节点的数量的,那么我们就需要把多余的删除
while (i <= e1) {
console.log(`需要删除当前的 vnode: `, c1[i].el);
hostRemove(c1[i].el); //这是一个移除DOM元素的函数,主要是调用 el.parentNode.removeChild(el) 删除自身
i++;
}
}
· 中间对比
这里就比较复杂了, 可以是改顺序了、删除了、新增了等
情况1,总节点数目相同,需要删除老的。就需要先遍历新孩子,建立key与index映射的Map,然后遍历旧孩子,找出key对应的索引(没有传入key的就根据索引,判断vnode是否相等才取)。如果新旧索引对应上了,就调用patch去更新。如果没有找到索引,就调用删除
情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比 ,这时候相比上面的情况,对比完c和e后 (已对比完新节点),就可以直接删除d了(d是个老节点还没对比)
通俗说法:中间部分,老的比新的多, 那么多出来的直接就可以被干掉(优化删除逻辑)
具体的做法就是,在遍历旧孩子的过程中,每次都判断 "已处理数量"是否≥"待处理总数",是的话就直接把后面的全删了
情况3:顺序变化。 对于 cde->ecd,最暴力的解法就是遍历全部,然后一个个插入到指定位置,但是这肯定造成了性能浪费。观察可以发现,实际上需要移动位置的只有e,cd顺序是不变的。
所以我们可以记录他们原本的索引 ,cde的索引是 2 3 4,变化位置之后,变成了 4 2 3,我们找到其中的 最长递增子序列,就可以发现 c d无需变化,只需要移动4 (也就是e)。
也就是给稳定的最长递增子序列保持不变,其它乱序的就执行插入操作,这样就可以减少我们的DOM操作。
为什么是最长递增子序列呢?因为我们要让被移动的节点尽可能少,所以找出最长的
注:最长递增子序列(LIS )概念:例如,{ 3,5,7,1,2,8 } 的 LIS 是 { 3,5,7,8 }
情况4:新增节点。在上面判断顺序变化时,我们会生成一个新索引与旧索引的映射关系newIndexToOldIndexMap,如果遍历时发现该索引对应的值还是初始值,说明"该节点在老数组中不存在",就执行新增操作 (详细见代码)
TypeScript
else {
//剩下的就是中间对比了, 可以是改顺序了、删除了、新增了等
//最普通的想法就是遍历中间所有节点,然后一个个去改,但是时间复杂度就是O(n)了,所以可以采用 key 来进行
//同样也分为多种情况
// 情况1,总节点数目相同,需要删除老的。a,b,(c,d),f,g --> a,b,(e,c),f,g
// 情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比,可以直接删除老的 a,b,(e,c,d),f,g --> a,b,(e,c),f,g
// 情况3,顺序变化。 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g
// 情况4,节点新增。 a,b,(c,e),f,g --> a,b,(e,c,d),f,g
/**旧children数组的 中间区间 的左边界 */
let s1 = i
/**新children数组的 中间区间 的左边界 */
let s2 = i
/**需要处理新节点的数量 */
const toBePatched = e2 - s2 + 1; // 这个可以用于优化情况2
/**老节点已经被patch处理的数量 */
let patched = 0; // 这个可以用于优化情况2
// 怎么判断当前这中间部分,是否有节点需要移动呢 -> 如果发现 所有节点的旧index都是升序的话,说明不需要移动
// 比如 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g ,其中cde的索引从 0 1 2 变成了 1 2 0,不是递增的,所以需要移动
/**是否有节点需要移动 - 标识符。为true的时候会使用最长递增子序列来进行移动 */
let moved = false;
/**当前新索引中,升序部分最大的值,用于判断索引是否为升序 */
let maxNewIndexSoFar = 0;
/**根据key查找index的映射Map,键是key,值是index */
const keyToNewIndexMap = new Map<string | number, number>();
/**- 新索引与旧索引的映射关系。
* - 初始化为0, 后面处理的时候如果发现是0的话,那么就说明新值在老的里面不存在
* - 在"遍历旧孩子们"的时候进行修改
*/
const newIndexToOldIndexMap: number[] = new Array(toBePatched).fill(0); //
//遍历新孩子们,设置 key 与 index 的映射
for (let j = s2; j <= e2; j++) {
/**当前索引对应的新孩子 */
const nextChild = c2[j]
if (nextChild.key) keyToNewIndexMap.set(nextChild.key, j) //用户有传入key才存
}
//遍历旧孩子们
for (let j = s1; j <= e1; j++) {
/**当前索引对应的旧孩子 */
const preChild = c1[j]
if (patched >= toBePatched) {//优化情况2
hostRemove(preChild.el)
continue//下面的就不需要执行了,优化算法速度
}
/**这个旧节点的新索引 */
let newIndex: number | undefined
if (preChild.key !== null || preChild.key !== undefined) { //用户有传入key才获取
newIndex = keyToNewIndexMap.get(preChild.key!) //从map中取出对应的索引
} else {// 用户有可能没有传入key,就用索引代替
for (let k = s2; k <= e2; k++) { //这时候就需要遍历了。 注意k <= e2,不要忘记等于号了
if (isSameVNodeType(preChild, c2[k])) {//判断 preChild 和 新孩子[k] 是否相等
newIndex = j
break
}
}
}
if (newIndex === undefined) { //如果经过上面的查找key,还没找到对应的索引,说明这个旧节点在新孩子数组中不存在,应该删除
hostRemove(preChild.el) //移除掉这个DOM
} else {//存在的话就patch,进行更深层次的更新
//根据newIndex是否升序,判断是否有节点需要移动
if (newIndex >= maxNewIndexSoFar) {//如果一直是升序的
maxNewIndexSoFar = newIndex
} else {//一旦发现有的不是升序,就认定有节点需要移动,启动最长递增子序列算法
moved = true
}
//在这里给 newIndexToOldIndexMap 赋值。 这时候的j就是旧index
// newIndex - s2 的含义:因为我们的数组索引是从"变化区域的左侧"开始,而newIndex的索引是从"整个children的左侧"开始,所以需要相减,才是我们真正想赋值的
// j + 1 的含义:因为j是从0开始,而在这个数组中,0是有特殊含义的(代表新值在老的里不存在)
newIndexToOldIndexMap[newIndex - s2] = j + 1
patch(preChild, c2[newIndex], container, parentComponent, null) //进行patch更新节点
patched++ //被处理过的值++
}
}
//使用最长递增子序列优化:因为如果有部分元素是升序的话,那么这些元素就是不需要移动的,只需要移动乱序的即可。
/** 若有节点需要移动,得到最长递增子序列(下标数组),仅对移动过的节点处理 */
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [] // 通过 moved 来进行优化,如果没有移动过的话 那么就不需要执行算法(这个算法也是有些时间复杂度的)
/**最长递增子序列的指针,从 (increasingNewIndexSequence.length - 1) 开始逆序遍历 */
let point = increasingNewIndexSequence.length - 1
//处理 顺序移动或新增 的情况。 使用逆序遍历,才能保证插入时的锚点已经被固定!!!
for (let j = toBePatched - 1; j >= 0; j--) {
// 假设toBePatched是5 , 此时的递增子序列 increasingNewIndexSequence 是[1, 2, 4] , point和j从末尾开始逆序
// j=4时,point=2,递增子序列[point] = 4, j === 4,则point--
// j=3时,ponit=1,递增子序列[point] = 2, j !== 2,则移动位置
// j=2时,ponit=1,递增子序列[point] = 2, j === 2,则point--
// j=1时,point=0,递增子序列[point] = 1, j === 1,则point--
/**确定当前要处理的节点索引 */
const nextIndex = s2 + j; // 因为j是 "中间区间" 的索引,而我们要拿到的孩子应该是在整个children数组中的索引
/**根据当前要处理的索引,拿到新孩子数组中指定的孩子 */
const nextChild = c2[nextIndex];
/**要插入的锚点。锚点等于当前节点索引+1,也就是当前节点的后面一个节点。 因为是倒遍历,所以锚点是位置确定的节点,不会变动了 */
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor; // 判断是否越界,越界了就用默认的
if (newIndexToOldIndexMap[j] === 0) {//如果当前值是0,说明要创建
patch(null, nextChild, container, parentComponent, anchor)//进行patch创建新节点
} else if (moved) {//需要移动的时候才
if (j !== increasingNewIndexSequence[point] || point < 0) {//除了判断j是否不相等, 还有point小于0的时候,就直接移动就行
//如果当前j和递增子序列的值不相同,说明是"非递增子序列",需要移动位置
console.log(nextChild.el, '这个节点要移动位置');
hostInsert(nextChild.el!, container, anchor) //在指定锚点的前面插入DOM,即 container.insertBefore(el, anchor)
} else {
point-- //不需要移动的话,说明匹配成功递增子序列了,就 point-- 即可
}
}
}
}
附:最长递增子序列算法:
TypeScript
/**获得最长递增子序列的索引数组
* @param arr 要被查找的数组
* @returns 返回最长子序列的下标数组。 比如 传入[4,2,3,1,5] ,得到 [1,2,4] (即最长递增序列是 2 3 5)
*/
function getSequence(arr: number[]): number[] {
const p = arr.slice();
const result = [0];
let i: number, j: number, u: number, v: number, c: number;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
4. 整个流程的完整代码
把前面的代码块都组合在一起,就是下面这个:
里面还有一些函数没有给出来,比如"hostInsert" 是 指定位置插入DOM 函数等,这些就不在这里写出来了,完整代码可以看我跟着视频敲出来的mini-vue代码: 手敲mini-vue: vue3源码的mini版本,写了特别详细的注释与简单的ts类型,便于自己日后复习 (gitee.com)
TypeScript
/**对比两个 children 数组 。diff算法就在这里
* @param c1 旧children数组
* @param c2 新children数组
* @param container 容器DOM元素
* @param parentComponent 父组件实例
* @param parentAnchor 父插入的锚点, 如果是需要插入的话,将要插在这个节点之前
*/
function patchKeyedChildren(c1: vnode[], c2: vnode[], container: container, parentComponent: componentInstance, parentAnchor: container) {
/**左边界索引 */
let i = 0;
/**新children数组的长度 */
const l2 = c2.length;
/**旧children数组的右边界索引 */
let e1 = c1.length - 1;
/**新children数组的右边界索引 */
let e2 = l2 - 1;
/**判断两个VNode是否相同 (基于 type 和 key 判断是否相等) */
const isSameVNodeType = (n1: vnode, n2: vnode) => {
return n1.type === n2.type && n1.key === n2.key;
};
// 先从左到右,找到左边界。 判断 n1 和 n2 是否相等,遇到不相等就跳出。当前的相等,就进行patch递归
while (i <= e1 && i <= e2) {
/**当前索引对应的旧孩子 */
const n1 = c1[i]
/**当前索引对应的新孩子 */
const n2 = c2[i]
if (!isSameVNodeType(n1, n2)) { //如果不相同
console.log("两个 child 不相等(从左往右比对),左边界为", i, `\n旧孩子: `, n1, `\n新孩子: `, n2);
break
}
patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
i++
}
//此时的i不再开始变化,而是 e1 和 e2 从右往左进行对比 (从后往前判断,看看是否屁股后也有相同的元素,免得插入了中间一个,后面的没变却要重新渲染)
//遇到不相等就跳出。当前的相等,就把这两个child节点进行patch递归
while (i <= e1 && i <= e2) {
/**当前索引对应的旧孩子 */
const n1 = c1[e1]
/**当前索引对应的新孩子 */
const n2 = c2[e2]
if (!isSameVNodeType(n1, n2)) { //如果不相同
console.log(`两个 child 不相等(从右往左比对),右边界分别为 旧 ${e1} 新 ${e2}`, `\n旧孩子: `, n1, `\n新孩子: `, n2);
break
}
patch(n1, n2, container, parentComponent, parentAnchor) //进行patch递归
e1--
e2--
}
console.log('边界确定', `i = ${i}, e1 = ${e1}`, `e2 = ${e2}`);
if (i > e1 && i <= e2) {
// 如果是这种情况的话就说明, 新节点的数量大于旧节点的数量 ==> 也就是说新增了 vnode (画图可以就理解为什么i在这俩中间)
// 锚点的计算:新的节点有可能需要添加到尾部,也可能添加到头部,所以需要指定添加的问题
/**判断要插入锚点的位置 */
const nextPos = e2 + 1 //应该使用e2 + 1 (而不是 i + 1,因为对于往左侧添加的话,当插入的数量≥2的时候,应该获取到 c2 的第一个元素,i+1就不准确了,可以画图看)
/**获得要插入的锚点 */
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor; // 如果比新children长度短,就正常以这个为锚点。 否则就越界了,应该使用父亲的Anchor
while (i <= e2) {
console.log(`需要新创建一个 vnode:`, c2[i]);
patch(null, c2[i], container, parentComponent, anchor as container) //第一个参数为null,代表是创建新节点
i++; // 光标移动,支持一次新建多个节点
}
} else if (i <= e1 && i > e2) {
// 这种情况的话说明新节点的数量是小于旧节点的数量的,那么我们就需要把多余的删除
while (i <= e1) {
console.log(`需要删除当前的 vnode: `, c1[i].el);
hostRemove(c1[i].el);
i++;
}
} else {
//剩下的就是中间对比了, 可以是改顺序了、删除了、新增了等
//最普通的想法就是遍历中间所有节点,然后一个个去改,但是时间复杂度就是O(n)了,所以可以采用 key 来进行
//同样也分为多种情况
// 情况1,总节点数目相同,需要删除老的。a,b,(c,d),f,g --> a,b,(e,c),f,g
// 情况2,新节点数目比旧节点少,已经对比完新节点,却还存在老节点没对比,可以直接删除老的 a,b,(e,c,d),f,g --> a,b,(e,c),f,g
// 情况3,顺序变化。 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g
// 情况4,节点新增。 a,b,(c,e),f,g --> a,b,(e,c,d),f,g
/**旧children数组的 中间区间 的左边界 */
let s1 = i
/**新children数组的 中间区间 的左边界 */
let s2 = i
/**需要处理新节点的数量 */
const toBePatched = e2 - s2 + 1; // 这个可以用于优化情况2
/**老节点已经被patch处理的数量 */
let patched = 0; // 这个可以用于优化情况2
// 怎么判断当前这中间部分,是否有节点需要移动呢 -> 如果发现 所有节点的旧index都是升序的话,说明不需要移动
// 比如 a,b,(c,d,e),f,g --> a,b,(e,c,d),f,g ,其中cde的索引从 0 1 2 变成了 1 2 0,不是递增的,所以需要移动
/**是否有节点需要移动 - 标识符。为true的时候会使用最长递增子序列来进行移动 */
let moved = false;
/**当前新索引中,升序部分最大的值,用于判断索引是否为升序 */
let maxNewIndexSoFar = 0;
/**根据key查找index的映射Map,键是key,值是index */
const keyToNewIndexMap = new Map<string | number, number>();
/**- 新索引与旧索引的映射关系。
* - 初始化为0, 后面处理的时候如果发现是0的话,那么就说明新值在老的里面不存在
* - 在"遍历旧孩子们"的时候进行修改
*/
const newIndexToOldIndexMap: number[] = new Array(toBePatched).fill(0); //
//遍历新孩子们,设置 key 与 index 的映射
for (let j = s2; j <= e2; j++) {
/**当前索引对应的新孩子 */
const nextChild = c2[j]
if (nextChild.key) keyToNewIndexMap.set(nextChild.key, j) //用户有传入key才存
}
//遍历旧孩子们
for (let j = s1; j <= e1; j++) {
/**当前索引对应的旧孩子 */
const preChild = c1[j]
if (patched >= toBePatched) {//优化情况2
hostRemove(preChild.el)
continue//下面的就不需要执行了,优化算法速度
}
/**这个旧节点的新索引 */
let newIndex: number | undefined
if (preChild.key !== null || preChild.key !== undefined) { //用户有传入key才获取
newIndex = keyToNewIndexMap.get(preChild.key!) //从map中取出对应的索引
} else {// 用户有可能没有传入key,就用索引代替
for (let k = s2; k <= e2; k++) { //这时候就需要遍历了。 注意k <= e2,不要忘记等于号了
if (isSameVNodeType(preChild, c2[k])) {//判断 preChild 和 新孩子[k] 是否相等
newIndex = j
break
}
}
}
if (newIndex === undefined) { //如果经过上面的查找key,还没找到对应的索引,说明这个旧节点在新孩子数组中不存在,应该删除
hostRemove(preChild.el) //移除掉这个DOM
} else {//存在的话就patch,进行更深层次的更新
//根据newIndex是否升序,判断是否有节点需要移动
if (newIndex >= maxNewIndexSoFar) {//如果一直是升序的
maxNewIndexSoFar = newIndex
} else {//一旦发现有的不是升序,就认定有节点需要移动,启动最长递增子序列算法
moved = true
}
//在这里给 newIndexToOldIndexMap 赋值。 这时候的j就是旧index
// newIndex - s2 的含义:因为我们的数组索引是从"变化区域的左侧"开始,而newIndex的索引是从"整个children的左侧"开始,所以需要相减,才是我们真正想赋值的
// j + 1 的含义:因为j是从0开始,而在这个数组中,0是有特殊含义的(代表新值在老的里不存在)
newIndexToOldIndexMap[newIndex - s2] = j + 1
patch(preChild, c2[newIndex], container, parentComponent, null) //进行patch更新节点
patched++ //被处理过的值++
}
}
//使用最长递增子序列优化:因为如果有部分元素是升序的话,那么这些元素就是不需要移动的,只需要移动乱序的即可。
/** 若有节点需要移动,得到最长递增子序列(下标数组),仅对移动过的节点处理 */
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [] // 通过 moved 来进行优化,如果没有移动过的话 那么就不需要执行算法(这个算法也是有些时间复杂度的)
/**最长递增子序列的指针,从 (increasingNewIndexSequence.length - 1) 开始逆序遍历 */
let point = increasingNewIndexSequence.length - 1
//处理 顺序移动或新增 的情况。 使用逆序遍历,才能保证插入时的锚点已经被固定!!!
for (let j = toBePatched - 1; j >= 0; j--) {
// 假设toBePatched是5 , 此时的递增子序列 increasingNewIndexSequence 是[1, 2, 4] , point和j从末尾开始逆序
// j=4时,point=2,递增子序列[point] = 4, j === 4,则point--
// j=3时,ponit=1,递增子序列[point] = 2, j !== 2,则移动位置
// j=2时,ponit=1,递增子序列[point] = 2, j === 2,则point--
// j=1时,point=0,递增子序列[point] = 1, j === 1,则point--
/**确定当前要处理的节点索引 */
const nextIndex = s2 + j; // 因为j是 "中间区间" 的索引,而我们要拿到的孩子应该是在整个children数组中的索引
/**根据当前要处理的索引,拿到新孩子数组中指定的孩子 */
const nextChild = c2[nextIndex];
/**要插入的锚点。锚点等于当前节点索引+1,也就是当前节点的后面一个节点。 因为是倒遍历,所以锚点是位置确定的节点,不会变动了 */
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor; // 判断是否越界,越界了就用默认的
if (newIndexToOldIndexMap[j] === 0) {//如果当前值是0,说明要创建
patch(null, nextChild, container, parentComponent, anchor)//进行patch创建新节点
} else if (moved) {//需要移动的时候才
if (j !== increasingNewIndexSequence[point] || point < 0) {//除了判断j是否不相等, 还有point小于0的时候,就直接移动就行
//如果当前j和递增子序列的值不相同,说明是"非递增子序列",需要移动位置
console.log(nextChild.el, '这个节点要移动位置');
hostInsert(nextChild.el!, container, anchor) //在指定锚点的前面插入DOM,即 container.insertBefore(el, anchor)
} else {
point-- //不需要移动的话,说明匹配成功递增子序列了,就 point-- 即可
}
}
}
}
}
/**获得最长递增子序列的索引数组
* @param arr 要被查找的数组
* @returns 返回最长子序列的下标数组。 比如 传入[4,2,3,1,5] ,得到 [1,2,4] (即最长递增序列是 2 3 5)
*/
function getSequence(arr: number[]): number[] {
const p = arr.slice();
const result = [0];
let i: number, j: number, u: number, v: number, c: number;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
5.结语
关于DIff算法的理解就到这里啦,可能有的地方写的不对,欢迎在评论区指出告诉我