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

那么什么方法才需要使用拦截器?很明显,能够改变数组自身数据的方法都需要拦截,整理后有以下几种:

  • 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();
}

最后更新于