前言 :
在学完基础的 html(html5),css(css3),js(es6) 等基础后,我们可以利用已有的知识去做原生开发。但是为了提高开发效率,可能会去学习一些方便操作 DOM,而封装的库或者框架,如 jQuery 等,还有一些 UI 组件库或框架,如 Bootstrap,Element-UI 等。如果你觉得操作 DOM 比较繁琐,那么就可以去了解比较流行的 react 和 vue 等前端框架。这些框架,它的核心是,数据响应式,实现了操作数据化,废弃了之前操作DOM的繁琐的任务。还有就是我们今天要讨论的,它们的虚拟DOM,diff 算法等核心概念。
- 什么是虚拟DOM(VDOM)?
虚拟 DOM(VDOM):所谓的 Virtual dom ,就是我们常说的虚拟节点,它是通过 JS 的 Object 对象模拟 DOM 中的节点,然后在通过特定的 render 函数去将其渲染成真实的 DOM 节点。
定义了一个vnode,它的数据结构是:
{
tag: 'div'
data: {
id: 'app',
class: 'page-box'
},
children: [
{
tag: 'p',
text: 'this is demo'
}
]
}
渲染出的实际的dom结构就是:
<div id="app" class="page-box">
<p>this is demo</p>
</div>
- 虚拟DOM,解决了哪些问题 ?
我们知道,在 jQuery 这样的库中,是没有虚拟 DOM 这个概念的。那么就得思考,为什么 react 和 vue 等框架中,会采用这种形式,这么做的好处和原因又是什么 ?当我们使用之前的一些库,如 jQuery 等的时候,我们不禁会大量的操作DOM,因此 DOM 元素引起的页面的回流和重绘就不可避免,频繁操作还是会出现页面卡顿,页面性能下降,影响用户体验。其实,框架并不一定需要使用虚拟DOM,关键看使用框架的过程中是否会频繁引起大面积的 DOM 操作,虚拟DOM的出现也是为了解决大面积重绘引发的性能问题。
- 真实DOM和虚拟DOM之间有哪些区别 ?
- 虚拟 DOM 不会进行排版与重绘操作。
- 真实 DOM 频繁排版和重绘的效率是相当低的。
- 虚拟 DOM 进行频繁修改,然后一次性比较并修改真实 DOM 中需要改的部分,最后并在真实 DOM 中进行排版和重绘,减少过多的 DOM 节点的排版和重绘损耗。
- 虚拟 DOM 有效降低大面积(真实 DOM 节点)的重绘和排版,因为最终与真实 DOM 比较差异,可以只渲染局部。
- 虚拟DOM原理 --- diff 算法 (patch)?
Virtual Dom 的原理是用 JavaScript 对象表示 DOM 信息结构,当状态改变的时候,重新构建一颗对象树,然后通过新渲染的对象树(newVnode)去和旧的对象树(oldVnode)进行对比,他使用了一个 diff 差异算法 计算差异,记录下来的不同就应用在真正的 dom 树上,从而减少页面的回流和重绘。
- 当数据发生变化时,Vue 怎么去更新节点 ?
先根据真实 DOM 生成一颗 Virtual DOM,当 Virtual DOM 某个节点的数据发生改变后会生成一个新的 Vnode,然后Vnode 和 oldVnode对比,发现不一样的地方就直接修改在真实的 DOM上,然后使 oldVnode 的值为 Vnode。diff 的过程就是调用 patch 函数,比较新旧节点,一边比较一边给真实的DOM打补丁(patch)。
- 详解 diff 算法 ?
两棵树如果采用 深度遍历 完全比较时间复杂度是O(n^3),因此为了为了提高速度(复杂度为O(n)),采取 diff 算法比较新旧节点的时候,比较只会在 同层级进行, 不会跨层级比较。算法首先会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个序号。在深度遍历的时候,每遍历到一个节点,我们就将这个节点和新的树中的节点进行比较,如果有差异,则将这个差异记录到一个对象中。
- diff 算法,核心方法 ?
diff 算法是为了以最小代价去将 oldVnode 修改成 newVnode ,核心方法是:
- sameVnode :通过判断传入的2个 vnode 的 key,tag, 是否同为注释节点 、inputType 等是否相同,来判断两节点是否值得比较,值得比较则执行 patchVnode。只有当基本属性相同的情况下才认为这个2个 vnode 只是局部发生了更新,然后才会对这2个 vnode 进行 diff,如果2个 vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom节点。
patchVnode
:判断(3种情况):都为文本且不相等,则替换文本;一个有子节点一个没有(直接做对应的添加或者删除节点);都有子节点调用updateChildren
进行比较子节点(核心讨论部分,也是 diff 的核心)updateChildren:
4个指针比较:对新老节点的子节点列表进行指针标记;oldStart+oldEnd,newStart+newEnd;即分别用两个指针标记头部和尾部,对比新老子节点进行匹配,并且做相应的指针移动。注意 : 对列表元素进行对比的时候,由于
TagName
是重复的,所我们需要给每一个子节点加上一个key
,列表对比的时候使用key
来进行比较,这样我们才能够复用老的 DOM 树上的节点。如果我们提供key
值,diff 算法会更高效,这样我们才能够复用老的DOM
树上的节点。因为本身 diff 算法里面有做key
的判断了。
//patch是整个diff的入口,会先从根节点对
function patch (oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
// 值得比较会执行patchVnode(oldVnode, vnode)
patchVnode(oldVnode, vnode)
} else {
const oEl = oldVnode.el// 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl)// 父元素
createEle(vnode)// 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))// 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el)// 移除以前的旧元素节点
oldVnode = null
}
}
return vnode
}
//sameVnode函数就是看这两个节点是否值得比较,两个vnode的key和sel相同才去比较它们(判断新旧节点是否一致)
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
// 值得比较会执行patchVnode(oldVnode, vnode)
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children, ch = vnode.children
//他们的引用一致,可以认为没有变化。
if (oldVnode === vnode) return
//文本节点的比较,需要修改,则会调用Node.textContent = vnode.text。
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
}else {
updateEle(el, vnode, oldVnode)
//两个节点都有子节点,而且它们不一样,调用updateChildren函数比较子节点
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
//只有新的节点有子节点,调用createEle(vnode),vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。
createEle(vnode) //create el's children dom
}else if (oldCh){
//新节点没有子节点,老节点有子节点,直接删除老节点。
api.removeChildren(el)
}
}
}
//两个节点都有子节点,而且它们不一样,调用updateChildren函数比较子节点
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) { //对于vnode.key的比较,会把oldVnode = 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]
//当新旧节点的头部值得对比,进入patchNode方法,同时各自的头部指针+1;
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
//当新旧节点的尾部值得对比,进入patchNode方法,同时各自的尾部指针-1;
}else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
//当oldStartVnode,newEndVnode值得对比,说明oldStartVnode已经跑到了后面,那么就将oldStartVnode.el移到oldEndVnode.el的后边。oldStartIdx+1,newEndIdx-1;
}else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
//当oldEndVnode,newStartVnode值得对比,说明oldEndVnode已经跑到了前面,那么就将oldEndVnode.el移到oldStartVnode.el的前边。oldEndIdx-1,newStartIdx+1;
}else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
//以上4种对比都不成立时,通过newStartVnode.key 看是否能在oldVnode中找到,如果没有则新建节点,如果有则对比新旧节点中相同key的Node,newStartIdx+1。
}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]
}
}
}
//oldStartIdx > oldEndIdx可以认为oldVnode对比完毕,当然也有可能 newVnode也刚好对比完,一样归为此类。此时newStartIdx和newEndIdx之间的vnode是新增的,调用addVnodes,把他们全部插进before的后边。
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
//newStartIdx > newEndIdx,可以认为newVnode先遍历完,oldVnode还有节点。此时oldStartIdx和oldEndIdx之间的vnode在新的子节点里已经不存在了,调用removeVnodes将它们从dom里删除。
}else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
- Vue 中的 key 有什么作用 ?
- key 是给每一个
vnode
的唯一 id,依靠 key,我们的 diff 操作可以更准确、更快速 (对于简单列表页渲染来说 diff 节点也更快,但会产生一些隐藏的副作用,比如可能不会产生过渡效果,或者在某些节点有绑定数据(表单)状态,会出现状态错位。- diff 算法的过程中,先会进行新旧节点的首尾交叉对比,当无法匹配的时候会用新节点的 key 与旧节点进行比对,从而找到相应旧节点。
- 更准确 : 因为带 key 就不是就地复用了,在 sameNode 函数 a.key === b.key 对比中可以避免就地复用的情况。所以会更加准确,如果不加 key,会导致之前节点的状态被保留下来,会产生一系列的 bug。
- 更快速 : key 的唯一性可以被 Map 数据结构充分利用,相比于遍历查找的时间复杂度 O(n),Map 的时间复杂度仅仅为 O(1)。
- 为什么不推荐数组下标作为 key 值 ?
Vue 不推荐使用数组下标作为 key 的原因。例如数组删除了一个元素,那么这个元素后方元素的下标全都前移了一位,之前 key 对应的数据和 dom 就会乱了,除非重新匹配 key,那就容易产生错误。如果重新匹配 key,等于全部重新渲染一遍,违背了使用 key 来优化更新 dom 的初衷。