Eeact/Vue的Diff算法


React

React16之前是直接递归渲染 vdom setState会触发重新渲染 对比渲染出的新旧vdom 对差异部分进行 dom 操作

React16之后 为了性能优化 会先将 vdom转换为fiber (从树转换成链表) 然后在渲染

  • render阶段: 从vdom转换为 fiber 并且对需要 dom 操作的节点打上 effectTag的标记
  • commit阶段: 对有 effectTag标记的 fiber节点进行 dom 操作 并执行所有的effect副作用函数

vdom转换成fiber这个阶段叫做调和(reconcile) 由scheduler调度执行 是可以打断的

diff算法作用在reconcile阶段 第一次渲染不需要diff 直接vdomfiber 再次渲染的时候 会产生新的vdom 这个时候就要和之前的 fiber进行对比决定如何产生新的fiber 对于可复用的节点打上修改标记 剩余的旧节点打上删除的标记 新节点打上新增标记

打上effectTag可以标识这个fiber发生了怎么样的变化(新增、更新、删除) 这些被打上标记的fiber会在complete阶段被收集起来 形成一个effectList链表 只包含这些需要操作的fiber 最后在commit阶段被更新掉

img

Diff 的主体

1
2
3
4
5
6
workInProgress.child = reconcileChildFibers(
workInProgress, //作为父节点传入 新生成的第一个fiber的return会指向它
current.child, //旧fiber节点 diff生成新的fiber节点时会用新生成的ReactElement和它比较
nextChildren, //新生成的ReactElement 会以它为标准生成新的fiber节点
renderLanes //本次渲染的优先级 最终会挂载到新fiber的lanes属性上
);

Diff 的基本原则

场景有节点自身更新 节点增删 节点移动等三种情况

  • 即使两个元素的子树完全一样 但前后父级元素不同 按照规则 div 元素及其子树会完全销毁 并重建新的一个 p 元素及其子树 不会尝试复用子树
  • 使用标签名(tag)和key识别节点 区分出前后的节点是否变化 以达到尽量复用无变化的节点(React 可以根据 tagkey来判断节点只是位置发生了变化来复用)

每轮循环都是自左向右进行查找比对的

Diff 使用场景

单节点

单节点更新以及增删

即在tagkey相同的情况下 节点的属性发生了变化 就是节点更新 如果节点的tag | key 不同 则新旧节点没有关系

单节点指newChildren为单一节点 但是oldFiber的数量不一定

1
2
3
1.old: A new: A
2.old: A - B - C new: B
3.old: -- new: A

对于单节点 其实只有更新操作 对 React 来说只有两种情况 即 oldFiber 是否为空

  • oldFiber不为空

    遍历它们 找到key和tag相同的节点 然后删除剩下的oldFiber节点 再用匹配的oldFibernewChildren中的新节点的props来生成新的fiber节点

    1
    2
    3
    4
    5
    6
    7
    // 先删除剩下的oldFiber节点
    deleteRemainingChildren(returnFiber, child.sibling);
    // 基于oldFiber节点和新节点的props新建新的fiber节点
    const existing = useFiber(child, element.props);
    existing.ref = coerceRef(returnFiber, child, element);
    existing.return = returnFiber;
    return existing;
  • oldFiber为空

    对于没有oldFibe节点的情况 只能新增 newFiber节点

    1
    2
    3
    4
    const created = createFiberFromElement(element, returnFiber.mode, lanes);
    created.ref = coerceRef(returnFiber, currentFirstChild, element);
    created.return = returnFiber;
    return created;

多节点

多节点分为节点更新、新增节点、删除节点、节点移动四种情况 调用 reconcileChildrenArray进行diff 它会以newChildren为主体进行最多三轮遍历 即第一轮从头开始 之后每一轮都是上一轮结束的断点继续

在平时的实践中 节点自身的更新是最频繁的 所以diff算法会优先处理更新的节点 所以第一轮是针对节点自身属性的更新 后面两轮依次处理节点的新增、移动

节点更新

第一轮从头开始遍历 newChildren 逐个和 oldFiber链中的节点进行比较 判断节点的 key |tag是否有变化

  • 没变则从oldFiber节点clone一个props被更新的fiber节点 新的节点来自于newChildren中的新节点 实现节点更新
  • 有变化则说明不满足复用条件 立即中断遍历进入下边的遍历 (diff 算法的复杂度也因为这个操作大幅降低)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 更新节点,对于DOM节点来说,updateSlot内部会判断
