Vue.js 的 data 属性、方法、计算属性和监听器

在前面的代码中,我们或多或少使用了一些 Vue 的特性,例如 data 属性、方法属性 methods 等,这些都是进行组件配置的重要内容。本节就来详细介绍一下这些配置。

data属性

Vue 的组件中,我们必不可少地会用到 data 属性,在 Vue 3 中,data 属性是一个函数,Vue 在创建新组件实例的过程中调用此函数。它应该返回一个对象,然后 Vue 会通过响应性系统将其包裹起来,并以 $data 的形式存储在组件实例中,如示例代码2-3-1所示。

const app = Vue.createApp({
    data() {
        return { count: 4 }
    }
})
const vm = app.mount('#app')
console.log(vm.$data.count) // => 4
console.log(vm.count)      // => 4
// 修改 vm.count 的值也会更新 $data.count
vm.count = 5
console.log(vm.$data.count) // => 5
// 反之亦然
vm.$data.count = 6
console.log(vm.count) // => 6

如果在组件初始化时 data 返回的对象中不存在某个 key,后面再添加,那么这个新增加的 key 所对应的属性 property 是不会被 Vue 的响应性系统自动跟踪的,代码如下:

<div id="app">
    <span>{{name}}</span>
    <span>{{age}}</span>
</div>
const vm = Vue.createApp({
    data() {
        return {
            name: 'John'
        }
    }
}).mount("#app")
vm.age = 12

上面的代码中,{{age}} 的值将不会被渲染出来。

方法

Vue.js 中,将数据渲染到页面上用得最多的方法莫过于插值表达式 {{}}。插值表达式中可以使用 文本 或者 JavaScript 表达式 来对数据进行一些处理,代码如下:

<div id="example">
    {{ message.split('').reverse().join('') }}
</div>

但是,设计它们的初衷是用于简单运算。在插值表达式中放入太多的程序逻辑会让模板过 “重” 且难以维护。因此,我们可以将这部分程序逻辑单独剥离出来,并放到一个方法中,这样共同的程序逻辑既可以复用,也不会影响模板的代码结构,并且便于维护。

这里的方法和之前讲解的使用 v-on 指令的事件绑定方法在程序逻辑上有所不同,但是在用法上是类似的,同样还是定义在 Vue 组件的 methods 对象内,如示例代码2-3-2所示。

示例代码2-3-2 方法
<div id="app">
    {{height}}
    {{personInfo()}}
</div>
const vmMethods = Vue.createApp({
    data() {
        return {
            name: 'Jack',
            age: 23,
            height: 175,
            country: 'China'
        }
    },
    methods:{
        personInfo(){
            console.log('methods')
            var isFit = false;
            // 'this' 指向当前Vue实例
            if (this.age > 20 && this.country === 'China') {
                isFit = true;
            }
            return this.name + '   ' + (isFit ? '符合要求' : '不符合要求');
        }
    }
}).mount("#app")

首先在 methods 中定义了一个 personInfo 方法,将众多的程序逻辑写在其中,然后在模板的插值表达式中调用 {{personInfo()}}与使用 data 中的属性不同的是,在插值表达式中使用方法需要在方法名后面加上括号 “()” 以表示调用

使用方法也支持传参,如示例代码2-3-3所示。

示例代码2-3-3 方法传参
<div id="app">
    {{personInfo('Tom')}}
</div>
...
methods:{
    personInfo(params){
        console.log(params)
    }
}

计算属性

前面一节介绍了采用方法的方案来解决在插值表达式中写入过多数据处理逻辑的问题,还有一种方案可以解决这类问题,那就是 计算属性,如示例代码2-3-4所示。

示例代码2-3-4 计算属性
<div id="app">
    {{height}}
    {{personInfo}}
</div>
const vmComputed = Vue.createApp({
    data() {
        return {
            name: "Jack",
            age: 23,
            height: 175,
            country: "China"
        }
    },
    computed:{
        personInfo(){
            console.log('computed')
            // "this" 指向当前Vue实例,即vm
            let isFit = false;
            if (this.age > 20 && this.country === "China") {
                isFit = true;
            }
            return this.name + "   " + (isFit ? "符合要求" : "不符合要求");
        }
    }
}).mount("#app")

在上面的代码中,同样实现了将数据处理逻辑剥离的效果,看似把之前的 methods 换成了 computed,以及将插值表达式的 {{personInfo()}} 换成了 {{personInfo}},虽然表面上的结构一样,但是内部却有着不同的机制。

插值中 计算属性 获取不需要括号。

计算属性和方法

