Vue3里watch和computed到底选哪个?踩坑指南+场景对比
最近接了几个前端新人的私信,都说刚转Vue3,总把watch和computed搞混——有时候明明该用computed的地方写了watch,导致代码又臭又长还可能有性能问题;有时候反过来,以为computed能搞定所有响应式衍生需求,结果没法处理异步操作或者历史值追踪,其实这俩虽然都是Vue3响应式体系里的核心工具,但分工明确得很,只要搞懂它们的核心逻辑、差异点,再结合几个常见场景记清楚,基本不会踩坑。
先搞懂本质:computed是“计算属性”,watch是“监听属性”
很多人一开始混淆,就是没记住它们的官方名字背后的意思,官方文档里对computed的定义其实很直白——基于现有响应式数据,自动计算并返回一个新的响应式数据,核心是“自动计算+缓存”;而watch呢,是监听某一个或多个响应式数据的变化,当变化发生时执行自定义的回调函数,核心是“变化触发+副作用操作”。
举个最简单的例子帮大家具象化:假设你有个电商页面的商品数量count和单价price,现在需要显示总价,这个总价就是完全由count和price决定的,只要这俩不变,总价就不该重新算——这时候用computed就对了,但如果你想在用户把商品数量从0改成1的时候,弹个“欢迎选购!”的提示框,或者需要在总价超过1000的时候,自动把商品的“包邮属性”从false改成true(这个包邮属性其实也算,但先放一边举更直接的),或者要把用户修改数量的操作记录到后端日志里——这些操作不是直接生成新数据,而是要做一些“额外的事”,这时候就得找watch。
核心差异梳理:这5点记牢,下次写代码前先过一遍
光懂本质还不够,得把能落地的差异点列出来,写代码的时候逐一对照,就能快速排除错误选项。
是否有缓存
这是computed和watch最关键的性能差异,也是大家最容易忽略的踩坑点之一,computed是带强缓存的:只要它依赖的所有响应式数据都没有变化,不管你在模板里调用多少次computed,它都只会在第一次调用时执行一次计算函数,之后直接返回第一次的结果;只有当依赖的某个数据变了,才会重新计算并更新缓存,而watch呢,没有任何缓存机制——每次监听的数据发生变化(不管是不是从1改成2,还是从2改成1,甚至是深层对象里的某个无关小属性变了),都会立即执行回调函数。
这里举个反例踩坑给大家看:假设你还是用count和price算总价,但脑抽用了watch,代码大概是这样的:
const count = ref(2);
const price = ref(100);
const total = ref(200); // 还要手动初始化
watch([count, price], () => {
total.value = count.value * price.value;
});
看起来好像也能用,但如果count和price在某个瞬间连续变了好几次(比如用户快速点击加减按钮10次),watch就会执行10次回调,total也会跟着更新10次,虽然最后结果是对的,但浪费了很多浏览器渲染资源,如果换成computed,不管连续变多少次,只有最后一次稳定下来(Vue内部有个微任务队列的更新机制)才会重新计算,然后只更新一次模板,性能差距一下子就出来了。
是否支持异步操作
这点也是分工明确的标志:computed的计算函数必须是同步的、纯函数——不能有await,不能修改外部数据(纯函数只能依赖输入,不能有副作用),否则要么会报错,要么会导致缓存失效或者数据不一致,而watch的回调函数是完全支持异步操作的,可以加await,可以调接口,可以修改其他响应式数据,甚至可以修改监听数据本身(不过要小心死循环)。
再举个场景:电商页面的地址选择器,用户选择省之后,需要自动调后端接口获取该省的城市列表,这个获取城市列表的操作就是异步的,而且依赖的是省这个响应式数据的变化,所以必须用watch,如果硬要用computed,比如这样写:
const selectedProvince = ref('');
// 错误写法,会报错或者得不到预期结果
const cityList = computed(async () => {
if (!selectedProvince.value) return [];
const res = await fetchCityList(selectedProvince.value);
return res.data;
});
这时候在模板里直接用cityList,会得到一个Promise对象,而不是真正的城市数组——因为computed不会等待计算函数里的异步操作完成,它只会把计算函数的返回值(也就是Promise)缓存起来,除非你再手动处理Promise,但这样就完全失去了computed的意义,还不如直接用watch。
是否有返回值
computed必须有返回值——因为它的作用就是生成新的响应式数据,所以计算函数的return语句是必不可少的,返回的值会自动变成一个响应式的computed实例(本质上是一个ref,但比普通ref多了缓存和依赖追踪),而watch呢,回调函数的返回值是没用的——它不会生成新数据,只是用来执行副作用操作,所以return语句可有可无,就算写了,Vue也不会管。
依赖追踪的方式
computed是自动追踪依赖的——你不需要提前告诉Vue它要依赖哪些数据,只要在计算函数里用到了某个响应式数据(ref的.value,reactive的属性),Vue的响应式系统就会自动把这个数据加入到computed的依赖列表里,之后这个数据变了,computed才会重新计算,而watch呢,是手动指定监听对象的——你必须明确告诉Vue要监听哪个ref、哪个reactive的属性、或者哪些数据的组合,而且监听对象的写法有很多种(比如监听ref直接传变量,监听reactive的深层对象要加deep:true,监听多个数据要传数组,监听reactive的某个具体属性可以传一个函数)。
自动追踪依赖有个好处,就是不容易漏依赖——比如你一开始用count和price算总价,后来突然想加个折扣率discount,只要在计算函数里加上discount.value,Vue就会自动把它加入依赖列表,不用你再去手动修改任何东西,但手动指定监听对象也有好处,就是更灵活——比如你只想监听reactive里的某个具体属性,而不是整个对象(整个对象加deep:true的话,性能开销会很大),这时候传一个函数:() => user.age,就可以只监听age的变化,其他属性变了都不会触发回调。
是否可以获取历史值
这也是watch的一个专属功能:watch的回调函数有两个参数,第一个参数是监听数据变化后的新值(newVal),第二个参数是监听数据变化前的旧值(oldVal)——这个旧值在很多场景下非常有用,比如你想记录用户的操作轨迹(从多少改成了多少),或者你想在数据超过某个阈值的时候才执行操作(比如当count从99改成100的时候弹提示,从100改成101就不用),而computed呢,完全没有获取历史值的功能——它只关心当前的依赖值和当前的计算结果。
场景对比速查表:别再纠结,直接对应就好
说了这么多本质和差异,不如整理成一张速查表,下次写代码前扫一眼,就能快速确定用哪个:
| 你的需求是什么? | 选watch | 选computed |
|---|---|---|
| 需要生成一个完全由现有响应式数据决定的新数据 | ||
| 需要处理异步操作(调接口、定时器等) | ||
| 需要做副作用操作(弹提示、修改外部数据、记录日志等) | ❌(严格来说纯函数不能有副作用) | |
| 需要获取数据变化前的旧值 | ||
| 需要尽可能高的性能(减少不必要的计算和渲染) | ⚠️(除非必须,否则选computed) | ✅(自带缓存,性能最优) |
| 需要自动追踪依赖,避免漏加 | ||
| 需要只监听对象的某个具体属性,而不是整个对象 | ✅(传函数或字符串路径) | ❌(自动追踪整个对象的所有用到的属性) |
避坑小贴士:这3个坑90%的新人都踩过
避坑1:computed里写异步操作
刚才已经举过例子了,computed的计算函数必须是同步纯函数,写异步的话要么得到Promise,要么导致缓存失效,记住这点就行。
避坑2:watch里修改监听数据本身导致死循环
比如你有个count的ref,然后写了这样的watch:
watch(count, (newVal) => {
if (newVal > 10) {
count.value = 10; // 这里又修改了count,会再次触发watch,形成死循环
}
});
虽然Vue内部有个最大循环次数的限制(好像是100次?),超过了会报错,但还是尽量避免这种写法,如果必须修改监听数据本身,可以加个判断条件,比如只有当newVal和oldVal不一样的时候才修改,或者用nextTick包裹一下,但最好的办法还是换一种逻辑,比如用computed生成一个“最大10的count”:
const limitedCount = computed(() => Math.min(count.value, 10));
避坑3:监听reactive对象加deep:true导致性能问题
reactive对象是深层响应式的,但如果你直接watch整个reactive对象,
const user = reactive({
name: '张三',
age: 18,
address: {
province: '北京',
city: '朝阳区'
}
});
watch(user, () => {
console.log('user变了');
});
这时候不管是修改name、age,还是address里的province、city,都会触发watch——但其实你可能只想监听age的变化,这时候加deep:true(虽然默认不加deep:true的话,watch整个reactive对象也会深层监听?不对,等一下,查一下资料确认:哦对,Vue3里直接watch一个reactive对象,默认是深层监听的,不管加不加deep:true;但如果watch一个ref对象,而这个ref的value是一个普通对象,那么默认是浅层监听的,只有修改整个ref.value才会触发,修改里面的属性不会,这时候才需要加deep:true,所以不管是哪种情况,尽量不要直接watch整个大对象,而是用函数传参的方式只监听你需要的具体属性,这样可以大大减少不必要的回调执行,提升性能。
进阶小技巧:computed也可以有“副作用”?watch也可以有“缓存”?
虽然官方说computed是纯函数,不能有副作用,但有时候你确实需要在computed的依赖变化时,做一些“轻量级的副作用”,比如修改某个DOM元素的样式(虽然最好用绑定的方式,但偶尔也有例外)——这时候可以用computed的getter和setter写法,在setter里写副作用,但注意setter只有当你直接给computed赋值的时候才会执行,不是依赖变化的时候。
const firstName = ref('张');
const lastName = ref('三');
const fullName = computed({
get() {
return firstName.value + lastName.value;
},
set(newVal) {
const [f, l] = newVal.split('');
firstName.value = f;
lastName.value = l;
// 轻量级副作用:修改输入框的placeholder
document.getElementById('fullNameInput').placeholder = '请输入新的姓名';
}
});
至于watch的“缓存”,其实Vue3里有个watchEffect,它是自动追踪依赖的(和computed一样),但它是执行副作用操作的(和watch一样),而且它没有缓存,依赖变化就会立即执行——不过如果你想让watchEffect只在第一次执行,或者只在某个条件满足的时候执行,可以用flush选项或者stop函数。
const stopEffect = watchEffect(() => {
if (count.value > 10) {
console.log('count超过10了');
stopEffect(); // 执行一次就停止监听
}
});
好了,今天关于Vue3 watch和computed的内容就说到这里,相信大家看完之后,再也不会纠结选哪个了——记住本质、核心差异、场景对比,再避过那3个常见的坑,就能写出既简洁又高效的Vue3代码,如果还有什么疑问,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



