Vue源码分析(三)-----更新策略

1,757 阅读7分钟

Vue更新视图

Vue源码分析(二)中大概讲解了Vue中的模板编译过程:将编写的模板字符串转化为虚拟Dom节点。

在之前的解析中,曾经提到过:在vue中我们为每个组件创建一个watcher,利用Object.definePropertyset方法中获取到数据更新之后,调用组件级别的watcher执行update完成视图的更新。因此在组件内部完成视图更新的方式其实并不是直接通过双向数据绑定直接实现的,而是通过虚拟Dom的patch方法完成视图的更新。本文让我们去探索一下Vue中组件是如何实现更新的。

内容回顾

在本文开始之前,我们回顾一下我们在上节课中查看Vue源码中的Vue.prototype.$mount方法,该方法的源码如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 当不存在render时,render为创建一个空节点的函数。
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  // 调用beforeMount生命周期钩子函数
  callHook(vm, 'beforeMount')

  // 传入Watcher的更新视图的函数
  let updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // 为组件创建Watcher,将updateComponent传入第二个参数
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

  // 执行mounted生命周期钩子函数
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

Vue源码分析(二)中我们知道Vue.prototype._render()最后会得到字符串模版对应的虚拟Dom节点vnode,然后在Vue.prototype._update方法中我们会依据新老vnode的比对出来的差异来更新我们的视图,从而在浏览器中显示正确的内容。 本文则去探索Vue.prototype._update方法在源码中的实现。

虚拟Dom

在内容开始之前,首先补充一下之前没有讲述的虚拟Dom的概念。 虚拟Dom其实是以JavaScript对象作为基础的树形结构,用对象的属性来描述节点内容。因此虚拟Dom其实只是一个js对象。我们可以通过这个js对象得到一个真实的dom视图。

之所以引入虚拟dom的概念,原因之一是我们直接操作js对象相比操作真实的dom节点而言,速度更快,效率更高(真实dom修改之后,会引起浏览器的重新渲染,而我们通过虚拟dom,可以通过diff算法以最小的操作去完成dom视图的更新)

其次就是由于本身虚拟Dom就是一个js对象,因此我们不用考虑平台之间差异,编写的代码可以用于各个平台。

尝试编写一个简单的虚拟DomVNode的类。

class VNode {
	constructor(tag, data, children, text, elm){
    	this.tag = tag;
        this.data = data;
        this.children = children;
        this.text = text;
        this.elm = elm;
    }
}

VNode对象的各属性作用:

  • tag:当前节点标签名
  • data:当前节点attrsprops等数据
  • children:当前节点的子节点,是一个数组
  • text:当前节点的文本
  • elm:当前节点对应的真实dom节点

知道了VNode类,尝试创建一些方法产生一些常用的vnode节点。

  • createEmptyVNode 创建空的虚拟dom节点
function createEmptyVNode(){
    const node = new VNode();
    node.text = '';
    return node;
}
  • createTextVNode 创建一个文本虚拟dom节点
function createTextVNode(val){
	return new VNode(undefined, undefined, undefined, String(val));
}
  • 克隆一个节点
function cloneVNode (vnode){
	return new VNode(
    	vnode.tag,
        vnode.data,
        vnode.children,
        vnode.text,
        vnode.elm
    )
}

tips:其实Vnode中从存在的节点类型不止元素节点,文本节点,克隆以及空节点。还存在组件节点,函数式组件,注释节点。我们目前只考虑元素节点,和文本节点。组件节点和元素节点类似,不同的是有两个独有的属性:

  • componentOptions: 包含组件propsData,tag, children等数据
  • componentInstance:组件实例(即Vue实例)。之间以及提及过,在Vue中每个Vue组件都是一个Vue实例。后续讲解过程只考虑标签时H5标签节点的情况。有兴趣可以自行查看Vue源码。

源码分析(二)中,我们知道有如下的函数用于快速生成VNode节点:

名称别名作用
createTextVNode_v创建文本节点
createEmptyVNode_e创建空节点
renderList_l循环创建节点(v-for使用)
createElement_c创建元素节点

其中,createTextVNodecreateEmptyVNode 在上文中已经实现了。因此实现一下renderList以及createElement方法

// content 为Vue实例
function createElement(content, tag, data, children){
    if(!tag){
    	return createEmptyVnode()
    }
    return new VNode(tag, data, children, undefined, undefined)
}

然后是renderList方法

// v-for语句调用的_l函数
function renderList (val, render) {
  let ret, i, l
  if (Array.isArray(val) || typeof val === 'string') {
    ret = new Array(val.length)
    for (i = 0, l = val.length; i < l; i++) {
      ret[i] = render(val[i], i)
    }
  } else if (typeof val === 'number') {
    ret = new Array(val)
    for (i = 0; i < val; i++) {
      ret[i] = render(i + 1, i)
    }
  } 
  return ret
}

在这里之后我们对Vnode,以及一些常见的方法有了了解。有了虚拟Dom的概念,那么虚拟Dom能够帮助我们做什么呢?有了虚拟Dom,我们便可以根据虚拟Dom生成真实的Dom节点(虚拟Dom保存了真实Dom节点所需要的数据)。这样,如果我们改变数据时,我们可以通过比对虚拟Dom来确定视图那些地方需要更新,从而使用最小的操作修改真实Dom节点,完成视图的更新。并且这个操作,我们可以通过算法完成,不需要自己手动操作真实的Dom节点,避免了我们手动进行繁琐的操作,提高自己开发效率的同时也能提高页面的更新效率。

patch

在查看源码之前,我们先对Vue中的更新算法做一个简单的了解。我们知道,Vue中是通过比对虚拟Dom来实现更新视图的,那么Vue中是如何比对虚拟Dom的呢?

用一副大家都看过无数遍的图来表示patch是如何比较两个vnode

patching

当我们获取新老两个根节点对应的虚拟Dom节点时,我们通过同级比较的方法,对节点进行更新。图中,我们是从上到下进行比对,颜色相同节点(圆点)进行比对。

vnode比对的patch方法是通过同级逐渐比较实现的。 在比对vnode时,无非会出现以下三种情况:

  • 新增VNode
  • 删除VNode
  • 修改VNode

patch实现

新增节点

什么时候需要新增节点呢?毫无疑问 当老的Vnode不存在而新的节点存在时,我们需要新增节点。 add

最典型的例子就是进行初始化时,开始老的vnode节点不存在。我们只需要将创建的VNode挂载到vm上,并且将新的VNode对应的Dom节点渲染到视图上。

其次,当老的VNode和新的VNode节点不一样时使用新的Vnode来渲染视图,因此需要将老的VNode对应的Dom删除,然后新增新的VNode对应的Dom。

删除节点

跟新增节点类似,当老的VNode存在但是新的VNode不存在时,我们需要删除老节点VNode对应的Dom。或者当老的VNode和新的VNode节点不是相同的vnode节点时,我们需要将老的VNode对应的Dom删除,将新的VNode对应的Dom添加。 delete

修改节点

在上述删除和新增中,只有新老两个VNode不是相同节点时,我们才会将老的节点删除,添加新的节点。其他情况下,我们将对两个节点进行细致的比对,然后对老的Vnode在视图对应的真实Dom节点进行更新。 update 举个栗子:如果存在两个文本节点,只是文本的内容发生的改变,那么其实最后我们在Dom只是将老节点的文本替换成新节点的文本内容而已。

Vue源码分析(二)中,我们知道在Vue.prototype.$mount方法中,我们最后调用了vm._update来对视图进行更新,那么查看Vue源码中_update函数内部是如何更新视图的

// 仅保留与本文内容相关的核心代码
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  vm._vnode = vnode
  if (!prevVnode) {
    // 首次渲染时oldVnode为真实dom节点,而newVnode为虚拟dom节点对象
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // 更新视图
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}

可以看到Vue源码中的update函数实际上是调用了Vue.prototype.__patch__方法,该方法根据不同平台有不同的实现方法,我们仅看Web平台下的实现方法。在Web平台下该代码位置在src/platforms/web/runtime/patch.js文件中,导出的patch函数是利用createPatchFunction工厂函数创建出来的patch函数。

createPatchFunction中传入的nodeOps对象内部是一些方法。为了使Vue.js实现跨平台的能力,根据不同的平台将平台的api封装在内,在nodeOps对象中对外提供相同的名称的方法。这样对vue中的虚拟dom而言可以实现跨平台的能力。由于Vue.js是在打包时根据打包的参数来分别不同平台的,例如在Vue.js源码中,src/platforms/web/runtime/node-ops.jssrc/platforms/weex/runtime/node-ops.js中导出的nodeOps对象中提供的方法名称会相同,从而达到vue.js跨平台能力。

web平台下nodeOps中的部分方法实现

const nodeOps = {
    setTextContent (text) {
    	node.textContent = text
    },
    createTextNode (text){
    	return document.createTextNode(text)
    },
    parentNode (node) {
        return node.parentNode
    },
    removeChild (node, child) {
        node.removeChild(child)
    },
    nextSibling (node) {
        return node.nextSibling
    },
    insertBefore (parentNode, newNode, referenceNode) {
        parentNode.insertBefore(newNode, referenceNode)
    }
}

createPatchFunction我们还传入了一个modules数组,这个数组中存放了一系列更新元素节点属性的方法(也会区分平台,我们主要讲述web平台)。在src/platforms/web/runtime/modules/中存放了更新属性的相关内容然后通过index.js导出。该文件夹下每个js文件都导出一个对象,其中都包含两个方法:createupdate

这里不过多讲解,选取一个文件class.js来查看是如何更新class属性的。

src/platforms/web/runtimes/modules/class.js

  import {
    genClassForVnode
  } from 'web/util/index'
  
  function updateClass (oldVnode, vnode) {
    const el = vnode.elm
    const data = vnode.data
    const oldData = oldVnode.data
    if (
      !data.staticClass &&
      !data.class && (
        !oldData || (
          !oldData.staticClass &&
          !oldData.class
        )
      )
    ) {
      return
    }
  
    let cls = `${data.staticClass} ${data.class}`
  
    // set the class
    if (cls !== el._prevClass) {
      el.setAttribute('class', cls)
      el._prevClass = cls
    }
  }
  
  export default {
    create: updateClass,
    update: updateClass
  }
  

可以看到该文件导出的对象的createupdate方法都是updateClass方法。在该方法中,首先判断元素节点的class属性是否需要更新。当同时满足如下条件时,class属性不更新

  1. 新的vnode上的data属性没有staticClass属性
  2. 新的vnode上的data属性没有class属性
  3. 老的vnodedata属性不存在;或者存在data但是data上的staticClassclass都不存在。

这样判断是保证新的vnode没有class的内容(无论是动态还是静态)情况下,老的vnode也是完全没有class的相关设置的节点时则无需进行class的更新,提高比较的效率。

当不同时满足上面一系列条件时。我们得到将静态的class和动态的class合并成一个字符串并返回,得到最终的class并设置到dom节点上。

知道了这些后让我们继续深入到src/core/vdom/patch.js查看createPatchFunction方法: 在该方法的开头Vue中实现了如下的代码:

    let i,j
    const cbs = {}
    const { modules,nodeOps } = backend;
    for (i = 0; i < hooks.length; ++i) {
      cbs.update = []
      for (j = 0; j < modules.length; ++j) {
        if (modules[j].update) {
          cbs.update.push(modules[j].update)
        }
      }
    }

这里对vue中的代码进行了简化。这里我们仅考虑更新操作。首先遍历src/platforms/${'对应平台'}/runtime/modules/index.js中导出的内容存储到数组中,数组中的元素都是一个包含update方法的对象。然后遍历该数组将对象的update方法存储到到cbs.update这个数组中,后面遍历调用cbs.update数组中的方法即可更新元素节点的属性。

然后createPatchFunction内部根据nodeOps封装了一系列方法,这些方法会在最后返回的patch方法中被使用调用

  • insert(parent, elm, ref):在parent节点下插入elm这个dom节点。如果指定了ref,则将elm插入到ref前。
function insert (parent, elm, ref){
    if(parent){
    	if(ref){
            if(nodeOps.parentNode(ref) === parent) {
            	nodeOps.insertBefore(parent, elm, ref)
            }
        }
    } else {
    	nodeOps.appendChild(parent, elm) 
    }
}
  • createElm(vnode, parentElm, refElm): 创建dom节点(忽略创建Vue组件的简化版)
function createElm(vnode, parentElm, refElm){
    if(vnode.tag){
    	vnode.elm = nodeOps.createElement(vnode.tag, vnode)
    }else{
    	vnode.elm = nodeOps.createTextNode(vnode.text)
    }
    insert(parentElm, vnode.elm, refElm)
    createChildren(vnode, vnode.children)
}

  • addVNodes(parentElm, refElm, vnodes, startIndex, endIndex) 批量添加dom节点
function addNodes(parentElm, refElm, vnodes, startIndex, endIndex){
    for(;startIndex <= endIndex; ++startIndex){
    	createElm(vnodes[startIndex], parentElm, refElm)
    }
}
  • createChildren(vnode, children): 创建元素节点的孩子节点
function createChildren(vnode, children){
    if(Array.isArray(children)){
        addVNodes(vnode.elm, null, children, 0, children.length - 1)
    }else if(vnode.text){
    	nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(vnode.text))
    }
}
  • removeNode(el): 移除一个节点
function removeNode(el){
    const parent = nodeOps.parentNode(el);
    if(parent){
    	nodeOps.removeChild(parent, el);
    }
}
  • removeNodes(vnodes, startIndex, endIndex ) 批量删除节点
function removeNodes(vnodes, startIndex, endIndex){
    for(;startIndex <= endIndex, ++startIndex){
    	const ch = vnodes[startIndex]
        if(ch){
            removeNode(ch.elm);
        }
    }
}

  • updateChildren(parentElm, oldCh, newCh): 更新某个元素的子节点。更新部分的操作都稍微有些复杂,我们留在后面再讲解。

tips: 这些方法都是修改真实dom的操作,我们在调用这些方法的时候,就在更新浏览器视图了

有了这些辅助函数,我们便可以来查看createPatchFunction返回的patch函数了。

patch更新的操作无非就是树形结构的同级比较,而比较的结果无非上面提到的三种:更新,添加与删除

return function patch (oldVnode, vnode) {
    // 删除节点
    if (!vnode) {
      if (oldVnode) removeVnodes(oldVnode.children, 0, oldVnode.children.length - 1)
      return
    }

    // 新增节点
    if (!oldVnode) {
      createElm(vnode)
    } else {
      const isRealElement = !!(oldVnode.nodeType)
      if (isRealElement && sameVnode(oldVnode, vnode)) {
        // 新老vnode均存在,比对两者
        // diff发生的地方
        patchVnode(oldVnode, vnode)
      } else {
      	// 执行初始化操作
        // 初始化时oldVnodes是一个真实的dom节点,不是空节点,因此转换成空节点
        if (isRealElement) { oldVnode = emptyNodeAt(oldVnode) }

        // 获取vnode对应的真实dom根结点实例
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)

        // 根据新vnode创建新的dom节点
        createElm(vnode, parentElm)

        // 删除老vnode对应的dom节点
        if (parentElm) {
          removeVnodes([oldVnode], 0, 0)
        }
      }
    }
    return vnode.elm
  }