首先,对于方法(methods),在之前的代码中,我们在 personInfo 对应的函数中添加一个 console.log("methods"),然后首先修改 data 中的 name 属性,代码如下:

vmMethods.name = 'Pom';

观察控制台,显示结果如图2-9所示。

image 2024 02 21 13 29 52 928
Figure 1. 图2-9 演示结果1

由于修改了 vmMethodsname 属性,而在 personInfo 方法里面用到了 name 属性,因此 personInfo 方法和 name 就存在依赖,那么 name 属性的修改就导致 personInfo 方法重新执行了一遍,因此可以看到 name 更新成了 pom,同时打印了一次 console.log("methods")。随后,我们再次调用 vmMethods.name ='Pom',这次没有打印出 console.log("methods"),原因是 name 的值并没有改变。

同理,对于计算属性(computed),尝试修改 vmComputedname 属性,代码如下:

vmComputed.name = 'Pom';

观察控制台,显示结果如图2-10所示。

image 2024 02 21 13 55 34 108
Figure 2. 图2-10 演示结果2

第一次调用 vmComputed.name='Pom'; 打印出了 console.log("computed")。然后又调用了一次 vmComputed.name = 'Pom';,这次没有打印出 console.log("computed")。到这里,看似和 methods 方法没有区别。

接下来,我们尝试去改变 height 属性,和 name 属性不同的是,height 属性写在了插值表达式 {{height}} 中,是直接绑定数据进行渲染的,和 personInfo 方法没有依赖。我们看看效果。

修改 vmMethodsheight 属性:

vmMethods.height = 180;

观察控制台,显示的结果如图2-11所示。

image 2024 02 21 14 02 52 012
Figure 3. 图2-11 演示结果3

可以看到打印出了 console.log("methods"),说明执行了 personInfo() 方法,那么再修改一下 vmComputedheight 属性:

vmComputed.height = 180;

观察控制台,显示的结果如图2-12所示。

image 2024 02 21 14 04 42 698
Figure 4. 图2-12 演示结果4

可以看到并没有打印出 console.log("computed"),说明没有执行 personInfo() 方法。通过上面的一系列操作,可以得到如下结论:

  • 如果 personInfo 依赖的数据发生改变,即通过修改 data 中的属性改变 nameagecountry 其中之一或多个,导致插值表达式 {{personInfo}}{{personInfo()}} 更新用户界面,那么 personInfo() 方法就会重新执行。反之,如果 personInfo 依赖的数据没有改变,personInfo() 方法就不会重新执行,methodscomputed 的表现是一致的。

  • 如果 personInfo 依赖的数据并没有发生改变,即通过修改 data 中的 height 属性,这个改变会导致插值表达式 {{height}} 更新用户界面,那么定义在 methods 中的 personInfo() 方法总是会执行,而定义在 computed 中的 personInfo() 方法则不会执行。

这就意味着,只要 data 中的属性 nameagecountry 没有发生改变,无论何时访问计算属性,都会立即返回之前的计算结果,而不必再次执行对应的函数,这说明计算属性是基于它们的响应式依赖进行缓存的,而方法却没有这种表现。总结一下它们的区别就是:

  • 计算属性:只要依赖的数据没发生改变,就可以直接返回缓存中的数据,而不需要每次都重复执行数据操作。

  • 方法:只要页面更新用户界面,就会发生重新渲染,methods 调用对应的方法,执行该函数,无论是不是它所依赖的。

对于计算属性来说,上面定义的 personInfo 所对应的函数其实只是一个 getter 方法,每一个计算属性都包含一个 getter 方法和一个 setter 方法,上面的两个示例都是计算属性的默认用法,只调用了 getter 方法来读取。

在需要时,也可以提供一个 setter 方法,当手动修改计算属性的值时,就会触发 setter 方法,执行一些自定义的操作,如示例代码2-3-5所示。

示例代码2-3-5 计算属性setter和getter
<div id="app">
    {{name}}
    <br/>
    {{personInfo}}
</div>
const vmComputed = Vue.createApp({
    data(){
        return {
            name: "Jack",
            age: 23,
            height: 175,
            country: "China"
        }
    },
    computed:{
        personInfo: {
            get(){
                console.log("get");
                return
                "height:"+this.height+",age:"+this.age+",country:"+this.country;
            },
            set(){
                this.height = 165;
                this.name = "Pom";
                console.log("set");
            }
        }
    }
}).mount("#app")

上面的 set 对应的 function 就代表 setter 方法,get 对应的 function 就代表 getter 方法。运行这段代码,可以看到页面显示如图2-13所示。

image 2024 02 21 14 17 11 069
Figure 5. 图2-13 计算属性的getter方法和setter方法的演示结果1

