有个需求几乎每个人都写过:输入框里填年龄,下面实时给一句提示。第一反应是在输入框上绑个 @input,方法里判断一下改文案。能跑,但数据一多就散了,页面上七八个字段都这么写,方法区里全是 handleXxxChange。Vue 给了两个更合适的工具,computed 和 watch。这两个东西功能上有重叠,很多人分不清什么时候用哪个,写着写着就全用 watch 了。这篇从那个年龄提示的需求一路改到三个版本,把两者的差别、各自的边界条件讲清楚,最后给一条能直接照着用的选型标准。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
watch的基础写法,val和oldVal两个参数分别是什么- 监听对象里的属性为什么必须开
deep,开了之后有什么代价 - 为什么
watch的处理函数不能写成箭头函数 - 监听计算属性这种绕一层的做法,什么时候值得
computed的缓存到底缓存了什么,和methods差在哪- 计算属性的
getter和setter,setter什么场景用得上 computed、watch、methods三者的选型标准- Vue 3 组合式 API 里这两个东西变成了什么
# 一、监听属性 watch
工作中常常需要监听某一个属性值的变化,做出响应,这时候用到的就是监听属性 watch。
# 1.1 基础版监听
场景是这样:输入框输入年龄,0 到 15 岁提示「你还是个小孩」,15 到 25 岁提示「你已经是个少年」,25 岁以上提示「你已经长大了」。
<template>
<div id="app">
年龄:<input type="number" v-model="age"><br>
提示信息:<span>{{infoMsg}}</span>
</div>
</template>
<script>
export default {
data() {
return {
age: "",
infoMsg:""
}
},
watch:{
age:function(val,oldval){
if(val>0 && val<15){
this.infoMsg="你还是个小孩"
}else if(val>=15 && val<25){
this.infoMsg="你已经是个少年"
}else{
this.infoMsg="你已经长大了"
}
}
}
}
</script>
watch 的写法就是「在 watch 对象里放一个和 data 属性同名的函数」,这个属性一变,函数就跑一次。函数收两个参数,第一个是新值,第二个是旧值。
原文这段的边界判断有个洞:第二个条件写的是 val > 15,那么正好等于 15 岁的时候,两个分支都不满足,直接落到 else 里提示「你已经长大了」。这里改成了 val >= 15。边界值这种问题在 code review 里最容易被放过,写区间判断的时候习惯性想一想两端各属于哪一边,能省不少事。
还有一点,age 绑的是 type="number" 的输入框,但 input.value 拿到的永远是字符串。"18" > 15 这种比较 JavaScript 会隐式转换,看起来没问题,可一旦用户输入的是 "1e5" 或者空字符串,行为就不好说了。稳妥的做法是加 .number 修饰符,这块在 Vue 表单控件与 v-model 里展开过。
# 1.2 进阶版监听,deep 深度监听
改一下需求:因为后台数据库的调整,需要提交这样一个数据结构:
data() {
return {
info: {
age: ""
},
infoMsg: ""
};
}
年龄从顶层属性变成了 info 对象里的一个字段。这时候直接写 watch: { info: function(){} } 是监听不到的,因为 info 这个引用本身没变,变的是它里面的属性。要监听得开深度监听:
<template>
<div id="app">
年龄:<input type="number" v-model="info.age"><br>
提示信息:<span>{{infoMsg}}</span>
</div>
</template>
<script>
export default {
data() {
return {
info: {
age: ""
},
infoMsg: ""
};
},
watch: {
info: {
handler: function(val, oldval) {
var that = this;
if (val.age > 0 && val.age < 15) {
that.infoMsg = "你还是个小孩";
} else if (val.age >= 15 && val.age < 25) {
that.infoMsg = "你已经是个少年";
} else {
that.infoMsg = "你已经长大了";
}
},
deep: true
}
}
};
</script>
写法从「一个函数」变成了「一个对象」,真正的处理逻辑挪到 handler 里,多出来的 deep 表示是否开启深度监听,true 开启,false 不开启。
有两个细节得说清楚。
这里的 function 不能用箭头函数替代。箭头函数没有自己的 this,它会沿着定义时的作用域往上找,在 export default 这个对象字面量里往上找就找到了模块顶层,this 会指向 undefined 或者全局对象,反正不是组件实例。Vue 需要在调用时把 this 绑成组件实例,箭头函数直接把这条路堵死了。同样的道理适用于 methods、computed、生命周期钩子,所有需要 this 的选项都别用箭头函数。
另一个是 oldval。深度监听下 val 和 oldval 指向的是同一个对象引用,所以你会发现两个参数打印出来一模一样,拿不到修改前的值。想拿到旧值只能自己在别处存一份深拷贝,或者干脆换成监听具体的路径。
那 deep 有什么代价?Vue 2 实现深度监听的方式是递归遍历这个对象的所有属性,逐个触发 getter 完成依赖收集。对象层级深、字段多的时候,这个遍历本身就有开销,而且对象内部任何一个字段变了都会触发回调,哪怕你只关心 age 一个。
所以更精确的写法是直接监听路径,字符串形式支持点号:
watch: {
'info.age': function(val, oldval) {
// 只有 age 变了才会进来,而且 oldval 是真的旧值
}
}
这个写法比 deep: true 精确得多,能用就优先用它。
# 1.3 高级版监听,监听计算属性
还有一种绕一层的做法,先用计算属性把要监听的值抽出来,再监听这个计算属性:
<template>
<div id="app">
年龄:<input type="number" v-model="info.age"><br>
提示信息:<span>{{infoMsg}}</span>
</div>
</template>
<script>
export default {
data() {
return {
info: {
age: "",
name: "",
hobit: ""
},
infoMsg: ""
};
},
computed: {
ageval: function() {
return this.info.age;
}
},
watch: {
ageval: {
handler: function(val, oldval) {
var that = this;
if (val > 0 && val < 15) {
that.infoMsg = "你还是个小孩";
} else if (val >= 15 && val < 25) {
that.infoMsg = "你已经是个少年";
} else {
that.infoMsg = "你已经长大了";
}
}
}
}
};
</script>
这次监听的是计算属性 ageval,而计算属性返回的是 info 对象中 age 的值。和上一版比,两次代码里监听的一个是对象 info,一个是 info 对象中 age 的值。
这么写有两个实打实的好处:val 和 oldval 都是原始值,旧值是真的旧值;info 里其他字段变化不会触发回调,name 和 hobit 怎么改都不会误触发。
顺带说一句,原文这一版里 ageval 上还留着 deep: true。计算属性返回的是一个原始值,深度遍历一个字符串没有任何意义,这里去掉了。
那什么时候用这种写法,什么时候直接监听 'info.age'?我自己的习惯是,如果被监听的值需要一点计算(比如拼接、取默认值、单位换算),就走计算属性;纯取值就直接写路径字符串,少一层间接。
# 1.4 两个常用配置
watch 的对象写法除了 deep 还有一个 immediate。默认情况下 watch 只在值变化时触发,页面初次渲染那次不算变化,回调不会跑。加上 immediate: true 就会在监听器创建时立刻执行一次:
watch: {
'info.age': {
handler(val) { /* ... */ },
immediate: true
}
}
「进页面先根据当前值算一次,之后跟着变」这类需求靠它,比在 created 里再手动调一遍干净。
另外 watch 的回调支持写成方法名字符串,watch: { age: 'onAgeChange' },处理函数放在 methods 里,多个监听共用一个处理逻辑的时候能省点代码。
# 二、计算属性 computed
# 2.1 什么是计算属性
模板内的表达式非常便利,但是设计它们的初衷是用于简单运算的。在模板中放入太多的逻辑会让模板过重且难以维护,例如:
<div id="example">
{{ message.split('').reverse().join('') }}
</div>
这一行还能看懂,再加两个条件判断就没法看了。而且模板里的表达式没法复用,同样的逻辑在三个地方用就得抄三遍。遇到复杂逻辑应该用 Vue 自带的计算属性 computed 来处理。
# 2.2 计算属性的用法
在一个计算属性里可以完成各种复杂的逻辑,包括运算、函数调用等,只要最终返回一个结果就可以:
<div id="example">
<p>Original message: "{{ message }}"</p>
<!-- 复杂处理放在了计算属性里面 -->
<p>Computed reversed message: "{{ reversedMessage }}"</p>
</div>
var vm = new Vue({
el: '#example',
data: {
message: 'Hello'
},
computed: {
reversedMessage: function () {
// `this` 指向 vm 实例
return this.message.split('').reverse().join('')
}
}
});
模板里用计算属性和用 data 属性写法完全一样,不带括号。这不是巧合,计算属性最终会被挂到实例上,从外面看它就是个只读属性。
计算属性还可以依赖多个数据,只要其中任一数据变化,计算属性就会重新执行,视图也会更新: