一、前言
1、什么是虚拟dom?
虚拟dom就是真实DOM以对象的形式模拟成树形的一种结构
我们看一下真实DOM与虚拟DOM长啥什么样?
真实Dom
<ul id="list">
<li class="item">11</li>
<li class="item">22</li>
<li class="item">33</li>
</ul>
对应的虚拟DOM
var oldVDOM = {
tagName: 'ul',
props: {
id: 'list'
},
children: [
{ tagName: 'li', props: { class: 'item' }, children: ['11'] },
{ tagName: 'li', props: { class: 'item' }, children: ['22'] },
{ tagName: 'li', props: { class: 'item' }, children: ['33'] },
]
}
2、当数据发生变化时,vue是怎么更新节点的?
要知道渲染真实DOM的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,有没有可能我们只更新我们修改的那一小块dom而不要更新整个dom呢?diff算法能够帮助我们。
首先我们会根据真实的DOM生成一个virtual DOM,然后当virtual DOM的某个节点数据发生改变后,会触发setter,然后通过Dep.notify去通知所有的订阅者watcher,然后订阅者就会去更新组件,然后生成新的virtual DOM,最后通过调用patch方法,通过对比新旧virtual DOM,边对比边给真实DOM打补丁,更新视图。
二、diff算法
新旧虚拟DOM对比的过程,就是通过diff算法来实现的,diff算法比较只会在同一层级进行,不会跨层级的。如下图所示:
diff流程图
当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。
二、具体分析
patch方法的作用就是判断同层的虚拟节点是否为同一种类型的节点,如果是就继续进行更深层次的比较,否则就直接将整个节点替换成新的虚拟dom对应的真是节点
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 (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
// 设置null,释放内存
oldVnode = null
}
}
return newVnode
}
那判断是否为同一种类型节点的标准是啥呢?我们接下来看一下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必须是相同的
)
}
patchVnode
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el // 获取真实dom,el
let i, oldCh = oldVnode.children, ch = vnode.children
if (oldVnode === vnode) return // 新旧虚拟节点指向同一个对象,直接返回,说明可以复用
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text) // 都有文本节点且文本不同,更新el为vnode对应的文本
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) { // 都有子节点
updateChildren(el, oldCh, ch)
}else if (ch){ // 只有Vnode有子节点,el新增Vnode子节点
createEle(vnode) //create el's children dom
}else if (oldCh){ 只有oldVnode有子节点,删除el对应的子节点
api.removeChildren(el)
}
}
}
代码分析:
- 找到oldVnode对应的真是dom,命名为el
- 判断Vnode与oldVnode是否指向同一个对象,如果是,就直接返回
- 如果他们有文本节点并且文本不相同,那么将el的文本节点设置为Vnode的文本节点
- 如果oldVnode有子节点,而Vnode没有,则删除el的子节点
- 如果oldVnode没有子节点,而Vnode有,则新增el的子节点
- 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要
其他几个点都很好理解,我们详细来讲一下updateChildren
updateChildren
// src/vdom/patch.js
// 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心 采用双指针的方式 对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
let oldStartIndex = 0; //老儿子的起始下标
let oldStartVnode = oldCh[0]; //老儿子的第一个节点
let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
let oldEndVnode = oldCh[oldEndIndex]; //老儿子的起结束节点
let newStartIndex = 0; //同上 新儿子的
let newStartVnode = newCh[0];
let newEndIndex = newCh.length - 1;
let newEndVnode = newCh[newEndIndex];
// 根据key来创建老的儿子的index映射表 类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
function makeIndexByKey(children) {
let map = {};
children.forEach((item, index) => {
map[item.key] = index;
});
return map;
}
// 生成的映射表
let map = makeIndexByKey(oldCh);
// 只有当新老儿子的双指标的起始位置不大于结束位置的时候 才能循环 一方停止了就需要结束循环
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIndex];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIndex];
} else if (isSameVnode(oldStartVnode, newStartVnode)) {
// 头和头对比 依次向后追加
patch(oldStartVnode, newStartVnode); //递归比较儿子以及他们的子节点
oldStartVnode = oldCh[++oldStartIndex];
newStartVnode = newCh[++newStartIndex];
} else if (isSameVnode(oldEndVnode, newEndVnode)) {
//尾和尾对比 依次向前追加
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldStartVnode, newEndVnode)) {
// 老的头和新的尾相同 把老的头部移动到尾部
patch(oldStartVnode, newEndVnode);
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); //insertBefore可以移动或者插入真实dom
oldStartVnode = oldCh[++oldStartIndex];
newEndVnode = newCh[--newEndIndex];
} else if (isSameVnode(oldEndVnode, newStartVnode)) {
// 老的尾和新的头相同 把老的尾部移动到头部
patch(oldEndVnode, newStartVnode);
parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIndex];
newStartVnode = newCh[++newStartIndex];
} else {
// 上述四种情况都不满足 那么需要暴力对比
// 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入
let moveIndex = map[newStartVnode.key];
if (!moveIndex) {
// 老的节点找不到 直接插入
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
newStartVnode = newCh[++newStartIdx]
} else {
let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷 防止老节点移动走了之后破坏了初始的映射表位置
parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
patch(moveVnode, newStartVnode);
newStartVnode = newCh[++newStartIdx]
}
}
}
// 如果老节点循环完毕了 但是新节点还有 证明 新节点需要被添加到头部或者尾部
if (newStartIndex <= newEndIndex) {
for (let i = newStartIndex; i <= newEndIndex; i++) {
// 这是一个优化写法 insertBefore的第一个参数是null等同于appendChild作用
const ele =
newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
parent.insertBefore(createElm(newCh[i]), ele);
}
}
// 如果新节点循环完毕 老节点还有 证明老的节点需要直接被删除
if (oldStartIndex <= oldEndIndex) {
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
let child = oldCh[i];
if (child != undefined) {
parent.removeChild(child.el);
}
}
}
}
函数功能分析:
- 首先将接收Vnode的子节点newCh,以及oldVnode的子节点oldCh
- 给newCh与oldVnode首尾分别新增两个变量newStartIdx、newEndIdx、oldStartIdx,oldEndIdx,进行两两比较,一共有4中方式,如果都没有匹配上,如果设置了key,就会用key进行比较,比较过程中,变量会往中间靠拢。
- 如果oldStartIdx > oldEndIdx,说明oldVnode提早结束遍历,Vnode剩余的节点就要插入到dom中 如果newStartIdx > newEndIdx,Vnode提早结束遍历,oldVnode有剩余,将oldVnod对应的剩余节点在对应的dom中删除
补充说明:如果前四种都没你匹配上,就使用key来进行比较。具体比较方式如下:
- 首先取出oldCh的oldStartIdx至oldEndIdx之间对应的节点,保存成对象oldKeyToIdx,属性为节点的key,属性值为节点的下标i
- 取出newStartIdx对应的key,去oldKeyToIdx中匹配
- 没有匹配上,说明说是新增的节点,将节点插入到真实的dom中
- 匹配上了,判断是否是同类型节点?
- 是同类型节点,继续进行深层次比较(继续进入子节点比较),然后将当前的oldCh对应的子节点设置为undefined(oldCh[idxInOld] = undefined)
- 不是同类型节点,说明只是key相同,就会将newStartIdx对应的节点作为新节点插入
接下来看一下具体实例
实例1
接下来分析一下比较过程
第1步
oldS=a oldE=c
newS=b newE=a
比较结果:oldS与newE相等,所以要将节点a移到最后,然后oldS++,newE--
第2步
oldS=b oldE=c
newS=b newE=e
比较结果:oldS与newS相等,所以要将节点b移到第1位,由于此时b就是第一位,就不移动了,然后oldS++,newS++
第3步
oldS=c oldE=c
newS=c newE=e
比较结果:oldS、oldE与newS相等,所以要将节点c移到第2位,此时c已经是第2位,就不移动了,然后oldS++,oldE--,newS++
由于此时oldS > oldE,oldVnode提早遍历完,因此需要将Vnode剩下的e插入到第3位
因此最新的dom为:
我们再来看一个收尾变量两两交叉匹配不上的情况
实例2
接下来分析一下比较过程
第1步
oldS=a oldE=c
newS=b newE=e
比较结果:两两交叉4种对比都没有匹配上,所以将newS=b与oldS=a至oldE=c中的a b c进行匹配,结果与b匹配上了。然后oldVnode的b设置为undefined(简写und),因此dom中b需要移到第一位。最后newS++
第2步
oldS=a oldE=c
newS=a newE=e
比较结果:oldS与newS相同,所以要将a移到第2位,因为此时dom中a就在第2位,因此就不移动。最后oldS++,newS++
第3步
oldS=und oldE=c
newS=c newE=e
比较结果:oldE与newS相同,所以要将c移到最后,此时dom中c就是在最后,因此不移动。最后oldE--,newS++
第4步
oldS=und oldE=und
newS=e newE=e
比较结果:由于此时newS与newE中的e没被匹配上,因此将e作为新节点插入到dom中,然后newS++,newE--
由于此时newS > newE,Vnode先遍历完,而oldVnode中还剩undefined节点,将它在dom中删除,由于dom并没有undefined,因此就不用删除啦!因此最终的dom排序,如下所示:
三、用index做为key
平时使用v-for时,为啥不建议使用index作为key呢?看一下下面这个例子, 往li中插入一个新值new,结果导致四个节点都被更新了
<ul> <ul>
<li key="0">a</li> <li key="0">new</li>
<li key="1">b</li> <li key="1">a</li>
<li key="2">c</li> <li key="2">b</li>
<li key="3">c</li>
</ul> </ul>
执行流程分析:
- 首先执行patch时,会比较Vnode与oldVnode的首部节点ul,他们是同种类型节点,就会调用pathVnode方法进一步的比较
- 他们都有子节点,并且是不同的,所以会进入updateChildren方法中执行
- 因为新旧节点li中key=0, 1, 2值相同,他们是属于同种类型节点,因此会进入patchVnode中,更新本文节点(a->new、b->a、c->b),而key=3的li节点由于在旧的虚拟dom找不到,会被当作新的节点插入到真实的dom中
那如果li中使用唯一的id,情况又有何不同呢?请看下面这个例子
<ul> <ul>
<li key="104">new</li>
<li key="101">a</li> <li key="101">a</li>
<li key="102">b</li> <li key="102">b</li>
<li key="103">c</li> <li key="103">c</li>
</ul> </ul>
执行流程分析:
- 首先执行patch时,会比较Vnode与oldVnode的首部节点ul,他们是同种类型节点,就会调用pathVnode方法进一步的比较
- 他们都有子节点,并且是不同的,所以会进入updateChildren方法中执行
- 使用收尾变量两两交叉四种匹配法
a b c
\ \ \
new a b c
第1次,c匹配上,位置也一样,复用
第2次,b匹配上,位置也一样,复用
第3次,a匹配上,位置也一样,复用
最后新的虚拟dom中剩余new节点,作为新节点,插入到dom中
小结:我们在使用v-for时,一定要使用唯一的标识作为key的值,这样能节约性能