阅读 1571

vue的响应式原理

数据劫持

在vue中,通过发布订阅的模式来监听数据状态的变化,通知视图进行更新。那么,是在何时进行订阅,何时进行发布,这就要用到数据劫持。

vue使用Object.defineProperty()进行数据劫持。

let msg = "hello"
const data = {};
Object.defineProperty(data, 'msg', {
    enumerable: true,
    configurable: true,
    get() {  //读取data.msg时会执行get函数
        console.log('get msg')
        return msg;
    },
    set(newVal) {  //为data.msg赋值时会执行set函数
        console.log('set msg')
        msg = newVal;
    }
});
data.msg   //'get msg'
data.msg = 'hi'  //'set msg'
复制代码

通过Object.defineProperty定义的属性,在取值和赋值的时候,我们都可以在它的get、set方法中添加自定义逻辑。当data.msg的值更新时,每一个取值data.msg的地方也需要更新,可视为此处要订阅data.msg,因此 在get方法中添加watcher。data.msg重新赋值时,要通知所有watcher进行相应的更新,因此 在set方法中notify所有watcher

在vue中,定义在data中的数据都是响应式的,因为vue对data中的所有属性进行了数据劫持。

function initData (vm) {
  var data = vm.$options.data;
  observe(data, true); 
}

function observe (value, asRootData) {
  var ob = new Observer(value);
  return ob
}

//Observer的作用就是对数据进行劫持,将数据定义成响应式的
var Observer = function Observer (value) {
  if (Array.isArray(value)) { //当数据是数组,数组劫持的方式与对象不同
    if (hasProto) {
      protoAugment(value, arrayMethods);
    } else {
      copyAugment(value, arrayMethods, arrayKeys);
    }
    this.observeArray(value);
  } else {
  //当数据是对象,递归对象,将对象的每一层属性都使用Object.defineProperty劫持,如 {a: {b: {c: 1}}}
    this.walk(value); 
  }
};
复制代码

使用vue时,data中经常会有数组,和对象不同,它的数据劫持不能通过Object.defineProperty来实现,下面我们分别来简单实现一下。

对象

对象的数据劫持,首先遍历对象的所有属性,对每一个属性使用Object.defineProperty劫持,当属性的值也是对象时,递归。

function observeObject(obj){
    //递归终止条件
    if(!obj || typeof obj !== 'object') return
	
    Object.keys(obj).forEach((key) => {
        let value = obj[key]
       
        //递归对obj属性的值进行数据劫持
        observeObject(value) 
        
        let dep = new Dep()  //每个属性都有一个依赖数组
        Object.defineProperty(obj,key,{
            enumerable: true,
            configurable: true,
            get(){
                dep.addSub(watcher) //伪代码, 添加watcher
                return value
            },
            set(newVal){
                value = newVal
                
                //obj属性重新赋值后,对新赋的值也进行数据劫持,因为新赋的值可能也是一个对象
                / **
                    let a = {
                    	b: 1
                    }
                    a.b = {c: 1}
                **/
                observeObject(value) 
                
                dep.notify()  //伪代码, 通知所有watcher进行更新
            }
        })
    })
}
复制代码

数组

数组状态的变化主要有两种: 一是数组的项的变化,二是数组长度的变化。因此数组的数据劫持也是考虑这两方面。

  • 数组项的劫持:
function observeArr(arr){
    for(let i=0; i<arr.length; i++){   
        observe(arr[i])  //伪代码,对每一项进行劫持
    }
}
复制代码

vue对于数组项是简单数据类型的情况没有劫持,这也导致了vue数组使用的一个问题,当数组项是简单数据类型时,修改数据项时视图并不会更新。

<div><span v-for="item in arr">{{item}}</span></div>
<button @click="changeArr">change array</button>    <!--点击按钮视图不会更新成523-->
复制代码
data:{
   arr: [1,2,3]
},
methods:{
  changeArr(){
     this.arr[0] = 5 
  }
}
复制代码
  • 数组长度变化的劫持是通过重写7个可以改变原数组长度的方法(push, pop, shift, unshift, splice, sort, reverse)实现的。
let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto); //arrayMethods继承自Array.prototype
let methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

methodsToPatch.forEach((method) => { //重写这7个方法
    arrayMethods[method] = function(...args) { 
        let result = arrayProto[method].apply(this,args) //调用原有的数组方法
        
        let inserted;
        switch (method) {
            case 'push':
            case 'unshift':
              inserted = args;
              break
            case 'splice':
              inserted = args.slice(2);
              break
        }
        if (inserted) { //push、unshift、splice可能加入新的数组元素,这里也要对新元素进行劫持
            observeArray(inserted); 
        }
        
        dep.notify(); //伪代码, 通知所有watcher进行更新
        return result
    }
})

