Vue2源码解析系列-响应式原理

综述

Vue的响应式利用变化侦测实现。
所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新。
总的来说就是Object是getter里收集依赖,setter触发依赖更新;Array是getter收集依赖,拦截器触发依赖更新。
其中数组和对象的实现思路略有区别,后面再详细分析。
上述是思路,具体实现我们还要解决以下问题
  • 怎么监听数据被访问和被修改
  • 什么是依赖?
  • 如何收集依赖?
  • 如何通知依赖更新

Object的变化侦测

怎么监听数据被访问和修改

前面说过所谓的变化侦测,就是在数据被访问的时候收集依赖,在改变的时候通知依赖更新,那么如何知道数据被访问或被修改了呢?
我们知道,对于Object.defineProperty可以声明对象属性,并且可以配置属性修饰符。而属性修饰符中的setter和getter访问器函数分别会在属性被修改和被访问时触发,因为我们可以利用Object.defineProperty来实现数据的监听。
let obj = {} Object.defineProperty(obj,'key',{ value: "123", set(){ console.log('数据被修改') }, get(){ console.log('数据被访问') } }) obj.key // 数据被访问 // 123 obj.key = 321 // 数据被修改

什么是依赖

什么是依赖?我们可以这样理解:某个数据的值需要访问另一个数据,我们就可以说这个数据依赖了另一个数据,例如计算属性 a(){return b + '123'},我们可以说a依赖b。
解决了什么是依赖,那么依赖应该存放在哪呢?我们很容易想到可以用一个数组存着,但是这样很耦合,我们抽象成一个类Dep,为这个类定义一些方法。
// vue-src\core\observer\dep.js export default class Dep { static target: ?Watcher; id: number; subs: Array<Watcher>; constructor () { this.id = uid++ this.subs = [] // 存放依赖 } /*添加一个观察者对象*/ addSub (sub: Watcher) { } /*移除一个观察者对象*/ removeSub (sub: Watcher) { } /*依赖收集,当存在window.target的时候添加观察者对象*/ depend () { } /*通知所有订阅者*/ notify () { } }
还有一个问题,前面我们说过,一个数据依赖另一个数据,那么就将这个依赖存起来,如果将来数据改变就通知依赖更新,那么所有依赖都要被收集吗?我们知道在methods中,如果一个方法里的响应式数据发生改变,这个方法却不会发送任何改变,而computed却会更新,这也就说明,Vue只会选择性的收集依赖,我们将这种会被当做依赖收集的数据抽象为类Watcher,收集只收集Watcher的实例。
现在可以回答前面的问题: 什么是依赖?依赖是Watcher。依赖收集后放哪里?放到Dep里。

如何收集依赖

前面说到依赖收集到Dep,那么具体如何实现呢?
我们可以利用一个Dep和数据都能访问到的唯一变量。当创造Watcher实例时,将这个唯一变量指向实例自身,然后获取一下数据,数据的getter会将这个唯一变量收集到Dep中。
注意:上述的唯一变量在本例中是window.target,而实际上是静态变量Dep.target,两者效果相同,后者的优点是不会污染全局变量。
// watcher将window.target指向自己,然后get一下数据 export default class Watcher{ constructor (){ if (typeof expOrFn === 'function') { this.getter = expOrFn } else { this.getter = parsePath(expOrFn) } this.get() } get(){ const vm = this.vm // 将实例添加到全局唯一变量中 window.target = this; // 调用数据的getter方法,一是获取数据的值,而是调用getter将watcher实例添加Dep中 let value = this.getter.call(vm, vm) // 清空 window.target = undefined; } } // 数据的getter调用后会将window.target添加到Dep之中 Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter() { /*如果原本对象拥有getter方法则执行*/ const value = (property && property.get) ? getter.call(obj) : val; if (window.target) { /*进行依赖收集*/ let dep = new Dep() dep.addSub(window.target) } return value; }, }
实际代码加上了很多处理和判断,更复杂,这里进行了简化,理解核心思路即可。

如何通知依赖更新

