前言
首先声明文章可能会比较长(是真的长),希望你做好心理准备,静下心来好好跟着文章的思路走。
面试中经常会被问到的虚拟dom究竟是什么?我相信很多使用Vue框架的小伙伴绝对会思考过这个问题,我自己也经常碰到。如果你觉得自己还是对其概念不够完全理解,那么阅读一下这篇文章吧。
文章会逐步介绍虚拟DOM以及Vue中的虚拟DOM是怎么定义的,然后介绍其核心的dom-diff过程,并且会结合思路分析以及源码解析,旨在帮助小伙伴们掌握virtual DOM的原理。
部分思路参考了下面文章,这篇文章也写得相当好:
https://nlrx-wjc.github.io/Learn-Vue-Source-Code/nlrx-wjc.github.io什么是虚拟DOM?
再来复习一下,什么是虚拟DOM。
虚拟DOM的定义是用一个javascript对象通过对象属性的方式去描述一个dom节点。
举个例子:
我们现在有一个a标签,如果用普通的写法是这样子的:
<a href="http://xxx">链接</a>
将a标签通过js对象来描述的话,是这样的:
{
tag: "a",
attrs: {
href: "http://xxx"
},
text: "链接",
children: []
}
一个真实的dom节点就这样通过js对象描述出来了。
为什么需要虚拟DOM?
来看下真实的a标签在浏览器下的输出:
这里仅仅是一个空的a标签,就存在300+个属性。浏览器上面的真实DOM,除了一些基本的属性以外,还有很多为了支持标签本身特性而存在的很多属性和方法,这些属性和方法都是在浏览器底层去使用的,而对js来说真正有用的属性可能不到10个。
因此,前端很多时候对于dom操作都是极力避免的,如果确实需要,则希望将操作次数减到最少。由此产生了虚拟DOM,利用js去描述一个dom节点,只保留一些必要的、足够表达这个节点的属性。更新js对象比直接更新dom节点要节省很多的时间。
我们可以用js来模拟dom节点的更新,每当dom更新之后,我们先比对虚拟dom,找出需要更新的地方,然后最后再统一更新真实dom。
Vue中的VNode类
我们打开vue源码文件的VNode类,这个类就是Vue用来描述真实dom节点的。
// src/core/vdom/vnode.js
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key
this.componentOptions = componentOptions
this.componentInstance = undefined
this.parent = undefined
this.raw = false
this.isStatic = false
this.isRootInsert = true
this.isComment = false
this.isCloned = false
this.isOnce = false
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
get child (): Component | void {
return this.componentInstance
}
}
可以看到上面有一些常用的属性:tag表示标签名,text表示节点的文本内容,elm表示虚拟节点对应的真实dom节点等等。
VNode类的类型
vue通过VNode可以描述6种节点类型:
- 注释节点
- 文本节点
- 克隆节点
- 元素节点
- 组件节点
- 函数式组件节点
下面我们从源码中将这些节点一一对应起来。
1.注释节点
创建注释节点只需要两个值:注释文本和一个是否为注释节点的标识。
// src/core/vdom/vnode.js
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text // 注释文本
node.isComment = true // 是否为注释节点
return node
}
2.文本节点
文本节点则更加简单,只需要一个值:节点的文本内容。
// src/core/vdom/vnode.js
/* 这里调用VNode构造函数创建文本节点,前三个参数分别是:
tag: 标签名称,文本节点没有名称
data: VNodeData,可以看下flow/vnode.js文件下定义的VNodeData接口
children: 子节点,文本节点没有子节点
*/
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
3.克隆节点
克隆节点只要把节点的所有属性复制过去,然后把isCloned属性改成true即可。
// src/core/vdom/vnode.js
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children && vnode.children.slice(),
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true
return cloned
}
4.元素节点
元素节点就是最常见的dom节点了,包含标签名称、也可能有子节点等等。因为涉及到的情况相对上面三种节点而言更复杂,所以Vue没有直接写死元素节点的创建,这个在下面创建节点的时候会提到如何创建一个元素节点。
5.组件节点
组件节点除了元素节点的属性以外,还有两个特有的属性:
- componentOptions: 组件的options选项,可以查看源码找到它的详细类型定义
- componentInstance: 组件节点对应的Vue实例
componentOptions的类型是VNodeComponentOptions,我们看到其类型定义如下:
// flow/vnode.js
declare type VNodeComponentOptions = {
Ctor: Class<Component>;
propsData: ?Object;
listeners: ?Object;
children: ?Array<VNode>;
tag?: string;
};
6.函数式组件节点
函数式组件节点除了拥有组件节点的属性之外,又有两个特有的属性:
- fnContext: 组件节点
- fnOptions: 组件的options选项,ComponentOptions类型,也就是我们熟悉的Vue模板语法中的js属性
列举一下常见ComponentOptions的类型定义:
// flow/options.js
declare type ComponentOptions = {
data: Object | Function | void;
props?: { [key: string]: PropOptions };
computed?: {
[key: string]: Function | {
get?: Function;
set?: Function;
cache?: boolean
}
};
methods?: { [key: string]: Function };
watch?: { [key: string]: Function | string };
// 生命周期
beforeCreate?: Function;
created?: Function;
beforeMount?: Function;
mounted?: Function;
beforeUpdate?: Function;
updated?: Function;
activated?: Function;
deactivated?: Function;
beforeDestroy?: Function;
destroyed?: Function;
components?: { [key: string]: Class<Component> };
filters?: { [key: string]: Function };
name?: string;
};
Vue的DOM-Diff
接下来我们开始进入正题,正式介绍Vue的diff算法。
首先,Vue的diff过程也称作patch(打补丁)过程,意味着vue的节点更新是为旧的那份节点做修补,将新的节点更新上去。这里我们定义两个概念:oldVNode就是数据更新之前视图对应的虚拟dom节点,newVNode就是数据更新之后视图对应的虚拟dom节点。
要理解diff过程,我们只要抓住最核心的理念:diff过程就是以newVNode为基准,找出oldVNode中跟newVNode不一致的地方,将oldVNode变为跟newVNode一致的过程。
那么思路就很简单了:
- 如果newVNode中存在,而oldVNode中不存在的节点,就创建这些节点并且插入到oldVNode中;
- 如果newVNode中不存在,而oldVNode中存在的节点,就删除oldVNode中的这些节点;
- 如果newVNode和oldVNode同时存在,就以newVNode的为基准,将oldVNode中的节点更新;
总结一下上面的过程,无非就是:创建节点、删除节点和更新节点。注意上面三点都有个共同点,就是只更新oldVNode节点。
1.创建节点
我们来看看如何创建节点。
上面我们提到了VNode总共可以描述6种类型的节点,其中只有3种是可以创建并且插入到dom中的,分别是元素节点、注释节点和文本节点。
下面是创建节点的源代码:
// src/core/vdom/patch.js
/* 创建元素节点的方法 */
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
/* 元素节点 */
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
setScope(vnode)
createChildren(vnode, children, insertedVnodeQueue)
insert(parentElm, vnode.elm, refElm)
} else if (isTrue(vnode.isComment)) {
/* 注释节点 */
vnode.elm = nodeOps.createComment(vnode.text)
insert(parentElm, vnode.elm, refElm)
} else {
/* 文本节点 */
vnode.elm = nodeOps.createTextNode(vnode.text)
insert(parentElm, vnode.elm, refElm)
}
}
可见源码中会判断是哪种类型的节点,然后针对该节点做单独的处理。
- 创建元素节点,只需要判断节点是否定义了tag属性,有的话就表示是元素节点;
- 创建注释节点,只需要判断isComment属性是否为真;
- 创建文本节点,上述两个都不满足的话,就默认创建文本节点;
2.删除节点
如果某些节点在newVNode中没有而在oldVNode中存在的话,就需要删除oldVNode的这些节点。删除节点方法的源代码如下:
// src/core/vdom/patch.js
/* 删除节点的方法 */
function removeNode (el) {
const parent = nodeOps.parentNode(el)
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
删除节点只需要找到其父节点,然后执行removeChild即可删除。
3.更新节点
更新节点相对创建、删除节点而言要复杂。但是没有关系,我们可以慢慢理清思路。首先我们来看下更新节点的源代码:
// src/core/vdom/patch.js
/* 更新节点的方法 */
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
/* 节点是否完全相等 */
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
/* 节点是否都是静态节点 */
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
/* 新节点不是文本节点 */
if (isDef(oldCh) && isDef(ch)) {
/* 节点都存在子节点 */
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)
}
}
更新函数接收了旧的节点oldVNode和新的节点VNode参数,同样上面也只保留了一些必要的代码。我们一步步来分析Vue更新节点的思路:
- 如果oldVNode完全等于VNode,则直接返回;
if (oldVnode === vnode) {
return
}
- 如果oldVNode和VNode都是静态节点,则直接返回;
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
这里讲一下什么是静态节点,我们看下面的一行代码:
<div>这里是静态节点</div>
这种节点内容不包含变量的,也就是无论数据怎么变化,也不会影响到这个节点的内容的,就称这个节点为静态节点。所以既然oldVNode和VNode都是静态节点,那么数据的变化就不可能会影响到该节点,所以可以直接返回。
- 如果VNode是文本节点,则判断oldVNode和VNode的文本是否一样,不一样的情况下更新oldVNode的文本内容;
else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
- 如果VNode是元素节点,那么要再进一步判断,一共有四种情况,分别如下:
- VNode和oldVNode都有子节点;
因为新旧节点都包含子节点,所以需要递归去对比两者的子节点,这部分在文章后面会继续讲到;
2. VNode有子节点但是oldVNode没有子节点;
这时候就要看oldVNode里面有没有文本,有的话要先将文本清空掉,然后再将VNode的子节点插入到oldVNode中;
3. VNode没有子节点但是oldVNode有子节点;
VNode没有子节点,同时它又不是注释节点和文本节点,只能说明它是空节点。因此需要将oldVNode的子节点清空掉;
4. VNode和oldVNode都没有子节点;
和第3点一样,说明VNode只能是空节点,只要判断oldVNode是否存在文本,存在的话就清空文本即可;
下面来看下源码对于元素节点的处理是否和上述思路一致:
/* vnode.text未定义,表示不是文本节点 */
if (isUndef(vnode.text)) {
/* 判断是否有子节点 */
if (isDef(oldCh) && isDef(ch)) {
/* 1.都有子节点的情况下,递归对比子节点并且更新 */
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
/* 2.oldVNode不包含子节点的情况下,判断oldVNode里面是否有文本,有则清空文本 */
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
/* 将vnode的子节点插入到oldVNode中 */
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
/* 3.vnode没有子节点,并且oldVNode有子节点的情况下,移除oldVNode的子节点 */
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
/* 4.vnode没有子节点,并且oldVNode也没有子节点的情况下,清空oldVNode的文本 */
nodeOps.setTextContent(elm, '')
}
}
可以看到Vue源码中的思路和上述的4点是一模一样的,这就是Vue中的patch过程,只要理解了上面的创建节点、删除节点和更新节点,然后理解了更新节点中的4点情况分析,再阅读源码就会清晰很多。
码字不易,看到这里的点个赞吧,感谢~
更新子节点
上面我们提到过,更新节点的时候,如果VNode和oldVNode都包含子节点,需要去递归更新子节点。那么这一个递归到底是怎样的一个过程呢?下面我们来看一下。
我们把VNode的子节点记为ch,把oldVNode的子节点记为oldCh。要比较两个数组的不同,显然需要利用循环,而且是双重循环。外层我们循环ch数组,内层循环oldCh,每一个ch项,我们都去内层oldCh数组中寻找是否存在一样的节点。那么这个的伪代码就类似下面所示:
for (const child of ch) {
for (const oldChild of oldCh) {
if (child === oldChild) {
// ...
}
}
}
这里同样会产生4种情况:
- ch中存在的子节点在oldCh中找不到,那么就说明这是一个新的子节点,需要创建子节点;
- ch已经循环完毕了,但是oldCh中还存在未处理的子节点,说明这些子节点是多余的,因此需要删除子节点;
- ch中存在的子节点在oldCh中找到了,但是oldCh的子节点所处的位置和ch的子节点位置不一样,说明需要移动子节点;
- ch中存在的子节点在oldCh中找到了,并且位置也一样,则更新oldCh中的子节点,使之与ch的子节点完全一样;
接下来我们来分别处理这4种情况:
- 创建子节点
如果ch中存在的子节点在oldCh中找不到,说明需要创建该子节点,创建方法可以参考上面的创建节点,这里不再赘述。那么问题是创建了这个子节点之后,应该插入到哪里去呢?
我们来看下Vue的源码:
// src/core/vdom/patch.js
if (isUndef(idxInOld)) {
/* 如果idxInOld未定义,也就是在oldCh中没找到对应的子节点,则创建子节点并插入到合适的位置 */
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
那么何为合适的位置呢?我们来看一个图:
上面左侧是新节点ch,下面表示已经循环处理过的子节点和尚未处理的子节点;同理右侧则是旧节点oldCh已处理和未处理的节点。
现在假设ch中的第三个子节点在oldCh中没找到,那么肯定需要在oldCh中新增这个子节点的,而这个子节点插入的位置就应该如上图所示。这个位置有两种解释:已处理的子节点之后或者未处理的节点之前。Vue选择的是哪种呢?也就是说我们要将新生成的子节点插入到已处理的子节点之后、还是插入到未处理的子节点之前呢?
我们看下如果插入到已处理的子节点之后会是什么情况:
假设现在ch的第四个子节点也在oldCh中找不到对应的子节点,那么同样也是需要新建这个子节点的。如果我们将新建的子节点按照上面所说的插入到已处理的子节点之后的位置,发现第四个子节点怎么插入的位置是oldCh的第三个子节点?
相信到这里你已经恍然大悟了,如果插入的位置是已处理的子节点之后,那么下一次插入的子节点就会跑到前面去了,因此合适的位置应该是所有oldCh未处理的子节点之前。
- 删除子节点
好的,我们接下来回到代码。创建子节点我们已经知道Vue是如何处理的了,接下来看下如何删除子节点。
如果子节点在ch中找不到但是在oldCh中存在,就说明这部分的子节点是多余的,需要删除。看下Vue源码:
// src/core/vdom/patch.js
/* 循环已经结束 */
if (oldStartIdx > oldEndIdx) {
// ...
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
上面代码中的几个判断条件和变量我们会放在后面讲,现在只需要关注当循环结束时,Vue会怎么处理oldCh中多余的子节点。可以看到上面是调用了removeVnodes方法,将oldCh中剩余的未处理的子节点全部都移除了。
- 移动子节点
当子节点在ch中和oldCh中都存在,但是位置不同的时候,就需要以ch的位置为基准,移动oldCh的位置。
移动到哪里才是一个合适的位置?我们在上文中已经说过了,所有的子节点移动,都是移动到oldCh未处理的子节点之前,就是合适的位置。如果你还是觉得有疑问,我们再画图聊聊这里:
如上图所展示的,左侧ch中第三个子节点和右侧oldCh的最后一个子节点相同但是位置不同,因此需要移动oldCh的位置。显然,需要移动的合适的位置,正是oldCh未处理节点之前的位置。
Vue的源码如下:
// src/core/vdom/patch.js
/* 拿到当前处理的旧子节点 */
vnodeToMove = oldCh[idxInOld]
/* 判断新旧子节点是否相同 */
if (sameVnode(vnodeToMove, newStartVnode)) {
/* 相同的话先更新旧节点的内容和新节点内容保持一致 */
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
/* 更新完之后判断节点是否需要移动,需要的话就插入到未处理的子节点之前 */
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
上面源码中有一点需要提一下,就是代码中都会先调用patchVnode更新了旧节点之后,再判断是否需要移动。
- 更新子节点
当子节点在ch和oldCh中都存在,并且位置也一样的时候,则表示只需要更新oldCh就行了。更新节点的方法可以看前面文章的分析。无非就是判断节点是否是全等、是否是静态节点、是否是文本节点、是否有子节点,有子节点的话就再次递归调用更新子节点的方法。
以上就是全部关于更新子节点的内容,Vue的dom-diff学习也接近尾声了。
但是目前还遗留下来一个问题。我们看上面的代码可以发现,判断新旧子节点是否相等我们使用了双层循环。这也就表示如果新旧节点有很多子节点的话,双层循环的时间复杂度将会特别高,因为本身双层循环的时间复杂度就是O(mn),m表示新节点的子节点数量,n表示旧节点的子节点数量。
那么Vue显然也考虑到了这个问题,它用了一个很巧妙的方式,来尽量减少对比新旧节点的时间,下面我们来看下吧。
Diff算法优化
首先这个优化的目的,就是为了减少新旧节点都包含子节点的时候,对比子节点所花的时间。
我们先来定义一些变量:
- newStart,表示ch数组中第一个未处理的子节点;
- newEnd,表示ch数组中最后一个未处理的子节点;
- oldStart,表示oldCh数组中第一个未处理的子节点;
- oldEnd,表示oldCh数组中最后一个未处理的子节点;
- newStartIdx、newEndIdx、oldStartIdx、oldEndIdx分别表示上面四个的索引;
用图来表示的话是这样:
那么有了这四个变量之后要怎么做呢?接下来就是Vue对比的步骤:
- 一开始,先比较newStart和oldStart是否相等,相等的话直接调用patchVnode更新oldCh的子节点;
- 如果不相等,再判断newEnd和oldEnd是否相等,相等的话直接调用patchVnode更新oldCh的子节点;
- 如果不相等,再判断newEnd和oldStart是否相等,相等的话先调用patchVnode更新oldCh的子节点,然后再将oldCh的子节点移动到和ch相同的位置;
- 如果不相等,最后判断newStart和oldend是否相等,相等的话先调用patchVnode更新oldCh的子节点,然后再将oldCh的子节点移动到和ch相同的位置;
- 如果还是不相等,则按照原来的处理方式,循环去寻找;
以上就是优化过后的diff过程了,增加了几个首部和尾部的子节点判断。我们来看下源码(下面会详细再讲解这块源码,所以如果觉得看源码吃力的可以先直接往下拉):
// src/core/vdom/patch.js
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]
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
/* 前面两个if是判断初始值为undef的时候,就往前/后移动索引 */
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
/* 校验newStart和oldStart是否相等,相等就调用patchVnode更新旧节点 */
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
/* 移动newStart和oldStart的索引 */
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
/* 校验newEnd和oldEnd是否相等,相等就调用patchVnode更新旧节点 */
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
/* 移动newEnd和oldEnd的索引 */
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
/* 校验newEnd和oldStart是否相等,相等就先调用patchVnode更新旧节点 */
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
/* 然后移动oldStart的位置到newEnd一样的位置 */
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
/* 注意这里,因为此时是处理完了newEnd子节点,所以是newEndInx往左移 */
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
/* 校验newStart和oldEnd是否相等,相等就先调用patchVnode更新旧节点 */
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
/* 然后移动newStart的位置到oldEnd一样的位置 */
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
/* 同样这里因为处理的是oldEnd子节点,所以是oldEndIdx索引往左移动 */
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
/* 这里就是我们前面所说的一开始的处理方式,是在上面四种方式都不满足的情况下执行的 */
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
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 {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
/* for循环结束后,处理的是newCh第一个子节点,也就是newStart,所以索引要往右移 */
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx > oldEndIdx) {
/* 这里是循环的终结,表示oldCh已经处理完了 */
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
/* 此时如果ch还剩没处理的数据,就表示是新增的,所以调用addVnodes插入到oldVNode中 */
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/* 这里也是循环的终结,表示ch已经处理完了,移除未处理的oldCh中的节点 */
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
好!接下来我们详细解析一下上面这段代码是什么意思(虽然我已经觉得代码里解释的很清楚了www)。
首先我们脑海中必须有一副图画,什么样的图画,大概就是类似这样的:
如果我们当前处理的是start的子节点,就将start的index索引加1(往右移);如果我们当前处理的是end的子节点,就将end的index索引减1(往左移)
带着这个概念和上面这幅图画,我们再来分析下Vue的diff优化过程:
- 校验newStart和oldStart是否相等,相等则调用patchVnode更新oldStart子节点,然后newStart和oldStart索引往右移;
else if (sameVnode(oldStartVnode, newStartVnode)) {
/* 校验newStart和oldStart是否相等,相等就调用patchVnode更新旧节点 */
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
/* 移动newStart和oldStart的索引 */
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
- 如果不相等,校验newEnd和oldEnd是否相等,相等则调用patchVnode更新oldEnd子节点,然后newEnd和oldEnd索引往左移;
else if (sameVnode(oldEndVnode, newEndVnode)) {
/* 校验newEnd和oldEnd是否相等,相等就调用patchVnode更新旧节点 */
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
/* 移动newEnd和oldEnd的索引 */
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
- 如果不相等,校验newEnd和oldStart是否相等,相等则先调用patchVnode更新oldStart子节点,然后移动oldStart到newEnd的位置,然后newEndIdx往左移和oldStart索引往右移;
else if (sameVnode(oldStartVnode, newEndVnode)) {
/* 校验newEnd和oldStart是否相等,相等就先调用patchVnode更新旧节点 */
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
/* 然后移动oldStart的位置到newEnd一样的位置 */
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
/* 注意这里,因为此时是处理完了newEnd子节点,所以是newEndInx往左移 */
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
- 如果不相等,校验newStart和oldEnd是否相等,相等则先调用patchVnode更新oldEnd子节点,然后移动oldEnd到newStart的位置,然后newStartIdx往右移和oldEnd索引往左移;
else if (sameVnode(oldEndVnode, newStartVnode)) {
/* 校验newStart和oldEnd是否相等,相等就先调用patchVnode更新旧节点 */
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
/* 然后移动newStart的位置到oldEnd一样的位置 */
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
/* 同样这里因为处理的是oldEnd子节点,所以是oldEndIdx索引往左移动 */
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
- 最后,如果上述情况都不满足,就走最初的双层循环流程。注意走这个流程的话,就表示处理的是newStart子节点了,因此最后处理完了是要把newStartIdx索引往右移;
else {
/* 这里就是我们前面所说的一开始的处理方式,是在上面四种方式都不满足的情况下执行的 */
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) {
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 {
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
/* for循环结束后,处理的是newCh第一个子节点,也就是newStart,所以索引要往右移 */
newStartVnode = newCh[++newStartIdx]
}
}
- 最后的最后,是关于上述diff过程如何结束。显然,当oldStartIdx大于oldEndIdx或者newStartIdx大于newEndIdx的时候,就表示已经处理完了。针对oldStartIdx大于oldEndIdx的情况,就表示是旧节点oldCh先处理完,这时候ch还剩余的内容需要插入到oldCh中;针对newStartIdx大于newEndIdx的情况,就表示是新节点ch先处理完,这时候oldCh还剩余的内容就表示是完全多余的,可以全部移除掉了。
if (oldStartIdx > oldEndIdx) {
/* 这里是循环的终结,表示oldCh已经处理完了 */
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
/* 此时如果ch还剩没处理的数据,就表示是新增的,所以调用addVnodes插入到oldVNode中 */
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
/* 这里也是循环的终结,表示ch已经处理完了,移除未处理的oldCh中的节点 */
removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}
好!到这里,我们的Vue源码分析dom-diff算法就算是完美结束了。如果你觉得写的还可以的,可以点个关注点个赞,都是我持续更新的动力。
感谢你的阅读,我们下一篇再见!