并且可以看到控制台上打印了 console.log('get'),这时在控制台上运行如下代码:

vmComputed.personInfo = 'hello';

可以看到 setter 方法会被调用,同时 heightname 的值也被修改了,控制台上显示的结果如图2-14所示。

image 2024 02 21 14 19 55 668
Figure 6. 图2-14 计算属性的getter方法和setter方法的演示结果2

对应页面的用户界面显示如图2-15所示。

image 2024 02 21 14 20 46 947
Figure 7. 图2-15 计算属性的getter方法和setter方法的演示结果3

需要说明的是,虽然直接去修改 computedpersonInfo 的值,但是并没有改变 personInfo 的值,这是因为如果要判断 personInfo 的值是否被改变了,首先要读取 personInfo 的值,而读取 personInfo 的值是由 getter 方法的 return 值控制的,所以一般使用 setter 方法的应用场合大多数是把它当作一个钩子函数来使用,并在其中执行一些业务逻辑。

由此可见,在绝大多数情况下,只会用默认的 getter 方法来读取一个计算属性,在业务中很少用到 setter 方法,因此在声明一个计算属性时,可以直接使用默认的写法,不必同时声明 getter 方法和 setter 方法。

了解了计算属性之后,可以发现,计算属性之所以叫作 “计算” 属性,是因为它是固定属性 data 的对应,同时多了一些对数据的计算和处理操作。

在通常情况下,如果一个值是简单的固定值,无须特殊处理,在 data 中添加之后,在插值表达式中使用即可。但是,如果一个值是不固定的,它可能随着一些固定属性的改变而改变,这时就可以把它设置在计算属性中。一般情况下,同一个属性名,设置了计算属性就无须设置固定属性。反之,设置了固定属性就无须设置计算属性。各位读者在编写代码时要注意这种原则。

在使用计算属性处理数据时,也是可以传递参数的,具体做法是在定义计算属性时,用 return 返回一个函数,如示例代码2-3-6所示。

<div id="app">
    {{personInfo('son')}}
</div>
const vmComputed = Vue.createApp({
    data() {
        return {
            name: "Jack",
        }
    },
    computed: {
        personInfo() {
            return (params) => {
                console.log(params);
                return this.name + params;
            }
        }
    }
}).mount("#app")

采用 {{personInfo('son')}} 将参数传递进去,看起来就像调用一个方法。

监听器

通过上面对计算属性的 setter 方法的讲解,我们知道 setter 方法提供了一个钩子函数,尽管利用这个钩子函数可以监听到属性的变化,但有时还需要一个自定义的监听器(侦听器),这个监听器有一个监听属性 watch,如示例代码2-3-7所示。

示例代码2-3-7 watch方法
<div id="app">
    {{name}}
</div>
const vmWatch = Vue.createApp({
    data() {
        return {
            name: 'Jack'
        }
    },
    watch:{
        name(newV, OldV){
            console.log('新值:'+newV+',旧值:'+oldV)
        }
    }
}).mount("#app")

在上面的代码中定义了一个监听属性 watch,它所监听的是 data 中定义的 name 属性,修改一下 vmWatchname 属性,代码如下:

vmWatch.name = 'Petter';

观察控制台,显示的结果如图2-16所示。

image 2024 02 21 14 30 46 910
Figure 8. 图2-16 监听器

可以看到,在 watch 中定义的 name 所对应的 function 被执行了,同时打印出了 name 的新旧值。监听属性 watch 的用法很简单,在逻辑上也比较好理解。也可以使用监听器监听父子组件传值时使用 props 传递的值,在后面3.2节会讲解。

这样使用 watch 时有一个特点,就是当值第一次绑定时,不会执行监听函数,只有当值发生改变才会执行。如果需要在最初绑定值的时候也执行函数,则需要用到 immediate 属性。比如当父组件向子组件动态传值时,子组件 props 首次获取到父组件传来的默认值时,也需要执行函数,此时就需要将 immediate 设为 true,如示例代码2-3-8所示。

const vmWatch = Vue.createApp({
    data() {
        return {
            name: 'Jack'
        }
    },
    watch: {
        name: {
            handler: function (newV,oldV) {
                ...
            },
            immediate: true
        }
    }
}).mount("#app")

这里把监听的数据写成对象形式,包含 handler 方法和 immediate,之前编写的函数其实就是在编写这个 handler 方法。immediate 表示在 watch 中首次绑定时,是否执行 handler,若值为 true,则表示在 watch 中声明时,就立即执行 handler 方法;若值为 false,则和一般使用 watch 一样,在数据发生变化的时候才执行 handler 方法。

