Vue源码之:虚拟DOM及DOM-diff算法

参考文档:vue

https://vue-js.com/learn-vue/node

https://github.com/answershuto/learnVuegit

前言

在刀耕火种的年代,咱们须要在各个事件方法中直接操做DOM来达到修改视图的目的。可是当应用一大就会变得难以维护。github

那咱们是否是能够把真实DOM树抽象成一棵以JavaScript对象构成的抽象树,在修改抽象树数据后将抽象树转化成真实DOM重绘到页面上呢?因而虚拟DOM出现了,它是真实DOM的一层抽象,用属性描述真实DOM的各个特性。当它发生变化的时候,就会去修改视图。web

能够想象,最简单粗暴的方法就是将整个DOM结构用innerHTML修改到页面上,可是这样进行重绘整个视图层是至关消耗性能的,咱们是否是能够每次只更新它的修改呢?因此Vue.js将DOM抽象成一个以JavaScript对象为节点的虚拟DOM树,以VNode节点模拟真实DOM,能够对这颗抽象树进行建立节点、删除节点以及修改节点等操做,在这过程当中都不须要操做真实DOM,只须要操做JavaScript对象后只对差别修改,相对于整块的innerHTML的粗暴式修改,大大提高了性能。修改之后通过diff算法得出一些须要修改的最小单位,再将这些小单位的视图进行更新。这样作减小了不少不须要的DOM操做,大大提升了性能。算法

Vue就使用了这样的抽象节点VNode,它是对真实DOM的一层抽象,而不依赖某个平台,它能够是浏览器平台,也能够是weex,甚至是node平台也能够对这样一棵抽象DOM树进行建立删除修改等操做,这也为先后端同构提供了可能。后端

虚拟DOM简介

什么是虚拟DOM ?

所谓虚拟DOM,就是用一个JS对象来描述一个DOM节点,打个比方,好比说我如今有这么一个VNode树:api

<div class="test">
 <span class="demo">hello,VNode</span> </div>  {  tag: 'div'  data: {  class: 'test'  },  children: [  {  tag: 'span',  data: {  class: 'demo'  }  text: 'hello,VNode'  }  ] } 复制代码

咱们把组成一个DOM节点的必要东西经过一个JS对象表示出来,那么这个JS对象就能够用来描述这个DOM节点,咱们把这个JS对象就称为是这个真实DOM节点的虚拟DOM节点。数组

为何要有虚拟DOM?

上文说了,Vue属于数据驱动视图,数据发生变化视图就要随之更新,在更新视图的时候不免操做DOM。并且操做DOM很是耗费性能。以下所示。浏览器

let div = document.createElement('div')
let str = '' for (const key in div) {  str += key + '' } console.log(str) 复制代码

上图中咱们打印一个简单的空div标签,就打印出这么多东西,更不用说复杂的、深嵌套的DOM节点了。因而可知,直接操做真实DOM是很是消耗性能的。

咱们能够用JS模拟出一个DOM节点,称之为虚拟DOM节点。当数据发生变化时,咱们对比变化先后的虚拟DOM节点,经过DOM-Diff算法计算出须要更新的地方,而后去更新须要更新的视图。

这就是虚拟DOM产生的缘由以及最大的用途。

Vue中的虚拟DOM

前文咱们介绍了虚拟DOM的概念以及为何要有虚拟DOM,那么在Vue中虚拟DOM是怎么实现的呢?接下来,咱们从源码出发,深刻学习一下。

VNode类