patch方法中,我们传入新老两个vnode。在第一次调用patch方法时,oldVnode传入的是真实dom节点,oldVnode节点是存在的(初始化操作)。依据上述我们讲解的对比操作,patch方法主要进行新增,删除,还有更新节点的操作。

添加和删除逻辑比较简单,代码也十分简洁。当新的vnode存在,老的vnode不存在时直接创建节点。当新的vnode不存在,老的vnode存在时,删除老得vnode对应的dom节点。

当是节点更新操作时,Vue源码中做了一次判断,因为在Vue源码进行初始化时(第一次执行patch方法),Vue会将dom根节点实例当作老节点传入,这时我们需要在更新操作里判断oldvnode是否是真实dom节点,如果是真实dom节点则将根节点进行处理,并将oldVnode赋值为空节点。然后复用两个节点不是同一个节点的逻辑:删除老的vnode,将新的vnode对应的元素节点加入到视图上。

更新节点当不是初始化操作时需要判断两个节点是不是同一个节点。如果不是相同的节点,我们直接使用和初始化一样的逻辑。直接将老vnode的对应的dom节点删除,将新的vnode节点添加就行了。如果是相同的节点,则我们调用patchVnode方法对比两个节点。

在Vue中利用sameVnode方法来判断两个节点是否是相同节点。

function sameVnode (a, b) {
    return  a.key === b.key && 
            a.tag === b.tag &&
            a.isComment === b.isComment &&
            (!!a.data) === (!!b.data) &&
            sameInputType(a, b) // input类型独有的验证
}
const isTextInputType = {
    text: true,
    number: true,
    password: true,
    search: true,
    email: true,
    tel: true,
    url: true
}
function sameInputType (a, b) {
    if (a.tag !== 'input') return true
    let i
    const typeA = (i = a.data) && (i = i.attrs) && i.type
    const typeB = (i = b.data) && (i = i.attrs) && i.type
    return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}

判断两个vnode是否是相同节点需要同时满足以下的要求:

  • 新老vnodekey属性相同(都是undefined或者是相同的值)

  • 新老vnodetag值相同即标签相同

  • 新老vnode都不是注释节点(isComment表示该节点是否是注释节点)

  • 新老vnodedata都存在或者都不存在

  • 如果新老节点是input类型,则input标签的类型也需要相同,通过调用sameInputType判断(走到这一步时新老vnodetag值一定相同)。

    • 两个vnode标签的tag不是input类型时直接返回true

    • 当两个节点的taginput类型,并且input类型的type的值相同 或者 新老vnodeinput类型都是isTextInputType上的类型

然后继续查看patchVnode方法是如何对比两个相同的vnode节点,实现更新视图的操作。

