什么是虚拟DOM?
虚拟DOM是一个对象,用来表示真实DOM的对象。
<!-- 真实DOM -->
<ul class=“list”>
<li class=“item”>a</li>
<li class=“item”>b</li>
<li class=“item”>c</li>
</ul>
// 对应的虚拟DOM
let listDom = {
tagName: ‘ul’, // 标签名
props: {
class: ‘list‘ // 标签属性
},
children: [ // 标签子节点
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘a’ },
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘b’ },
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘c’ }
]
}
此时修改一个li的文本,把c修改为d
<ul class=“list”>
<li class=“item”>a</li>
<li class=“item”>b</li>
<li class=“item”>d</li>
</ul>
这时生成新的虚拟DOM为:
let listNewDom = {
tagName: ‘ul’, // 标签名
props: {
class: ‘list‘ // 标签属性
},
children: [ // 标签子节点
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘a’ },
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘b’ },
{ tagName: ‘li’, props: { class: ‘item’ }, children: ‘d’ }
]
}
这时候新的虚拟DOM是数据的最新状态。
问题:现在我拿这个新的虚拟DOM去渲染成真实DOM,效率会比直接操作真实DOM高吗?
通过这张图,我们可以发现,肯定是第二种方式比较快,因为第一种方式中间还夹杂着一个虚拟DOM的步骤,所以虚拟DOM比真实DOM快这句话是不严谨的,正确的说法是虚拟DOM算法操作真实DOM,性能高于直接操作真实DOM,虚拟DOM和虚拟DOM算法是两个概念。
虚拟DOM算法= 虚拟DOM + Diff算法
什么是Diff算法
前面我们提到,只更新一个li的文本内容,其他都是不变的,所以没必要所有的节点都更新,只要更新这个li标签就行,Diff算法就是要找出哪个li标签需要进行更新的算法。
总结:Diff算法是一种对比算法,对比两者的新旧虚拟DOM,找到是哪个虚拟节点更改了,并只更新这个虚拟节点所对应的真实节点,而不更新其他数据没发生改变的节点,实现精准的更新真实DOM,进而提高效率。
使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改查 + 真实DOM增删改查 + (可能较少节点)排版与重绘
直接操作真实DOM的损耗计算: 总损耗 = 真实DOM完全增删改查 + (可能较多的节点)排版与重绘
在这里简单说一下上面标出的使用虚拟DOM算法的排版和重绘的节点少的原因:
1.批量更新,虚拟DOM可以进行批量更新操作,通过比较差异,只对差异部分进行更新,不会每次都操作真实DOM。
2.虚拟DOM的内存操作,在虚拟DOM中,所有的操作都是在内存中运行的,而不是操作真实的DOM,而这也意味着在进行排版和重绘时,不会触发浏览器的回流和重绘操作,和两个操作时十分消耗性能的。相反,虚拟DOM会将所有的操作都集中在一起,然后一次性的将差异更新到真实DOM中,从而减少回流和重绘操作。
Diff算法的原理
diff的执行策略是:深度优先,同层比较
深度优先是指在比较过程中首先比较当前层级的节点,然后再递归地比较子节点。
Diff对比流程
当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者watcher,订阅者就会调用patch方法,给真实的DOM打补丁,更新相应的视图。
newVnode 和 oldVnode: 是指同层的新旧虚拟节点
接下来分别介绍下图中的方法
patch方法
patch方法的作用,就是对比当前同层的虚拟节点是否为同一种类型的标签
是:继续执行patchVnode方法 进行深层对比
否:不再进行对比,直接整个节点替换成新虚拟节点
// patch的核心原理代码
function patch (oldVnode, newVnode) {
// 比较是否为一个类型的节点
if (sameVnode(oldVnode, newVnode)) {
// 是:继续进行深层比较
patchVnode(oldVnode, newVnode)
} else {
// 否
const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
const parentEle = api.parentNode(oldEl) // 获取父节点
createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
if (parental !== null) {
api.insetBefore(parentEle, vnode.el, api.nextsibling(oEl)) // 将新元素添加到父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧的元素节点
oldVnode = null // 设置null,释放内存
}
}
return newVnode
}
sameVnode方法
patch关键的一步就是sameVnode方法判断是否为同一类型节点,那怎么才算同一类型节点呢,类型的标准是什么呢?
sameVnode方法的核心原理代码:
function sameVnode(oldVnode, newVnode) {
return {
oldVnode.key === newVnode.key && // key值是否一样
oldVnode.tagName === newVnode.tagName && // 标签名是否一样
oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
}
}
- Key是虚拟节点的一个特殊属性,用于在更新过程中标识节点的唯一性。如果连个key值的节点不同,被认为是不同节点。
- 虚拟节点的tagName表示节点的标签名,例如‘div’ ‘span’ 等,如果两个标签名不同,就是不同节点。
- 注释节点是一种特殊的节点类型,表示HTML中的注释,如果两个节点都是注释节点,被认为是相同类型的节点。
- Data是虚拟节点的属性对象,包含节点的属性、事件等信息,如果两个节点都定义了data,说明都具有属性信息,否则认为是不同类型的节点。
- 当标签为input时,他的type属性决定了输入框的类型,例如文本框、复选框、单选框等,如果他们的type不同,那么认为是不同节点。
patchVnode方法
function patchVnode(oldVnode, newVnode) {
// 获取真实DOM对象 也就是el
const el = newVnode.el = oldVnode.el
// 获取新旧虚拟子节点的子节点数组
const oldCh = oldVnode.children, newCh = newVnode.children
// 如果新旧虚拟节点是同一个对象 直接return
if (oldVnode === newVnode) return
// 如果新旧虚拟节点是文本节点,且文本不一样
if (oldVnode.text !== null &&
newVnode.text !== null &&
oldVnode.text !== newVnode.text) {
// 直接将真实DOM中文本更新为虚拟节点的文本
api.setTextContent(el, newVnode.text)
} else {
// 新旧虚拟节点都有子节点 并且子节点不一样 执行updateChildren方法比较子节点
if (oldCh && newCh && oldCh !== newCh) {
// 对比子节点 并更新
updateChildren(el, oldCh, newOld)
} else if (newCh) {
// 如果新虚拟节点有子节点,旧虚拟节点没有
// 创建新虚拟节点的子节点,并更新到真实DOM上去
createEle(newVnode)
} else if (oldCh) {
// 旧虚拟节点有子节点,新虚拟节点没有
// 直接删除真实DOM里对应的子节点
api.removeChild(el)
}
}
}
patchVnode方法做了以下的事情,总结:
- 找到对应的真实DOM,称为el
- 判断newVnode和oldVnode是否指向同一个对象,如果是,直接return
- 如果他们都有文本节点并且不相等,那么将el的文本节点设置为newVnode的文本节点
- 如果oldVnode有子节点而newVnode没有,则删除el的子节点
- 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化后添加到el
- 如果两者都有子节点,则执行updateChildren函数比较子节点
updateChildren方法
这是最重要的一个方法,新旧虚拟节点的子节点对比,就是发生在updateChildren方法中
通过首尾指针法,新的子节点集合和旧的子节点集合,各有首尾两个指针。举例:
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
<!-- 修改数据后 -->
<ul>
<li>b</li>
<li>c</li>
<li>e</li>
<li>a</li>
</ul>
那么新旧两个子节点集合以及首尾指针为:
进行互相比较,共有5种比较情况:
1、oldS和newS使用sameVnode方法进行比较,sameVnode(oldS, newS)
2、oldS和newE使用sameVnode方法进行比较,sameVnode(oldS, newE)
3、oldE和newS使用sameVnode方法进行比较,sameVnode(oldE, newS)
4、oldE和newE使用sameVnode方法进行比较,sameVnode(oldE, newE)
5、如果以上逻辑都匹配不到,再把所有的旧子节点的key做一个映射到旧节点下标的key->index表,然后用新vnode的key去找出旧节点中可以复用的位置
实例分析
请大家记住一点,最终的渲染结果都要以newVDOM为准,这也解释了为什么之后的节点移动需要移动到newVDOM所对应的位置
第一步
oldS = a, oldE = c
newS = b, newE = a
比较结果:oldS 和 newE 相等,需要把节点a移动到newE所对应的位置,也就是末尾,同时oldS++,newE--
第二步
oldS = b, oldE = c
newS = b, newE = e
比较结果:oldS 和 newS相等,需要把节点b移动到newS所对应的位置,同时oldS++,newS++
第三步
oldS = c, oldE = c
newS = c, newE = e
比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++,newS++
第四步
oldS > oldE,则oldCh先遍历完成了,而newCh还没遍历完,说明newCh比oldCh多,所以需要将多出来的节点,插入到真实DOM上对应的位置上
最后附上updateChildren的核心原理代码:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, 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
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
}
else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel)
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
} else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
用index做key
问题:平常v-for循环渲染的时候,为什么不建议用index作为循环项的key呢?
举个例子,上边是初始列表,然后我在列表前插入一条新数据d,变成下边的列表
<ul>
<li key="0">a</li>
<li key="1">b</li>
<li key="2">c</li>
</ul>
<ul>
<li key="0">d</li>
<li key="1">a</li>
<li key="2">b</li>
<li key="3">c</li>
</ul>
按理说,最理想的结果是:只插入一个li标签新节点,其他都不动,确保操作DOM效率最高。但是我们这里用了index来当key的话,和理想结果是有出入的:
<ul>
<li v-for="(item, index) in list" :key="index">{{ item.title }}</li>
</ul>
<button @click="add">增加</button>
list: [
{ title: "a", id: "a" },
{ title: "b", id: "d" },
{ title: "c", id: "c" }
]
add() {
this.list.unshift({ title: "d", id: "d" });
}
点击查看运行结果
点击按钮我们可以看到,并不是我们预想的结果,而是所有li标签都更新了,为什么会这样呢?通过下图来解释
按理说,a,b,c三个li标签都是复用之前的,因为他们三个根本没改变,改变的只是前面新增了一个d
但是我们前面说了,在进行子节点的diff算法过程中,会进行旧首节点和新首节点的sameNode对比,这一步命中了逻辑,因为现在新旧两次首部节点的key 都是 0了,同理,key为1和2的也是命中了逻辑,导致相同key的节点会去进行patchVnode更新文本,而原本就有的c节点,却因为之前没有key为4的节点,而被当做了新节点,所以使用index做key,最后新增的却是本来就已有的c节点。所以前三个都进行patchVnode更新文本,最后一个进行了新增,那就解释了为什么所有li标签都更新了。
那我们可以怎么解决呢?其实我们只要使用一个独一无二的值来当做key就行了
<ul>
<li v-for="item in list" :key="item.id">{{ item.title }}</li>
</ul>
再来看看效果 点击查看运行效果
为什么用了id来当做key就实现了我们的理想效果呢,因为这么做的话,a,b,c节点的key就会是永远不变的,更新前后key都是一样的,并且又由于a,b,c节点的内容本来就没变,所以就算是进行了patchVnode,也不会执行里面复杂的更新操作,节省了性能,而d节点,由于更新前没有他的key所对应的节点,所以他被当做新的节点,增加到真实DOM上去了。