export default class VNode {
 tag: string | void;  data: VNodeData | void;  children: ?Array<VNode>;  text: string | void;  elm: Node | void;  ns: string | void;  context: Component | void; // rendered in this component's scope  functionalContext: Component | void; // only for functional component root nodes  key: string | number | void;  componentOptions: VNodeComponentOptions | void;  componentInstance: Component | void; // component instance  parent: VNode | void; // component placeholder node  raw: boolean; // contains raw HTML? (server only)  isStatic: boolean; // hoisted static node  isRootInsert: boolean; // necessary for enter transition check  isComment: boolean; // empty comment placeholder?  isCloned: boolean; // is a cloned node?  isOnce: boolean; // is a v-once node?   constructor (  tag?: string,  data?: VNodeData,  children?: ?Array<VNode>,  text?: string,  elm?: Node,  context?: Component,  componentOptions?: VNodeComponentOptions  ) {  /*当前节点的标签名*/  this.tag = tag  /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,能够参考VNodeData类型中的数据信息*/  this.data = data  /*当前节点的子节点,是一个数组*/  this.children = children  /*当前节点的文本*/  this.text = text  /*当前虚拟节点对应的真实dom节点*/  this.elm = elm  /*当前节点的名字空间*/  this.ns = undefined  /*当前节点的编译做用域*/  this.context = context  /*函数化组件做用域*/  this.functionalContext = undefined  /*节点的key属性,被看成节点的标志,用以优化*/  this.key = data && data.key  /*组件的option选项*/  this.componentOptions = componentOptions  /*当前节点对应的组件的实例*/  this.componentInstance = undefined  /*当前节点的父节点*/  this.parent = undefined  /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/  this.raw = false  /*是否为静态节点*/  this.isStatic = false  /*是否做为跟节点插入*/  this.isRootInsert = true  /*是否为注释节点*/  this.isComment = false  /*是否为克隆节点*/  this.isCloned = false  /*是否有v-once指令*/  this.isOnce = false  }   // DEPRECATED: alias for componentInstance for backwards compat.  /* istanbul ignore next */  get child (): Component | void {  return this.componentInstance  } } 复制代码

从上面的代码中能够看出:VNode类中包含了描述一个真实DOM节点所须要的一系列属性,如tag表示节点的标签名,text表示节点中包含的文本,children表示该节点包含的子节点等。经过属性之间不一样的搭配,就能够描述出各类类型的真实DOM节点

VNode的类型

上一小节最后咱们说了,经过属性之间不一样的搭配,VNode类能够描述出各类类型的真实DOM节点。那么它均可以描述出哪些类型的节点呢?经过阅读源码,能够发现经过不一样属性的搭配,能够描述出如下几种类型的节点。

