Vue源码之依赖收集

573 阅读6分钟

核心思想

收集依赖的⽬的是为了当这些响应式数据发生变化,触发它们的 setter 的时候,能知道应该通知哪些订阅者去做相应的逻辑处理,一个key创建一个Dep管家,管家里面存放着与key相关的Watcher。

入口

当对数据对象的访问才会触发他们的 get ⽅法,那么这些对象什么时候被访问呢?在初始化Vue的mount过程中会创建一个Watcher传入updateComponent回调函数。

Watcher

  1. this.getter赋值

判断updateComponent是否是函数,如果是函数赋值给getter属性。否则调用parsePath方法,它最终会返回一个函数(这个后面说到watch源码涉及再说)。

  1. this.get()

首先调用pushTarget(this)函数,实际上就是把 Dep.target 赋值为当前的渲染 watcher 并压栈(为了恢复⽤)。接着执行了getter函数,也就是执行updateComponent,也就是执行vm._update(vm._render(), hydrating),它会先执⾏ vm._render() ⽅法,因为之前分析过这个⽅法会⽣成 渲染 VNode,并且在这个过程中会对 vm 上的数据访问,这个时候就触发了数据对象的 get。

pushTarget

get

每个key都会创建一个Dep类 Dep类初始化时会设置一个id:this.id = uid++,设置一个数组:this.subs = []。也就是说每个key对应的Dep它的id是不一样的。在触发 get 的时候会调⽤ dep.depend() ⽅法。

dep.depend()

函数会执⾏ Dep.target.addDep(this) 。刚才我们提到这个时候 Dep.target 已经被赋值为当前的渲染 watcher。

Dep.target.addDep(this)

获取dep的id,每次addDep的时候会先判断有没此id保证不会添加重复dep,如果不存在最终调用dep.addSub(this)。

dep.addSub(this)

执行 this.subs.push(sub) ,也就是说把当前的 watcher 订阅到这个数据持有的 dep 的 subs中,这个⽬的是为后续数据变化时候能通知到哪些 subs 做准备。到此数据就已经订阅成功了。

popTarget()

回到把this.get(),popTarget主要是把Dep.target 恢复成上⼀个状态,因为当前 vm 的数据依赖收集已经完成,那么对应的渲染 Dep.target 也需要改变(这个骚操作在后面说到computed源码涉及再说)。

this.cleanupDeps()

考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 render,那么 vm._render() ⽅法⼜会再次执⾏,并再次触发数据的 get,所以 Wathcer 在构造函数中会初始化 2 个 Dep 实例数组, newDeps 表⽰新添加的 Dep 实例数组,⽽ deps 表⽰上⼀次添加的 Dep 实例数组。

在执⾏ cleanupDeps 函数的时候,会⾸先遍历 deps ,移除对 dep 的订阅,然后把 newDepIds和 depIds 交换, newDeps 和 deps 交换,并把 newDepIds 和 newDeps 清空。那么为什么需要做 deps 订阅的移除呢,在添加 deps 的订阅过程,已经能通过 id 去重避免重复订阅了。

考虑到⼀种场景,我们的模板会根据 v-if 去渲染不同⼦模板 a 和 b,当我们满⾜某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候我们对 a 使⽤的数据添加了 getter,做了依赖收集,那么当我们去修改 a 的数据的时候,理应通知到这些订阅者。那么如果我们⼀旦改变了条件渲染了 b 模板,⼜会对 b 使⽤的数据添加了 getter,如果我们没有依赖移除的过程,那么这时候我去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。

因此Vue在每次添加完新的订阅,会移除掉旧的订阅,这样就保证了在刚才的场景中,如果渲染 b 模板的时候去修改 a 模板的数据,a 数据订阅回调已经被移除了,所以不会有任何浪费,这是Vue对⼀些细节上的处理。

总结(鬼画符)

1.Vue在mount过程中会创建一个Watcher传入updateComponent回调函数
(updateComponent = () => {vm._update(vm._render(), hydrating)})。

2.Watcher构造函数,首先判断updateComponent是否是函数,如果是函数赋值给getter属性。否则调用parsePath方法,它最终会返回一个函数(这个后面说到watch源码涉及再说)。接着会调用get函数。

3.get函数

  • 首先调用pushTarget(this)函数,pushTarget首先往targetStack队列中丢当前的watcher然后把当前的watcher赋值给Dep.target。

  • 紧接着执行getter函数也就是执行updateComponent回调函数,开始执行vm._render()。

4.vm._render()会获解析render函数创建vnode,访问到模板数据时触发数据对应的get,也就是上篇我们说的get。

5.get

  • 首先获取到val值,其次判断是否存在Dep.target,很显然是存在的此时的Dep.target就是当前的watcher

  • 然后就会调用dep.depend()。如果存在childOb也就是说key的值是个object类型,那么也会调用childOb.dep.depend(),如果key为数组会遍历数组设置每一项.ob.dep.depend()。

  • depend函数调用 Dep.target.addDep(this),也就是调用此watcher的addDep函数并传入此Dep类。注意Dep类初始化时会设置一个id:this.id = uid++,设置一个数组:this.subs = []。也就是说每个key对应的Dep它的id是不一样的。

  • addDep函数首先获取dep的id,每次addDep的时候会先判断有没此id保证不会添加重复dep,如果不存在最终调用dep.addSub(this)。this为当前的watcher。

  • addSub就是往subs数组中丢入watcher。此时此key对应的dep中的subs就订阅到了watcher。

6.回到get函数

  • this.getter执行完后会执行popTarget函数,popTarget首先删除targetStack队列中最后一位,然后将 Dep.target赋值为删除后的targetStack队列中最后一位。目的是为了啥?把 Dep.target 恢复成上⼀个状态,因为当前组件的数据依赖收集已经完成,那么对应的渲染 Dep.target 也需要改变成上一个组件(这个骚操作在后面说到computed源码涉及再说)。

  • 最后调用cleanupDeps函数,cleanupDeps函数主要目的是防止下面这个场景:v-if 去渲染不同⼦模板 a 和 b,当满⾜某种条件的时候渲染 a 的时候,会访问到 a 中的数据,这时候对 a 使⽤的数据添加了 getter,做了依赖收集,那么去修改 a 的数据的时候,理应通知到这些订阅者。那么如果⼀旦改变了条件渲染了 b 模板,⼜会对 b 使⽤的数据添加了 getter,如果没有依赖移除的过程,那么这时候去修改 a 模板的数据,会通知 a 数据的订阅的回调,这显然是有浪费的。所以他做的事就是一个优化,主要是下次添加依赖时把之前多余的给删了防止浪费。