Vue的虚拟DOM是怎样的? 为何要引入虚拟DOM? 它的好处是什么呢?
虚拟DOM 虚拟DOM = 描述真实DOM的js对象 1.为什么要有虚拟DOM?
原因有二:
1.虚拟DOM是对渲染过程的抽象,而不是一堆HTML,这意味着可以渲染到 web
(浏览器) 以外的平台。
2.经过DOM0,2,3的层层补充加强,一个DOM对象是非常庞大的,DOM操作在很多层面都不尽人意。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 let str ='' ;for (const key in $$('div' )[0 ]) {str +=`${key} '\n'` }sizzle-1584611898211 align title lang translate dir hidden accessKey draggable spellcheck autocapitalize contentEditable isContentEditable inputMode offsetParent offsetTop offsetLeft offsetWidth offsetHeight style innerText outerText oncopy oncut onpaste onabort onblur oncancel oncanplay oncanplaythrough onchange onclick onclose oncontextmenu oncuechange ondblclick ondrag ondragend ondragenter ondragleave ondragover ondragstart ondrop ondurationchange onemptied onended onerror onfocus onformdata oninput oninvalid onkeydown onkeypress onkeyup onload onloadeddata onloadedmetadata onloadstart onmousedown onmouseenter onmouseleave onmousemove onmouseout onmouseover onmouseup
2.有了虚拟DOM之后怎么结合来优化渲染速度?
Vue的做法是:以虚拟DOM,结合变化侦测。利用js的计算性能来换取操作DOM的性能。通过diff
算法来比较新旧DOM节点,以最小的代价来更新DOM,减少操作DOM次数,从而达到优化
template
和new Vue
中的模板代码先被编译成VNode
并被缓存起来,在监听到变化侦测后,对比缓存的VNode
的差异,进行精准的更新(精准与否,就是屠宰和手术的区别~)
VNode类 这个类是vue
中来进行DOM描述的核心类,提供一些属性,来描述一个真实DOM
节点。
一个例子如下:
1 2 3 4 5 6 7 8 9 10 { el: div tagName: 'DIV' , sel: 'div#v.classA' data: null , children: [], text: null , }
源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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 } }
组合设置类的一些属性,可以模拟出真实的DOM
节点,以下为一些例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 export const createEmptyVnode = (text: string = "" ) => { const node = new Vnode(); node.text = text; node.isComment = true ; return node; } export function createTextVNode (val: string | number ) { return new VNode(undefined , undefined , undefined , String (val)) } var createEmptyVNode = function (text ) { if ( text === void 0 ) text = '' ; var node = new VNode(); node.text = text; node.isComment = true ; return node }; function cloneVNode (vnode ) { var 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 }
还有元素节点,组件节点,函数式组件节点等等,都是经过VNode
基类属性和部分其他属性组合来描述不同节点。
在HcySunYang 大佬的文章中,对如何对Vnode进行设计进行了详细的分析,强烈建议去看看
http://hcysun.me/vue-design/zh/vnode.html#%E7%94%A8-vnode-%E6%8F%8F%E8%BF%B0%E6%8A%BD%E8%B1%A1%E5%86%85%E5%AE%B9
给出了如下图所示的Vnode分类图
很有趣的一个点是,在判断Vnode种类的过程中,使用了位运算(主要是<<
左移运算符)和按位|
还有按位&
来对判断过程进行优化。恕在下见识短浅,还是第一次看见位运算在非运算的场景下的应用,感觉学到了。
Diff 上文优化的关键点就是如何快速高效的找出数据更新前后的差异 ,也是FEer耳濡目染的DOM Diff
算法,虚拟DOM核心所在。
首先,相比于传统的Diff
,不会进行DOM节点的跨级的比较(划重点) ,react
和vue
在这点上一致。 然后,我们一般修改的节点的什么? props,data,class,event等等,也就是:1.节点属性2.子节点。这正是vue
进行比较的核心部分。
比较过程
在Vue
中怎么找出需要更新的节点?为了便于理解,保留核心部分的代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function patch (oldVnode, vnode ) { if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode) } else { const oEl = oldVnode.el let parentEle = api.parentNode(oEl) createEle(vnode) if (parentEle !== null ) { api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) api.removeChild(parentEle, oldVnode.el) oldVnode = null } } return vnode }
1 2 3 4 5 6 7 8 9 10 function sameVnode (a, b ) { return ( a.key === b.key && a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) }
1 2 3 4 5 6 7 8 function sameInputType (a, b ) { if (a.tag !== 'input' ) return true let i const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB) }
以newVnode
对比oldVnode
,发生变化的有三种情况:
前者有,后者无,创建节点 ,增加到后者
前者无,后者有,从后者上删除节点
前者有,后者也有,更新节点 ,使后者和前者一致
总之,以newVnode
为准,改造oldVnode
,最终使得两者相同 ,这个过程称为pacth
,意为补丁。
下面详细说下这三种情况Vue
都是怎么实现的。
1.创建节点 因为这类节点创建之后是被直接插入到DOM中,所以创建的节点类型只能是: 元素节点、文本节点、注释节点 。
那就得判断newVnode
有oldVnode
没有的节点属于那种类型,再分别进行创建后插入DOM。
1.创建节点,如果Vnode类型为元素进行2,注释进行3,文本进行4
2.创建元素节点,遍历并添加元素节点子节点,进行5
3.创建注释节点,进行5
4.创建文本节点,进行5
5.插入DOM,完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 function createElm (vnode, parentElm, refElm ) { const data = vnode.data const children = vnode.children const tag = vnode.tag if (isDef(tag)) { vnode.elm = nodeOps.createElement(tag, 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) } }
2.删除节点 这个就很简单咯,直接removeChild
就完事
1 2 3 4 5 6 function removeNode (el ) { const parent = nodeOps.parentNode(el) if (isDef(parent)) { nodeOps.removeChild(parent, el) } }
代码中的nodeOps
是Vue
为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()
在浏览器端等同于document.createTextNode()
3.更新节点 更新节点因为涉及子节点的比较,所以情况较多,也复杂很多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 function patchVnode (oldVnode, vnode, insertedVnodeQueue, 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(elm, oldCh, 0 , oldCh.length - 1 ) } else if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, '' ) } } else if (oldVnode.text !== vnode.text) { nodeOps.setTextContent(elm, vnode.text) } }
这我看得懂个鬼哦???? 不要着急,先过一遍代码,努力思考一下片段中到底判断了些什么东西~
vnode.isStatic 是否是静态节点(没有任何变量,数据更新与它无关)
最好,拿出笔和纸,如果你对你的脑容量有自信,那就把流程图画在脑子里,看看是否一致!
1.新旧节点是否完全相同?是,完成;不是,进行2
2.新旧节点是否是具有相同key
的静态节点(没有数据,不会变化)并且新节点是个克隆节点或者老节点上有Vnode
指令?是,新节点的组件实例=老节点的组件实例,完成;不是,进行3
3.新节点是否具有text
属性?是,进行4;不是,进行5
4.新旧节点是否是具有相同text
?是,完成;不是,新节点text替换为真实DOM的text,完成
5.新旧节点是否都具有子节点?是,进行6;不是,进行7
6.新旧节点是否都具有相同的子节点? 是,进行7;不是,updateChildren更新子节点,进行7
7.新节点有子节点?是,进行8;不是,进行9
8.旧节点的子节点是否具有text
属性?是,清除真实DOM文本,再把新节点的子节点添加到真实DOM中,进行 9;不是,把新节点的子节点添加到真实DOM中,进行9
9.老节点有子节点?是,清空DOM中的子节点,进行10;不是,进行10
10.老节点有text
属性?是,清除老节点text
,进行4;不是,进行4
什么,key
是什么玩意? 请看Vnode
类
为什么不画流程图? 因为懒…… 用文字表述的逻辑是一样的,并且你应该知道:
进行2时,新旧节点不完全相同;
进行4时,新旧节点不完全相同,且不是具有相同key
的静态节点,且新节点具有text
属性
……
为啥只判断新节点是否有text
属性,不判断老节点是否有text
属性?好好想想,以那啥为准
简单来说,还是比较了新旧节点的文本节点和子节点
以新节点为准,改造老节点,最终使得两者相同,并且删除不需要的文本节点
updateChildren方法是什么?先卖个关子,咱们总结一下。
盯着干嘛,赶紧自己梳理,该记笔记记笔记啊?!别指望我给你写着然后cv
大法,想得美!
updateChildren
该函数的调用的前提 :新旧节点都有子节点。
代码量较多,这里主要的是进行新旧子节点数组的循环对比 ,目的是找出两者之间的差异 ,并以新节点为准改造旧节点,使之相同(再强调一遍) 。
循环过程
我们假定:
新旧节点子节点都没有设置key
旧数组 = 真实DOM = [a,b,c,d];
新数组=[e,f,g,h];
oldStartIdx = newStartIdx = 0;
旧首 = 旧数组第一个元素;旧尾=旧数组最后一个元素;
两两对比有四种结果:
旧首 = 新首;继续下个循环
旧首 = 新尾;真实DOM= [b,c,d,a],也就是a挪到最后;递归检查子节点;继续下个循环
旧尾 = 新首;真实DOM= [d,a,b,c],也就是d挪到最前;递归检查子节点;继续下个循环
旧尾 = 新尾;继续下个循环
下次循环,旧首,旧尾,新首,新尾都根据index和数组变化更新,进行上述比较。
这里忽略了常规的边缘判断,例如:旧首,旧尾,新首,新尾其中为空的情况,源码之中是有处理的
细心的可能会发现xxxstartIndex
只会增,xxxEndIndex
只会减,循环是从两边向中间循环的!为什么是双端比较?
这要从react的diff算法说起,它是存在优化空间的
占位!!!!
循环完成后
循环结束条件为:oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx
;
循环结束后只有两种情况:oldStartIdx > oldEndIdx ,newStartIdx > newEndIdx
如果 oldStartIdx > oldEndIdx,说明old先遍历完,并且new中新增了节点,需要将newStartIdx
和newEndIdx
之间的新增子节点添加到真实DOM中。
如果 newStartIdx > newEndIdx,说明new先遍历完,并且old中移除了节点,需要将oldStartIdx
和oldEndIdx
之间的移除的子节点从真实DOM中移除。
为什么可以根据新旧首尾idx来得出那么多信息? 我们可以逆向思维一下。
比如:什么时候会出现oldStartIdx > oldEndIdx ?
旧: a,b,c
新:d,a,b,c
第一轮循环: 新旧c找到了可复用的,pacth更新
旧: a,b
新:d,a,b
第二轮循环: 新旧b找到了可复用的,pacth更新
旧: a
新:d,a
第三轮循环: 新旧a找到了可复用的,pacth更新,–oldEndIdx后,oldEndIdx就为-1了,
旧:
新:d
这时候发现新中还有节点未匹配上,如此就找到新增的节点了。
如果 出现 newStartIdx > newEndIdx,那明显就是旧中有,新中没有的节点,我们需要删除该节点
根据key
对比
1.会根据oldCh key
建立索引树,即调用createKeyToOldIdx
函数
2.查找newStartVnode
的key
,如果在oldCh
索引中没有,则被认为是新节点,直接在头部创建节点
3.如果有,找到oldCh
对应索引的节点且赋值给vnodeToMove
,调用sameVnode
和newStartVnode
比较
4.是同一个节点,递归检查子节点,删除掉oldCh
上的vnodeToMove
,vnodeToMove
的位置与newStartVnode
保持一致(即vnodeToMove把挪到最前)
5.不是,说明也是新节点,与2一样对待。
6.index自增
有索引的对比与上面循环对比更加简单高效,这也是为什么在开发过程中devtools
总是叫你绑定key
的原因。
1 2 3 4 5 6 7 8 9 10 11 function createKeyToOldIdx (children, beginIdx, endIdx ) { let i, key const map = {} for (i = beginIdx; i <= endIdx; ++i) { key = children[i].key if (isDef(key)) map[key] = i } return map }
建议:使用key时不要使用数组的index,在删除数组元素时会发生意料之外的错误,详见:
https://juejin.im/post/5e8694b75188257372503722#heading-14
合起来整个updateChildren就是下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly ) { 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] let oldKeyToIdx, idxInOld, vnodeToMove, refElm const canMove = !removeOnly if (process.env.NODE_ENV !== 'production' ) { checkDuplicateKeys(newCh) } while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) 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) } } newStartVnode = newCh[++newStartIdx] } } if (oldStartIdx > oldEndIdx) { refElm = isUndef(newCh[newEndIdx + 1 ]) ? null : newCh[newEndIdx + 1 ].elm addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(oldCh, oldStartIdx, oldEndIdx) } }
总结: 从虚拟DOM概念说起,到详细了解了Vue
中的虚拟DOM实现方案。
用Vnode
类描述真实节点,用Vue
独有的Diff
策略进行新旧节点对比,以新节点为准,patch
旧节点,使两者一致 。
updateChildren
函数是整个Vue Diff
中最复杂的一块,但也是最核心的一块,对新旧节点的子节点数组以两边向中间的方式进行循环对比,十分有意思。