  • 注释节点
  • 文本节点
  • 元素节点
  • 组件节点
  • 函数式组件节点
  • 克隆节点

注释节点 (空VNode节点)

export const createEmptyVNode = (text: string = '') => {
 const node = new VNode()  node.text = text  node.isComment = true  return node } 复制代码

从上面代码中能够看到,描述一个注释节点只需两个属性,分别是:textisComment。其中text属性表示具体的注释信息,isComment是一个标志,用来标识一个节点是不是注释节点。

文本节点

文本节点描述起来比注释节点更简单,由于它只须要一个属性,那就是text属性,用来表示具体的文本信息。源码以下:

// 建立文本节点
export function createTextVNode (val: string | number) {  return new VNode(undefined, undefined, undefined, String(val)) } 复制代码

克隆节点

克隆节点就是把一个已经存在的节点复制一份出来,它主要是为了作模板编译优化时使用,这个后面咱们会说到。关于克隆节点的描述,而现有节点和新克隆获得的节点之间惟一的不一样就是克隆获得的节点isClonedtrue

// 建立克隆节点
export function cloneVNode (vnode: VNode): VNode {  const cloned = new VNode(  vnode.tag,  vnode.data,  vnode.children,  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 } 复制代码

元素节点

相比之下,元素节点更贴近于咱们一般看到的真实DOM节点,它有描述节点标签名词的tag属性,描述节点属性如classattributes等的data属性,有描述包含的子节点信息的children属性等。因为元素节点所包含的状况相比而言比较复杂,源码中没有像前三种节点同样直接写死(固然也不可能写死),那就举个简单例子说明一下:

// 真实DOM节点
<div id='a'><span>难凉热血</span></div>  // VNode节点 {  tag:'div',  data:{},  children:[  {  tag:'span',  text:'难凉热血'  }  ] } 复制代码

组件节点

组件节点除了有元素节点具备的属性以外,它还有两个特有的属性:

  • componentOptions :组件的option选项,如组件的 props
  • componentInstance :当前组件节点对应的Vue实例

函数式组件节点

函数式组件节点相较于组件节点,它又有两个特有的属性:

  • fnContext:函数式组件对应的Vue实例
  • fnOptions: 组件的option选项

小结

以上就是VNode能够描述的多种节点类型,它们本质上都是VNode类的实例,只是在实例化的时候传入的属性参数不一样而已。

VNode的做用

说了这么多,那么VNode在Vue的整个虚拟DOM过程起了什么做用呢?

其实VNode的做用是至关大的。咱们在视图渲染以前,把写好的template模板先编译成VNode并缓存下来,等到数据发生变化页面须要从新渲染的时候,咱们把数据发生变化后生成的VNode与前一次缓存下来的VNode进行对比,找出差别,而后有差别的VNode对应的真实DOM节点就是须要从新渲染的节点,最后根据有差别的VNode建立出真实的DOM节点再插入到视图中,最终完成一次视图更新。

有了数据变化先后的VNode,咱们才能进行后续的DOM-Diff找出差别,最终作到只更新有差别的视图,从而达到尽量少的操做真实DOM的目的,以节省性能。

Vue中的DOM-Diff

path

Vue中,把DOM-Diff过程叫作patch过程。patch,意为“补丁”,即指对旧的VNode修补,打补丁从而获得新的VNode,很是形象哈。那无论叫什么,其本质都是把对比新旧两份VNode的过程。咱们在下面研究patch过程的时候,必定把握住这样一个思想:所谓旧的VNode(即oldVNode)就是数据变化以前视图所对应的虚拟DOM节点,而新的VNode是数据变化以后将要渲染的新的视图所对应的虚拟DOM节点

因此咱们要以生成的新的VNode为基准,对比旧的oldVNode

  • 建立节点:若是新的VNode上有的节点而旧的oldVNode上没有,那么就在旧的oldVNode上加上去;

  • 删除节点:若是新的VNode上没有的节点而旧的oldVNode上有,那么就在旧的oldVNode上去掉;

  • 更新节点:若是某些节点在新的VNode和旧的oldVNode上都有,那么就以新的VNode为准,更新旧的oldVNode,从而让新旧VNode相同。

以新的VNode为基准,改造旧的oldVNode使之成为跟新的VNode同样,这就是patch过程要干的事。

建立节点

VNode类能够描述6种类型的节点,而实际上只有3种类型的节点可以被建立并插入到DOM中,它们分别是:元素节点、文本节点、注释节点。因此Vue在建立节点的时候会判断在新的VNode中有而旧的oldVNode中没有的这个节点是属于哪一种类型的节点,从而调用不一样的方法建立并插入到DOM中。

/*建立一个节点*/
 function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested) {  /*insertedVnodeQueue为空数组[]的时候isRootInsert标志为true*/  vnode.isRootInsert = !nested // for transition enter check  /*建立一个组件节点*/  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {  return  }   const data = vnode.data  const children = vnode.children  const tag = vnode.tag  if (isDef(tag)) {  ....  ....  vnode.elm = vnode.ns  ? nodeOps.createElementNS(vnode.ns, tag)  : 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)  }  } 复制代码

从上面代码中,咱们能够看出:

判断是否为元素节点只需判断该VNode节点是否有tag标签便可。若是有tag属性即认为是元素节点,则调用createElement方法建立元素节点,一般元素节点还会有子节点,那就递归遍历建立全部子节点,将全部子节点建立好以后insert插入到当前元素节点里面,最后把当前元素节点插入到DOM中。

判断是否为注释节点,只需判断VNodeisComment属性是否为true便可,若为true则为注释节点,则调用createComment方法建立注释节点,再插入到DOM中。

若是既不是元素节点,也不是注释节点,那就认为是文本节点,则调用createTextNode方法建立文本节点,再插入到DOM中。

删除节点

若是某些节点再新的VNode中没有而在旧的oldVNode中有,那么就须要把这些节点从旧的oldVNode中删除。删除节点很是简单,只需在要删除节点的父元素上调用removeChild方法便可。源码以下:

function removeNode (el) {
 const parent = nodeOps.parentNode(el) // 获取父节点  if (isDef(parent)) {  nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法  }  } 复制代码

更新节点

建立节点和删除节点都比较简单,而更新节点就相对较为复杂一点了,其实也不算多复杂,只要理清逻辑就能理解了。

更新节点就是当某些节点在新的VNode和旧的oldVNode中都有时,咱们就须要细致比较一下,找出不同的地方进行更新。

介绍更新节点以前,咱们先介绍一个小的概念,就是什么是静态节点?咱们看个例子:

<p>我是不会变化的文字</p>
复制代码

OK,有了这个概念之后,咱们开始更新节点。更新节点的时候咱们须要对如下3种状况进行判断并分别处理:

  • 若是VNode和oldVNode均为静态节点

咱们说了,静态节点不管数据发生任何变化都与它无关,因此都为静态节点的话则直接跳过,无需处理。

  • 若是VNode是文本节点

若是VNode是文本节点即表示这个节点内只包含纯文本,那么只需看oldVNode是否也是文本节点,若是是,那就比较两个文本是否不一样,若是不一样则把oldVNode里的文本改为跟VNode的文本同样。若是oldVNode不是文本节点,那么不论它是什么,直接调用setTextNode方法把它改为文本节点,而且文本内容跟VNode相同。

  • 若是VNode是元素节点

若是VNode是元素节点,则又细分如下两种状况: 该节点包含子节点

若是新的节点内包含了子节点,那么此时要看旧的节点是否包含子节点,若是旧的节点里也包含了子节点,那就须要递归对比更新子节点;若是旧的节点里不包含子节点,那么这个旧节点有多是空节点或者是文本节点,若是旧的节点是空节点就把新的节点里的子节点建立一份而后插入到旧的节点里面,若是旧的节点是文本节点,则把文本清空,而后把新的节点里的子节点建立一份而后插入到旧的节点里面。

该节点不包含子节点

若是该节点不包含子节点,同时它又不是文本节点,那就说明该节点是个空节点,那就好办了,无论旧节点以前里面都有啥,直接清空便可。

OK,处理完以上3种状况,更新节点就算基本完成了,接下来咱们看下源码中具体是怎么实现的,源码以下:

 /*patch VNode节点*/  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {  /*两个VNode节点相同则直接返回*/  if (oldVnode === vnode) {  return  }  // reuse element for static trees.  // note we only do this if the vnode is cloned -  // if the new node is not cloned it means the render functions have been  // reset by the hot-reload-api and we need to do a proper re-render.  /*  若是新旧VNode都是静态的,同时它们的key相同(表明同一节点),  而且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),  那么只须要替换elm以及componentInstance便可。  */  if (isTrue(vnode.isStatic) &&  isTrue(oldVnode.isStatic) &&  vnode.key === oldVnode.key &&  (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {  vnode.elm = oldVnode.elm  vnode.componentInstance = oldVnode.componentInstance  return  }  let i  const data = vnode.data  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {  /*i = data.hook.prepatch,若是存在的话,见"./create-component componentVNodeHooks"。*/  i(oldVnode, vnode)  }  const elm = vnode.elm = oldVnode.elm  const oldCh = oldVnode.children  const ch = vnode.children  if (isDef(data) && isPatchable(vnode)) {  /*调用update回调以及update钩子*/  for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)  if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)  }  /*若是这个VNode节点没有text文本时*/  if (isUndef(vnode.text)) {  if (isDef(oldCh) && isDef(ch)) {  /*新老节点均有children子节点,则对子节点进行diff操做,调用updateChildren*/  if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)  } else if (isDef(ch)) {  /*若是老节点没有子节点而新节点存在子节点,先清空elm的文本内容,而后为当前节点加入子节点*/  if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')  addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)  } else if (isDef(oldCh)) {  /*当新节点没有子节点而老节点有子节点的时候,则移除全部ele的子节点*/  removeVnodes(elm, oldCh, 0, oldCh.length - 1)  } else if (isDef(oldVnode.text)) {  /*当新老节点都无子节点的时候,只是文本的替换,由于这个逻辑中新节点text不存在,因此直接去除ele的文本*/  nodeOps.setTextContent(elm, '')  }  } else if (oldVnode.text !== vnode.text) {  /*当新老节点text不同时,直接替换这段文本*/  nodeOps.setTextContent(elm, vnode.text)  }  /*调用postpatch钩子*/  if (isDef(data)) {  if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)  }  } 复制代码

上面代码里注释已经写得很清晰了,接下来咱们画流程图来梳理一下整个过程,流程图以下:

另外,你可能注意到了,若是新旧VNode里都包含了子节点,那么对于子节点的更新在代码里调用了updateChildren方法

更细子节点

更新子节点

当新的VNode与旧的oldVNode都是元素节点而且都包含子节点时,那么这两个节点的VNode实例上的children属性就是所包含的子节点数组。咱们把新的VNode上的子节点数组记为newChildren,把旧的oldVNode上的子节点数组记为oldChildren,咱们把newChildren里面的元素与oldChildren里的元素一一进行对比,对比两个子节点数组确定是要经过循环,外层循环newChildren数组,内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点,伪代码以下:

for (let i = 0; i < newChildren.length; i++) {
 const newChild = newChildren[i];  for (let j = 0; j < oldChildren.length; j++) {  const oldChild = oldChildren[j];  if (newChild === oldChild) {  // ...  }  } }  复制代码

那么以上这个过程将会存在如下四种状况:

  • 建立子节点

若是newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是以前没有的,是须要这次新增的节点,那么就建立子节点。

  • 删除子节点

若是把newChildren里面的每个子节点都循环完毕后,发现在oldChildren还有未处理的子节点,那就说明这些未处理的子节点是须要被废弃的,那么就将这些节点删除。

  • 移动子节点

若是newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,可是所处的位置不一样,这说明这次变化须要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。

  • 更新节点

若是newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,而且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。

OK,到这里,逻辑就相对清晰了,接下来咱们只需分门别类的处理这四种状况就行了。

建立子节点

若是newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是以前没有的,是须要这次新增的节点,那么咱们就建立这个节点,建立好以后再把它插入到DOM中合适的位置。

那么建立好以后如何插入到DOM中的合适的位置呢?显然,把节点插入到DOM中是很容易的,找到合适的位置是关键。接下来咱们分析一下如何找这个合适的位置。咱们看下面这个图:

上图中左边是新的VNode,右边是旧的oldVNode,同时也是真实的DOM。这个图意思是当咱们循环newChildren数组里面的子节点,前两个子节点都在oldChildren里找到了与之对应的子节点,那么咱们将其处理,处理事后把它们标志为已处理,当循环到newChildren数组里第三个子节点时,发如今oldChildren里找不到与之对应的子节点,那么咱们就须要建立这个节点,建立好以后咱们发现这个节点本是newChildren数组里左起第三个子节点,那么咱们就把建立好的节点插入到真实DOM里的第三个节点位置,也就是全部已处理节点以后,OK,此时咱们拍手称快,全部已处理节点以后就是咱们要找的合适的位置,可是真的是这样吗?咱们再来看下面这个图:

假如咱们按照上面的方法把第三个节点插入到全部已处理节点以后,此时若是第四个节点也在oldChildren里找不到与之对应的节点,也是须要建立的节点,那么当咱们把第四个节点也按照上面的说的插入到已处理节点以后,发现怎么插入到第三个位置了,可明明这个节点在newChildren数组里是第四个啊

这就是问题所在,其实,咱们应该把新建立的节点插入到全部未处理节点以前,这样以来逻辑才正确。后面无论有多少个新增的节点,每个都插入到全部未处理节点以前,位置才不会错。

因此,合适的位置是全部未处理节点以前,而并不是全部已处理节点以后

删除子节点

若是把newChildren里面的每个子节点都循环一遍,能在oldChildren数组里找到的就处理它,找不到的就新增,直到把newChildren里面全部子节点都过一遍后,发如今oldChildren还存在未处理的子节点,那就说明这些未处理的子节点是须要被废弃的,那么就将这些节点删除。

更新子节点

若是newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,而且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。

移动子节点

若是newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,可是所处的位置不一样,这说明这次变化须要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。

一样,移动一个节点不难,关键在于该移动到哪,或者说关键在于移动到哪一个位置,这个位置才是关键。咱们看下图:

在上图中,绿色的两个节点是相同节点可是所处位置不一样,即newChildren里面的第三个子节点与真实DOM即oldChildren里面的第四个子节点相同可是所处位置不一样,按照上面所说的,咱们应该以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,因此咱们应该把真实DOM即oldChildren里面的第四个节点移动到第三个节点的位置,通过上图中的标注咱们不难发现,全部未处理节点以前就是咱们要移动的目的位置。若是此时你说那可不能够移动到全部已处理节点以后呢?那就又回到了更新节点时所遇到的那个问题了:

回到源码

// 源码位置: /src/core/vdom/patch.js
 if (isUndef(idxInOld)) { // 若是在oldChildren里找不到当前循环的newChildren里的子节点  // 新增节点并插入到合适位置  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx) } else {  // 若是在oldChildren里找到了当前循环的newChildren里的子节点  vnodeToMove = oldCh[idxInOld]  // 若是两个节点相同  if (sameVnode(elmToMove, newStartVnode)) {  /*若是新VNode与获得的有相同key的节点是同一个VNode则进行patchVnode*/  patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)  /*由于已经patchVnode进去了,因此将这个老节点赋值undefined,以后若是还有新节点与该节点key相同能够检测出来提示已有重复的key*/  oldCh[idxInOld] = undefined  /*当有标识位canMove实能够直接插入oldStartVnode对应的真实Dom节点前面*/  canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)  newStartVnode = newCh[++newStartIdx]  } else {  // same key but different element. treat as new element  /*当新的VNode与找到的一样key的VNode不是sameVNode的时候(好比说tag不同或者是有不同type的input标签),建立一个新的节点*/  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)  newStartVnode = newCh[++newStartIdx]  } } 复制代码

以上代码中,首先判断在oldChildren里可否找到当前循环的newChildren里的子节点,若是找不到,那就是新增节点并插入到合适位置;若是找到了,先对比两个节点是否相同,若相同则先调用patchVnode更新节点,更新完以后再看是否须要移动节点,注意,源码里在判断是否须要移动子节点时用了简写的方式

优化更新子节点

上节说的新的VNode 与 旧的oldVNode都是元素节点且有子节点时候 先外层循环newChildren数组,再内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点,最后根据不一样的状况做出不一样的操做。

优化策略介绍

假如咱们现有一份新的newChildren数组和旧的oldChildren数组,以下所示:

newChildren = ['新子节点1','新子节点2','新子节点3','新子节点4']
oldChildren = ['旧子节点1','旧子节点2','旧子节点3','旧子节点4'] 复制代码

那么咱们该怎么优化呢?其实咱们能够这样想,咱们不要按顺序去循环newChildrenoldChildren这两个数组,能够先比较这两个数组里特殊位置的子节点,好比:

  • 先把newChildren数组里的全部未处理子节点的第一个子节点和oldChildren数组里全部未处理子节点的第一个子节点作比对,若是相同,那就直接进入更新节点的操做;

  • 若是不一样,再把newChildren数组里全部未处理子节点的最后一个子节点和oldChildren数组里全部未处理子节点的最后一个子节点作比对,若是相同,那就直接进入更新节点的操做;

  • 若是不一样,再把newChildren数组里全部未处理子节点的最后一个子节点和oldChildren数组里全部未处理子节点的第一个子节点作比对,若是相同,那就直接进入更新节点的操做,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

  • 若是不一样,再把newChildren数组里全部未处理子节点的第一个子节点和oldChildren数组里全部未处理子节点的最后一个子节点作比对,若是相同,那就直接进入更新节点的操做,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

  • 最后四种状况都试完若是还不一样,那就按照以前循环的方式来查找节点。

其过程以下

在上图中,咱们把:

  • newChildren数组里的全部未处理子节点的第一个子节点称为:新前;

  • newChildren数组里的全部未处理子节点的最后一个子节点称为:新后;

  • oldChildren数组里的全部未处理子节点的第一个子节点称为:旧前;

  • oldChildren数组里的全部未处理子节点的最后一个子节点称为:旧后; OK,有了以上概念之后,下面咱们就来看看其具体是如何实施的。

新前与旧前

newChildren数组里的全部未处理子节点的第一个子节点和oldChildren数组里全部未处理子节点的第一个子节点作比对,若是相同,那好极了,直接进入以前文章中说的更新节点的操做而且因为新前与旧前两个节点的位置也相同,无需进行节点移动操做;若是不一样,不要紧,再尝试后面三种状况。

新后与旧后

newChildren数组里全部未处理子节点的最后一个子节点和oldChildren数组里全部未处理子节点的最后一个子节点作比对,若是相同,那就直接进入更新节点的操做而且因为新后与旧后两个节点的位置也相同,无需进行节点移动操做;若是不一样,继续日后尝试。

新后与旧前

newChildren数组里全部未处理子节点的最后一个子节点和oldChildren数组里全部未处理子节点的第一个子节点作比对,若是相同,那就直接进入更新节点的操做,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

此时,出现了移动节点的操做,移动节点最关键的地方在于找准要移动的位置。咱们一再强调,更新节点要以新VNode为基准,而后操做旧的oldVNode,使之最后旧的oldVNode与新的VNode相同。那么如今的状况是:newChildren数组里的最后一个子节点与oldChildren数组里的第一个子节点相同,那么咱们就应该在oldChildren数组里把第一个子节点移动到最后一个子节点的位置,以下图:

从图中不难看出,咱们要把oldChildren数组里把第一个子节点移动到数组中全部未处理节点以后。

若是对比以后发现这两个节点仍不是同一个节点,那就继续尝试最后一种状况。

新前与旧后

newChildren数组里全部未处理子节点的第一个子节点和oldChildren数组里全部未处理子节点的最后一个子节点作比对,若是相同,那就直接进入更新节点的操做,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

一样,这种状况的节点移动位置逻辑与“新后与旧前”的逻辑相似,那就是newChildren数组里的第一个子节点与oldChildren数组里的最后一个子节点相同,那么咱们就应该在oldChildren数组里把最后一个子节点移动到第一个子节点的位置,以下图:

从图中不难看出,咱们要把oldChildren数组里把最后一个子节点移动到数组中全部未处理节点以前。

OK,以上就是子节点对比更新优化策略种的4种状况,若是以上4种状况逐个试遍以后要是还没找到相同的节点,那就再经过以前的循环方式查找。

回到源码

// 循环更新子节点
 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {  let oldStartIdx = 0 // oldChildren开始索引  let oldEndIdx = oldCh.length - 1 // oldChildren结束索引  let oldStartVnode = oldCh[0] // oldChildren中全部未处理节点中的第一个  let oldEndVnode = oldCh[oldEndIdx] // oldChildren中全部未处理节点中的最后一个   let newStartIdx = 0 // newChildren开始索引  let newEndIdx = newCh.length - 1 // newChildren结束索引  let newStartVnode = newCh[0] // newChildren中全部未处理节点中的第一个  let newEndVnode = newCh[newEndIdx] // newChildren中全部未处理节点中的最后一个   let oldKeyToIdx, idxInOld, vnodeToMove, refElm   // removeOnly is a special flag used only by <transition-group>  // to ensure removed elements stay in correct relative positions  // during leaving transitions  const canMove = !removeOnly   if (process.env.NODE_ENV !== 'production') {  checkDuplicateKeys(newCh)  }   // 以"新前""新后""旧前""旧后"的方式开始比对节点  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  if (isUndef(oldStartVnode)) {  oldStartVnode = oldCh[++oldStartIdx] // 若是oldStartVnode不存在,则直接跳过,比对下一个  } else if (isUndef(oldEndVnode)) {  oldEndVnode = oldCh[--oldEndIdx]  } else if (sameVnode(oldStartVnode, newStartVnode)) {  // 若是新前与旧前节点相同,就把两个节点进行patch更新  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)  oldStartVnode = oldCh[++oldStartIdx]  newStartVnode = newCh[++newStartIdx]  } else if (sameVnode(oldEndVnode, newEndVnode)) {  // 若是新后与旧后节点相同,就把两个节点进行patch更新  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)  oldEndVnode = oldCh[--oldEndIdx]  newEndVnode = newCh[--newEndIdx]  } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right  // 若是新后与旧前节点相同,先把两个节点进行patch更新,而后把旧前节点移动到oldChilren中全部未处理节点以后  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  oldStartVnode = oldCh[++oldStartIdx]  newEndVnode = newCh[--newEndIdx]  } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left  // 若是新前与旧后节点相同,先把两个节点进行patch更新,而后把旧后节点移动到oldChilren中全部未处理节点以前  patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)  oldEndVnode = oldCh[--oldEndIdx]  newStartVnode = newCh[++newStartIdx]  } else {  // 若是不属于以上四种状况,就进行常规的循环比对patch  if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)  idxInOld = isDef(newStartVnode.key)  ? oldKeyToIdx[newStartVnode.key]  : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)  // 若是在oldChildren里找不到当前循环的newChildren里的子节点  if (isUndef(idxInOld)) { // New element  // 新增节点并插入到合适位置  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  } else {  // 若是在oldChildren里找到了当前循环的newChildren里的子节点  vnodeToMove = oldCh[idxInOld]  // 若是两个节点相同  if (sameVnode(vnodeToMove, newStartVnode)) {  // 调用patchVnode更新节点  patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)  oldCh[idxInOld] = undefined  // canmove表示是否须要移动节点,若是为true表示须要移动,则移动节点,若是为false则不用移动  canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)  } else {  // same key but different element. treat as new element  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)  }  }  newStartVnode = newCh[++newStartIdx]  }  }  if (oldStartIdx > oldEndIdx) {  /**  * 若是oldChildren比newChildren先循环完毕,  * 那么newChildren里面剩余的节点都是须要新增的节点,  * 把[newStartIdx, newEndIdx]之间的全部节点都插入到DOM中  */  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)  } else if (newStartIdx > newEndIdx) {  /**  * 若是newChildren比oldChildren先循环完毕,  * 那么oldChildren里面剩余的节点都是须要删除的节点,  * 把[oldStartIdx, oldEndIdx]之间的全部节点都删除  */  removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)  }  }  复制代码

读源码以前,咱们先有这样一个概念:那就是在咱们前面所说的优化策略中,节点有多是从前面对比,也有多是从后面对比,对比成功就会进行更新处理,也就是说咱们有可能处理第一个,也有可能处理最后一个,那么咱们在循环的时候就不能简单从前日后或从后往前循环,而是要从两边向中间循环。

那么该如何从两边向中间循环呢?请看下图:

首先,咱们先准备4个变量:

  • newStartIdx:newChildren数组里开始位置的下标;

  • newEndIdx:newChildren数组里结束位置的下标;

  • oldStartIdx:oldChildren数组里开始位置的下标;

  • oldEndIdx:oldChildren数组里结束位置的下标;

在循环的时候,每处理一个节点,就将下标向图中箭头所指的方向移动一个位置,开始位置所表示的节点被处理后,就向后移动一个位置;结束位置所表示的节点被处理后,就向前移动一个位置;因为咱们的优化策略都是新旧节点两两更新的,因此一次更新将会移动两个节点。说的再直白一点就是:newStartIdxoldStartIdx只能日后移动(只会加),newEndIdxoldEndIdx只能往前移动(只会减)。

当开始位置大于结束位置时,表示全部节点都已经遍历过了。

OK,有了这个概念后,咱们开始读源码:

1.若是oldStartVnode不存在,则直接跳过,将oldStartIdx加1,比对下一个

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
 if (isUndef(oldStartVnode)) {  oldStartVnode = oldCh[++oldStartIdx]  } } 复制代码

2.若是oldEndVnode不存在,则直接跳过,将oldEndIdx减1,比对前一个

else if (isUndef(oldEndVnode)) {
 oldEndVnode = oldCh[--oldEndIdx] }  复制代码

3.若是新前与旧前节点相同,就把两个节点进行patch更新,同时oldStartIdxnewStartIdx都加1,后移一个位置

patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
 oldStartVnode = oldCh[++oldStartIdx]  newStartVnode = newCh[++newStartIdx] } 复制代码

4.若是新后与旧后节点相同,就把两个节点进行patch更新,同时oldEndIdxnewEndIdx都减1,前移一个位置

else if (sameVnode(oldEndVnode, newEndVnode)) {
 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)  oldEndVnode = oldCh[--oldEndIdx]  newEndVnode = newCh[--newEndIdx] } 复制代码

5.若是新后与旧前节点相同,先把两个节点进行patch更新,而后把旧前节点移动到oldChilren中全部未处理节点以后,最后把oldStartIdx加1,后移一个位置,newEndIdx减1,前移一个位置

else if (sameVnode(oldStartVnode, newEndVnode)) {
 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  oldStartVnode = oldCh[++oldStartIdx]  newEndVnode = newCh[--newEndIdx] }  复制代码

6.若是新前与旧后节点相同,先把两个节点进行patch更新,而后把旧后节点移动到oldChilren中全部未处理节点以前,最后把newStartIdx加1,后移一个位置,oldEndIdx减1,前移一个位置

else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)  oldEndVnode = oldCh[--oldEndIdx]  newStartVnode = newCh[++newStartIdx] } 复制代码

7.若是不属于以上四种状况,就进行常规的循环比对patch 8.若是在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildrennewChildren先循环完毕,那么newChildren里面剩余的节点都是须要新增的节点,把[newStartIdx, newEndIdx]之间的全部节点都插入到DOM中

if (oldStartIdx > oldEndIdx) {
 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) }  复制代码

9.若是在循环中,newStartIdx大于newEndIdx了,那就表示newChildrenoldChildren先循环完毕,那么oldChildren里面剩余的节点都是须要删除的节点,把[oldStartIdx, oldEndIdx]之间的全部节点都删除

else if (newStartIdx > newEndIdx) {
 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } 复制代码

以上就是Vue中的patch过程,即DOM-Diff算法全部内容了,到这里相信你再读这部分源码的时候就有比较清晰的思路了。

本文使用 mdnice 排版

相关文章
相关标签/搜索