响应式原理(概述)
回忆
Observe观察者(建立响应式对象)
概括:它给数据通过defineProperty进行响应式化。依赖收集的入口:在每个数据初始化dep实例后,通过get方法在当前dep实例的subs数组中收集当前渲染watcher(当前watcher对数据实现订阅),在当前渲染watcher的newDeps中加入当前dep实例。派发更新的入口:通过set方法在数据变化时通过dep实例的subs数组中遍历所有订阅了数据变化的watcher执行watcher.run实现重现渲染。其中优化:派发更新的过程中会把所有要执行update的watcher推入队列queue中,在nextTick后统一依次执行flush进行watcher.run()。
依赖收集过程:
_init()时候会有一个initState()方法主要是对props、methods、data、computed 和 wathcer 等属性做了初始化操作。
我们先说Data的响应式原理,initData会调用observe方法,判断data下有无_ob_对象,有的话直接返回data._ob_,没有的话new Observe(data)对象。
Observe类会判断data是数组还是对象,数组就对每一个子元素调用observe(),否则就执行walk()对 对象每一个属性执行defineReactive()方法。
defineReactive()中new Dep()初始化一个订阅器。如果子属性是个对象的话再对 子属性执行observe()否则对子属性通过object.defineProperty() 定义get和set函数,get用来收集依赖,set用来派发更新。
当订阅者Watcher中执行updateComponent,_render开始实例化Vnode时,会获取data数据,触发get函数。后续做了两件事,1、把当前数据属性dep实例加入到当前渲染watcher的newDeps中。2、在当前数据属性dep实例中的subs属性中加入订阅当前渲染watcher。接下来我们看看过程:
如果Dep.target存在(此时watcher已经实例化,Dep.target存在),执行dep.depend(),间接执行Dep.target.addDep(this)相当于当前Watcher.addDep(this)(把这个属性在defineproperty中new的dep作为this)。这时候会做一些逻辑判断(保证同一dep实例不会被添加多次)后执行 dep.addSub(this),那么该dep实例就会执行 this.subs.push(sub),也就是说把当前的 watcher 订阅到这个数据持有的 dep 的 subs 中,这个目的是为后续数据变化时候能通知到哪些 subs 做准备。
派发更新过程:
值更新改变触发set方法。其中,把新值替代旧值(如果新值是一个对象,也对它使用observe());执行dep.notify()派发更新。
dep.notify(),遍历dep实例的subs数组(即Watcher实例数组)调用每一个watcher的update方法。
update(),即执行queueWatcher队列方法(把watcher push到 quequ中形成队列,通过id防止多次添加同一个watcher到quequ中),再通过nextTick异步执行flushSchedulerQueue方法。
flushSchedulerQueue(),把队列根据id从小到大排列后,遍历队列拿到每一个watcher执行watcher.run(),重置初始状态,执行update钩子。
watcher.run(),通过this.get()取得新值并触发updateComponent = () => {vm._update(vm._render(), hydrating) } 进行重新渲染。
关于用户定义的watch或者$.watch订阅数据的派发更新。例如,有一个响应式msg数据,通过a.click可以改变它,同时watch对它监听执行 也改变它。那么当a.click改变它时,遍历dep.subs下订阅该数据的watcher(有两个,一个userWatcher和一个渲染watcher),通过queueWatcher把watcher都添加到queue队列中,再异步统一依次执行flushSchedulerQueue。首先执行userWatcher(用户定义的watch比渲染watch先执行),其中会执行watcher.run(),watcher.run()中会执行watcher.get()取得新值(因为userWatcher是有回调函数的即watch内定义的函数),若与旧值不同则执行回调函数。执行回调函数改变了msg,订阅msg的userWatcher和渲染Watcher又通过queueWatcher被添加到queue队列中(userWatcher能添加,渲染watcher因为上一个相同的渲染watcher还没执行id还没清空所以不能添加),此时queue队列中有3个watcher,2个相同的userWatcher和1个渲染watcher。然后又执行第二个userWatcher的watcher.run()又执行回调函数,又添加同一个userWatcher,一直循环添加下去,渲染watcher轮不到执行。那么当循环次数超过一定值,vue会报错。
Dep订阅器(管理订阅者)
defineReactive()中会new Dep(),Dep 是一个 Class,它定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 target,这是一个全局唯一 Watcher,这是一个非常巧妙的设计,因为在同一时间只能有一个全局的 Watcher 被计算,另外它的自身属性 subs 也是 Watcher 的数组。
Watcher订阅者(实例化和派发更新)
实例化
在执行$mount触发的mountComponent方法中,定义了方法updateComponent = () => {vm._update(vm._render(), hydrating)}(把render函数实例化Vnode进行patch),和vm._watcher = new Watcher(vm, updateComponent, noop)两段关键代码。
实例化订阅者Watcher,会执行Watcher类的get方法。
其中pushTarget(this),Dep.target存在情况下,会往targetStack(一个数组)pushDep.target。另外会把当前Watcher实例赋值给Dep.target()(此时开始,Dep.target中有值了)。
其中value = this.getter.call(vm, vm),即执行updateComponent方法。其中vm._render()方法调用vnode = render.call(vm._renderProxy, vm.$createElement)实例化vnode时会去获取data中数据,会触发观察者Observe的defineReactive()中对应数据定义的get函数,进行依赖收集了。
接下来,if (this.deep) { traverse(value)}这个是要递归去访问 data,触发它所有子项的 getter,这个之后会详细讲。
接下来执行:popTarget(),即Dep.target = targetStack.pop(),实际上就是把 Dep.target 恢复成上一个状态,因为当前 vm 的数据依赖收集已经完成,那么对应的渲染Dep.target 也需要改变。
watcher中的deps[]、newDeps[]、depIdsp[]、newDepIds[]是干嘛的?是用来维护dep.subs的。依赖收集的时候,会把当前dep实例加入到newDeps中。watcher的get()执行完会执行cleanupDeps(), 遍历deps中dep和newDeps中dep做比较,如果deps中有的dep实例在newDeps却没有,就在该dep实例中的subs中把当前订阅者watcher移除(该wathcer不再订阅该dep实例所对应数据)。并把newDeps赋值给deps,再把newDeps清除。主要目的是触发当重新渲染时,在当前渲染watcher的newDeps收集这次渲染存在的响应式数据,和deps中上一次该watcher渲染收集的响应式数据对比,现在不存在的而上次却存在的dep实例,把该dep实例的subs中把该watcher取消掉,即当前watcher取消对该dep实例对应数据的订阅。
最后执行:this.cleanupDeps(),考虑到 Vue 是数据驱动的,所以每次数据变化都会重新 vm._render() ,并再次触发数据的 getters,所以 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 对一些细节上的处理。
概述
前面 2 章介绍的都是 Vue 怎么实现数据渲染和组件化的,主要讲的是初始化的过程,把原始的数据最终映射到 DOM 中,但并没有涉及到数据变化到 DOM 变化的部分。而 Vue 的数据驱动除了数据渲染 DOM 之外,还有一个很重要的体现就是数据的变更会触发 DOM 的变化。
其实前端开发最重要的 2 个工作,一个是把数据渲染到页面,另一个是处理用户交互。Vue 把数据渲染到页面的能力我们已经通过源码分析出其中的原理了,但是由于一些用户交互或者是其它方面导致数据发生变化重新对页面渲染的原理我们还未分析。
当我们去修改 this.message 的时候,模板对应的插值也会渲染成新的数据,那么这一切是怎么做到的呢?在分析前,我们先直观的想一下,如果不用 Vue 的话,我们会通过最简单的方法实现这个需求:监听点击事件,修改数据,手动操作 DOM 重新渲染。这个过程和使用 Vue 的最大区别就是多了一步“手动操作 DOM 重新渲染”。这一步看上去并不多,但它背后又潜在的几个要处理的问题:
我需要修改哪块的 DOM?我的修改效率和性能是不是最优的?我需要对数据每一次的修改都去操作 DOM 吗?我需要 case by case 去写修改 DOM 的逻辑吗?
如果我们使用了 Vue,那么上面几个问题 Vue 内部就帮你做了,那么 Vue 是如何在我们对数据修改后自动做这些事情呢,接下来我们将进入一些 Vue 响应式系统的底层的细节。