arr.__proto__ = arrayMethods  //arr是需要进行劫持的数组,修改它原有的原型链方法。
复制代码

实现一个简单的双向数据绑定

  • 第一步,初始化。
class Vue {
    constructor(options){
        this.$data = options.data
        this.$getterFn = options.getterFn
        observe(this.$data)  // 将定义在options.data中的数据作响应式处理
        
        //options.getterFn是一个取值函数,模拟页面渲染时要做的取值操作
        
        new Watcher(this.$data, this.$getterFn.bind(this), key => {
            console.log(key + "已修改,视图刷新")
        })
    }
}
复制代码
  • 第二步,实现observe方法。主要就是用到上面的发布订阅模式和数据劫持。
function observe(data){
    if(!data || typeof data !== 'object') return
    let ob;
    //为数据创建observer时,会将observer添加到数据属性,如果数据已经有observer,会直接返回该observer
    if (data.hasOwnProperty('__ob__') && data.__ob__ instanceof Observer) { 
        ob = data.__ob__;
    }else{
        ob = new Observer(data)
    }
    return ob
}


class Observer {
    constructor(data){
        this.dep = new Dep()   //将dep挂载到observer上,用于处理data是数组的情况
        Object.defineProperty(data, '__ob__', {  //将observer挂载到要data上,方便通过data访问dep属性和walk、observeArray方法
            enumerable: false,
            configurable: false,
            value: this
        })
        if(Array.isArray(data)){  //如果是数组,重写数组的7个方法,对数组的每一项作响应式处理
            data.__proto__ = arrayMethods  
            this.observeArray(data)
        }else{
            this.walk(data)
        }
    }

    walk(data){
        let keys = Object.keys(data)
        keys.forEach((key) => {
            defineReactive(data, key)
        })
    }

    observeArray(data){
        data.forEach((val) => {
            observe(val)
        })
    }
}

//重写数组的7个方法
let arrayProto = Array.prototype;
let arrayMethods = Object.create(arrayProto); //arrayMethods继承自Array.prototype
let methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
methodsToPatch.forEach((method) => { 
    arrayMethods[method] = function(...args) { //将一个不定数量的参数表示为一个数组
       let result = arrayProto[method].apply(this,args) //调用原有的数组方法        
        let inserted;
        switch (method) {
            case 'push':
            case 'unshift':
              inserted = args;
              break
            case 'splice':
              inserted = args.slice(2);
              break
        }
        if (inserted) { //push、unshift、splice可能加入新的数组元素,这里也要对新元素进行劫持
            this.__ob__.observeArray(inserted); 
        }
        this.__ob__.dep.notify('array')  //触发这个数组dep的notify方法
        return result
    }
})


function defineReactive(data,key){
    let dep = new Dep()  //每个属性对应一个dep,来管理订阅
    let value = data[key]
    
    //当value是数组时,不会为数组的每个属性添加dep,而是为整个数组添加一个dep。
    //当数组执行上面那7个方法时,就触发这个dep的notify方法  this.__ob__.dep.notify('array')
    let childOb = observe(value) 
    
    Object.defineProperty(data,key,{
        enumerable: true,
        configurable: true,
        get(){
        
            //添加订阅者。Dep.target是一个全局对象。它指向当前的watcher
            Dep.target && dep.addSub(Dep.target)
            
            if(Array.isArray(value)) {
                Dep.target && childOb.dep.addSub(Dep.target)
            }
            
            return value
        },
        set(newVal){
            if(newVal === value) return
            value = newVal
            observe(value)
            dep.notify(key)
        }
    })
}
复制代码

何时触发watcher还是明显的。添加watcher就有点不太明显了。这里对watcher的构造函数作了一些修改。

Dep.target = null
class Watcher{
    constructor(data,getterFn,cb){
        this.cb = cb
		
        Dep.target = this
        getterFn()
        Dep.target = null
    }

    update(key){
        this.cb && this.cb(key)
    }
}
复制代码

关键就是:

Dep.target = this
getterFn()
Dep.target = null
复制代码

new Watcher()时,就会执行这三行代码。Dep.target = this将当前创建的watcher赋值给Dep.target这个全局变量,执行getterFn()时,会对取vm.$data中的值,上面已经将vm.$data作了响应式处理,所以取它值的时候就会执行各属性的get方法

get(){ 
   //此时Dep.target指向当前的watcher,此时就将当前watcher添加到这个属性对应的订阅数组里。
   Dep.target && dep.addSub(Dep.target)
            
   if(Array.isArray(value)) {
       Dep.target && childOb.dep.addSub(Dep.target)  //如果属性对应的值是数组,就将当前watcher添加到该数组对应的订阅数组里。
   }
            
   return value
},
复制代码

这样就完成了对需要访问的属性添加watcher的操作,然后将Dep.target还原成null。

测试代码:(渲染视图也是对data里的属性取值,如{{msg.m}},添加watcher,完成订阅。这里我们就简单访问取值来进行模拟)

let vm = new Vue({
   el: '#root',
   data:{
       msg: {
           m: "hello world"
       },
       arr: [
          {a: 1},
          {a: 2}
       ]
   },
   getterFn(){
       console.log(this.$data.msg.m)
       this.$data.arr.forEach((item) => {
           console.log(item.a)
       })
   }
})
复制代码

效果: 可以看到,getterFn访问过的数据,在修改值时就会触发watcher的回调函数。

vue的几种watcher

vue里面主要有三种watcher:

  • 渲染watcher: 当渲染用到的data数据变化时,重新渲染页面
  • computed watcher: 当data数据变化时,更新computed的值
  • user watcher: 当要watch的数据变化时,执行watch定义的回调函数

渲染watcher

渲染watcher是在vm.$mount()方法执行时创建的。

Vue.prototype.$mount = function () {
  var updateComponent = function () {
      vm._update(vm._render(), hydrating);
  };
  //updateComponent就是进行视图渲染的函数,对data中数据的取值的操作就是在该函数中完成
  new Watcher(vm, updateComponent, noop, options,true);
};
复制代码

Watcher的构造函数:

var Watcher = function Watcher (vm,expOrFn,cb,options,isRenderWatcher) {
  this.vm = vm;
  
  if (options) {
    ...
    this.lazy = !!options.lazy;  //主要用于computed watcher
  } else {
    this.deep = this.user = this.lazy = this.sync = false;
  }
  
  this.cb = cb;
 
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn;   //expOrFn对应上面的updateComponent方法
  } else {
    this.getter = parsePath(expOrFn);
  }
  
  //如果this.lazy为false,就立即执行this.get()
  //所以在创建watcher的时候就会执行updateComponent方法
  this.value = this.lazy? undefined: this.get();  
};

Watcher.prototype.get = function get () {
  pushTarget(this);   //类比上面简易版的Dep.target = this
  var value;
  var vm = this.vm;
  
  value = this.getter.call(vm, vm);  //执行取值函数,完成watcher订阅
  
  popTarget();  //类比上面简易版的Dep.target = null
 
  return value
};
复制代码

在渲染watcher创建的时候,就立即执行取值函数,完成响应式数据的依赖收集。可以看出,定义在data中的数据,它们的watcher都是同一个,就是在vm.$mount()方法执行时创建的watcher。watcher的update方法:

Watcher.prototype.update = function update () {
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);   //渲染watcher会走这里的逻辑,其实最终都会执行this.run(),只是这里用队列进行优化
  }
};

Watcher.prototype.run = function run () {
	var value = this.get();  //又会执行updateComponent方法
}
复制代码

定义在data中的数据,它们的watcher都是同一个,当data每一次数据中数据更新时,都会执行watcher.update()。渲染watcher的update()最终会执行updateComponent方法,如果一次性修改N个data属性时,比如下面例子中的change,理论上会执行N次updateComponent(),很明显,这是不科学的。

作为优化,维护一个watcher队列,每次执行watcher.update()就尝试往队列里面添加watcher(queueWatcher(this)),如果当前watcher已经存在于队列中,就不再添加。最后在nextTick中一次性执行这些watcher的run方法。

这样,如果一次性修改N个data属性时,实际上只会执行一次updateComponent()

data:{
    msg: "hello",
    msg2: "ni hao"
}, 
methods:{
    change(){
        this.msg = "hi"
        this.msg2 = "hi"
  }
},
复制代码

computed watcher

data:{
    msg: "hello"
},
computed: {
    newMsg(){
        return this.msg + ' computed'
    }
},
复制代码
<div>{{newMsg}}</div>
复制代码

当msg更新时,newMsg也会更新。因为computed会对访问到的data数据(这里是msg)进行订阅。

