面试官:Vue中的数据响应式

1,431 阅读6分钟

Vue中的数据响应式

在Vue的使用中,我们通过对组件中data的数据进行设置,从而实现页面的更新渲染。

一、什么是数据响应式

在Vue中,一个组件 data 的数据一旦变化,立刻触发视图的更新,这就是Vue的数据响应式,它也是实现数据驱动页面渲染的第一步。而data的本质是一个对象,我们通过为对象添加属性名(页面中的变量名)及属性值(变量值)的方式来声明页面中的数据。那么我们如何实现数据响应呐?在这个问题之前,我们先来了解一下data对象中的每个属性(页面中的每个变量)。

二、Object.getOwnPropertyDescriptor( )

该方法返回一个对象,该对象描述给定对象上特定属性的配置。

我们可以通过这个方法先来了解一下在普通JS中data对象每个属性的具体配置信息。例如:我们在JS文件中声明一个data对象,并在这个data对象中声明一个name属性,赋值为‘张三’。

const data={
  name:'张三'
}
console.log(Object.getOwnPropertyDescriptor(data,'name'));

那么我们就可以通Object.getOwnPropertyDescriptor(data,'name')的方法来获取name属性的配置信息。

Snipaste_2022-07-18_13-37-54.png

在浏览器的控制台中,我们可以看到,name属性中有4个配置信息,分别是configurable(是否可删)、enumerable(是否可枚举)、writable(是否可写)以及value(属性值)。那么我们如何修改属性的配置信息,这又和Vue的数据响应式有什么关系呐?这就得聊聊Object.defineProperty()。

三、Object.defineProperty()

该方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

通过这个方法,我们可以对上述的属性的配置项进行修改。例如,我们先将writable(是否可写)设置为false,然后通过data.name='李四'的方式尝试修改name的属性值。

const data={
  name:'张三'
}
Object.defineProperty(data,'name',{
  writable:false //将name属性设置为不可写
})
data.name='李四'
console.log(Object.getOwnPropertyDescriptor(data,'name'));

在浏览器中,我们会发现,name的属性值由于设置为不可写,因此属性值依然为‘张三’。

Snipaste_2022-07-18_14-55-28.png

四、set和get方法

那么,这和数据响应有什么关系呐?我们可以使用这个方法给属性添加一个set和get方法,从而实现1个对象的数据响应式。例如,我们为name属性配置set和get方法,当我们读取数据的时候,会触发Object.defineProperty()中的get()方法,并将其return的返回值作为读取的数据;同理,我们在设置数据时,会触发Object.defineProperty()中的set()方法,通过这个方法来修改属性的值。

const data={
  name:'张三'
}
//使用变量来接收一下name.data,防止死循环
let username=data.name
Object.defineProperty(data,'name',{
  get: function() { 
    console.log('get触发')
    return 123
  },
  set: function(newVal) {
    console.log('set接收到的newVal:',newVal)
    username = newVal
  }
})
// 修改属性值
data.name='李四'
// 读取属性值
console.log('get返回结果:',data.name);
console.log(Object.getOwnPropertyDescriptor(data,'name'));

在页面中,我们读取到的数据是:123(get方法中的返回值,这里只是举例,实际中我们返回的是真实数据,即案例中的username),并且将data中的name属性赋值为'李四'。 Snipaste_2022-07-23_10-52-42.png

五、单一属性的数据响应

有了get和set方法,我们就可以实现对data中属性的自由操作,那么应该如何让修改的数据驱动页面的更新,从而实现单一属性的数据响应那?这里我们需要借助document.getElementById和innerHTML来现实。例如,我们可以封装一个函数叫做setView(),用来同步视图的数据,并且在set方法中调用它。这样每次数据更新之后,setView()方法被触发,改变页面中的视图,从而实现数据响应式。

<body>
    <div>
      <p id="name"></p>
      <p id="age"></p>
    </div>
    <script>
      // 定义数据
      const data = {
        name: '张三',
        age: 19
      }

      // 同步视图的方法
      function setView() {
        document.getElementById('name').innerHTML = data.name
        document.getElementById('age').innerHTML = data.age
      }
      setView()

      // 利用循环对数据进行监听
      Object.keys(data).forEach(key => {
        // 利用外部数据进行数据的读写控制
        let getValue = data[key]
        Object.defineProperty(data, key, {
          // 读数据
          get() {
            return getValue
          },
          // 写数据
          set(val) {
            getValue = val
            setView()
          }
        })
      })
    </script>
  </body>

六、多个属性的数据响应

通过上述的方法,我们实现了data对象中单一属性的数据响应,但是我们可以发现Object.defineProperty()中每次只可以操作data对象中的1个属性。

那么我们如何实现多个属性的数据响应呐?并且,如果属性值对象,我们又应该如何处理?其实,对于多个属性,我们可以使用for(key in obj)的方式来实现遍历data对象中的属性,而对于属性值为对象的属性,我们则可以通过‘递归函数’的方式来解决。

 <body>
    <div>
      <p id="name"></p>
      <p id="age"></p>
      <p id="idCard"></p>
      <p id="address"></p>
    </div>
    <script>
      // 数据
      const data = {
        name: '张三',
        age: 19,
        info: {
          idCard: 233455667889001223,
          address: 'XXX'
        }
      }

      // 同步视图的方法
      function setView() {
        document.getElementById('name').innerHTML = data.name
        document.getElementById('age').innerHTML = data.age
        document.getElementById('idCard').innerHTML = data.info.idCard
        document.getElementById('address').innerHTML =data.info.address
      }
      setView()

      observer(data)

      // 递归的方法
      function observer(data) {
        //Object.keys(data).forEach()等同于for(key in Obj)
        Object.keys(data).forEach(key => {
          //判断是否为复杂型数据,Arrary和Object通过typeof返回的都是Object
          if (typeof data[key] === 'object') {
            //如果是,则递归调用自己,并且将递归的结果返回
            return observer(data[key])
          }
          //赋值中间变量,防止死循环
          let getValue = data[key]
          Object.defineProperty(data, key, {
            get() {
              return getValue
            },
            set(val) {
              getValue = val
              //同步视图的方法
              setView()
            }
          })
        })
      }
    </script>
  </body>

以上就是实现数据响应式的方法。在Vue中,也是类似的,当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter

这些 getter/setter 对用户来说是不可见的,但是在内部它们让 Vue 能够追踪依赖,在 property 被访问和修改时通知变更。

并且每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

data.png

七、Vue中数据不响应的问题

在Vue中,如果我们单纯的对某一个数组中的单个数据进行修改,或者为某一个属性值类型为对象的数据添加新的属性,此时通过data.obj.key=val的形式赋值,并不会实现页面的数据响应式。原因分为2个方面:

1、为属性值为对象类型的属性添加新属性

注意,这里我们说的是添加新的属性,而不是修改现有的数据。造成这一问题的根本原因是Object.defineProperty()方法自身的一个缺陷,这个方法只能对一个对象上现有的属性进行监听拦截,而不能对新增的属性进行监听拦截。

当我们的页面初次加载时,就已经触发了Object.defineProperty()对属性进行监听;之后我们再为对象添加新的属性,只要页面不触发Object.defineProperty()方法,就无法让Object.defineProperty()对新添加的属性进行监听,从而实现数据响应式。

2、通过下角标的方式修改数组中的某一个元素

其实,对数组中现有的某一元素进行修改,Object.defineProperty()是可以进行数据监听拦截,并实现数据响应式的,但是Vue的官方团队放弃这一功能,因为数组的长度是不固定的,这可能会引起性能代价与用户体验不成正比。在GitHub中,Vue的作者尤大大也是这样回应使用者的。

image-20210306141251319.caa7adba.png

image-20210306141433455.f45d1058.png

3、如何解决

上述的两个问题,我们可以通过this.$set(obj, key, value)的方式来解决。另外,也可以在修改数组中某一现有元素或者为对象添加新的属性之后,正常的再修改另外一个数据,这样就会引起页面的刷新,从而让Object.defineProperty()获取最新的属性。即使第二种方法也可以实现所需的效果,但我们还是推荐使用第一个方式。