Skip to content

解析vue2.0的diff算法 #2

Open
@aooy

Description

@aooy
Owner

作者:杨敬卓

转载请注明出处

目录

  • 前言
  • virtual dom
  • 分析diff
  • 总结

前言

vue2.0加入了virtual dom,有向react靠拢的意思。vue的diff位于patch.js文件中,我的一个小框架aoy也同样使用此算法,该算法来源于snabbdom,复杂度为O(n)。
了解diff过程可以让我们更高效的使用框架。
本文力求以图文并茂的方式来讲明这个diff的过程。

virtual dom

如果不了解virtual dom,要理解diff的过程是比较困难的。虚拟dom对应的是真实dom, 使用document.CreateElementdocument.CreateTextNode创建的就是真实节点。

我们可以做个试验。打印出一个空元素的第一层属性,可以看到标准让元素实现的东西太多了。如果每次都重新生成新的元素,对性能是巨大的浪费。

var mydiv = document.createElement('div');
for(var k in mydiv ){
  console.log(k)
}

virtual dom就是解决这个问题的一个思路,到底什么是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

一篇相当经典的文章React’s diff algorithm中的图,react的diff其实和vue的diff大同小异。所以这张图能很好的解释过程。比较只会在同层级进行, 不会跨层级比较。

举个形象的例子。
<!-- 之前 -->
<div>           <!-- 层级1 -->
  <p>            <!-- 层级2 -->
    <b> aoy </b>   <!-- 层级3 -->   
    <span>diff</Span>
  </P> 
</div>

<!-- 之后 -->
<div>            <!-- 层级1 -->
  <p>             <!-- 层级2 -->
      <b> aoy </b>        <!-- 层级3 -->
  </p>
  <span>diff</Span>
</div>

我们可能期望将<span>直接移动到<p>的后边,这是最优的操作。但是实际的diff操作是移除<p>里的<span>在创建一个新的<span>插到<p>的后边。
因为新加的<span>在层级2,旧的在层级3,属于不同层级的比较。

源码分析

文中的代码位于aoy-diff中,已经精简了很多代码,留下最核心的部分。

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函数有两个参数,vnodeoldVnode,也就是新旧两个虚拟节点。在这之前,我们先了解完整的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,patchvnode参数的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相同才去比较它们,比如pspandiv.classAdiv.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种情况

  1. if (oldVnode === vnode),他们的引用一致,可以认为没有变化。

  2. if(oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text),文本节点的比较,需要修改,则会调用Node.textContent = vnode.text

  3. if( oldCh && ch && oldCh !== ch ), 两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren函数比较子节点,这是diff的核心,后边会讲到。

  4. else if (ch),只有新的节点有子节点,调用createEle(vnode)vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点。

  5. 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)
        }
}

代码很密集,为了形象的描述这个过程,可以看看这张图。

过程可以概括为:oldChnewCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChnewCh至少有一个已经遍历完了,就会结束比较。

具体的diff分析

设置key和不设置key的区别:
不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。

diff的遍历过程中,只要是对dom进行的操作都调用api.insertBeforeapi.insertBefore只是原生insertBefore的简单封装。
比较分为两种,一种是有vnode.key的,一种是没有的。但这两种比较对真实dom的操作是一致的。

对于与sameVnode(oldStartVnode, newStartVnode)sameVnode(oldEndVnode,newEndVnode)为true的情况,不需要对dom进行移动。

总结遍历过程,有3种dom操作:

  1. oldStartVnodenewEndVnode值得比较,说明oldStartVnode.el跑到oldEndVnode.el的后边了。

图中假设startIdx遍历到1。

  1. oldEndVnodenewStartVnode值得比较,说明 oldEndVnode.el跑到了newStartVnode.el的前边。(这里笔误,应该是“oldEndVnode.el跑到了oldStartVnode.el的前边”,准确的说应该是oldEndVnode.el需要移动到oldStartVnode.el的前边”)
  1. newCh中的节点oldCh里没有, 将新节点插入到oldStartVnode.el的前边。

在结束时,分为两种情况:

  1. oldStartIdx > oldEndIdx,可以认为oldCh先遍历完。当然也有可能newCh此时也正好完成了遍历,统一都归为此类。此时newStartIdxnewEndIdx之间的vnode是新增的,调用addVnodes,把他们全部插进before的后边,before很多时候是为null的。addVnodes调用的是insertBefore操作dom节点,我们看看insertBefore的文档:parentElement.insertBefore(newElement, referenceElement)
    如果referenceElement为null则newElement将被插入到子节点的末尾。如果newElement已经在DOM树中,newElement首先会从DOM树中移除。所以before为null,newElement将被插入到子节点的末尾。
  1. newStartIdx > newEndIdx,可以认为newCh先遍历完。此时oldStartIdxoldEndIdx之间的vnode在新的子节点里已经不存在了,调用removeVnodes将它们从dom里删除。

下面举个例子,画出diff完整的过程,每一步dom的变化都用不同颜色的线标出。

  1. a,b,c,d,e假设是4个不同的元素,我们没有设置key时,b没有复用,而是直接创建新的,删除旧的。
  1. 当我们给4个元素加上唯一key时,b得到了的复用。

这个例子如果我们使用手工优化,只需要3步就可以达到。

总结

  • 尽量不要跨层级的修改dom
  • 设置key可以最大化的利用节点
  • diff的效率并不是每种情况下都是最优的

Activity

userand

userand commented on Mar 23, 2017

@userand

@

fengmiaosen

fengmiaosen commented on Mar 24, 2017

@fengmiaosen

mark

cshenger

cshenger commented on Mar 24, 2017

@cshenger

配图好评,看代码绝对会晕的

ascoders

ascoders commented on Mar 24, 2017

@ascoders

和 react 的几乎一样

aooy

aooy commented on Mar 24, 2017

@aooy
OwnerAuthor

@ascoders 是的

ghost

ghost commented on Mar 25, 2017

@ghost
mofengfly

mofengfly commented on Apr 9, 2017

@mofengfly

赞,mark

iterry

iterry commented on Apr 12, 2017

@iterry

详细,点赞

wangweida

wangweida commented on Apr 26, 2017

@wangweida

写的很好,点个赞。
当oldEndVnode,newStartVnode值得比较,说明 oldEndVnode.el跑到了newStartVnode.el的前边。
这句话估计你写笔误了吧...

aooy

aooy commented on Apr 27, 2017

@aooy
OwnerAuthor

@wangweida 是的笔误了,谢谢提醒。

zengwenfu

zengwenfu commented on Apr 27, 2017

@zengwenfu

赞,有一处需要指出的是:
function sameVnode(oldVnode, vnode){
return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}
vue中sameVnode不会比较vnode.sel===oldVnode.sel,按照定义,这里的sel是选择器,如果选择器不一致就不值得比较的话,那么vue里面的v-bind:class动态绑定class就变得完全不提倡使用了,事实上vue并没有做这个比较:
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)
)
}

aooy

aooy commented on Apr 27, 2017

@aooy
OwnerAuthor

@zengwenfu 谢谢指正,本文从原始算法去解读vue的diff,没有完全契合vue,vue实际情况会更复杂,它考虑到了class的动态绑定和input的type值,对sameVnode进行了修改。

dean5277

dean5277 commented on Jun 9, 2017

@dean5277

配图满分,很好理解

linzb93

linzb93 commented on Jun 27, 2017

@linzb93

有个疑问,假如一个节点是根节点的好几层子节点,那么它对应的vnode的el属性应该怎么表示?是从根节点一层层套下去吗?我看了你的代码还没明白。

56 remaining items

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @dean5277@mofengfly@iterry@Inakiz@ivonzhang

        Issue actions

          解析vue2.0的diff算法 · Issue #2 · aooy/blog