// key 和 tag。任意一个不同,则返回null
const newFiber = updateSlot( returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber为null则说明当前的节点不是更新的场景,中止这一轮循环
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
...

//eg: old: a -b-c-d newChildren: a-b-d-c
1.第一轮遍历中 ab都是一样的 可以复用 在d这里跳出第一轮循环 这里的最后一个可复用的节点 即b(lastPlacedIndex )是下一轮循环的移动节点参照物
//跳出逻辑
....
//如果不跳出 记录最新的固定节点的位置
lastPlacedIndex = placedChild(newFiber, lastPlacedIndex, newIndex)
//当节点无需移动的时候 会返回当前节点的位置 对于固定节点来说 就是返回自身的index


节点删除

遍历 newChildren 当它遍历结束但是 oldFiber链还没遍历完 说明剩下的节点都要被删除 可以直接在oldFiber的节点上打上 Delection的删除标记理删除

1
2
3
4
5
6
if (newIdx === newChildren.length) {
// 新子节点遍历完,说明剩下的oldFiber都是没用的了,可以删除
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
//删除不仅仅是标记了删除标记 而且还会将这个被删除的 fiber节点添加到父级的effectList中

节点新增

oldFiber链遍历完 但newChildren还没遍历完 那么余下的节点都属于新插入的节点 会新建fiber节点并以sibling为指针连成 fiber

节点移动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//old: a-b-c-d-e-f new: a-b-d-c-e
第一轮 发现固定节点为ab 此时oldFiber为CDEF newChildren为 DCE
之后将oldFiber节点放入一个以key为键的map中 existingChildren
newChildren中剩余的节点 都是不确定是否要移动的 所以遍历它们 每一个去看看这个节点在oldFiber中的旧位置 遍历到它在newChildren中的新位置
如果旧位置在lastPlacedIndex的右边 说明这个节点不变
如果旧节点在左边 而新节点在右边 说明位置没变化 更新新的lastPlacedIndex
如果旧位置在右边 而新位置也在右边 说明位置不变 更新lasyPlacedIndex
如果旧位置在lastPlacedIndex的左边,当前这个节点的位置要往右挪。
原因是旧位置在lastPlacedIndex的左边,新位置却在lastPlacedIndex的右边,所以它要往右挪,但它不是固定节点。此时无需更新lastPlacedIndex
//过程

1. 最右侧固定节点为b lastPlacedIndex为1 existingChildren {c: '节点c' d: '节点d', e: "节点e", f: “节点f”}
2. newchildren DCE继续遍历 首先遍历D D在oldFiber中为3 3>1 oldFiber中D的位置在B的右边 newCHildren也是如此 所以D的位置不动 此时最新的节点变为D lasyPlacedIndex为3 并从existingChildren删除D
3.再遍历到c c在oldFiber中的位置是2 2 < 3 c原来在d的左边 而newChildren中的c在d的右边 所以要将它移动到右边 并从 existingChildren 中删除c
4.再遍历到e e在oldFiber中的位置是4 4 > 3 所以e的位置不懂 最新的固定点变成e 从 existingChildren中删除e
5.newChildren中的节点都处理完了 针对移动节点的遍历结束 existingFiber中还有f节点 直接删除就好

总结

  • 第一轮遍历 一一对比vdomoldFiber 如果可以复用就处理下一节点 否则就结束遍历
    • ​ 如果所有新的vdom都处理完了 就把剩下的fiber节点删除掉就行
    • 如果还有vdom没有处理 就就行第二次遍历
  • 第二轮遍历 把oldFiber中的lastPlacedIndex之后的节点放入 existingChildrenmap中 遍历剩下的vdommap中查找 如果找到了 在判断是否需要移动 (是否需要移动都需要在查找后从map中删除该节点)
  • 第二轮遍历后 就将剩余的oldFiber删除掉 剩余的vdom新增

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes
): Fiber | null {
/* * returnFiber:currentFirstChild的父级fiber节点
* currentFirstChild:当前执行更新任务的WIP(fiber)节点
* newChildren:组件的render方法渲染出的新的ReactElement节点
* lanes:优先级相关
* */

// resultingFirstChild是diff之后的新fiber链表的第一个fiber。
let resultingFirstChild: Fiber | null = null;
// resultingFirstChild是新链表的第一个fiber。
// previousNewFiber用来将后续的新fiber接到第一个fiber之后
let previousNewFiber: Fiber | null = null;

// oldFiber节点,新的child节点会和它进行比较
let oldFiber = currentFirstChild;
// 存储固定节点的位置
let lastPlacedIndex = 0;
// 存储遍历到的新节点的索引
let newIdx = 0;
// 记录目前遍历到的oldFiber的下一个节点
let nextOldFiber = null;

// 该轮遍历来处理节点更新,依据节点是否可复用来决定是否中断遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// newChildren遍历完了,oldFiber链没有遍历完,此时需要中断遍历
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 用nextOldFiber存储当前遍历到的oldFiber的下一个节点
nextOldFiber = oldFiber.sibling;
}
// 生成新的节点,判断key与tag是否相同就在updateSlot中
// 对DOM类型的元素来说,key 和 tag都相同才会复用oldFiber
// 并返回出去,否则返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes
);