在setter中通知Dep,Dep遍历存储依赖的数组,依次调用更新方法。
Object.defineProperty(obj, key, { enumerable: true, configurable: true, set: function reactiveSetter(newVal) { if (setter) { /*如果原本对象拥有setter方法则执行setter*/ setter.call(obj, newVal); } else { val = newVal; } /*新的值需要重新进行observe,保证数据响应式*/ childOb = observe(newVal); /*dep对象通知所有的观察者*/ dep.notify(); }, }); export default class Dep { static target: ?Watcher; id: number; subs: Array<Watcher>; constructor () { this.id = uid++ // 设置Dep编号 this.subs = [] } /*通知所有订阅者*/ notify () { // stabilize the subscriber list first const subs = this.subs.slice() for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() // 调用watcher的更新方法 } } } export default class Watcher { update () { // 调用对应的更新方法 } }

总结

Object的变化侦测在getter收集依赖,存到Dep中,setter里通知Dep中的依赖进行更新操作,依赖就是watcher,Vue在需要依赖响应式数据的地方创造watcher实例,比如计算属性、模板语法或用户的watch。

Array的变化侦测

我们知道Array其实本质上也是对象,因此也可以使用之前说的Object.defineProperty方法,但是实际上Vue并没有采用这种方法,请看下面代码。
var obj = [] Object.defineProperty(obj,0,{ set(){ console.log('数据被修改') } } obj[0] = 1 // 数据被修改 // 1 obj.push(2) // 2
可以看到,如果调用Object.defineProperty声明数组元素,固然使用中括号方法改变属性时可以执行setter,但是如果使用push这类数组方法,却无法触发setter。
事实上,这样写同样也会遇到性能问题,数组有些情况是需要存放大量数据的,如果每个元素都设置setter,对性能会有一定影响,因此Vue采用了另一种巧妙的方式实现数组的响应式。

拦截器

什么是拦截器?举个例子。
class A { toString() { return "我是A"; } } class B extends A {} let a = new A(); let b = new B(); console.log(a.toString(), b.toString()); // 我是A 我是A
类A里声明了toString方法,而类B继承于A,自然也有toString方法,并且A和B的toString方法相同。
这个时候如果我们在类B中再声明一个toString方法,那么实例b调用的就是类B自身的toString方法,而不是继承过来的类A的toString,我们可以理解类B的toString被拦截了。
class A { toString() { return "我是A"; } } class B extends A { // 新增 toString() { return "我是B"; } } let a = new A(); let b = new B(); console.log(a.toString(), b.toString()); // 我是A 我是B
同理,我们声明的数组是Array的实例,调用的push等方法其实是调用数组原型对象上的push方法,因此我们只需要在我们声明的数组里添加拦截器,那么调用push方法就是调用我们自定义的push方法,而不是Array.prototype.push
notion image
那么什么方法才需要使用拦截器?很明显,能够改变数组自身数据的方法都需要拦截,整理后有以下几种:
  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse
明白这些之后,我们就可以开始写拦截器了。
// vue-src\core\observer\array.js /*取得原生数组的原型*/ const arrayProto = Array.prototype /*创建一个新的数组对象,修改该对象上的数组的七个方法,防止污染原生数组方法*/ export const arrayMethods = Object.create(arrayProto); [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] .forEach(function (method) { /*将数组的原生方法缓存起来,实际操作数组的时候还是需要调用原生方法*/ const original = arrayProto[method] // def函数的对Object.defineProperty的封装 def(arrayMethods, method, function mutator () { let i = arguments.length const args = new Array(i) while (i--) { args[i] = arguments[i] } /*调用原生的数组方法*/ const result = original.apply(this, args) /*ob指向数据的Observer实例,后面再讲*/ const ob = this.__ob__ /* 以下方法会新增元素,将新增的元素存起来,然后执行observeArray, 这是为了把新增元素也变成响应式的 */ let inserted switch (method) { case 'push': inserted = args break case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } if (inserted) ob.observeArray(inserted) // notify change /*dep通知所有注册的观察者进行响应式处理*/ ob.dep.notify() return result }) }) export function def (obj: Object, key: string, val: any, enumerable?: boolean) { Object.defineProperty(obj, key, { value: val, enumerable: !!enumerable, writable: true, configurable: true }) }
现在我们得到了一个数组方法拦截器。

挂载拦截器

写好拦截器后下一步就是要将拦截器挂载到响应式数组上。
拦截器本质上是个包含七个特殊方法的对象,并且继承了Array的原型,因此拦截器的挂载可以直接将拦截器作为响应式数组的prototype,在浏览器环境下对象的原型被暴露为__proto__属性,因此我们可以:
// 判断浏览器是否支持__proto__属性 const hasProto = '__proto__' in {} if(hasProto){ target.__proto__ = arrayMethods; }else { // 如果不支持,则调用Object.defineProperty将方法覆盖到目标上 for (let i = 0, l = arrayMethods.length; i < l; i++) { const key = arrayMethods[i]; def(target, key, src[key]); } }

收集和通知依赖

拦截器解决了数组触发依赖更新的问题,那么我们如何收集依赖呢?
答案是getter。请看以下代码:
let obj = { arr: [1, 2, 3], }; Object.defineProperty(obj, "_arr", { enumerable: true, configurable: true, get() { console.log("数据被访问"); // 收集依赖 let dep = new Dep() dep.depend(); return obj.arr; }, }); obj._arr[0]; // 数据被访问 obj._arr.pop(); // 数据被访问
以上代码,我们首先声明一个包含数组arr的对象(obj),这是模拟Vue中data的写法,同时配置好修饰符,在getter中收集依赖后返回真实的数据,最后测试使用数组方法和中括号访问是否能触发,很明显这种方法效果很好。

Vue中的watcher

前面说到vue会将响应式状态的依赖封装成Watcher,那么我们现在就来看看Vue中有哪些Watcher。
// lifecycle export function mountComponent(){ // ... new Watcher( vm, updateComponent, noop, { before() { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, "beforeUpdate"); } }, }, true /* isRenderWatcher */ ); } // state.js Vue.prototype.$watch = function(){ // ... const watcher = new Watcher(vm, expOrFn, cb, options); } function initComputed(){ // ... watchers[key] = new Watcher( vm, getter || noop, noop, computedWatcherOptions ); }
可以看到,vue使用响应式的地方主要有
  • 组件
  • computed
  • $watch()
  • 其他

组件

事实上,组件本身其实也是响应式的,例如你使用了一个文本插值(即{{}}),然后更新响应式状态,此时UI跟着改变。
<template> <div> {{a}} </div> </template> <script> export default{ data(){ return { a: 1 } }, mounted() { setInterval(() => { this.a++ }, 1000) } } </script>
我们来分析一下这个过程。
  • 首先vue解析文本插值语法,真正的值取出并渲染
  • 当文本插值的响应式状态改变时,通知依赖(组件)更新(即重新渲染)。
而要达到当响应式状态改变通知组件更新这一目的,就需要将组件转成watcher,作为状态的依赖。
  • 首先将组件封装成Watcher,将渲染函数作为更新时的回调传入
  • 组件第一次渲染时会获取响应式状态的值,这个时候就会将组件作为依赖加入到状态的dep中。
  • 当更改响应式状态时,会触发dep.notify(),通知状态的所有依赖更新,而组件的更新回调就是组件的渲染函数,因此就能达到重新渲染UI的目的。
在代码中的流程是这样的(省略了不重要的代码)
// 首先将组件 new Watcher,并且标记这是一个RenderWatcher // updateComponent是组件渲染函数 // 在watcher更新执行,会执行所有beforeUpdate函数(生命周期函数) new Watcher( vm, updateComponent, noop, { before() { if (vm._isMounted && !vm._isDestroyed) { callHook(vm, "beforeUpdate"); } }, }, true /* isRenderWatcher */ );
接着会将组件会执行渲染后函数updateComponent
export default class Watcher { constructor ( vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) { this.vm = vm if (isRenderWatcher) { vm._watcher = this } vm._watchers.push(this) if (typeof expOrFn === 'function') { // getter指向了组件的渲染函数 this.getter = expOrFn } // 执行get方法 this.value = this.lazy ? undefined : this.get() } get(){ pushTarget(this) let value const vm = this.vm // 这里首次执行了渲染函数,vue将模板渲染成UI value = this.getter.call(vm, vm) return value } } // pushTarget方法在dep.js里 export function pushTarget(target: ?Watcher) { targetStack.push(target); Dep.target = target; }
先将Dep.target执行组件的watcher,然后执行updateComponent来触发状态的getter,状态的getter函数会将Dep.target加入到状态的deps中,这样就成功的将组件作为依赖传入到所有用到的状态的deps中。如果有某一个状态改变,就会触发setter,然后触发deps.notify来通知所有依赖执行回调,对于组件的wather来说,回调就是渲染函数,因此就会重新执行渲染。

computed

computed可以看做是一个特殊的响应式状态,它的值取决于对其他的响应式状态的计算,computed可以是一个函数,也可以是一个包含set或get的对象,如果是函数,那么等同于包含get函数的对象。computed真正强大之处在于它的值会被缓存,只有当其他响应式状态改变时才会重新计算,这对性能很有好处。
vue首先会在initComputed中为每个computed创建watcher
function initComputed(vm: Component, computed: Object) { const watchers = (vm._computedWatchers = Object.create(null)); for (const key in computed) { const userDef = computed[key]; const getter = typeof userDef === "function" ? userDef : userDef.get; if (!isSSR) { // 为每个computed创建watcher watchers[key] = new Watcher( vm, getter || noop, noop, {lazy: true} // 开启缓存 ); } defineComputed(vm, key, userDef); } }
然后会使用一个中间层函数createComputedGetter,这个函数会判断是否需要重新计算,如果需要才会调用computed真实的函数。createGetterInvoker则是在无法使用缓存的情况下使用。
const sharedPropertyDefinition = { enumerable: true, configurable: true, get: noop, set: noop, }; export function defineComputed( target: any, key: string, userDef: Object | Function ) { // SSR const shouldCache = !isServerRendering(); if (typeof userDef === "function") { sharedPropertyDefinition.get = shouldCache ? createComputedGetter(key) : createGetterInvoker(userDef); sharedPropertyDefinition.set = noop; } else { // 根据shouldCache && userDef.cache !== false条件 // 来判断是否要使用缓存 sharedPropertyDefinition.get = userDef.get ? (shouldCache && userDef.cache !== false ? createComputedGetter(key) : createGetterInvoker(userDef.get)) : noop; sharedPropertyDefinition.set = userDef.set || noop; } Object.defineProperty(target, key, sharedPropertyDefinition); } function createComputedGetter(key) { return function computedGetter() { const watcher = this._computedWatchers && this._computedWatchers[key]; if (watcher) { // watcher.dirty是一个用于判断数据是否发生了变化的标志 if (watcher.dirty) { // 执行watcher.get()方法,并设置dirty为false watcher.evaluate(); } // 收集依赖 if (Dep.target) { watcher.depend(); } return watcher.value; } }; } function createGetterInvoker(fn) { return function computedGetter() { return fn.call(this, this); }; }
vue对computed的处理和对state的处理很类似,具体流程是:首先vue将每个computed创建watcher(传递lazy来表明要使用缓存),以便作为依赖被使用到的响应式状态添加到deps中,接着通过Object.defineProperty创建属性,但是get函数加了一个中间层来判断是否要重新计算(即createComputedGetter函数)。
computed每次获取时都会触发getter,也就是 createComputedGetter函数,这里会收集依赖,并且会通过watcher.dirty标记来判断是否要重新计算。如果computed的依赖更新,那就会通知依赖(也就是computed的watcher)进行更新,此时就会将watcher.dirty标记设为true,那么就会重新计算。

$watch()

$watch()方法实际上就是将Watcher主动暴露给用户使用,用户指定监听的响应式状态和回调,然后该状态会将watcher作为依赖收集到deps,更新时就会触发执行回调函数。

响应式API

Vue2.0中有三个常用响应式API,分别是vm.#watchvm.$setvm.$delete

Vue.$watch

vm.$watch 能监听一个表达式或computed函数,触发时执行特定函数。本质上也是利用Watcher。在使用vm.$watch时会为目标数据创建一个watcher,watcher的构造函数里会读取一下数据,这样就将这个watcher添加到dep中了,如果数据更新了,就会触发对应的回调函数。
// vue-src\core\instance\state.js Vue.prototype.$watch = function ( expOrFn: string | Function, cb: Function, options?: Object ): Function { const vm: Component = this options = options || {} options.user = true const watcher = new Watcher(vm, expOrFn, cb, options) /*有immediate参数的时候会立即执行*/ if (options.immediate) { cb.call(vm, watcher.value) } /*返回一个取消观察函数,用来停止触发回调*/ return function unwatchFn () { /*将自身从所有依赖收集订阅列表删除*/ watcher.teardown() } }
vm.$watch第一个参数能接收表达式或computed函数
如果是函数,直接作为getter,如果是keypath,则需要处理
if (typeof expOrFn === 'function') { this.getter = expOrFn } else { this.getter = parsePath(expOrFn) } // 下面是处理keypath的函数,其实就是遍历一层一层的查找 const bailRE = /[^\w.$]/ export function parsePath (path: string): any { if (bailRE.test(path)) { return } const segments = path.split('.') return function (obj) { for (let i = 0; i < segments.length; i++) { if (!obj) return obj = obj[segments[i]] } return obj } }
vm.$watch会返回一个函数,执行函数会取消监听。我们看到前面代码里返回了一个函数,函数里就执行了watcher.teardown()语句,因此取消监听重点是在watcher.teardown()上,它的作用是将watcher自身从所有依赖收集订阅列表删除。
要实现将watcher自身从所有依赖收集订阅列表删除,首先需要在Watcher中记录自己都订阅了谁,也就是watcher实例被收集进了哪些Dep里。然后当Watcher不想继续订阅这些Dep时,循环自己记录的订阅列表来通知它们(Dep)将自己从它们(Dep)的依赖列表中移除掉。
这个简单,我们首先为每个dep定义一个编号,每次将watcher添加到dep时就将这个dep的编号存到一个数组里。
class Watcher{ /*添加一个依赖关系到Deps集合中*/ addDep (dep: Dep) { const id = dep.id // 如果当前Watcher已经订阅了这个Dep则跳过 if (!this.depIds.has(id)) { // 记录当前Watcher已经订阅了这个Dep this.depIds.add(id) // 记录自己都订阅了哪些Dep this.deps.push(dep) // 将自己订阅到Dep dep.addSub(this) } } }
然后再看watcher.teardown()
class Watcher{ /*将自身从所有依赖收集订阅列表删除*/ teardown () { // this.active表示当前watcehr实例是否正在被使用 if (this.active) { /*从vm实例的观察者列表中将自身移除,由于该操作比较耗费资源,所以如果vm实例正在被销毁则跳过该步骤。*/ if (!this.vm._isBeingDestroyed) { remove(this.vm._watchers, this) } let i = this.deps.length while (i--) { this.deps[i].removeSub(this) } this.active = false } } } class Dep{ /*移除一个观察者对象*/ removeSub (sub: Watcher) { if (this.subs) { const index = this.subs.indexOf(sub) if (index > -1) { return this.subs.splice(index, 1) } } }
watcher.teardown()会遍历this.deps依次执行removeSub方法,将自身从dep上删除。

Vue.$set

Vue.prototype.$set = set export function set(target: Array<any> | Object, key: any, val: any): any { /*如果传入数组则在指定位置插入val*/ if (Array.isArray(target) && typeof key === "number") { target.length = Math.max(target.length, key); target.splice(key, 1, val); /*因为数组不需要进行响应式处理,数组会修改七个Array原型上的方法来进行响应式处理*/ return val; } /*如果是一个对象,并且已经存在了这个key则直接返回*/ if (hasOwn(target, key)) { target[key] = val; return val; } /*获得target的Oberver实例*/ const ob = (target: any).__ob__; /* _isVue 一个防止vm实例自身被观察的标志位 ,_isVue为true则代表vm实例,也就是this vmCount判断是否为根节点,存在则代表是data的根节点,Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property) */ if (target._isVue || (ob && ob.vmCount)) { /* Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property)。 https://cn.vuejs.org/v2/guide/reactivity.html#变化检测问题 */ return val; } if (!ob) { target[key] = val; return val; } /*为对象defineProperty上在变化时通知的属性*/ defineReactive(ob.value, key, val); ob.dep.notify(); return val; }

Vue.$delete

Vue.prototype.$delete = del export function del(target: Array<any> | Object, key: any) { if (Array.isArray(target) && typeof key === "number") { target.splice(key, 1); return; } const ob = (target: any).__ob__; // Vue 不允许在已经创建的实例上动态添加新的根级响应式属性(root-level reactive property)。 if (target._isVue || (ob && ob.vmCount)) { return; } // 如果没有这个属性则跳过 if (!hasOwn(target, key)) { return; } delete target[key]; // 如果数据不是响应式的则结束,否则还要通知依赖 if (!ob) { return; } ob.dep.notify(); }