虚拟DOM与真实DOM的区别
真实DOM:
<ul>
<li>a</li>
<li>b</li>
<li>c</li>
</ul>
虚拟DOM
let vnode = h('ul.list',[
h('li','a')
h('li','b')
h('li','c')
])
虚拟DOM不会进行排版与重绘操作,虚拟DOM就是把真实DOM转化为JS代码
引入原因
Vue是数据驱动视图(数据的变化将引起视图的变化),但你发现某个数据改变时,视图是局部刷新而不是整个重新渲染,如何精准的找到数据对应的视图并进行更新呢?那就需要拿到数据改变前后的dom结构,找到差异点并进行更新。
虚拟dom实质上是针对真实dom提炼出的简单对象。就像一个简单的div包含200多个属性,但真正需要的可能只有tagName,所以对真实dom直接操作将大大影响性能。
实现 Virtual DOM
Virtual DOM 主要包括以下三个方面:
- 使用 js 数据对象 表示 DOM 结构 -> VNode
- 比较新旧两棵 虚拟 DOM 树的差异 -> diff
- 将差异应用到真实的 DOM 树上 -> patch
虚拟DOM原理
虚拟节点(vnode)大致包含以下属性:
{
tag: 'div', // 标签名
data: {}, // 属性数据,包括class、style、event、props、attrs等
children: [], // 子节点数组,也是vnode结构
text: undefined, // 文本
elm: undefined, // 真实dom
key: undefined // 节点标识
}
虚拟dom的比较,就是找出新节点(vnode)和旧节点(oldVnode)之间的差异,然后对差异进行打补丁(patch)。大致流程如下:
解析:
新旧节点如果不相似,直接根据新节点创建dom;如果相似,先是对data比较,包括class、style、event、props、attrs等,有不同就调用对应的update函数,然后是对子节点的比较,子节点的比较用到了diff算法。
- patch()函数
function patch (oldVnode, vnode) {
var elm, parent;
if (sameVnode(oldVnode, vnode)) {
// 相似就去打补丁(增删改)
patchVnode(oldVnode, vnode);
} else {
// 不相似就整个覆盖
elm = oldVnode.elm;
parent = api.parentNode(elm);
createElm(vnode);
if (parent !== null) {
api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
return vnode.elm;
}
patch()函数接收新旧vnode两个参数,传入的这两个参数有个很大的区别:oldVnode的elm指向真实dom,而vnode的elm为undefined...但经过patch()方法后,vnode的elm也将指向这个(更新过的)真实dom。
-
打补丁
-
对于新旧vnode不一致的处理方法很简单,就是根据vnode创建真实dom,代替oldVnode中的elm插入DOM文档。
-
对于新旧vnode一致的处理,就是我们前面经常说到的打补丁了。patchVnode()方法
-
function patchVnode (oldVnode, vnode) {
// 新节点引用旧节点的dom
let elm = vnode.elm = oldVnode.elm;
const oldCh = oldVnode.children;
const ch = vnode.children;
// 调用update钩子
if (vnode.data) {
updateAttrs(oldVnode, vnode);
updateClass(oldVnode, vnode);
updateEventListeners(oldVnode, vnode);
updateProps(oldVnode, vnode);
updateStyle(oldVnode, vnode);
}
// 判断是否为文本节点
if (vnode.text == undefined) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
api.setTextContent(elm, vnode.text)
}
}
所有数据比较完后,就到子节点的比较了。先判断当前vnode是否为文本节点,如果是文本节点就不用考虑子节点的比较;若是元素节点,就需要分三种情况考虑:
- 新旧节点都有children,那就进入子节点的比较(diff算法);
- 新节点有children,旧节点没有,那就循环创建dom节点;
- 新节点没有children,旧节点有,那就循环删除dom节点。
vue2中diff算法
diff算法干嘛的
DIFF算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM。
diff算法做的事情是比较VNode和oldVNode,再以VNode为标准的情况下在oldVNode上做小的改动,完成VNode对应的Dom渲染。
diff算法原理
-
先去同级比较,然后再去比较子节点
-
先去判断一方有子节点一方没有子节点的情况
-
比较都有子节点的情况
-
递归比较子节点
子节点比较图:
图中的oldCh和newCh分别表示新旧子节点数组,它们都有自己的头尾指针oldStartIdx,oldEndIdx,newStartIdx,newEndIdx,数组里面存储的是vnode,为了容易理解就用a,b,c,d等代替,它们表示不同类型标签(div,span,p)的vnode对象。
子节点的比较实质上就是循环进行头尾节点比较。循环结束的标志就是:旧子节点数组或新子节点数组遍历完,(即 oldStartIdx > oldEndIdx || newStartIdx > newEndIdx)。大概看一下循环流程:
第一步 头头比较。若相似,旧头新头指针后移(即 oldStartIdx++ && newStartIdx++),真实dom不变,进入下一次循环;不相似,进入第二步。
第二步 尾尾比较。若相似,旧尾新尾指针前移(即 oldEndIdx-- && newEndIdx--),真实dom不变,进入下一次循环;不相似,进入第三步。
第三步 头尾比较。若相似,旧头指针后移,新尾指针前移(即 oldStartIdx++ && newEndIdx--),未确认dom序列中的头移到尾,进入下一次循环;不相似,进入第四步。
第四步 尾头比较。若相似,旧尾指针前移,新头指针后移(即 oldEndIdx-- && newStartIdx++),未确认dom序列中的尾移到头,进入下一次循环;不相似,进入第五步。
第五步 若节点有key且在旧子节点数组中找到sameVnode(tag和key都一致),则将其dom移动到当前真实dom序列的头部,新头指针后移(即 newStartIdx++);否则,vnode对应的dom(vnode[newStartIdx].elm)插入当前真实dom序列的头部,新头指针后移(即 newStartIdx++)。
没有key的子节点的比较:
解析:
- 第一次是头头相似(都是a),dom不改变,新旧头指针均后移。a节点确认后,真实dom序列为:a,b,c,d,e,f,未确认dom序列为:b,c,d,e,f;
- 第二次是尾尾相似(都是f),dom不改变,新旧尾指针均前移。f节点确认后,真实dom序列为:a,b,c,d,e,f,未确认dom序列为:b,c,d,e;
- 第三次是头尾相似(都是b),当前剩余真实dom序列中的头移到尾,旧头指针后移,新尾指针前移。b节点确认后,真实dom序列为:a,c,d,e,b,f,未确认dom序列为:c,d,e;
- 第四次是尾头相似(都是e),当前剩余真实dom序列中的尾移到头,旧尾指针前移,新头指针后移。e节点确认后,真实dom序列为:a,e,c,d,b,f,未确认dom序列为:c,d;
- 第五次是均不相似,直接插入到未确认dom序列头部。g节点插入后,真实dom序列为:a,e,g,c,d,b,f,未确认dom序列为:c,d;
- 第六次是均不相似,直接插入到未确认dom序列头部。h节点插入后,真实dom序列为:a,e,g,h,c,d,b,f,未确认dom序列为:c,d; 但结束循环后,有两种情况需要考虑:
新的字节点数组(newCh)被遍历完(newStartIdx > newEndIdx)。那就需要把多余的旧dom(oldStartIdx -> oldEndIdx)都删除,上述例子中就是c,d; 新的字节点数组(oldCh)被遍历完(oldStartIdx > oldEndIdx)。那就需要把多余的新dom(newStartIdx -> newEndIdx)都添加
vue3中diff算法
与vue2的区别
- 事件缓存:静态事件
- 添加静态标记:vu2中是全量Diff,vue3是静态标记+非全量Diff
- 静态提升:创建静态节点时保存,后续直接复用
key
key面试的讲解
- key的作用主要是为了更高效的更新虚拟DOM,因为使用key可以精确的找到相同节点,patch过程会非常高效
- vue在patch过程中会判断两个节点是不是相同节点时,key是一个必要条件。比如渲染列表时,如果不写key,vue在比较时,可能会频繁更新元素,市整个patch过程比较低效,影响性能。
- vue 判断两个节点是否相同同时主要判断两者的元素类型和key等,不设置key,就可能会认为是两个相同节点,只能去做更新操作,造成大量不必要的DOM更新操作。
key存在的价值
key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。
key值使用场景
-
在列表渲染时使用key属性官方文档:当 Vue.js 用v-for正在更新已渲染过的元素列表时,它默认用“就地复用”策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序, 而是简单复用此处每个元素,并且确保它在特定索引下显示已被渲染过的每个元素。<div v-for="num in numbers">{{num}}</div>循环numbers值的变化, 例如: 旧numbers:[1,2,3,5,7.9],新numbers:[0,1,2,3,5,7,9]
如果没有key属性,原先内容为1的
<div>元素内容变成0,原先内容为2的<div>元素内容变成1,……以此类推,最后新增一个<div>元素,内容为9。如果没有key属性Vue无法跟踪每个节点,只能通过改变原来元素的内容和增加/减少元素来完成这个改变。
如果有了key属性之后,Vue会记住元素们的顺序,并根据这个顺序在适当的位置插入/删除元素来完成更新,这种方法比没有key属性时的就地复用策略效率更高。
-
使用key属性强制替换元素key属性还有另外一种使用方法,即强制替换元素,从而可以触发组件的生命周期钩子或者触发过渡。因为当key改变时,Vue认为一个新的元素产生了,从而会新插入一个元素来替换掉原有的元素。
<transition> <span :key="text">{{text}}</span> </transition>如果text发生改变,整个
<span>元素会发生更新,因为当text改变时,这个元素的key属性就发生了改变,在渲染更新时,Vue会认为这里新产生了一个元素,而老的元素由于key不存在了,所以会被删除,从而触发了过渡。
假设没有key属性:
<transition>
<span>{{text}}</span>
</transition>
那么当text改变时,Vue会复用元素,只改变<span>元素的内容,而不会有新的元素被添加进来,也不会有旧的元素被删除。
同理,key属性被用在组件上时,当key改变时会引起新组件的创建和原有组件的删除,此时组件的生命周期钩子就会被触发。
key值的工作原理
/*
因为key值主要使用在虚拟DOM算法,即diff算法中。
所以我们在src\core\vdom\patch.js文件中,从源码级别进行探讨
先说这里的核心方法patch。这个方法在vue进行update,
即将render函数(虚拟DOM生成的函数)转化为真实DOM的时候执行,
里面主要首次渲染创建真实DOM树,进行虚拟DOM节点直接的对比,
以及真实DOM的更新的一系列操作,并且会进行一系列判断和兼容处理,其中就有对key值的具体使用
这个方法主要在patch方法中调用
方法名很语义化 sameVnode === 相同虚拟DOM节点
*/
function sameVnode (a, b) {
return (
// 判断a, b两个Vnode上的key值是否相等
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
/*
在简单说下patchVnode方法的作用,这个方法会在patch方法里面调用,
是直接对比新旧虚拟Vnode节点,也是diff算法真正执行的地方
以下代码在patchVnode方法中
在开始进行判断,符合条件的话就跳出方法,不再进行下面的diff对比
vnode.key === oldVnode.key判断双方是不是同一个组件
*/
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
在例子中可以看出,对Vnode进行patch的时候会调用sameVnode方法,里面会使用key值是否相等来判断Vnode是否为同一个。并且在对比过程中作为组件复用的一个判断条件。
总结
key值是在DOM树进行diff算法时候发挥作用:
- 一个是用来判断新旧Vnode是否为同一个,从而进行下一步的比较以及渲染。
- 另外一个作用就是判断组件是否可以复用,是否需要重新渲染。
额外面试题
-
虚拟DOM与真实DOM的区别
-
虚拟DOM不会进行排版与重绘操作
-
虚拟DOM进行频繁修改,然后一次性比较并修改真实DOM中需要改的部分(注意!),最后并在真实DOM中进行排版与重绘,减少过多DOM节点排版与重绘损耗
-
真实DOM频繁排版与重绘的效率是相当低的
-
虚拟DOM有效降低大面积(真实DOM节点)的重绘与排版,因为最终与真实DOM比较差异,可以只渲染局部(同2)
-
-
v-for循环时,可以使用index代替key么?
不可以,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作