// newFiber为 null说明 key 或 tag 不同,节点不可复用,中断遍历
if (newFiber === null) {
if (oldFiber === null) {
// oldFiber 为null说明oldFiber此时也遍历完了
// 是以下场景,D为新增节点
// 旧 A - B - C
// 新 A - B - C - D oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
// shouldTrackSideEffects 为true表示是更新过程
if (oldFiber && newFiber.alternate === null) {
// newFiber.alternate 等同于 oldFiber.alternate
// oldFiber为WIP节点,它的alternate 就是 current节点
// oldFiber存在,并且经过更新后的新fiber节点它还没有current节点,
// 说明更新后展现在屏幕上不会有current节点,而更新后WIP
// 节点会称为current节点,所以需要删除已有的WIP节点
deleteChild(returnFiber, oldFiber);
}
}
// 记录固定节点的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新fiber连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
// 将oldFiber节点指向下一个,与newChildren的遍历同步移动
oldFiber = nextOldFiber;
}

// 处理节点删除。新子节点遍历完,说明剩下的oldFiber都是没用的了,可以删除.
if (newIdx === newChildren.length) {
// newChildren遍历结束,删除掉oldFiber链中的剩下的节点
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}

// 处理新增节点。旧的遍历完了,能复用的都复用了,所以意味着新的都是新插入的了
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
// 基于新生成的ReactElement创建新的Fiber节点
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
// 记录固定节点的位置lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新生成的fiber节点连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
// 执行到这是都没遍历完的情况,把剩余的旧子节点放入一个以key为键,值为oldFiber节点的map中
// 这样在基于oldFiber节点新建新的fiber节点时,可以通过key快速地找出oldFiber
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

// 节点移动
for (; newIdx < newChildren.length; newIdx++) {
// 基于map中的oldFiber节点来创建新fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
// 因为newChildren中剩余的节点有可能和oldFiber节点一样,只是位置换了,
// 但也有可能是是新增的.

// 如果newFiber的alternate不为空,则说明newFiber不是新增的。
// 也就说明着它是基于map中的oldFiber节点新建的,意味着oldFiber已经被使用了,所以需
// 要从map中删去oldFiber
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key
);
}
}

// 移动节点,多节点diff的核心,这里真正会实现节点的移动
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 将新fiber连接成以sibling为指针的单向链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// 此时newChildren遍历完了,该移动的都移动了,那么删除剩下的oldFiber
existingChildren.forEach((child) => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}

vue

vue2Diff 双端比较

即新列表和旧列表两个列表的头尾互相比对 在比对的过程中逐渐向内靠拢 知道某一个列表的所有节点全部遍历过 对比停止(即将复杂的变化情况留在中间位置)

算法规则

  1. 如果新节点有子节点而老节点没有 则判断老节点是否有文本内容 如果有就清空老节点的文本内容 然后为其新增子节点
  2. 如果新节点没有子节点而老节点有子节点 则先删除老节点的子节点 然后设置文本内容
  3. 如果新节点没有子节点 而老节点也没有子节点 则进行文本的对比 然后设置其文本内容
  4. 如果新节点有子节点 老节点也有子节点 则进行新老子节点的比对 然后进行新增 移动 删除 修改等操作 也是diff算法操作的情况

执行时机

在页面首次渲染时会调用一次patch并创建新的 vdom 但是这个时候不会使用diff算法进行更深层次的比较

