-
Notifications
You must be signed in to change notification settings - Fork 99
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Vue源码详解之v-for 与 Vuex #7
Comments
m |
棒棒哒 |
maybe you can understand this directive compare angular ng-for |
期待computed的lazy watcher |
@huangmxsysu 嗯, 计算属性的 lazy watcher 实现其实是非常重要的部分。跟你简单说一下计算属性原理吧: Vue 实例的初始化步骤中会取出 option 中用户声明的 computed 计算属性对象/函数,对于用户定义的每个计算属性的 getter,都会通过
上面这个最终的 getter 里有比较关键的两个步骤:
然后这个 getter 通过 defineProperty 封装到 vm 上去,任何别的地方访问 vm 的对应计算属性就会触发这个 getter,例如你定义一个 a 的计算属性,你就可以从 this.a 上拿到 a 的值。 我们用一个例子来看整个过程:
经过 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 上去。 综上:
Vuex 中核心的地方也是响应式实例和计算属性,可以看看。 |
it's great! |
感谢分享。 |
学习到了,谢谢 |
@GTRgoSky
这个是简单表示移动后 DOM 对应的数据是怎样的。 |
我在之前的主线文章中已经多次介绍了,大数据量的重复渲染生成是考量一个前端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:
可以看到,就是先复制一份模板,然后将linker和scope,传入,Fragment会执行以scop为参数执行linker,并且会在this上记录对应的DOM、scope等内容。
因此,大体执行过程和相关类如下图:
上述过程是v-for指令的初始化阶段,现在一堆绑定到具体数组元素的响应式DOM已经构建完成,v-for的使命已经完成一半,另一半则是在数组变动时,使用diff对Fragment进行操作,删除和新建响应式DOM。
我们先来结合具体代码看看这个初始化的过程。
bind很简单,解析了一下v-for表达式,并生成相关anchor,最后执行的
new FragmentFactory(this.vm, this.el)
是关键。这里,我们看到了前文所说的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方法完成了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的销毁操作。
上述过程在中间的注释里解释得很清楚了,删除缓存,执行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。最后再执行:
这样就一次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]哈)*变成了:
先说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后插入即可。至此,新的数组就完全映射到页面上了。
The text was updated successfully, but these errors were encountered: