作者简介:
李晓伟
9年前端工作经验,
主要分享:Javascript、HTML5、CSS3、Vue、React、Angular、小程序、hybird、前后端开发协作、互联网、人工智能、用户体验等多方面知识
公众号:前端之阶
前言
前端主流框架 vue 和 react 中都使用了虚拟DOM(virtual DOM)技术,因为渲染真实DOM的开销是很大的,性能代价昂贵,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,而我们只需要更新修改过的那一小块dom而不要更新整个dom,这时使用diff算法能够帮助我们。那什么是虚拟DOM和diff算法呢?
虚拟DOM和VNode介绍
所谓虚拟DOM,是一个用于表示真实 DOM 结构和属性的 JavaScript 对象,这个对象用于对比虚拟 DOM 和当前真实 DOM 的差异化,然后进行局部渲染从而实现性能上的优化。在Vue.js 中虚拟 DOM 的 JavaScript 对象就是 VNode。
VNode 表示 虚拟节点 Virtual DOM,为什么叫虚拟节点呢,因为不是真的 DOM 节点。
他只是用 javascript 对象来描述真实 DOM,这么描述,把DOM标签,属性,内容都变成对象的属性。
就像用 JavaScript 对象描述一个人一样:
{sex:'女', name:'voanit', salary:5000,children:null}
过程就是,把你的 template 模板 描述成 VNode,然后一系列操作之后通过 VNode 形成真实DOM进行挂载。
什么用?
1兼容性强,不受执行环境的影响。VNode 因为是 JS 对象,不管 Node 还是 浏览器,都可以统一操作, 从而获得了服务端渲染、原生渲染、手写渲染函数等能力
2减少操作 DOM。任何页面的变化,都只使用 VNode 进行操作对比,只需要在最后一步挂载更新DOM,不需要频繁操作DOM,从而提高页面性能
我们可以做个试验。打印出一个空元素的第一层属性,可以看到标准让元素实现的东西太多了。如果每次都重新生成新的元素,对性能是巨大的浪费。
var mydiv = document.createElement('div');
for(var k in mydiv ){
console.log(k)
}
virtual dom就是解决这个问题的一个思路,用一个简单的对象去代替复杂的dom对象。
举个简单的例子,我们在body里插入一个class为a的div。
var mydiv = document.createElement('div');
mydiv.className = 'a';
document.body.appendChild(mydiv);
对于这个div我们可以用一个简单的对象mydivVirtual代表它,它存储了对应dom的一些重要参数,在改变dom之前,会先比较相应虚拟dom的数据,如果需要改变,才会将改变应用到真实dom上。
//伪代码
var mydivVirtual = {
tagName: 'DIV',
className: 'a'
};
var newmydivVirtual = {
tagName: 'DIV',
className: 'b'
}
if(mydivVirtual.tagName !== newmydivVirtual.tagName || mydivVirtual.className !== newmydivVirtual.className){
change(mydiv)
}
// 会执行相应的修改 mydiv.className = 'b';
//最后 <div class='b'></div>
读到这里就会产生一个疑问,为什么不直接修改dom而需要加一层virtual dom呢?
很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。
virtual dom 另一个重大意义就是提供一个中间层,js去写ui,ios安卓之类的负责渲染,就像reactNative一样。
分析diff
diff算法源自于:linux的基本命令,对比文本。vue和react的虚拟DOM的diff算法大致相同,其核心是基于两个简单的假设:1. 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构。2. 同一层级的一组节点,他们可以通过唯一的id进行区分。
例如
<ul id='list'>
<li class='item'>Item 1</li>
<li class='item'>Item 1</li>
</ul>
生成的vdom为:
{
tag: 'url',
attrs: {id: 'list'},
children: [
{
tag: 'li',
attrs:{className:'item'},
children:['Item 1']
},
{
tag: 'li',
attrs:{className:'item'},
children:['Item 2']
},
]
}
比较只会在同层级进行, 不会跨层级比较。
举个形象的例子。
<div>
<p>
<b> aoy </b>
<span>diff</Span>
</P>
</div>
<div>
<p>
<b> aoy </b>
</p>
<span>diff</Span>
</div>
我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。
因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。
源码分析
diff的过程就是调用patch函数,就像打补丁一样修改真实dom。
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
}
patch
函数有两个参数,vnode
和oldVnode
,也就是新旧两个虚拟节点。在这之前,我们先了解完整的vnode都有什么属性,举个一个简单的例子:
// body下的 <div id="v" class="classA"><div> 对应的 oldVnode 就是
{
el: div //对真实的节点的引用,本例中就是document.querySelector('#id.classA')
tagName: 'DIV', //节点的标签
sel: 'div#v.classA' //节点的选择器
data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style
children: [], //存储子节点的数组,每个子节点也是vnode结构
text: null, //如果是文本节点,对应文本节点的textContent,否则为null
}
需要注意的是,el属性引用的是此 virtual dom对应的真实dom,patch
的vnode
参数的el
最初是null,因为patch
之前它还没有对应的真实dom。
来到patch
的第一部分,
if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode)}
sameVnode
函数就是看这两个节点是否值得比较,代码相当简单:
function sameVnode(oldVnode, vnode){ return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel}
两个vnode的key和sel相同才去比较它们,比如p
和span
,div.classA
和div.classB
都被认为是不同结构而不去比较它们。
如果值得比较会执行patchVnode(oldVnode, vnode)
,稍后会详细讲patchVnode
函数。
当节点不值得比较,进入else中
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
}
}
过程如下:
-
取得
oldvnode.el
的父节点,parentEle
是真实dom -
createEle(vnode)
会为vnode
创建它的真实dom,令vnode.el
=真实dom
-
parentEle
将新的dom插入,移除旧的dom
当不值得比较时,新节点直接把老节点整个替换了
最后
return vnode
patch最后会返回vnode,vnode和进入patch之前的不同在哪?
没错,就是vnode.el,唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom。
var oldVnode = patch (oldVnode, vnode)
至此完成一个patch过程。
patchVnode
两个节点值得比较时,会调用patchVnode
函数
patchVnode (oldVnode, vnode) {
const el = vnode.el = oldVnode.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)
}else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
}else if (ch){
createEle(vnode) //create el's children dom
}else if (oldCh){
api.removeChildren(el)
}
}
}
const el = vnode.el = oldVnode.el
这是很重要的一步,让vnode.el
引用到现在的真实dom,当el
修改时,vnode.el
会同步变化。
节点的比较有5种情况
-
if (oldVnode === vnode)
,他们的引用一致,可以认为没有变化。 -
if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text)
,文本节点的比较,需要修改,则会调用Node.textContent = vnode.text
。 -
if( oldCh && ch && oldCh !== ch )
, 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren
函数比较子节点,这是diff的核心,后边会讲到。 -
else if (ch)
,只有新的节点有子节点,调用createEle(vnode)
,vnode.el
已经引用了老的dom节点,createEle
函数会在老dom节点上添加子节点。 -
else if (oldCh)
,新节点没有子节点,老节点有子节点,直接删除老节点。
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]
}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)
}
}
代码很密集,为了形象的描述这个过程,可以看看这张图。
imageimage
过程可以概括为:oldCh
和newCh
各有两个头尾的变量StartIdx
和EndIdx
,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx
表明oldCh
和newCh
至少有一个已经遍历完了,就会结束比较。
作者简介:
李晓伟
9年前端工作经验,
主要分享:Javascript、HTML5、CSS3、Vue、React、Angular、小程序、hybird、前后端开发协作、互联网、人工智能、用户体验等多方面知识
公众号:前端之阶
本文已经获得李晓伟老师授权转发,其他人若有兴趣转载,请直接联系作者授权。
网友评论