Vue源码详解之v-for 与 Vuex
Ma63d opened this issue · comments
我在之前的主线文章中已经多次介绍了,大数据量的重复渲染生成是考量一个前端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后插入即可。至此,新的数组就完全映射到页面上了。
m
棒棒哒
maybe you can understand this directive compare angular ng-for
期待computed的lazy watcher
@huangmxsysu 嗯, 计算属性的 lazy watcher 实现其实是非常重要的部分。跟你简单说一下计算属性原理吧:
Vue 实例的初始化步骤中会取出 option 中用户声明的 computed 计算属性对象/函数,对于用户定义的每个计算属性的 getter,都会通过 makeComputedGetter
函数,把这些 getter 封装一下,每个都封装成为另一个 getter:
function makeComputedGetter (getter, owner) {
// 新建一个lazy watcher, 闭包在下述真正的 getter 中
var watcher = new Watcher(owner, getter, null, {
lazy: true
})
// 这个函数才是计算属性的真正getter,
return function computedGetter () {
if (watcher.dirty) {
// 如果数据是脏的 那么需要更新,否则就直接返回了,
// 这也就是官网计算属性的奥秘一章中Date.now()的那个例子中,每次计算属性得到的时间戳不变的原因
watcher.evaluate()
}
if (Dep.target) {
// 这一步非常关键,
watcher.depend()
}
return watcher.value
}
}
上面这个最终的 getter 里有比较关键的两个步骤:
- 如果数据是脏的,那么就执行 watcher.evaluate 操作,求出数据的新值,其中 watcher.evaluate 操作会完成数据求值、新依赖订阅、旧依赖退订的操作,与我之前文章中介绍的 watcer.run 的依赖订阅步骤非常相似,可以参考之前文章。
- 如果 Dep.target 有值,那么执行 watcher.depend() 操作。之前的文章中说过,watcher 在收集依赖的过程中,会把自己放在 Dep.target 上,然后计算 watcher 的表达式的值,从而完成依赖订阅。现在,Dep.target 有值,说明此刻有别的 watcher (非这个计算属性的 lazy watcher)在进行着依赖订阅。那么就会让这个别的 watcher 去订阅计算属性 lazy watcher 的所有依赖。这个操作的原因后文会解释。
然后这个 getter 通过 defineProperty 封装到 vm 上去,任何别的地方访问 vm 的对应计算属性就会触发这个 getter,例如你定义一个 a 的计算属性,你就可以从 this.a 上拿到 a 的值。
我们用一个例子来看整个过程:
// 假如 data 如下:
data: {
firstName: 'Chuck',
lastName: 'Liu'
}
// 假如有如下一个叫 fullName 的计算属性:
computed: {
fullName: function () {
return this.firstName + ' ' + this.lastName
}
}
// 假如模板中有这么一段 DOM
<div>{{fullName}}</div>
经过 Vue 简单处理会生成这么一些 Dep 实例 和 watcher:
接着,在 DOM watcher 的初始化过程会完成该 watcher 的求值,于是会去求 fullName 的值,然后就进入到上述封装好的那个真正 getter 里,此时,fullName 的 lazy watcher 是刚新建好的 lazy watcher,watcher.dirty 为 true。于是进入到 watcher.evaluete() 函数。此时 lazy watcher 完成求值和依赖订阅,并且把 watcher.dirty 改为 false:
接着,由于此时 Dep.target 上存放的是 DOM watcher。会进入 watcher.depend(),让 DOM watcher 订阅所有 lazy watcher 订阅的依赖。这一步就会让 DOM watcher 订阅到 lazy watcher 刚刚收集好的 firstName 和 lastName 的两个 dep。
于是,接下来如果 firstName 和 lastName变动,那么他们的 dep 就会通知 lazy watcher 把 lazy watcher 的 dirty 状态改为 true,但是此时并不会计算更新 lazy watcher 的值;接着这些 dep 也通知到 DOM watcher。从而实现真正 DOM 更新。DOM watcher 在批处理阶段进行 DOM 更新时,会计算 DOM watcher 的真正值,也就会去执行 计算属性的 getter,从而会使得 lazy watcher 真正求值,这个时候拿到真正的新值,从而更新到 DOM 上去。
综上:
- 计算属性使用 lazy watcher 来进行依赖订阅和懒加载的过程非常神作。 计算属性是一个中介的角色,自己先订阅、收集好依赖,再让需要计算属性的对象(诸如 DOM watcher)去真正订阅这些依赖,比如上例中的 DOM watcher 表面上依赖于 fullName,其实他应该监听 firtstName 和 lastName:计算属性在计算过程中用到的变量中,只有是 data 上的哪些才是响应式的,只有 data 上的属性变动才会让计算属性计算新值,所以任何依赖于计算属性的值的 watcher 应该订阅那些会真正让计算属性变动的根源,所以他们应该订阅计算属性的依赖。如果不是用这个中介的 lazy watcher,而是让 DOM watcher 直接去执行用户的 fullName 的 getter,也依然可以保证 DOM watcher 真正订阅到他想订阅的依赖: lastName 和 firstName,但是如果是多个 DOM watcher 需要 fullName 那么这个过程就会有计算、依赖比对后重订、退订的重复开销,现在加入 lazy watcher,这些过程只用让 lazy watcher 自己做一遍,其他 watcher 就利用 lazy watcher 做好的结果即可,一步 watcher.depend() 搞定。
- 计算属性有内部缓存机制:当 lazy watcher 的依赖发生变动时,应该完成依赖订阅、新值计算、旧依赖退订的三个步骤,但是这一切应该在计算属性被真正用到时即某段代码触发了计算属性的 getter 时(触发 getter 说明真正到了要用计算属性的值的时候了)才去做,这样中途的依赖变动并不会产生额外计算开销,只会把计算属性标记为 dirty,依赖可能变动10次,但只有在真正计算属性用到时才计算一次。
- 结合第二点和第一点,所以计算属性的性能很高:lastName 变动会使得计算属性 fullName 变成 dirty 的,但不求值、检查依赖,lastName 变动也会让 DOM watcher 监听到,在 DOM watcher 真正更新时,触发 fullName 求值、检查依赖,去进行真正的计算属性的值更新。同时之后任何再依赖 fullName 值的 watcher 直接可以利用本次求出来的 lazy watcher 的 value 即可。因此既让 DOM watcher 真正监听到了他需要监听的根源数据,又没有因为 lastName 和 firstName 的多次变动而导致计算属性重复计算,并且在依赖有再次变动之前,后续其他用到 fullName 的地方都可以直接利用本次计算的 fullName 结果。
Vuex 中核心的地方也是响应式实例和计算属性,可以看看。
it's great!
感谢分享。
有一点疑问:
文中-----比如['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次,结果是正确的,但是过程多余了。
在v-for数组变化中为什么数组会发生变化,我的理解是b节点会先移动到start anchor,然后依次c移到b后面,而数组不会发生变化。能不能解释下,十分感谢。
学习到了,谢谢