当需要监听一个复杂对象的改变时,普通的 watch 方法无法监听到对象内部属性的改变,例如监听一个对象,只有这个对象整体发生变化时,才能监听到,如果是对象中的某个属性发生变化或者对象属性的属性发生变化,此时就需要使用 deep 属性来对对象进行深度监听,如示例代码2-3-9所示。

const vmWatch = Vue.createApp({
    data() {
        return {
            name: 'Jack'
        }
    },
    watch:{
        name: {
            handler: function(newV, oldV) {
            ...
            },
            deep: true
        }
    }
}).mount("#app")

在上面的代码中,尝试修改 this.obj.num 的值,会发现并不会触发 watch 监听的方法,当添加 deep:true 时,watch 监听的方法便会触发。另外,这种直接监听 obj 对象的写法会给 obj 的所有属性都加上这个监听器,当对象属性较多时,每个属性值的变化都会执行 handler 方法。如果只需要监听对象中的一个属性值,则可以进行优化,使用字符串的形式监听对象属性,代码如下:

watch:{
    'obj.num': {
        handler: function(newNum, oldNum) {
            ...
        },
    }
}

此时,就无须设置 deep:true 选项了。在 Vue 3 中,如果需要监听 data 某个数组的变化,分为两种情况:

  • 直接重新赋值数组。

  • 调用数组的 push()pop() 等方法。

const vmWatch = Vue.createApp({
    data() {
        return {
            names: ['Jack','Tom']
        }
    },
    watch:{
        names: {
            handler: function(newV, oldV) {
                console.log('watch')
            },
            deep: true
        }
    }
}).mount("#app")
vmWatch.names = ['John']
vmWatch.names.push('John') // 添加了deep才会触发

监听器和计算属性

虽然计算属性 computed 和监听属性 watch 都可以监听属性的变化,而后执行一些逻辑处理,但是它们都有各自适用的场合。例如需求是实时地改变 fullName,我们采用监听器来实现,如示例代码2-3-10所示。

示例代码2-3-10 watch 与 computed 对比
<div id="app">{{ fullName }}</div>

const vm = Vue.createApp({
    data() {
        return {
            firstName: 'Foo',
            lastName: 'Bar',
            fullName: 'Foo Bar'
        }
    },
    watch: {
        firstName (val) {
            this.fullName = val + ' ' + this.lastName
        },
        lastName (val) {
            this.fullName = this.firstName + ' ' + val
        }
    }
}).mount("#app")

在上面的代码中,使用插值表达式在页面上渲染 fullName 的值,想要实现的效果是:当 firstName 的值或者 lastName 的值中有任何一个改变时,就动态地更新 fullName 的值,于是就利用监听属性 watch 来监听 lastNamefirstName

然后运行下面的代码,试一下是否生效:

vm.firstName = 'Petter';
vm.lastName = 'Jackson';

可以看到页面上的 fullName 动态改变了,表明监听属性 watch 可以满足要求。下面接着使用计算属性 computed 来完成这个需求,如示例代码2-3-11所示。

<div id="app">{{ fullName }}</div>

const vm = Vue.createApp({
    data() {
        return {
            firstName: 'Foo',
            lastName: 'Bar',
            fullName: 'Foo Bar'
        }
    },
    computed: {
        fullName () {
            return this.firstName + ' ' + this.lastName
        }
    }
}).mount("#app")

同样,在运行上面的代码之后,可以看到 fullName 也实时更新了。但是,比较这两段代码可以看到,后面的这段代码更加清晰,使用更加合理一些。

所以,对于计算属性 computed 和监听属性 watch,它们在什么场合使用,以及使用时需要注意哪些地方,应当遵循以下原则:

  • 当只需要监听一个定义在 data 中的属性是否变化时,需要在 watch 中设置一个同样的属性 key 值,然后在 watch 对应的 function 方法中去执行响应逻辑,而不需要在 computed另外定义一个值,然后让这个值依赖于在 data 中定义的这个属性,这样反倒绕了一圈,代码逻辑结构并不清晰。

  • 如果需要监听一个属性的改变,并且在改变的回调方法中有一些异步的操作或者数据量比较大的操作,这时应当使用监听属性 watch。而对于简单的同步操作,使用计算属性 computed 更加合适。

  • 当你需要监听单个 data 属性的变化,并且在变化时执行某些逻辑,使用 watch 会更简洁清晰。

  • 当你需要基于多个 data 中的值计算一个新值,并且该新值是衍生出来的,并且需要缓存时,使用 computed 是最佳选择。

建议各位读者在编写相关代码时遵循这样的原则,切勿随意使用。