function initComputed (vm, computed) {
  var watchers = vm._computedWatchers = Object.create(null);

  for (var key in computed) {
    var userDef = computed[key];
    var getter = typeof userDef === 'function' ? userDef : userDef.get;
    
    watchers[key] = new Watcher(   //watcher的取值函数就是我们在computed中定义的函数
      vm,
      getter || noop,
      noop,
      computedWatcherOptions     // { lazy: true }
   );
   
   if (!(key in vm)) {
      defineComputed(vm, key, userDef);
   }
  } 
}
复制代码

在initComputed的时候,创建了watcher,它有个属性lazy: ture。在watcher的constructor中,lazy: ture表示创建watcher的时候不会执行取值函数,所以,此时watcher并没有加入msg的订阅数组。

this.value = this.lazy? undefined: this.get();  
复制代码

只有在页面对computed进行取值{{newMsg}}的时候,watcher才会加入msg的订阅数组。这里主要来看看defineComputed方法,它的大致逻辑如下:

function defineComputed (target,key,userDef) {  // target:vm, key: newMsg
 
 Object.defineProperty(target, key, {
      enumerable: true,
      configurable: true,
      get: function computedGetter () {  //当视图对newMsg进行取值的时候会执行这里
        var watcher = this._computedWatchers && this._computedWatchers[key];
        if (watcher) {
          if (watcher.dirty) {   //这里要对照Watcher的构造函数来看,默认watcher.dirty = watcher.lazy,首次执行为true
            watcher.evaluate();  //会执行watcher.evaluate()
          }
          if (Dep.target) {
            watcher.depend();
          }
          return watcher.value
        }
      },
      set: userDef.set || noop
  });
}

Watcher.prototype.evaluate = function evaluate () {
  this.value = this.get();    //执行watcher的取值函数,返回取值函数执行的结果,并将watcher添加到msg的订阅数组
  this.dirty = false;  //this.dirty置为false,用于缓存。
};
复制代码

computed watcher有个属性dirty,用于标记是否执行取值函数。

1、初始化watcher时,watcher.dirty = watcher.lazy,值为true。页面第一次访问newMsg时就会执行watcher.evaluate()

2、取值完成后,watcher.dirty = false。下一次页面再取值就会直接返回之前计算得到的值 watcher.value 。

3、如果watcher订阅的 msg 发生变化,就会通知执行watcher的 watcher.update()。lazy属性为true的watcher执行update方法是watcher.dirty = true,这样页面取值newMsg就会重新执行取值函数,返回新的值。这样就实现了computed的缓存功能。

Watcher.prototype.update = function update () {
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};
复制代码

user watcher

watch:{
   msg(newValue,oldValue){
      console.log(newValue,oldValue)
   }       
},
复制代码

或者这样:

mounted(){
   this.$watch('msg',function(newValue,oldValue){
       console.log(newValue,oldValue)
   })
}
复制代码

user watcher的核心方法就是vm.$watch:

Vue.prototype.$watch = function (expOrFn,cb,options) {

    //核心就是这里
    //expOrFn  ---> msg
    //cb  ---> 用户自己定义的回调函数,function(oldValue,newValue){console.log(oldValue,newValue)}
    
    var watcher = new Watcher(vm, expOrFn, cb, options);
  };
}
复制代码

和渲染watcher、 computed watcher的expOrFn不同,user watcher 的expOrFn是个表达式。

//watcher的构造函数中
if (typeof expOrFn === 'function') {
  this.getter = expOrFn;
} else {
  this.getter = parsePath(expOrFn);
}
复制代码

创建user watcher时,会根据这个表达式完成取值操作,添加watcher到订阅数组。

expOrFn: 'msg'   -----> vm.msg
expOrFn: 'obj.a'  -----> vm.obj ----->vm.obj.a
复制代码

deep:true时,会递归遍历当前属性对应的值,将watcher添加到所有属性上,每一次修改某一个属性都会执行watcher.update()

Watcher.prototype.get = function get () {
  pushTarget(this);
  var value;
  var vm = this.vm;
  value = this.getter.call(vm, vm);
  
  if (this.deep) {
     traverse(value);  //递归遍历取值,每次取值都添加该watcher到取值属性的订阅数组。
  }
  popTarget();
  return value
};
复制代码

vue源码系列文章:

vue2.0的响应式原理

vue编译流程分析

vuex原理之由浅入深手写vuex

vue组件从构建VNode到生成真实节点树

文章分类
前端
文章标签