function patchVnode (oldVnode, vnode) {
  	// 新老节点是同一个对象直接返回
    if (oldVnode === vnode) {
      return
    }

	// 得到目前dom实例的元素节点(真实dom)
    const elm = vnode.elm = oldVnode.elm

    // 比对时:
    // 1.自身属性更新
    // 2.节点更新

    // 获取新老孩子节点
    const oldCh = oldVnode.children
    const ch = vnode.children

    // 属性更新
    if (data) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (i = i.update) i(oldVnode, vnode)
    }

    // 节点更新
    if (!vnode.text) { // 新节点没有文本
      if (oldCh && ch) {
        // 双方都有孩子节点,比对双方孩子变化
        if (oldCh !== ch) updateChildren(elm, oldCh, ch)
      } else if (ch) {
        if (oldVnode.text) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1)
      } else if (oldCh) {
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (oldVnode.text) {
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
  }

一个节点的更新分成两部分:一个是自身节点属性的更新一个是自身节点的更新(包括更新自身节点类型或者更新孩子节点)

首先查看更新自身属性,自身属性的更新就是调用在最开始时,我们加入到cbs.update数组的函数。依次调用数组中的函数,即可完成class,attrs,event等属性的更新。

然后我们去更新节点。在该方法着重看节点更新。 在节点更新比对之前,我们首先得到elm, elm是该节点对应的真实dom。

节点的更新我们分为以下几步:

  1. 当新vnode是元素节点,而老vnode也是元素节点,两者都有孩子时,调用updateChildren来更新孩子节点。
  2. 当新的vnode是元素节点并且有孩子节点,老的vnode没有孩子节点。如果老的vnode是文本节点就删除真实根结点elm的文本内容,然后将新vnode对应孩子的真实dom添加到真实根结点elm上。
  3. 当新的vnode没有孩子节点也不是文本节点时(空节点),老的vnode有孩子节点。在elm上删除所有的孩子节点。
  4. 当新的vnode是空节点时,老的vnode是文本节点。将elm的文本清空
  5. vnode是文本节点。如果老的vnode也是文本节点,并且文本内容与新vnode文本相同则不更新;否则将elm的文本设置为新本文节点的内容。

在这五条中,除了新老节点都是元素节点,并且都存在孩子节点的情况处理起来比较复杂以外,其他的情况处理其他都十分的简单。代码也十分简洁。 剩下的就是新老节点都是元素节点,并且都存在孩子节点的情况。然后查看updateChildren的实现。

updateChildren

在讲述该方法之前,还是先讲解一下当两个节点都存在孩子节点时,我们通过什么方法来更新。

在刚刚我们实现了一个patchVnode方法来比较两个节点,并且更新视图。如果我们现在要比较两个节点的孩子节点数组,那我们该怎么办呢。 这时候我们可以想到一个最简单粗暴的方法,我们直接使用双重循环,找到新老vnode孩子节点数组中相同的节点,然后再次执行patchVnode方法。这种方法比较粗暴,时间复杂度也比较高。在这种情况下,Vue根据我们实际操作Dom的逻辑,优化了遍历孩子节点比较并更新的逻辑。

假设我们现在有新老vnode的两个孩子节点数组。 我们现在有四个下标变量,指向新vnode孩子节点数组中的开头和末尾下标,老vnode孩子节点数组的开头和末尾下标。 我们将其命名为:

  • newStartIdx:新vnode的开头
  • oldStartIdx:老vnode的开头
  • newEndIdx:新vnode的结尾
  • oldEndIdx:老vnode的结尾

在直接粗暴的查找相同的节点前,可以根据实际情况下对Dom操作进行如下的假设。

  1. 新前与旧前 如果newStartIdxoldStartIdx是对应新老vnode是同一个节点。我们将newStartIdxoldStartIdx都加一。然后将两者vnode进行patchVnode操作。

  2. 新后与旧后 如果newEndIdxoldEndIdx是对应新老vnode是同一个节点。我们将newEndIdxoldEndIdx都减一。然后将两者vnode进行patchVnode操作

  3. 新前与旧后 如果newStartIdxoldEndIdx是对应新老vnode是同一个节点。这个时候节点的位置发生了变化我们需要将oldEndIdx对应的dom节点插入到oldEndIdx对应dom节点的前面;将newStartIdx加一;oldEndIdx减一。然后将两者vnode进行patchVnode操作

  4. 新后与旧前 如果newEndIdxoldStartIdx是对应新老vnode是同一个节点。这个时候节点的位置发生了变化我们需要将oldEndIdx对应的dom节点插入到oldEndIdx对应dom节点的前面;将newEndIdx减一;将oldStarIdx加一。然后将两者vnode进行patchVnode操作

  5. 前四种情况都不成立 当这四种情况下,下标所对应的节点都不是相同的节点时,我们拿到newStartIdx对应的vnode,去老的vnode孩子数组中遍历查找相应的节点。然后进行相应的操作。

有了这个大致的思路,我们接下来来查看代码的具体实现。

function updateChildren (parentElm, oldCh, newCh) {
    // 获取4个游标及对应节点
    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

    // 结束条件游标不要相遇
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      // 前两个是调整情况,当下标对应的节点不存在时,将节点向后移动直至下标对应的节点存在为止。
      if (!oldStartVnode) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (!oldEndVnode) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 新前与旧前下标对应的vnode是相同节点
        patchVnode(oldStartVnode, newStartVnode)
        // 游标双双向后移动
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 新老与旧老下标对应的vnode是相同节点
        patchVnode(oldEndVnode, newEndVnode)
        // 游标双双前移
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { 
        // 新后与旧前下标对应的节点相同
        patchVnode(oldStartVnode, newEndVnode)
        // 将老节点中vnode对应的dom移动到 oldEndIdx对应的节点前面
        nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 游标调整
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { 
        // 新前与旧后下标对应的节点相同
        patchVnode(oldEndVnode, newStartVnode)
        nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 以上四个下标没有找到相同节点的情况下
        // 从新数组中拿出排头节点,去老数组中查找
        if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = newStartVnode.key
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
          // 索引不存在
        if (!idxInOld) { // New element
          createElm(newStartVnode, parentElm, oldStartVnode.elm)
        } else {
          // 索引存在
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode)
            oldCh[idxInOld] = undefined
            nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, parentElm, oldStartVnode.elm)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    } // 循环结束。新vnode遍历完成或者老vnode遍历完成

    // 后续处理: 
    // 如果老节点遍历完,那么就将剩下的新vnode对应的真实dom全部添加到父节点元素中。
    // 如果新节点遍历完,那么就将剩下的老vnode对应的真实dom全部删除
    if (oldStartIdx > oldEndIdx) {
      refElm = !(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx)
    } else if (newStartIdx > newEndIdx) {
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

这段代码的逻辑较为复杂且是更新最核心的代码。我们慢慢来分析代码的实现。

首先在代码中,定义了循环的条件:

oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx

首先正如我们讲述的大致思路,该方法定义了四个下标变量指向新老孩子节点数组的头尾。然后我们定义四个变量保存当前四个下标变量。

我们比较两个节点数组的思路无非就是在新节点孩子数组中,依次选取一个节点。然后在老节点孩子数组中查找与该节点相同的节点,然后进行更新节点的操作。

当我们将新数组遍历完成或者老数组遍历完成时我们的遍历其实就可以结束了。因为当新节点的孩子数组遍历完成,而老数组中的节点还剩下没有被选取的节点一定是被删除的节点,那么我们直接将剩下的vnode对应的dom删除就好了;而老节点遍历完成,而新节点孩子数组还没选取完时,那么我们可以肯定新节点剩下都是需要新增的,因此我们直接将这些vnode对应的dom加入到vnode对应的dom上就好了。

继续查看循环体中的内容,首先是前面两个判断

if (!oldStartVnode) {
  oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (!oldEndVnode) {
  oldEndVnode = oldCh[--oldEndIdx]
}

最开始的两个判断是排除我们下标相应的节点不存在或者已经移动了该节点去别的地方。这时候我们需要更新下标。

然后就是我们提到的判断新老开始下标对应的vnode是否是同一个vnode

else if (sameVnode(oldStartVnode, newStartVnode)) {
// 新前与旧前下标对应的vnode是相同节点
patchVnode(oldStartVnode, newStartVnode)
// 游标双双向后移动
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}

如果两个节点是同一个节点,那么就通过patchVnode再去比对新老节点,完成更新操作。然后我们开始的下标往后移动,保证开始下标到结束下标之间的内容都是没有更新的vnode新前与旧前

然后同理,我们去判断新老的结束下标对应的vnode是否是同一个vnode

else if (sameVnode(oldEndVnode, newEndVnode)) {
  // 新老与旧老下标对应的vnode是相同节点
  patchVnode(oldEndVnode, newEndVnode)
  // 游标双双前移
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
} 

然后逻辑上基本和上面的逻辑相同,不同的就是我们将结束的下标减一而不是加一 新后与旧后

然后就是新节点的末尾下标和老节点的开始下标对比

else if (sameVnode(oldStartVnode, newEndVnode)) { 
  // 新后与旧前下标对应的节点相同
  patchVnode(oldStartVnode, newEndVnode)
  // 将老节点中vnode对应的dom移动到 oldEndIdx对应的节点前面
  nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  // 游标调整
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

我们发现,这里貌似跟之前的两次判断不一样。首先这里也调用patchVnode更新了节点。但是,随后 我们将老节点开始下标对应的dom从原先的位置移动到了老节点结束下标的后面。因为节点的更新除了自身的更新还存在dom上节点位置的更新,所以这里是上面两个操作不一样的地方。然后就是我们之所以移动该节点到老节点结束下标的后面,是因为该下标前面的都是尚未更新的节点,后面都是更新完毕的节点,该节点已经更新完毕,因此移动到后面。 新后旧前

同理,在判断新节点的开始下标和老节点的结束下标对应节点是相同节点后,也要在比对节点之后进行节点的移动操作:

else if (sameVnode(oldEndVnode, newStartVnode)) { 
  // 新前与旧后下标对应的节点相同
  patchVnode(oldEndVnode, newStartVnode)
  nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

新前旧后

当上述四个判断都不满足时,那么我们就要直接进行遍历,查看找相同的节点了:

 else {
  // 以上四个下标没有找到相同节点的情况下
  // 从新数组中拿出排头节点,去老数组中查找
  if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
  idxInOld = newStartVnode.key
    ? oldKeyToIdx[newStartVnode.key]
    : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    // 索引不存在
  if (!idxInOld) { // New element
    createElm(newStartVnode, parentElm)
  } else {
    // 索引存在
    vnodeToMove = oldCh[idxInOld]
    if (sameVnode(vnodeToMove, newStartVnode)) {
      patchVnode(vnodeToMove, newStartVnode)
      oldCh[idxInOld] = undefined
      nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
    } else {
      // same key but different element. treat as new element
      createElm(newStartVnode, parentElm)
    }
  }
  newStartVnode = newCh[++newStartIdx]
}

function createKeyToOldIdx(){
    let i, key
    const map = {}
    for(i = beginIdx; i<= endIdx; ++i){
    	key = children[i].key
        if(key) map[key] = i
    }
    return map
}
function findIdxInOld (node, oldCh, start, end) {
  for (let i = start; i < end; i++) {
    const c = oldCh[i]
    if (c && sameVnode(node, c)) return i
  }
}

首先我们将剩下的老节点孩子数组中尚未更新的节点(oldStartIdxoldEndIdx之间的元素)的下标和key建立关系。 通过createKeyToOldIdx遍历尚未更新的节点,如果某个vnode存在key值,利用对象当作一个map,保存key =》 i的映射关系。然后将返回的map(就是一个对象)返回赋值给oldKeyToIdx

然后我们取出newStartIdx对应的vnodenewStartVnode。如果newStartVnode存在key值,则通过oldKeyToIdx去查找在老节点孩子数组中该节点的下标。如果不存在key值,那么调用findIdxInOld直接拿该节点和老节点孩子数组的所有尚未更新的元素一一比对。

通过上述查找下标的操作,然后我们判断idxInOld是否为空。如果为空说明该节点是新建的孩子节点,那么我们直接创建该节点,然后将newStartIdx向后移动,选择下一个新的节点;如果idxInOld存在,那么我们取出老节点数组中对应的vnode,调用patchVnode对这两个节点进行比对,比对完成后将老节点数组中该下标对应的值置空,防止该节点再次被使用,最后我们将该节点的位置调整到正确的位置。

这就是循环体里面的所有内容了,然后当我们跳出循环时。最后剩下的节点就比较好处理了:

// 后续处理: 
// 如果老节点遍历完,那么就将剩下的新vnode对应的真实dom全部添加到父节点元素中。
// 如果新节点遍历完,那么就将剩下的老vnode对应的真实dom全部删除
if (oldStartIdx > oldEndIdx) {
  refElm = !(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
  removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}

当新节点数组还有剩下的节点,那么我们统一添加到dom上,如果老节点还有剩下的节点,那么我们统一删除。

到此为止,更新节点的操作我们便完成了。

tips: 在将新老vnode进行比对时,我们的目的是比较出差异,然后以最小的操作去更新dom相比于直接去来回的修改Dom,提高了视图的渲染效率和性能。对比完成后直接将新的vnode挂在到原来的vue上,下次更新时,就会变成oldvnode。如此,下次数据更新时,我们还是同样的操作,以保证视图总是可以正确的被更新。

更新思维导图

异步,批量更新

在Vue中当数据更新时,我们去触发Object.definePropertyset方法,从而将在Dep中收集到该数据对应的组件的watcher,然后执行watcher上面的update方法,在内部采用虚拟dom完成视图的更新。这一整个流程的解析基本完成了。

然后我们来考虑另外一个事情,那就是在Vue中,当我们数据发生改变时,视图的更新是立即执行的吗? 举个栗子:

<template>
  <div>
    <div>{{ text }}</div>
    <div @click="handleClick">click</div>
  </div>
</template>
export default {
    data () {
        return {
            text: 'test'
        };
    },
    methods: {
        handleClick () {
            for(let i = 0; i < 1000; i++) {
                this.number += 't';
            }
        }
    }
}

按照我们之前的逻辑,我们这里将number的数据改变了1000次,那么我们回去触发1000次的set操作,从而执行1000次watcherupdate方法,最后我们会修改1000次dom节点。很明显,这是十分不合理的操作,如此低效的更新效率肯定是不可能在项目中使用的。 因此在Vue.js中,选择了另外一种的更新方法: 批量,异步更新策略。

在Vue中,如果我们修改了数据,触发了相应的set函数,则会将这个数据对应组件的Watcher实例加入队列queen中。

tips: queen可以看作是一个添加和删除元素只允许使用unshiftshift方法的数组。(先进先出)

然后在下一个tick时机时,将queen数组中的watcher清空,并执行watcher中的更新视图的方法。 当还没有到达下一个tick时机时,当有用一个数据发生多次变化时,我们并不会重复添加相同的watcherqueen中。这样就保证了,我们在下一个tick时机到来时我们只需要更新视图一次,并且该视图一定是该数据最新值对应的视图。

那么tick如何实现相信大家都能猜到。最好的方法就是实现一个异步的操作,可以是一个promise,可以是一个setTimout,可以是一个setImmediate。Vue会根据浏览器的支持情况选择不同的操作。

如此,我们可以查看Vue源码中的具体实现。回顾之前将的内容,我们可以知道更新的操作应该是在Watcher实例的update方法中实现更新的,因此我们去src/core/observer/watcher查看其实现:

  update () {
    // computed属性的lazy为true
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
    // 同步更新,一般很少用到
      this.run()
    } else {
    // 一般情况下的该函数的调用
      queueWatcher(this)
    }
  }

update方法中大多数情况下更新操作会执行queueWathcher方法,src/core/observer/scheduler.js继续查看其实现:

let has = {};
let queue = [];
let flushing = false;
export function queueWatcher (watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      // 核心代码
      nextTick(flushSchedulerQueue)
    }
  }
}

在该函数中首获取的wathcerid,我们判断该id是否保存到queue中(通过has这个对象),如果没有保存到queue中,那么将该watcher添加到queue队列中。在最后如果目前不是waiting状态,那么我们可以将waiting设置为true,尝试执行nextTick函数,实现异步更新

nextTick就是我们平时使用的$nextTick方法,查看其方法:

let callback = [];
export function nextTick (cb, ctx) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 如果当前没有等待,尝试异步执行任务
  if (!pending) {
    pending = true
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

nextTick函数中,我们尝试将传入的cb添加到callbacks数组中等待将来的执行。然后我们判断当前是否是等待状态(!pending)。如果目目前没有等待,则可以尝试异步执行任务timerFunc()

timerFunc其实就是一个异步操作。在Vue首先会判断浏览器是否会支持promise,如果支持promise则首先考虑;如果不支持promise则会考虑MutationObserver;如果没有考虑setImmediate,最后考虑使用setTimeout。在该异步方法中尝试将callbacks数组遍历,执行其中的函数。

在执行queueWatcher时,调用nextTick时传入了flushSchedulerQueue这个参数我们再来查看flushSchedulerQueue函数。

function flushSchedulerQueue () {
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    id = watcher.id
    has[id] = null
    watcher.run()
  }
  has = {}
  waiting = flushing = false
}

在该函数中,我们将flushing设置为true,表明现在正在执行更新操作,然后将queue数组中的watcher按照id排序,然后对queen数中的watcher执行run函数。

在这里,我们可以看出,watcher中真正更新的操作,变成了run函数。对于组件级别的watcher来说run函数中会执行我们前面所描述的vm._update函数,当执行该函数时,我们便可以完成视图的更新。

完成视图更新后将之前收集到的watcher清空,并且在将waitingflushing的状态修改回来。到此为止,批量异步更新的操作我们就完成了。在回顾上面的栗子,可以知道执行$nextTick时会将我们编写的回调放置到callbacks上。

因此,总体思路如下: 批量异步更新

在这里我们可以尝试一个小栗子来展示Vue的异步更新:

<!DOCTYPE html>
<html>

<head>
    <title>Vue源码剖析</title>
    <script src="../../dist/vue.js"></script>
</head>

<body>
    <div id="demo">
        <h1>异步更新</h1>
        <p id="p1">{{foo}}</p>
    </div>
    <script>
        const app = new Vue({
            el: '#demo',
            data: { foo: 'ready~~' },
            mounted() {
                new Promise((resolved)=>{
                    resolved(1)
                }).then(()=>{
                    console.log("promise: 1",'p1.innerHTML:' + p1.innerHTML)
                })
                this.$nextTick(()=>{
                    console.log("nextTick: 1",'p1.innerHTML:' + p1.innerHTML)
                })
                this.foo = Math.random()
                console.log('1:' + this.foo);
                this.foo = Math.random()
                console.log('2:' + this.foo);
                this.$nextTick(()=>{
                    console.log("nextTick: 2",'p1.innerHTML:' + p1.innerHTML)
                })
                this.foo = Math.random()
                console.log('3:' + this.foo);
                console.log('p1.innerHTML:' + p1.innerHTML)
                new Promise((resolved)=>{
                    resolved(2)
                }).then(()=>{
                    console.log("promise: 2",'p1.innerHTML:' + p1.innerHTML)
                })
                this.$nextTick(()=>{
                    console.log("nextTick: 3",'p1.innerHTML:' + p1.innerHTML)
                })
            }
        });
    </script>
</body>

</html>

输出结果: 输出结果

tips: 浏览器不支持promise的情况下输出的顺序可能不相同。

mount代码从上往下执行时。依次触发了如下函数promise,nextTick,Object.defineProperty中的set,Object.defineProperty中的setnextTickObject.defineProperty中的setpromise,nextTick

nextTick方法中传入的函数会加入到callback数组中。然后尝试启动异步操作,在异步操作时会遍历callback数组的方法并执行。

set方法也是利用nextTickwatcherrun方法通过flushSchedulerQueue加入到callback数组中。且watcher不会重复添加,这里修改的是同一个组件下的数据,三次更新数据值使用的是同一个watcher,因此只会执行一次视图更新。watcher视图更新的函数在foo第一次改变时就已经加入callback数组中。

通过上述分析,我们知道,callback中存放的函数依次是:nextTick1flushSchedulerQueue(内部存在watcher.run(),可以更新浏览器视图),nextTick2nextTick3

因此,nextTick和数据更新时触发更新视图的函数都在callback数组中,共用一个异步操作完成。最后微任务队列只存在三个异步任务:promsie1中的then函数,遍历callbacks数组并执行的函数,promise2then函数

因此,回过头来查看打印顺序。首先执行的是同步的函数1:, 2:, 3: ,然后视图更新的函数在callback数组中,是异步的。因此直接同步打印出来的p1.innerHTML值没有变化,是ready~~

然后执行微任务队列的异步操作: 因此首先打印promise1,更新操作在callback中的flushSchedulerQueue中。此时p1.innerHTML还未更新,因此promise1打印的p1.innerHTML值为ready~~。然后我们我们执行callback数组中存储的函数。callback数组中现在存在四个函数,第一个,就是我们在开头加入的nextTick: 1,由于没有执行视图的更新, 因此这里的p1.innerHTML也是ready~~;然后我们执行flushSchedulerQueue,浏览器视图更新;接着执行nextTick2,此时视图更新完成,p1.innerHTML值为最新值:0.5621077852310856。然后执行nextTick2,同理,p1.innerHTML值为最新值:0.5621077852310856。 最后我们执行promise2flushSchedulerQueue已经执行过了,这里的p1.innerHTML值为最新值:0.5621077852310856

到这里我们异步批量更新的小demo就讲完了~。

总结

本文相关:

  1. Vue源码分析(一)-----双向数据绑定
  2. Vue源码分析(二)-----编译

在本文中主要讲述了如何通过比对虚拟dom,从而完成视图更新的操作。至此Vue源码分析(一)Vue源码分析(一)。我们大致了解了vue是如何在data发生变化是更新视图的。

这个月由于项目比较紧急加上自己国庆开开心心的玩了一个星期,导致自己的博客没有在16号写出来😭。

下个月打算写两篇博客算是给自己的一点小小的惩罚把。 下个月就是Vue源码分析的最后一篇了:Vue源码分析(四)-----全局分析Vue流程 以及我打算编写的另外一篇博客:重学JS(一)-----js基础篇

然后路过的小伙伴们如果觉得文章写的还不错的话,记得点赞 + 关注哟~。本人每个人会写一篇好文(臭不要脸的我😯)。求求大家的点赞和关注~

最后:

Down to Gehenna, or up to the Throne, he travels the fastest who travels alone.


无论是坠入炼狱还是登高王权,独自旅行往往走得更快。

----《1917》