Vue(二)数据响应式

314 阅读2分钟

这篇博客记录了学习Vue数据响应式过程中的一些理解和实践,主要参考了Vue官方文档的深入响应式原理,在梳理并写下这些内容的过程中,我收获了很多,其中包括JS中getter和setter,以及Object.defineProperty()的用法。


何为响应式

响应式:事物的特征会根据条件变化,自动作出对应变化 响应式页面:页面内容能够根据设备尺寸的变化,自动显示不同样式(字体、布局)

Vue数据响应式:当数据发生变化后,使用到该数据的视图也会相应进行自动更新。


一个小实验

用Vue创建实例,传入参数options选项-数据里有一种是data,我们需要深入理解

data是Vue 实例的数据对象。Vue 会递归地把 data 的 property 转换为getter/setter,从而让 data 的 property 能够响应数据变化

const vm=new Vue(options)

首先看这个例子:myData是一个普通的对象

const myData={
  n:0
}
console.log(myData)  //{n:0}

把myData对象传入Vue实例,作为data选项

const vm=new Vue({
  data: myData,
  template:`<div>{{n}}<button @click="add">+10</button></div>`,
  methods:{
    add(){
      this.n=this.n+10  //this指向vm,this.n===vm.n===myData.n
    }
  }
}).$mount('#app')
​
setTimeout(()=>{
  myData.n=myData.n+10
  console.log(myData) //{n:(...)}
},3000)

分析两次打印的结果:

  • 第一次打印出{n:0}对象
  • 在传给new Vue之后,打印出也是一个对象,但是属性n却成为了n:(...),并且多了get n、set n方法:

image-20211228222654898.png

Vue对data做了什么?要搞清这个问题,首先要明确gettersetter,以及Object.defineProperty方法


getter与setter

let obj1 = {
  姓: "张",
  名: "三",
  姓名() {
    return this.姓 + this.名;
  }
};
console.log(obj1.姓名());  // 姓名后面的括号不能删掉,因为它是函数

打印出obj1的内容,有三个属性,姓,名,姓名

image-20211229110151086.png

getter:不加括号的函数,仅此而已。

let obj2 = {
  姓: "张",
  名: "三",
  get 姓名() {
    return this.姓 + this.名;
  }
};
console.log(obj2.姓名);  //get使得 姓名 是一个属性

setter :属性 也可以被写

let obj3 = {
  姓: "张",
  名: "三",
  get 姓名() {
    return this.姓 + this.名;
  },
  set 姓名(xxx){
    this.姓 = xxx[0]
    this.名 = xxx.slice(1)
  }
};
obj3.姓名 = '李四'  //给这个属性赋值时调用
console.log(`姓 ${obj3.姓},名 ${obj3.名}`) //姓李,名四 

打印出obj3这个对象细节:有属性“姓”和属性“名”,姓名:(...)表示并不存在姓名这个属性,而是通过get 姓名set 姓名对姓名这个虚拟属性进行读和写。

image-20211229110242795.png

这个姓名:(...)与实验一中的n:(...)有些类似,但又有些不同之处:obj3中定义了get set方法,使得姓名是一个虚拟属性,实验一种的myData经过new Vue之后的数据怎么就被加上了get set方法,使得n也变成虚拟属性了呢?请接着往下看。


Object.defineProperty( )

对这个对象的定义已经完成,但还想继续添加一些属性和方法

创建了一个obj3对象的虚拟属性xxx,可以通过get xxx set xxx的方法读写这个虚拟属性:

var _xxx=0  //先声明一个变量 给它赋值
Object.defineProperty(obj3,'xxx',{
  get(){
    return _xxx  //如果对属性xxx读,读出的是_xxx的值
  },
  set(value){
    _xxx=value   //如果对属性xxx进行写,值也会写入_xxx中
  }
})

Object.defineProperty(对象,虚拟属性xxx,{ get set }) 会给这个对象自动加上get set方法来操作这个虚拟属性xxx,从而能悄悄改变变量_xxx的值

console.log(obj3.xxx) //0
obj3.xxx=20  //对obj3.xxx属性进行写操作,也能写入到_xxx这个变量
console.log(_xxx)  //20

也可以添加一个真实属性并给出赋值:

Object.defineProperty(obj3,'xxx1',{
  value:0
})

image-20211229114526970.png

由此可以总结出,Object.defineProperty()的作用是:

(1)给对象追加真实属性 (2)给对象追加虚拟属性,给对象添加getter/setter方法,对这个虚拟属性进行读写

看到这里,思路逐渐清晰,Object.defineProperty(对象,虚拟属性xxx,{ get set }) 会给这个对象自动加上get set方法来操作这个属性xxx,从而能悄悄改变变量_xxx的值。

是否new Vue时也会对myData对象,加上了虚拟属性n,然后get n,set n来对这个虚拟属性进行操作?从而悄悄改变了另一处的_n?如果是,这样做的目的是什么呢?请接着往下看。


另一个实验

let data0={ n:0 }
  1. 用Object.defineProperty实现为对象添加属性
let data1={ }
Object.defineProperty(data1,'n',{
  value:0
})
  1. 加约束条件:n 不能小于 0:即 data2.n = -1 应该无效,但 data2.n = 1 有效
let data2 = {}
data2._n = 0 // _n是真实属性  _n用来偷偷存储 n 的值Object.defineProperty(data2, 'n', {
  get(){
    return this._n  //this就是data2
  },
  set(value){
    if(value < 0) 
      return
    else 
      this._n = value
  }
})
​
console.log(data2.n)  //0
data2.n = -1
console.log(data2.n)  //0  设置为 -1 失败
data2.n = 1
console.log(data2.n)  //1  设置为 1 成功

在本例中 ,_n才是我们要关心的真实属性,对data2的虚拟属性n的get set ,在修改真实属性 _n,做到让 _n的值不能<0

问题:data2.n是虚拟属性,值不能<0,但是直接对data2._n赋值,则可以修改

  1. 使用代理:不暴露这个对象任何可以修改的属性
let data3 = proxy({ data:{n:0} })  // 括号里是匿名对象,因为没有名字,就无法访问function proxy({data}){
  const obj = {}
  Object.defineProperty(obj, 'n', { 
    get(){
      return data.n
    },
    set(value){
      if(value<0)return
      data.n = value
    }
  })
  return obj // obj 就是代理
}
​
// data3 就是 obj
console.log(data3.n)   //0
data3.n = -1
console.log(data3.n)   //-1
data3.n = 1
console.log(data3.n)   //1

obj3就是代理,所有操作data对象值的操作都要通过obj3,对obj3的虚拟属性n操作,都是对data的真实属性n操作,通过proxy函数返回这个代理,为data3,以上做法只会暴露代理obj3,不会暴露对象data。

问题:如果绕过了代理呢?

let myData = {n:0}
let data4 = proxy({ data:myData }) //给了这个对象一个名字myDataconsole.log(data4.n)  //   0
myData.n = -1         //修改myData
console.log(data4.n)  //  -1

因为proxy只能代理data的属性不被修改,如果对象的存储时被给了一个名字myData引用了这个属性n

4.代理+监听:就算用户擅自修改 myData,也要拦截他

let myData5 = {n:0}
let data5 = proxy2({ data:myData5 }) // 括号里是匿名对象,无法访问function proxy2({data}){
  // 监听 data
  let value = data.n  //先把这个n的值存下来 监听
  //这里这个旧data就已经没用了,data.n也没有了,这样就没有办法操控原始数据了
  Object.defineProperty(data, 'n', {  //声明一个新data对象的虚拟属性n
    get(){
      return value
    },
    set(newValue){
      if(newValue<0)return
      value = newValue
    }
  })
​
  // 原来的代理
  const obj = {}
  Object.defineProperty(obj, 'n', {
    get(){
      return data.n
    },
    set(value){
      if(value<0) return
      data.n = value
    }
  })
  
  return obj // obj 就是代理
}

将原来的对象data重新赋值一个n,原来的n就删掉了,myData也访问不到这个n,旧data和新data仍然是一个data,但是里面的n已经不是从前那个了,是被加了监听和代理的虚拟属性n。

console.log(data5.n) //0 
myData5.n = -1
console.log(data5.n) //0
myData5.n = 1
console.log(data5.n) //1

等等,这些代码看着眼熟吗?

let myData = {n:0}
let data5 = proxy2({ data:myData })  //实验二let vm = new Vue({ data: myData })   //实验一

是不是new Vue也使用类似proxy的方法在传入内部数据data前后,也对这个data里的属性加了监听 & 代理

原理如下:

image-20211228203403535.png

  • 给data内部数据传入一个对象{ n:0 },new Vue会对这个data内部进行改造:加监听

    data:{  n:0  } 
    --------------------------------------------------
    //删掉了n  以下内容是新data(仍然是同一个对象)
    let value=0  
    {
      get n(){ return value  } //读取value
      set n(v){ value=v  }     //写入value
    }
    //如果data内部属性除了n 还有m,k   那么还会有get n( )、get m( )、get k( )
    
  • vm对data进行代理:全权负责新data的读和写,加get set封装返回了vm对象

new Vue 对data做了哪些事:

  • 监听:对myData的所有属性进行监控。为myData安装虚拟属性n,以及getter/setter方法,无论对myData的哪个属性进行读写,都会被Vue监听到。

    为何要监听?为了防止myData的属性值被修改,vm不知道,如果vm知道了,调用UI=render(data),UI自动刷新页面

  • 代理:让vm成为myData的代理:对myData对象属性的读写,由另一个对象vm全权负责,(vm是房东,myData是中介)对vm中虚拟属性n的读、写就相当于对myData的n进行读写,我们必须用vm.nthis.n来操作myData.n

以上就是Vue的数据响应式的原理,Vue通过Object.defineProperty()来实现对data的代理 & 监听


data中存在的bug

代理时需要使用Object.defineProperty(obj,"n",{...}),对这个代理对象obj.n的操作就相当于对data.n操作,但是必须要有一个 n,才能够监听和代理obj.n。

bug1:Vue监听不到后添加的数据属性

但是如果我们原来传入的data没有提前定义n怎么办?Vue没办法监听到一开始不存在的数据对象的属性。

new Vue({
  data: {
    obj: {
      a: 0   // obj.a 会被 Vue 监听 & 代理
  //  b:undefined  //方法一
    }
  },
  template: `
    <div>
      {{obj.b}}<button @click="setB">set b</button>
    </div>
  `,
  methods: {
    setB() {
       this.obj.b = 1;   // 页面中不会显示 1 
      //Vue.set(this.obj,'b',1)  
      //this.$set(this.obj,'b',1) 第二种写法
    }
  }
}).$mount("#app");

点击按钮,页面中不会显示 1 。因为Vue没办法监听一开始就不存在的b。

有两种办法解决:

  • 提前把所有的key在data里声明好

  • Vue.set()或者this.$set()代替this.obj.b=1的操作,做了三件事:

    • 新增key b
    • 自动创建代理和监听(如果没有创建过)
    • 触发UI更新(不会立即更新)

bug2:对数组的修改

let vm = new Vue({
    data: { 
      array: ['a', 'b', 'c'] 
   } 
})  
vm.array[3] = 'd'  //没办法做到

因为数组数据array=['a','b','c']相当于 array:{0:'a',1:'b',2:'c'}

  • 方法1:array=['a','b','c',undefined]无效,数组长度很难知道

  • 方法2:this.$set(this.array,3,'d'),可以,但是set没有自动创建代理和监听,也不会更新UI

  • 方法3:vm.array.push('d'),数组被传给Vue之后,会被篡改,push会调以前的push,并加入了Vue.set,这就是数组的变异

    Vue将传入的数组的原型链上加了一层原型VueArray,里面有七个API,覆盖了数组的七个API,对数组进行增删,并且可以自动处理监听和代理,并更新UI,因此建议用这7个API来更新UI,大致的思想如下:

class VueArray extends Array{
  push(...args){
    super.push(...args)  //首先继承Array的push方法
    /*这里就可以篡改这个数组的原型,增加Vue.set()去代替Push的操作   */
  }
}
const arr=new VueArray(1,2,3,4)

这七个API有:push()pop()shift()unshift()splice()sort()reverse()


总结:何为Vue的数据响应式

  • 当数据发生变化后,使用到该数据的视图也会相应进行自动更新,这就是数据响应式。
  • let vm = new Vue({ data: myData }) Vue通过 Object.defineProperty() 来实现数据响应式,对内部数据data添加了监听 & 代理。对vm.n的读写修改,就是通过get n、set n方法对data.n的修改,从而触发视图的重新渲染。
  • Vue不能检测到对象属性的添加或删除,如果一开始没有在Data上声明属性,就算你对这个属性做出更改,也不会更新UI。应该调用Vue.set或者this.$set来对这个属性代理 & 监听,并更新UI。
  • 对于数组,也可以用 Vue.set 或 this.$set 来新增,但更推荐使用7个变更过的数组API:push(), pop(), shift(), unshift(), splice(), sort(), reverse()。