之后在组件的状态数据发生改变时 会触发setter并通过 Notify 通知Watcher 对应的Watcher会通知更新并执行更新函数 会执行render函数获取新的vDom 然后执行patch对比上次渲染的结果和oldVdom 并计算出最小的变化 然后再更根据这个最小的变化去更新真实的dom

实现描述

1
2
3
4
//vue/src/core/vdom/path.js
let oldStartIdx, oldEndIdx, newStartIdx, newEndIdx ;//分别用来表示新旧vnode的首尾索引
//当 oldStartIdx > oldEndIdx 表示旧vdom已经遍历完成 接下来是新增vdom的操作
//当 newStartIdx > newEndIdx 表示新vdom已经遍历完成 直接对剩余的旧vdom进行删除操作就行

先对以下四种情况做优化策略

  • oldStartIdx / newStartIdx
  • oldEndIdx / newEndIdx
  • oldStartIdx / newEndIdx
  • oldEndIdx / newStartIdx

如果以上的情况都没找到 则从新数组的第一个节点去老数组中查找 找到了就递归更新 没找到就创建新节点

最后循环结束后 需要对未处理的节点进行处理 如果是老节点列表先循环完毕 这个时候如果新节点列表还有剩余的节点 则说明这些节点直接创建并插入到Dom中就行了 如果新节点列表先循环完毕 而老节点列表还有剩余节点 则说明这些节点都是要被废弃的节点 直接批量删除就好了

Diff 算法的优化

1.只比较同一层级 不跨级比较

Diff过程只会对同一层级的DOM进行比较来简化比较次数

image-20221023210939892

2.通过 tag 以及 key
1.tag

如果同一层的 tag 不同 则直接移除老的DOM对应的节点

2.key

如果tag && key 相同 则认为是相同的节点 不会继续按照这个树结构做深度比较

key 的作用

  • key 可以精确的找到相同的新旧虚拟节点 可以加快 patch 过程 更高效地更新虚拟DOM
  • 如果patch的时候发现key值不同 则会直接认为不是相同的节点 所以如果key值不唯一 可能会有一些隐藏的 bug 比如对列表数据进行增删 还有使用相同标签元素进行过度切换的时候 会导致只替换内部属性而不会触发过渡效果

源码解读

源码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
//如果vnode不存在而旧的oldVnode存在 则直接删除旧节点
if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
return;
}
//初始化不是首次false
let isInitialPatch = false;
const insertedVnodeQueue = [];
//如果旧虚拟dom不存在而新虚拟dom存在 则直接创建新虚拟dom并插入 比如第一次渲染
if (isUndef(oldVnode)) {
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
//如果新旧节点都存在的话
const isRealElement = isDef(oldVnode.nodeType);
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
//如果新旧节点是统一节点则通过patchVnode进行后续的比较
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
} else {
if (isRealElement) {
//如果是元素节点且是服务端渲染的话(nodeType == 1表示服务端渲染)
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
oldVnode.removeAttribute(SSR_ATTR); //删掉这个属性 并用hydrating表示服务端渲染
hydrating = true;
}
if (isTrue(hydrating)) {
//如果是服务端渲染 则使用hydrating将oldNode和realElement混合
if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
invokeInsertHook(vnode, insertedVnodeQueue, true);
return oldVnode;
} else if (process.env.NODE_ENV !== "production") {
warn(
"The client-side rendered virtual DOM tree is not matching " +
"server-rendered content. This is likely caused by incorrect " +
"HTML markup, for example nesting block-level elements inside " +
"<p>, or missing <tbody>. Bailing hydration and performing " +
"full client-side render."
);
}
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
//如果不是服务端渲染或者混合失败 就创建一个空的注释节点替换oldVnode
oldVnode = emptyNodeAt(oldVnode);
}
//获取oldVnode的父节点
// replacing existing element
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);
//根据新的vnode创建一个DOM节点 挂在到父节点上
// create new node
createElm(
vnode,
insertedVnodeQueue,
// extremely rare edge case: do not insert if old element is in a
// leaving transition. Only happens when combining transition +
// keep-alive + HOCs. (#4590)
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
//如果新的vnode的根节点存在 说明根节点被修改了 那么就要遍历更新父节点
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent;
const patchable = isPatchable(vnode);
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor);
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert;
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}

// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
}

invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
return vnode.elm;
}

