Skip to content

Vue源码详解之v-for 与 Vuex #7

Open
@Ma63d

Description

@Ma63d

我在之前的主线文章中已经多次介绍了,大数据量的重复渲染生成是考量一个前端UI框架性能的主要场景。也大致介绍了一些Vue为优化这个场景下性能所使用的手段。现在我们来完整的看一看这个Vue优化最多、使用缓存最多的指令。

主线文章中说过,对于同一段模板,查找模板中指令的compile过程是不变的,因此只用执行一次,解析出整个模板中的所有指令描述符。这些指令描述符被闭包在linker中。在你给linker函数传递一段通过cloneNode复制出的模板DOM实例,并传入这段模板需要绑定的数据(scope或者vm),那么linker便会将对应的指令描述符生成真正的指令,绑定在你传进来的DOM实例上,同时,每个指令都会生成一个watcher,而watcher则会订阅到你传入的数据上。至此,我们看到了一个完整的响应式的DOM的构建得以完成。而为什么编译阶段只是编译生成指令描述符,而不是建立指令实例也得以解释:每个指令实例是要绑定到具体的DOM上的,而具体的DOM在linker的执行阶段才得到的,因此,compile只是先生成指令描述符,在linker阶段得到DOM之后才为DOM生成指令,指令又建立watcher,watcher又绑定数据。

第一阶段:创建

我们开始说v-for,之前已经多次强调,v-for是一个terminal directive。其会接管其子元素的DOM的编译过程。在v-for的bind和update方法中,真正为数据中的每个元素创建响应式DOM。

比如这么一段模板:template:`<li v-for="element in array">{{element}}</li>

那么v-for就要负责为array中的每个element创建响应式的li元素。同时,每当array中的element有变化时,就需要创建/删除新的响应式li元素。因此,上述过程中,必然要反复执行linker。对此,Vue抽象出FragmentFactory和Fragment的两个类(Fragment不是我们常用的document fragment)。

一个v-for指令有一个FragmentFactory实例,在bind阶段创建,FragmentFactory创建过程中会为v-for中的元素(也就是ul中的li)执行compile,生成linker,存放在FragmentFactory实例的linker属性上。

而在v-for指令的update阶段会为数组的每个元素创建scope,scope为继承自当前vm的对象。并在这个对象上存放数组元素的具体内容。

然后调用FragmentFactory实例创建Fragment:

FragmentFactory.prototype.create = function (host, scope, parentFrag) {
  var frag = cloneNode(this.template)
  return new Fragment(this.linker, this.vm, frag, host, scope, parentFrag)
}

可以看到,就是先复制一份模板,然后将linker和scope,传入,Fragment会执行以scop为参数执行linker,并且会在this上记录对应的DOM、scope等内容。

因此,大体执行过程和相关类如下图:

上述过程是v-for指令的初始化阶段,现在一堆绑定到具体数组元素的响应式DOM已经构建完成,v-for的使命已经完成一半,另一半则是在数组变动时,使用diff对Fragment进行操作,删除和新建响应式DOM。

我们先来结合具体代码看看这个初始化的过程。

bind () {
    // support "item in/of items" syntax
    var inMatch = this.expression.match(/(.*) (?:in|of) (.*)/)
    if (inMatch) {
      var itMatch = inMatch[1].match(/\((.*),(.*)\)/)
      if (itMatch) {
        // v-for="{k,v} in array"的形式,iterator就是'k',别名为v
        this.iterator = itMatch[1].trim()
        this.alias = itMatch[2].trim()
      } else {
        // v-for="ele in array"的形式,别名为ele
        this.alias = inMatch[1].trim()
      }
      this.expression = inMatch[2]
    }

    if (!this.alias) {
      process.env.NODE_ENV !== 'production' && warn(
        'Invalid v-for expression "' + this.descriptor.raw + '": ' +
        'alias is required.',
        this.vm
      )
      return
    }

    // uid as a cache identifier
    // 这个id是每个v-for指令实例的id
    this.id = '__v-for__' + (++uid)

    // check if this is an option list,
    // so that we know if we need to update the <select>'s
    // v-model when the option list has changed.
    // because v-model has a lower priority than v-for,
    // the v-model is not bound here yet, so we have to
    // retrive it in the actual updateModel() function.
    var tag = this.el.tagName
    this.isOption =
      (tag === 'OPTION' || tag === 'OPTGROUP') &&
      this.el.parentNode.tagName === 'SELECT'

    // setup anchor nodes
    // 生成anchor记录v-for内容的起始和结束,因为v-for会为每个数据创建DOM,因此需要标记这些DOM的边界
    this.start = createAnchor('v-for-start')
    this.end = createAnchor('v-for-end')
    replace(this.el, this.end)
    before(this.start, this.end)

    // cache
    this.cache = Object.create(null)

    // fragment factory
    this.factory = new FragmentFactory(this.vm, this.el)
  },

bind很简单,解析了一下v-for表达式,并生成相关anchor,最后执行的new FragmentFactory(this.vm, this.el)是关键。

export default function FragmentFactory (vm, el) {
  this.vm = vm
  var template
  var isString = typeof el === 'string'
  if (isString || isTemplate(el) && !el.hasAttribute('v-if')) {
    template = parseTemplate(el, true)
  } else {
    template = document.createDocumentFragment()
    template.appendChild(el)
  }
  this.template = template
  linker = compile(template, vm.$options, true)
  // linker存储在了FragmentFactory实例上,因此每次让FragmentFactory产出Fragment的过程,
  // 就是传入复制的DOM和scope来执行linker的过程
  this.linker = linker
}

这里,我们看到了前文所说的v-for执行compile生成linker。

同时,在主线文章中我们说过指令的创建阶段执行完bind后,会以具体表达式的值执行指令的update,v-for的update主要过程就是执行diff(data),对于初始化阶段,diff的工作就是为遍历每个数据,将数据传入v-for的create方法中,生成scope,并使用FragmentFactory创建出Fragment,从而将数据转化为包含了数据、DOM、DOM移动方法的Fragment.
v-for的create方法:

create (value, alias, index, key) {
    var host = this._host
    // create iteration scope
    // 因为存在多重v-for嵌套的情况,所以有限继承v-for指令的this._scope
    var parentScope = this._scope || this.vm
    // scope继承自上级scope或vm
    var scope = Object.create(parentScope)
    // make sure point $parent to parent scope
    scope.$parent = parentScope
    // for two-way binding on alias
    scope.$forContext = this
    // define scope properties
    // important: define the scope alias without forced conversion
    // so that frozen data structures remain non-reactive.
    // 比如v-for="element in arr"
    // 那么就要实现scope['element'] = arr中具体的元素
    // 但是只需要设置element属性响应式的,并不用去把`arr中具体的元素`改造成响应式的
    // 因为最开始Vue启动时,就已经把数据设置为响应式的,此处不用多次一举
    // 此外有的数据可能被设置为frozen的,因此我们依然要保留其为frozen,所以要在此处withoutConversion
    withoutConversion(() => {
      defineReactive(scope, alias, value)
    })
    defineReactive(scope, '$index', index)
    if (key) {
      defineReactive(scope, '$key', key)
    } else if (scope.$key) {
      // avoid accidental fallback
      def(scope, '$key', null)
    }
    if (this.iterator) {
      defineReactive(scope, this.iterator, key !== null ? key : index)
    }
    // 创造fragment,这里执行了linker,生成了一个响应式的DOM
    // 完成了指令描述符到真正指令的生成,并为指令完成watcher的创建,watcher也监听到了scope对应属性上
    var frag = this.factory.create(host, scope, this._frag)
    frag.forId = this.id
    // 缓存Frag
    this.cacheFrag(value, frag, index, key)
    return frag
  },

上述create方法完成了Fragment的真正创建,并将Fragment存进了缓存当中。FragmentFactory的create方法Fragment的构造函数在此不再赘述,想详细了解的可以查看注释版源码。

在完成Fragment的创建之后,使用Fragment的插入方法,将Fragment的DOM插入方法即完成了DOM真正插入页面的过程。v-for指令现在就按照我们的预定设计完整的将内容呈现了出来。

数据更新时的标记清除阶段

其实Vue并没有提出标记清除的概念,只不过是我觉得和GC的相关内容比较相似,自己标题党一波。

当数据变动,比如array由['a','b','c']变成了['c','b','d'],Vue要做的不是立马无脑的删除原来的Fragment和DOM然后重建他们,这样做意味着大量指令、watcher和scope的删除、依赖的退订以及DOM的移除和再次创建他们,性能上的开销是不能接受的。

因此Vue先尽最大可能的去复用已有的Fragment,这里就是track-by和Fragment缓存共同起作用的地方了。Fragment缓存其实就是一个Map,之前创建Fragment过程中,就按照track-by的具体值存入到了Map里,如果现在一个数据要看它有没有可以复用的Fragment,那也就依然拿着自己track-by的对应值去Map里寻找即可。

对于没写track-by的情况,在数据是字符串、数字等原始值情况下,使用数据自身作为Map的key。因此['a','b','c']'a'对应的Fragment就存在Map['a']中。对于其他复杂情况,大家自行查看注释版源码即可。

我们假设v-for的数组原先为['a','b','c'],然后我们修改数组为['b','a','d'],监听着数组__ob__.dep的v-for的watcher收到通知,然后在指令的update阶段拿到新的数组,执行diff。

首先缓存原来的frags数组var oldFrags = this.frags,新建frags数组var frags = this.frags = new Array(data.length),之后就开始遍历data,每个data的元素先从fragCache里找当前元素对应的Fragment,没有找到的话就新建Fragment,过程同上文所述。找到的话,就复用,设置frag.reused = true。最后把这个被复用的或者新创建的Fragment放入frags[i] = frag中的对应下标中,于是,data中的元素顺序同frags中Fragment的顺序,保持了一致。

现在我们完成了新的frags的创建,但是原先的oldFrags并不能坐视不管,他们的DOM还在网页里,而且也还绑定着对应的scope,指令和watcher都还在正常运行,因此需要对他们执行scope、指令、watcher、DOM的销毁操作。

// Second pass, go through the old fragments and
// destroy those who are not reused (and remove them
// from cache)
var removalIndex = 0
var totalRemoved = oldFrags.length - frags.length
// when removing a large number of fragments, watcher removal
// turns out to be a perf bottleneck, so we batch the watcher
// removals into a single filter call!
// 这里很关键,如尤雨溪所述,如果在这一步就找出不需要的watcher并在他的teardown里把他从vm._watchers中移除的话,
// 那么每找一次就是O(N),N个oldFrags就是O(N方),即使只有一个frag没reuse也是O(N),而后述的方法始终O(N)
// 因此这里的批处理就是先this.vm._vForRemoving为true,
// 在watcher的teardown方法中检测到this.vm._vForRemoving为true后只是做watcher上相关属性的删除
// 和watcher.active改为false
// 在oldFrags遍历完成后,_watchers.filter(w => w.active)找出没有被teardown的watcher,
// 并将这个数组赋值到this.vm._watchers,
// 而原先的包含了所有watcher的数组则不再被引用,也就会被JS的垃圾收集机制给收集掉,
// 那些被teardown的watcher也就因此被gc给干掉了.
this.vm._vForRemoving = true
for (i = 0, l = oldFrags.length; i < l; i++) {
	frag = oldFrags[i]
	if (!frag.reused) {
		this.deleteCachedFrag(frag)
		// 每当有未复用的fragment,removalIndex加一
		this.remove(frag, removalIndex++, totalRemoved, inDocument)
	}
}
this.vm._vForRemoving = false
if (removalIndex) {
	// 找出没有被teardown的watcher
	this.vm._watchers = this.vm._watchers.filter(w => w.active)
}

上述过程在中间的注释里解释得很清楚了,删除缓存,执行this.remove(),最终执行fragment.beforeRemove()和fragment.remove(),前者会对watcher执行teardown,后者会执行unlink,也就把link阶段的指令实例都销毁,remove最终移除每个Fragment的DOM。

但是因为watcher是存在于vm.watchers数组中的,还需要从数组中vm.watchers删除,删除只能使用splice方法,而splice必须要知道要删除的元素的下标在哪,只能事先indexOf进行遍历,因此每个watcher的删除就具有了O(n)的复杂度。对此,Vue做了优化:先不删除,只是标记一下watcher.active为false。最后再执行:

this.vm._watchers = this.vm._watchers.filter(w => w.active)

这样就一次O(n)找出了依然要用的watcher,而那些不用的watcher则伴随着原先的watchers数组一起被GC给自动的清除掉了。

数据更新时的DOM移动

我们在前面的操作中,只是复用和创建了Fragment,这个操作只是完成了对应的Fragment的创建。但是我们要知道的是原先那些被复用的Fragment依然是存在于DOM里的。一方面他们的顺序是老版data的顺序,并非现在data的真正顺序,另一方面,新创建的Fragment的对应DOM还没有插入到页面里。

因此,这一步就是diff算法的核心了。Vue声称用了一些启发式的算法来完成相关DOM的移动。所谓启发式其实就是更加接近于人的本能的思考过程的算法,可能并不是一个问题的最优解。对于两个序列a和b计算出b变成a需要操作多少步的最优解肯定是使用levenshtein distance算法,比如leetcode的72题,但是这个算法的复杂度是O(mn)的,最终得到的操作数可能也会达到O(n)级别,而算法的计算过程中因为之前的Fragment已经插入到DOM里,只能用DOM api遍历去知道之前Fragment的顺序,因此O(mn)的算法计算过程可能会带来较大的计算开销,虽然最终的操作是最少的,但是不一定总时间最优。Vue现在采用了一种O(n)的,看起来比较简单粗暴的办法。

我们先记住move这个基本操作:
要移动DOM,把DOM插入到一个合适的节点,必然是使用insertBefore api。Vue也是基于此,要移动一个DOM到合适的位置,执行this.move(frag, prevEl),就可以把frag摆放到prevEl之后。等等,之后?不是insertBefore吗?其实执行的是prevEl.parentNode.insertBefore(frag.node,prevEl.nextSibling),即插入到prevEl的后一个兄弟节点之前,所以就可以把frag插入到prevEl之后了。

我们假设我们的数组是['a','b','c','d','e','f','g'],现在我把数组改成['a','d','e','f','g','b','c']。

同时,然后我遍历frags数组,现在frags数组是完全按照['a','d','e','f','g','b','c']的顺序的,但是DOM里依然是['a','b','c','d','e','f','g']的顺序。

因此,我从头到尾遍历frags。对于当前遍历到的frag[i],我们希望他摆放在frags[i-1]之后就好了(后文会说原因)。我先从DOM里查看他摆在哪的。假定他当前是摆在currentPrev后面的,如果currentPrev不是frags[i-1],那么,很明显frag[i]没有摆放到正确位置。那就执行this.move(frag,prevEl),prevEl就是frags[i-1]的最后一个节点。实现将frag正确摆到frags[i-1]之后。当然,对于i=0的情况,frags[i-1]就是undefined,那么prevEl就是v-for的start anchor。(好吧,本来想单步解释的,图都画好了,但是其实过程很简单,没必要单步细说吧)

上面的处理过程中,我在第i步其实都是把当前frags[i]移到frags[i-1]之后,而在第i-1步,我成功的把frags[i-1]移到frags[i-2]之后........而在第0步的时候,其实是把frag[0]放在start anchor之后。前i-1步保证了frags[0到i-1]是正确按照我期望的位置和顺序摆放的。因此在第i步,我可以放心的把frags[i]移到frags[i-1]之后。上述代码执行过程中就是这么个简单的逻辑,当frags遍历完毕,DOM也移动为了正确的顺序。

如尤雨溪在注释里所说,实际上,这个流程里存在一个问题。@livoras 戴嘉华发现了这个问题,并提出了优化建议(戴老师在前端方向思考极深,他的blog很牛逼)。比如['a','b','c','d','e','f']变成了['b','c','d','e','f','a'],最简单的方案肯定是只移动一次就完事了,但是上述过程却是先移动'b',变成['c','d','e','f','a','b'],再移动'c',变成['d','e','f','a','b','c']......最终要移动5次,结果是正确的,但是过程多余了。其实这个情况可以优化一下。优化过程也很简单,移动的判断条件由if(currentPrev !== targetPrev) *(这里的targetPrev是frags[i-1]哈)*变成了:

if (
	currentPrev !== targetPrev && (!currentPrev || currentPrev的Prev !== targetPrev )
) {
	this.move(frag, prevEl)
}

先说currentPrev的Prev !== targetPrev,这一步使得即使frag的currentPrev的和targetPrev不一样,但是currentPrev再往前在看一下,这就避免了abcde变成bcdea或者abcde变成acdeb的时候,对于那些不需要移动的元素的位移,先保证他们不变,最后找到那个真正需要变的元素b的时候,再一次移动他就ok了。
至于!currentPrev这是abcde变成bcdea,前4个bcde都不会移动,现在遍历到a,a的currentPrev就是undefined,所以需要移动他到末尾。

上述的优化其实只是优化了单个元素向后移动的情况,但是对于超过1个元素的后移,比如abcdefg变成cdefgab的情况是无能为力的。当然,因为本来就是启发式算法,本来就不是最优的,这种优化如果是对于一些均匀的testcase,性能上肯定是起不了优化效果的。

上面讲述了对于重用的frag的正确移动。对于frags数组中,新创建的frag,就直接让在prevEl后插入即可。至此,新的数组就完全映射到页面上了。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions