数据代理是指通过一个对象代理另一个对象中属性的(读/写)操作。

数据代理的原理

数据代理可以通过Object.defineProperty()实现。在Vue中,很多技术的实现都使用到了Object.defineProperty()这个方法。

Object.defineProperty()的用法如下所示:

let person = {
  name: '张三',
  sex: '男',
  // age: 18,
}

/**
 * 使用 Object.defineProperty() 给对象添加属性
 * 1. 参数1:要添加属性的对象
 * 2. 参数2:要给对象添加的属性名称
 * 3. 参数3:要添加的属性的配置
 */
Object.defineProperty(person, 'age', {
  value: 18,  // 定义属性的值
  enumerable: true, // 控制属性是否可枚举,默认为false
  writable: true, // 控制属性是否可被写入(修改),默认为false
  configurable: true, // 控制属性是否可被删除,默认为false
})

console.log(person);
// console.log(Object.keys(person));
let articleSize = 100

let article = {
  name: 'Vue',
  // size: articleSize,
}

Object.defineProperty(article, 'size', {
  /**
   * Getter:
   * 当article.size被读取时,get()会被调用,并且将返回值作为article.size的值
   */
  get() {
    console.log('The article.size is read.');
    return articleSize
  },

  /**
   * Setter:
   * 当article.size被修改时,set()会被调用,并且将参数value作为article.size的值
   */
  set(value) {
    console.log(`The article.size is modified to ${value}.`);
    articleSize = value
  },
})

console.log(article);

假设有两个对象obj1obj2,需要能通过obj2来修改obj1,可以这样实现:

let obj1 = {x: 100}
let obj2 = {y: 200}

Object.defineProperty(obj2, 'x', {
  get() {
    return obj1.x
  },
  set(value) {
    obj1.x = value
  }
})

Vue 数据代理

Vue实例实际上是Vue实例中的data的数据代理对象。Vue实例中的data通过数据代理,将其对象中的属性交予Vue实例来直接管理。

验证Vue数据代理:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.js"></script>
  <title>数据代理</title>
</head>
<body>
  <div id="app">
    <h1>姓名:{{name}}</h1>
    <h1>年龄:{{age}}</h1>
  </div>

  <script type="text/javascript">
    const data = {
      name: '张三',
      age: '18',
    }

    const vm = new Vue({
      el: "#app",
      data,
    });

    console.log(data);
    console.log(vm);

    console.log(`vm._data === data ??? ${vm._data === data}`);
  </script>
</body>
</html>

实际上,Vue对象在实例化时,会将配置(options)中的data实例化为vm._data。所以实际上vm对象代理的是vm._data对象。

由于Vue模板语法中,在使用插值时获取的是Vue实例中的对象。如果Vue没有使用数据代理,那么要获取data中的数据,相应的表达式应该写作{{_data.name}}(假设获取name)。当使用了数据代理,data中的对象可以通过Vue实例对象来进行操作,那么表达式就可以直接写作{{name}}


Vue 计算属性

Vue.js计算属性在处理一些复杂逻辑时是很有用的。

计算属性的关键词是computed,在Vue实例的配置中指定computed即可定义计算属性。

Vue计算属性示例如下:

<div id="app">
  姓:<input type="text" v-model="firstName"><br><br>
  名:<input type="text" v-model="lastName"><br><br>
  姓名:<input type="text" v-model="fullName">
</div>
<script type="text/javascript">
  new Vue({
    el: "#app",
    data: {
      firstName: '张',
      lastName: '三',
    },
    computed: {
      fullName: {
        get() {
          // 此处的 this 依旧是指向 vm
          return this.firstName + '-' + this.lastName
        },
        set(value) {
          const arr = value.split('-')
          this.firstName = arr[0]
          this.lastName = arr[1]
        },
      },
    },
  });
</script>

计算属性中有两种方法(就像数据代理一样):

  • Getter:

    作用:当对应的计算属性(如上例为fullName)被读取时,get()就会被调用,且返回值作为该计算属性的值。

    Vue对计算属性做了缓存,所以当计算属性被读取时,并不总是会调用get()方法。

    计算属性的get()方法被调用的时机:

    1. 初次读取该计算属性时。
    2. 所依赖的数据(这类数据必须是由Vue来管理的数据,如firstNamelastName)发生变化时。其它不被依赖的属性发生改变时, get() 方法不会被调用。

    常见的计算数据不需要修改,因此仅使用Getter即可。当仅使用Getter时,可以使用以下简写形式:

    computedAttribute() {
      /* ... */
    }
    

    即,将计算属性使用函数式定义(上方所示是对象式定义),该函数即用作该计算属性的get()。例如将上例修改为仅使用Getter的简写形式:

    new Vue({
      el: "#app",
      data: {
        firstName: '张',
        lastName: '三',
      },
      computed: {
        fullName() {
          // 此处的 this 依旧是指向 vm
          return this.firstName + '-' + this.lastName
        },
      },
    });
    
  • Setter:

    如果计算属性会被用户修改,则需要使用Setter;否则,Setter可省略。

    作用:当对应的计算属性被修改时,set()就会被调用,且返回值作为该计算属性的值。

使用计算属性的好处:与methods相比,计算属性内部有缓存机制(复用),效率更高,更加方便调试。

注:计算属性不能与data属性重名。


Vue 监听属性

通过Vue.js提供的监听属性watch来响应数据的变化。

例如:

<div id="app">
    <h2>今天天气很{{info}}</h2>
    <button @click="isHot = !isHot">切换天气</button>
</div>
<script type="text/javascript">
  new Vue({
    el: "#app",
    data: {
      isHot: true,
    },
    computed: {
      info() {
        return this.isHot ? '炎热' : '凉爽'
      }
    },
    watch: {
      info: {
        immediate: true,  // 初始化时执行 handler()
        handler(newValue, oldValue) {
          const update = {
            newValue,
            oldValue,
          }
          console.log('The attribute "info" was updated: ');
          console.log(update);
        },
      }
    }
  });
</script>
  • watch中,将要监听的属性直接作为watch配置的属性定义即可(名称要相同)。如上例,要监听计算属性info,则直接在watch中定义info即可。
  • watch可监听的属性包括datacomputed中的属性。
  • Handler:当监听的属性发生改变时,其对应的handler()方法会被调用。

如果在监听属性的配置中,不需要添加其它属性来修改配置(仅定义了handler()时),可以使用以下简写形式来定义:

watchingAttribute() {
  /* handler ... */
}

即函数式定义监听属性,定义的函数将作为该属性的handler()所使用。如上例,将其修改为仅定义Handler的简写形式:

new Vue({
  el: "#app",
  data: {
    isHot: true,
  },
  computed: {
    info() {
      return this.isHot ? '炎热' : '凉爽'
    }
  },
  watch: {
    info() {
      const update = {
        newValue,
        oldValue,
      }
      console.log('The attribute "info" was updated: ');
      console.log(update);
    }
  }
});

监听属性还有另外一种定义的形式,使用Vue示例对象的方法vm.$watch()来定义。

vm.$watch()接受两个参数:

  1. 参数1:指定监听的属性。
  2. 参数2:该监听属性的配置对象(与在Vue示例中的配置对象一样)。

例如:

const vm = new Vue({
  el: "#app",
  data: {
    isHot: true,
  },
  computed: {
    info() {
      return this.isHot ? '炎热' : '凉爽'
    }
  },
});

vm.$watch('info', {
  immediate: true,
  handler(newValue, oldValue) {
    const update = {
      newValue,
      oldValue,
    }
    console.log('The attribute "info" was updated: ');
    console.log(update);
  },
});

仅配置Handler时,简写形式如下:

vm.$watch('info', function (newValue, oldValue) {
  const update = {
    newValue,
    oldValue,
  }
  console.log('The attribute "info" was updated: ');
  console.log(update);
});

Vue 深度监听

Vue中的watch

  • watch默认不监测对象内部值的改变(只监视对象整体的改变)。
  • 配置deep: true可以监测对象内部值的改变(监视对象多层内部属性的改变)。
<div id="app">
  <h2>numbers.x = {{numbers.x}}</h2>
  <button @click="numbers.x++">x + 1</button>
  <button @click="numbers.x = 0">x = 0</button>
  <h2>numbers.y = {{numbers.y}}</h2>
  <button @click="numbers.y++">y + 1</button>
  <button @click="numbers.y = 0">y = 0</button><br><br>
  <button @click="numbers = {x: 666, y: 233}">改变 numbers</button>
</div>
<script type="text/javascript">
  const vm = new Vue({
    el: "#app",
    data: {
      numbers: {
        x: 0,
        y: 0,
      }
    },
    watch: {
      // 监听对象内部属性
      'numbers.x': {
        handler(newValue, oldValue) {
          console.log({
            variable: 'numbers.x',
            newValue,
            oldValue,
          });
        },
      },
      'numbers.y': {
        handler(newValue, oldValue) {
          console.log({
            variable: 'numbers.y',
            newValue,
            oldValue,
          });
        },
      },
      // 监听对象内部多层级属性
      numbers: {
        deep: true, // 开启监听多级结构中所有属性的变化(深度监视)
        handler(newValue, oldValue) {
          console.log({
            variable: 'numbers',
            newValue,
            oldValue,
          });
        }
      }
    }
  });
</script>

其中,提供了另外一种用于监听对象内部属性变化的watch简写形式:

'numbers.y'(newValue, oldValue) {
  /* ... */
},

计算属性中的案例进行修改,让名字的修改延迟1秒:

<div id="app">
  姓:<input type="text" v-model="user.firstName"><br><br>
  名:<input type="text" v-model="user.lastName"><br><br>
  姓名:<span>{{user.fullName}}</span>
</div>
<script type="text/javascript">
  new Vue({
    el: "#app",
    data: {
      user: {
        firstName: '张',
        lastName: '三',
        fullName: '张-三',
      },        
    },
    watch: {
      user: {
        deep: true,
        handler(val) {
          /**
           * 延迟 1s 进行修改
           * 这里的setTimeout()不能使用 function 定义的函数,只能使用lambda,因为使用function的话函数中的this指向的是window
           */
          setTimeout(() => {
            this.user.fullName = val.firstName + '-' + val.lastName
          }, 1000)
        }
      },
    }
  });
</script>

计算属性不能进行异步操作,所以在有些情况下使用监听属性相对较好。

由Vue管理的函数,最好以普通函数function() {}的形式去定义;而其它不被Vue所管理的函数(例如定时器回调函数、Ajax回调函数、Promise回调函数),最好使用lambda表达式() => {}的形式去定义。


数据劫持

Vue加载data配置和data中的数据发生更新的过程大致如下:

  1. 加工data配置。

    Vue为data中每个属性都通过Object.defineProperty()添加了Getter和Setter(响应式处理)。当对data中的属性进行更改时,会自动调用对应的Setter。当调用Setter时,Setter会自动解析模板中对应的内容。

    Setter调用时执行的流程大致如下:

    1. 重新解析模板,生成新的虚拟DOM。
    2. 新旧虚拟DOM对比。
    3. 更新页面。

    Vue中Getter和Setter的大致实现方式如下:

    let data = {
      x: 1,
      y: 1000,
    }
    
    // 创建监视者实例对象,用于监视data中属性的变化
    const obs = new Observer(data)
    
    // 模拟Vue实例对象
    let vm = {}
    vm._data = data = obs
    
    function Observer(obj) {
      // 汇总对象中所有的属性形成一个数组
      const keys = Object.keys(obj)
      // 遍历
      keys.forEach((key) => {
        Object.defineProperty(this, key, {
          get() {
            return obj[key]
          },
          set(val) {
            console.log(`${key} 被修改`);
            console.log('解析模板,生成虚拟DOM');
            console.log('......');
            obj[key] = val
          },
        })
      })
    }
    

    Vue通过递归将data中所有的对象及其属性通过Object.defineProperty()的方式设置了Getter和Setter。通过Object.defineProperty()添加Getter和Setter来进行响应式处理的动作叫做数据劫持

    数组中的对象仅会对它们的属性进行响应式处理,而数组中的元素是没有Getter和Setter的(没有进行响应式处理)。

  2. 将加工完成的data赋给Vue示例的_data属性。即vm._data = data


添加新的响应式数据

例如页面中存在需要后续添加的数据:

<div id="app">
  <h2>姓名:{{user.name}}</h2>
  <h2>性别:{{user.sex}}</h2>
  <h2>年龄:</h2>
  <ul>
    <li>真实年龄:{{user.age.rAge}}</li>
    <li>对外年龄:{{user.age.sAge}}</li>
  </ul>
  <hr>
  <h2>好友:</h2>
  <table>
    <thead><tr><td>姓名</td><td>年龄</td></tr></thead>
    <tbody>
      <tr v-for="(friend, index) in user.friends" :key="index">
        <td>{{friend.name}}</td><td>{{friend.age}}</td>
      </tr>
    </tbody>
  </table>
</div>
<script type="text/javascript">
  const vm = new Vue({
    el: "#app",
    data: {
      user: {
        name: '张三',
        // sex: '男',
        age: {
          rAge: 40,
          sAge: 29,
        },
        friends: [
          {name: '李四', age: 35},
          {name: '王五', age: 36},
        ]
      }
    },
  });
</script>

这部分后续添加的数据(如上例中的user.sex),直接为其赋值(vm._data.user = '男')并不能让页面产生改变。这是因为在Vue实例中,后续添加的数据Vue并不会自动帮它们进行响应式数据处理(即通过Object.defineProperty()添加Getter和Setter)。

为了解决这些问题,Vue提供了一个Vue.set()方法来为Vue实例或Vue组件实例中的数据对象添加新的响应式数据。Vue.set()方法的参数如下:

  1. 参数1(target):要添加属性的对象。
  2. 参数2(key):要为target添加的属性名称。
  3. 参数3(value):要为target对象添加的key属性所赋的值。

如上例,可以使用以下方式添加响应式数据user.sex

Vue.set(vm._data.user, 'sex', '男')

此时页面才能进行正常的更新。

在Vue实例中也存在这样的方法,即vm.$set()方法(参数与Vue.set()一致)。如上例,添加响应式数据user.sex的另一种方式:

vm.$set(vm.user, 'sex', '男')

Vue.set()vm.$set()中参数target的前缀可以是vm._data也可以是vm这是因为在Vue实例vm中,Vue将vm._data中的一些属性交给了vm来代理(数据代理)。

对上例进行修改:

<div id="app">
  <button @click="addSex">添加性别(默认值是男)</button>
  <h2>姓名:{{user.name}}</h2>
  <h2 v-if="user.sex">性别:{{user.sex}}</h2>
  <h2>年龄:</h2>
  <ul>
    <li>真实年龄:{{user.age.rAge}}</li>
    <li>对外年龄:{{user.age.sAge}}</li>
  </ul>
  <hr>
  <h2>好友:</h2>
  <table>
    <thead><tr><td>姓名</td><td>年龄</td></tr></thead>
    <tbody>
      <tr v-for="(friend, index) in user.friends" :key="index">
        <td>{{friend.name}}</td><td>{{friend.age}}</td>
      </tr>
    </tbody>
  </table>
</div>
<script type="text/javascript">
  new Vue({
    el: "#app",
    data: {
      user: {
        name: '张三',
        // sex: '男',
        age: {
          rAge: 40,
          sAge: 29,
        },
        friends: [
          {name: '李四', age: 35},
          {name: '王五', age: 36},
        ]
      }
    },
    methods: {
      addSex() {
        this.$set(this.user, 'sex', '男')
      }
    }
  });
</script>

Vue.set()vm.$set()的使用有一些局限性。它们在使用时不允许target为Vue实例对象或Vue实例对象的直接数据对象(如_data等,Vue组件实例对象也一样)。

在Vue中,后续添加的新对象,如果添加的方式满足Vue的规范,添加的元素Vue会对其进行响应式处理。


Vue 数组的修改

在Vue中,对数组的某些修改并不会使页面发生改变。例如直接使用数组索引对元素赋值(如list[0] = 0)。这是因为Vue在加载和更新时并不会对数组中元素的本身作响应式处理(但是数组中对象元素的属性会做响应式处理),所以导致直接使用索引对数组元素赋值的修改并不会使页面发生改变。

问题演示:

<div id="app">
  <h2>姓名:{{user.name}}</h2>
  <h2>年龄:</h2>
  <ul>
    <li>真实年龄:{{user.age.rAge}}</li>
    <li>对外年龄:{{user.age.sAge}}</li>
  </ul>
  <hr>
  <h2>好友:</h2>
  <table>
    <thead><tr><td>姓名</td><td>年龄</td></tr></thead>
    <tbody>
      <tr v-for="(friend, index) in user.friends" :key="index">
        <td>{{friend.name}}</td><td>{{friend.age}}</td>
      </tr>
    </tbody>
  </table>
  <hr>
  <h2>爱好:</h2>
  <ul>
    <li v-for="(hobby, index) in user.hobbies" :key="index">
      {{hobby}}
    </li>
  </ul>
</div>
<script type="text/javascript">
  const vm = new Vue({
    el: "#app",
    data: {
      user: {
        name: '张三',
        age: {
          rAge: 40,
          sAge: 29,
        },
        hobbies: ['唱', '跳', 'Rap', '篮球'],
        friends: [
          {name: '李四', age: 35},
          {name: '王五', age: 36},
        ]
      }
    },
  });
</script>

在浏览器控制台中使用如下命令修改数组元素,并不会让页面更新:

vm.user.hobbies[0] = '唱歌'
vm.user.hobbies[1] = '跳舞'
vm.user.hobbies[3] = '打篮球'

为了解决这些问题,Vue指定了7个操作数组的方法,并承认它们的操作是对数组进行了修改,所以使用这7个方法对数组进行修改后,页面才能正常更新。Vue指定的这7个操作数组的方法分别是:

方法 说明
array.push() 向数组的末尾添加一个或者多个元素,并返回新数组的长度
array.pop() 删除并返回数组的最后一个元素
array.shift() 删除并返回数组的第一个元素
array.unshift() 向数组的开头添加一个或多个元素,并返回新数组的长度
array.splice() 删除元素,并向数组添加新元素
array.sort() 对数组的元素进行排序
array.reverse() 颠倒数组中元素的顺序

这7个方法能奏效是因为Vue对Array.prototype中对应的这7个方法进行了封装。

Vue封装的这7个方法大致上都做了以下两件事:

  1. 调用Array.prototype中对应的原生方法。
  2. 数据更新引起的模板的解析和页面更改等操作。

在Vue官方文档中,将这7个方法称为数组的变更方法。这7个方法其实是对数组变化的监测。

其实在Vue中修改数组也可以使用Vue.set()vm.$set()。如上例,在控制台修改其中的数组元素:

Vue.set(vm._data.user.hobbies, 0, '唱歌')
vm.$set(vm.user.hobbies, 1, '跳舞')

Vue 数据监听总结

Vue会监听data中所有层次的数据。

  • 监听对象中数据的方式:

    通过Setter实现监听,且要在new Vue()时就传入要监测的数据。

    在Vue监听对象中需要注意:

    • 对象中后追加的属性,Vue默认不做响应式处理。

    • 如需给后添加的属性做响应式,请使用如下API:

      Vue.set(target, propertyName|index, value)
      vm.$set(target, propertyName|index, value)
      
  • 监听数组中数据的方式:

    通过包裹数组更新元素的7个方法实现,本质就是做了两件事:

    1. 调用原生(Array.prototype)对应的方法对数组进行更新。
    2. 重新解析模板,进而更新页面。

    在Vue修改数组中的某个元素一定要用如下方法:

    • 使用以下API:

      • array.push()
      • array.pop()
      • array.shift()
      • array.unshift()
      • array.splice()
      • array.sort()
      • array.reverse()
    • 使用Vue.set()vm.$set()

      注:Vue.set()vm.$set()不能给vmvm的根数据对象添加属性。