//sameVnode 用来判断是不是同一个节点
function sameVnode(a, b) {
return (
(a.key === b.key &&
(a.asyncFactory == b.asyncFactory) & //是不是异步组件
(a.tag === b.tag &&
a.isComment == b.isComment &&
isDef(a.data) == isDef(b.data) && //内容数据是不是一样
sameInputeType(a, b))) || //判断input的type是不是一样
(isTrue(a.isAsyncPlaceholder) && isUndef(b.isAsyncFactory.error))
);
}

patchVnode 方法的源码

这是在新的 vnode 和 oldVnode 是同一节点的情况下 才会执行的函数 主要对比节点文本变化或子节点变化

  • 如果oldVnodevnode的引用地址是一样的 就表示节点没有变化 直接返回
  • 如果 oldVnode && isAsyncPlaceholder 则跳过异步组件的检查 直接返回
  • 如果oldVnodevnode都是静态节点且有唯一的key 并且vnode是克隆节点或者v-once控制节点时 将oldVnode.elmoldVnode.child都复制到vnode上 然后返回
  • 如果vnode不是文本节点也不是注释的情况下
    • 如果vnodeoldVnode都有子节点 并且子节点不一样 就使用 updateChildren更新节点
    • 如果只有vnode有子节点 就直接使用addVnodes创建子节点
    • 如果只有``oldVnode有子节点 就调用removeVnodes`删除子节点
    • 如果vnode文本为undefined就删掉vnode.elm
  • 如果vnode是文本节点但是和oldVnode文本的内容不一样 就更新文本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
function patchVnode(
oldVnode, // 老的虚拟 DOM 节点
vnode, // 新的虚拟 DOM 节点
insertedVnodeQueue, // 插入节点的队列
ownerArray, // 节点数组
index, // 当前节点的下标
removeOnly // 只有在
) {
// 新老节点引用地址是一样的,直接返回
// 比如 props 没有改变的时候,子组件就不做渲染,直接复用
if (oldVnode === vnode) return;

// 新的 vnode 真实的 DOM 元素
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode);
}

const elm = (vnode.elm = oldVnode.elm);
// 如果当前节点是注释或 v-if 的,或者是异步函数,就跳过检查异步组件
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue);
} else {
vnode.isAsyncPlaceholder = true;
}
return;
}
// 当前节点是静态节点的时候,key 也一样,或者有 v-once 的时候,就直接赋值返回
if (
isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance;
return;
}
// hook 相关的不用管
let i;
const data = vnode.data;
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
i(oldVnode, vnode);
}
// 获取子元素列表
const oldCh = oldVnode.children;
const ch = vnode.children;

if (isDef(data) && isPatchable(vnode)) {
// 遍历调用 update 更新 oldVnode 所有属性,比如 class,style,attrs,domProps,events...
// 这里的 update 钩子函数是 vnode 本身的钩子函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
// 这里的 update 钩子函数是我们传过来的函数
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
}
// 如果新节点不是文本节点,也就是说有子节点
if (isUndef(vnode.text)) {
// 如果新老节点都有子节点
if (isDef(oldCh) && isDef(ch)) {
// 如果新老节点的子节点不一样,就执行 updateChildren 函数,对比子节点
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
} else if (isDef(ch)) {
// 如果新节点有子节点的话,就是说老节点没有子节点

// 如果老节点文本节点,就是说没有子节点,就清空
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
// 添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果新节点没有子节点,老节点有子节点,就删除
removeVnodes(oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果老节点是文本节点,就清空
nodeOps.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// 新老节点都是文本节点,且文本不一样,就更新文本
nodeOps.setTextContent(elm, vnode.text);
}
if (isDef(data)) {
// 执行 postpatch 钩子
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode);
}
}

updateChildren 新的vnodeoldVnode都有子节点并且子节点不一样的时候进行对比子节点的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
function updateChildren(
parentElm,
oldCh,
newCh,
insertedVnodeQueue,
removeOnly
) {
//定义新旧vdom的首位索引以及首尾vdom
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
//后续查找需要的变量
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

const canMove = !removeOnly;
//当前环境的判断
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(newCh);
}
//当新vdom和旧vdom都没遍历结束
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]; //上面两部是去掉在之前的对比中可能被删除的节点
} else if (sameVnode(oldStartVnode, newStartVnode)) {
//新老首部对比
patchVnode(
oldStartVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
//旧尾部和新首部
patchVnode(
oldEndVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 旧首部和新尾部
patchVnode(
oldStartVnode,
newEndVnode,
insertedVnodeQueue,
newCh,
newEndIdx
);
canMove &&
nodeOps.insertBefore(
parentElm,
oldStartVnode.elm,
nodeOps.nextSibling(oldEndVnode.elm)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 旧尾部和新尾部
patchVnode(
oldEndVnode,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
canMove &&
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
//当前面四种情况都之后还没结束的时候 则进行循环遍历查找
if (isUndef(oldKeyToIdx))
//根据老vdom创建一个map 避免用数组重复遍历查询 属于时间换空间的算法
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
//新vdom的第一个头结点(并不是初始的头结点 而是经过前面四种情况处理后的newStartIdx)
//去旧vdom生成的map中查找
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
if (isUndef(idxInOld)) {
// New element
//如果找不到的话 则进行创建并插入未处理的旧节点的前面 (oldStartVnode.elem)的前面
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
} else {
//如果找到了
vnodeToMove = oldCh[idxInOld];
//判断是否时相同节点
if (sameVnode(vnodeToMove, newStartVnode)) {
//如果是的话 则递归更新
patchVnode(
vnodeToMove,
newStartVnode,
insertedVnodeQueue,
newCh,
newStartIdx
);
oldCh[idxInOld] = undefined;
canMove &&
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
} else {
// same key but different element. treat as new element
//否则创建新的节点进行替换
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
//循环查找的过程结束
if (oldStartIdx > oldEndIdx) {
//如果老的vdom结束 而新的vdom还在 则直接批量创建并插入
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
refElm,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else if (newStartIdx > newEndIdx) {
//否则直接批量删除
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}

vue3

diff 的作用域

存在 children 的 vnode 类型

1.element 元素类型 vnode

patchElement用于处理element类型的vnode

2.fragment 碎片类型的 vnode

fragment是虚拟的 并不会在DOM树中呈现 这样我们就可以将组件的功能绑定到单一的元素中 而不需要创建一个多余的DOM节点 使用 processFragment处理Fragment类型的vnode

1
2
3
4
5
if(patchFlag && PatchFlag.KEYD_FRAGMENT) {
...//对于存在key的情况使用diff
} else if (patch && PatchFlags.UNKEYED_FRAGMENT) {
//对于不存在key的情况 直接patch
}

patchChildren根据是否存在key进行真正的 diff或者直接patch

diff 算法具体操作

不存在 key 的时候

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength) //比较新旧children的最小值作为公共部分 进行新的patch工作
let i
for (i = 0; i < commonLength; i++) { /* 依次遍历新老vnode进行patch */
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
if (oldLength > newLength) { /* 老vnode 数量大于新的vnode,删除多余的节点 */
unmountChildren(c1, parentComponent, parentSuspense, true, commonLength)
} else { /* /* 老vnode 数量小于于新的vnode,创造新的即诶安 */
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized,
commonLength
)
}
//

存在 key 的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
    /*  c1 老的vnode c2 新的vnode  */
let i = 0 /* 记录索引 */
const l2 = c2.length /* 新vnode的数量 */
let e1 = c1.length - 1 /* 老vnode 最后一个节点的索引 */
let e2 = l2 - 1 /* 新节点最后一个节点的索引 */
//首先新旧children首首对比 发现不同立马跳出
while(i <= e1 && i <= e2) {
.....
}
//从新旧children的尾部开始向前对比 发现不同立马退出
while( i <= e1 && i <= e2) {
//.....
e1--;
e2--;
}
//如果老节点全部patch 新节点没有patch完 则创建新的vnode插入
if(i > e1) {
if(i <= e2)
while(i <= e2) {
patch(...)
}
}
//如果新的节点全部patch 而节点有剩余 则卸载所有老节点
else if(i > e2) {
while(i <= e1) {
unmount(...);
i++;
}
}
//如果双端对比后 没有处理完 则需要继续处理 即diff的核心 最长连续稳定序列算法
const s1 = i //第一步遍历到的index
const s2 = i
const keyToNewIndexMap: Map<string | number, number> = new Map()
/* 把没有比较过的新的vnode节点,通过map保存 */
for (i = s2; i <= e2; i++) {
if (nextChild.key != null) {
keyToNewIndexMap.set(nextChild.key, i)
}
}
let j;//记录剩下新的节点的索引
let patched = 0 ;//记录这个过程patch的节点的数量
const toBePatched = e2 - s2 + 1 /* 没有经过 path 新的节点的数量 */
let moved = false /* 证明是否移动过 */
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched);//用来存放新节点的索引和老节点的索引的数组
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
/* 建立一个数组,每个子元素都是0 [ 0, 0, 0, 0, 0, 0, ] */
---------------------------------------
//接下来是diff算法的核心
//第一步 通过老节点的key找到对应新节点的index 开始遍历老的节点 判断有没有key 如果存在key通过新节点的keyToNewIndexMNap找到新节点index 如果不存在则会遍历生下来的新节点试图找到对应index
//如果存在index证明有对应的老节点 则直接复用老节点进行patch 如果没有找到老节点对应的新节点 则删除当前的老节点
//newIndexToOldIndexMap找到对应新老节点关系
for (i = s1; i <= e1; i++) { /* 开始遍历老节点 */
const prevChild = c1[i]
if (patched >= toBePatched) {
/* ①如果已经patch的大于等于需要patch的,说明新children已经处理完成,那么统一卸载老的节点 */
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
/* ② 如果,老节点的key存在 ,通过key找到对应的index */
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key)
} else { /* ③ 如果,老节点的key不存在 */
for (j = s2; j <= e2; j++) { /* 遍历剩下的所有新节点 */
if (
newIndexToOldIndexMap[j - s2] === 0 && /* newIndexToOldIndexMap[j - s2] === 0 新节点没有被patch */
isSameVNodeType(prevChild, c2[j] as VNode)
) { /* 如果找到与当前老节点对应的新节点那么 ,将新节点的索引,赋值给newIndex */
newIndex = j
break
}
}
}
if (newIndex === undefined) { /* ①没有找到与老节点对应的新节点,删除当前节点,卸载所有的节点 */
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
/* ②把老节点的索引,记录在存放新节点的数组中, */
newIndexToOldIndexMap[newIndex - s2] = i + 1
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
/* 证明有节点已经移动了 */
moved = true
}
/* 找到新的节点进行patch节点 */
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
optimized
)
patched++
}
}
//虽然已经patch过所有的老节点 可是对于已经发生移动的节点 要怎么真正移动dom元素 以及对于新增的节点 要怎么处理
/*移动老节点创建新节点*/
/* 根据最长稳定序列移动相对应的节点 */
const increasingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)//通过getSequece获取一个最长稳定序列 对于index === 0的情况也就是新增节点 需要重新创建一个vnode 然后对于发生移动的节点进行统一的移动

: EMPTY_ARR
j = increasingNewIndexSequence.length - 1
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) { /* 没有老的节点与新的节点对应,则创建一个新的vnode */
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG
)
} else if (moved) {
if (j < 0 || i !== increasingNewIndexSequence[j]) { /*如果没有在长*/
/* 需要移动的vnode */
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}

Q:为什么要得到最长稳定序列

A:我们需要一个序列作为基础的参照序列 其他未在稳定序列的节点进行移动

总结

  1. 新旧children首部开始对比 发现不同立马跳出
  2. 如果第一步没有patch完 立即从后往前开始patch 发现不同立马跳出循环
  3. 如果新的节点大于老的节点数 对于剩下的节点全部以新的 vnode 处理
  4. 对于老的节点大于新的节点 则将超出的节点卸载
  5. 前面四轮没有 patch 完的元素与 3,4 对立关系
    1. 将没有比较过的新节点通过map保存 记录已经 patch 过的新节点的数量 patched 没有进过patch新的节点的数量toBePatched 进阿里一个数组 newIndexToOldIndexMap 来记录老节点的索引 数组的索引就是新节点的索引
    2. 开始遍历老节点
      1. 如果toBePatched新的节点数量为 0 说明已经比较完成 可以统一卸载老节点
      2. 如果老节点的key存在 则通过key找到对应的新节点的index
      3. 如果老节点的key不存在
        1. 遍历剩下的所有新节点
        2. 如果找到与当前老节点对应的新节点 那么将新节点的索引 赋值给 newIndex
      4. 如果没有找到与老节点对应的新节点 卸载当前老节点
      5. 如果找到与老节点对应的新节点 则将老节点的索引 记录在存放新节点的数组中
        1. 如果节点发生移动 用moved记录移动了
          1. 根据newIndexOldIndexMap新节点索引列表找到最长稳定序列
          2. 对于newIndexToOldIndexMap - item == 0证明不存在老节点 直接创建新的vnode
          3. 对于发生移动的节点进行移动处理
        2. patch新老节点 找到新的节点进行patch
  6. 遍历结束

在这里插入图片描述

vue3 相比 vue2 的优化

事件缓存

将事件进行缓存,这样如果多次用到相同的事件,就不用重复去创建以及绑定

1
2
3
4
5
6
7
 <button @click={toClickSec}>clickSecondBtn</button>
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_createElementVNode("button", {
onClick: _cache[1] || (_cache[1] = $event => ({toClickSec: _ctx.toClickSec}))
}, "clickSecondBtn")
]))

静态标记

对于一些属性或者内容固定的节点,对其打上静态节点的标记,这样在后续的diff过程中,可以跳过这些不必要的比较来优化diff算法 ,同样的 还有类似于class绑定、props等,也是同样的道理,利用标记来避免不必要的比较

1
2
3
4
5
6
7
8
 <h2>i am olddog</h2>
<div>i am div</div>
<div :class={style}>i am class div</div>
<div :pro="props" :class={style}> i am props div </div>
onst _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("h2", null, "i am olddog", -1 /* HOISTED */)
const _hoisted_3 = /*#__PURE__*/_createElementVNode("div", null, "i am div", -1 /* HOISTED */)
const _hoisted_4 = ["pro"]

可以看到,这些标记其实就是一些位运算

不仅 vue。react 使用的也是位运算来标记,除此之外,位运算还可以用于在权限系统中(例如一个系统拥有多个用户,而每个用户只有几个权限的情况下 但是需注意这种情况下的权限查找较不方便)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const enum PatchFlags {

TEXT = 1,// 表示具有动态textContent的元素
CLASS = 1 << 1, // 表示有动态Class的元素
STYLE = 1 << 2, // 表示动态样式(静态如style="color: red",也会提升至动态)
PROPS = 1 << 3, // 表示具有非类/样式动态道具的元素。
FULL_PROPS = 1 << 4, // 表示带有动态键的道具的元素,与上面三种相斥
HYDRATE_EVENTS = 1 << 5, // 表示带有事件监听器的元素
STABLE_FRAGMENT = 1 << 6, // 表示其子顺序不变的片段(没懂)。
KEYED_FRAGMENT = 1 << 7, // 表示带有键控或部分键控子元素的片段。
UNKEYED_FRAGMENT = 1 << 8, // 表示带有无key绑定的片段
NEED_PATCH = 1 << 9, // 表示只需要非属性补丁的元素,例如ref或hooks
DYNAMIC_SLOTS = 1 << 10, // 表示具有动态插槽的元素
}

vue2是全量Diff(即不管任何节点都会进行diffvue3是 静态标记 + 非全量DIff(即通过静态标记标记不同Vdom的类型,以此跳过不必要的比较来优化diff的效率)

静态提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<h2>i am olddog</h2>
<div>i am div</div>
<div :class={style}>i am class div</div>
<div :pro="props" :class={style}> i am props div </div>
onst _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("h2", null, "i am olddog", -1 /* HOISTED */)
const _hoisted_3 = /*#__PURE__*/_createElementVNode("div", null, "i am div", -1 /* HOISTED */)
const _hoisted_4 = ["pro"]
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_hoisted_2,
_hoisted_3,
_createElementVNode("div", {
class: _normalizeClass({style: _ctx.style})
}, "i am class div", 2 /* CLASS */),

可以看到 对于一些内容或属性固定的静态节点 vue3 将它们定义在 render 函数外部来做到 静态提升 这样当多次用到这些节点的时候 可以直接引用而不是重复创建 大大提高了性能

使用最长稳定子序列优化对比流程

vue2
  • 旧头和新头相比
  • 旧头和新尾相比
  • 旧尾和新头相比
  • 旧尾和新尾相比
  • 都没有命中的对比
vue3
  • 旧头和新头对比
  • 旧尾和新尾对比
  • 基于最长递增子序列进行移动/增加/删除 (使用最长递增子序列可以最大程度的减少DOM的移动 达到最少的DOM操作)

文章作者: olddog
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 olddog !
  目录