Vue3 computed到底该怎么用?和watch、method比有啥不同?还有什么隐藏坑?
别把method和computed搞混
先回忆下你第一次写Vue项目时,会不会经常有这种情况:模板里要显示两个数的乘积,或者把数组里的元素按价格排序?一开始可能直接写个<div>{{ a * b }}</div>,后来数据多了复杂了,比如排序再加过滤,直接塞模板里就太乱了,然后就顺手抽了个method:getFilteredAndSortedGoods(),模板里直接调,但后来发现,每次其他和这个无关的变量更新时,这个方法都会重新跑一遍,页面会不会有点卡?对,这就是computed出场的时机了。
举个实际的电商商品列表例子:假设我们有个商品数组goods,还有两个输入框控制价格范围minPrice和maxPrice,另外还有个无关的、记录用户搜索框光标停留时间的变量cursorStayTime。
如果用method:
// script setup
import { ref } from 'vue'
const goods = ref([
{ name: '机械键盘', price: 399 },
{ name: '无线鼠标', price: 129 },
{ name: '显示器支架', price: 89 },
{ name: '降噪耳机', price: 799 }
])
const minPrice = ref(0)
const maxPrice = ref(1000)
const cursorStayTime = ref(0)
const getFilteredGoods = () => {
console.log('getFilteredGoods执行了!')
return goods.value.filter(item =>
item.price >= minPrice.value && item.price <= maxPrice.value
)
}
// 模拟每秒更新光标停留时间
setInterval(() => cursorStayTime.value++, 1000)
<!-- template -->
<div>
过滤后的商品:
<ul>
<li v-for="item in getFilteredGoods()" :key="item.name">{{ item.name }} - {{ item.price }}</li>
</ul>
光标停留时间:{{ cursorStayTime }}
</div>
打开控制台你会发现,不管你动没动价格输入框,每隔1秒,getFilteredGoods执行了!都会打印一次,这时候要是商品数组有1000+条,或者过滤逻辑特别复杂(比如加了正则、关联其他接口返回的属性),那每秒重新计算一次,不卡才怪。
那换成computed呢?我们修改下script部分:
// script setup
import { ref, computed } from 'vue'
// 其他变量定义不变
const filteredGoods = computed(() => {
console.log('filteredGoods重新计算了!')
return goods.value.filter(item =>
item.price >= minPrice.value && item.price <= maxPrice.value
)
})
<!-- template里把getFilteredGoods()改成filteredGoods --> <li v-for="item in filteredGoods" :key="item.name">...</li>
再看控制台:只有当goods、minPrice或者maxPrice这三个和过滤逻辑有关的依赖变量更新时,才会打印那句话,光标停留时间变了完全不影响,而且更重要的是,computed是缓存结果的,第一次计算后,依赖不变的话,不管你在模板里调用多少次(比如一个地方显示数量,一个地方显示列表,一个地方显示总价),都只会取缓存值,不会重新跑过滤逻辑,method就不行了,模板里多写几次调用,就多执行几次,哪怕依赖没变化。
接下来是和watch的对比:什么时候用计算,什么时候用监听
这个对比也是新手常问的,先理清楚两个的核心定位:
- computed的核心是“计算出一个新的响应式数据”,它的输入是依赖的响应式数据,输出是经过处理的新值,而且输出必须有地方用到(不管是模板、其他computed还是watch里),不然Vue内部可能会做垃圾回收相关的优化,导致看起来“不生效”(不过3.4+好像调整了这个逻辑,但还是建议有明确输出用途再用)。
- watch的核心是“监听某个响应式数据的变化,然后执行一段副作用逻辑”,副作用逻辑就是“没有明确输出值的操作”,比如调用接口、修改DOM、修改localStorage、触发弹窗、甚至修改其他响应式数据(虽然可以改,但如果能用computed生成新值替代修改原数据的需求,还是优先用computed,更符合单向数据流的理念)。
还是用刚才的电商例子延伸一下:
- 刚才的过滤后商品
filteredGoods,显然是“生成新数据”的需求,所以必须用computed。 - 现在加个需求:当用户修改了价格范围后,自动把当前的价格范围存到localStorage里,下次打开页面自动填充,这就是“副作用逻辑”,需要存localStorage,没有生成新的、用于展示或其他计算的核心数据,所以用watch:
// script setup // 先从localStorage读取初始值 const savedMinPrice = localStorage.getItem('minPrice') const savedMaxPrice = localStorage.getItem('maxPrice') const minPrice = ref(savedMinPrice ? Number(savedMinPrice) : 0) const maxPrice = ref(savedMaxPrice ? Number(savedMaxPrice) : 1000)
// 监听minPrice和maxPrice的变化 watch([minPrice, maxPrice], ([newMin, newMax]) => { localStorage.setItem('minPrice', newMin) localStorage.setItem('maxPrice', newMax) })
这里用了watch的数组监听模式,可以同时监听多个响应式数据的变化,只要其中一个变了,回调就会执行。
3. 再延伸一个稍微复杂的点:有时候你可能会觉得,“这个需求好像两边都能用?”比如刚才的过滤,如果硬用watch写,是不是也行?我们试试:
```javascript
// 别这么写!除非万不得已
const filteredGoods = ref([]) // 先手动声明一个空的响应式数组
watch([goods, minPrice, maxPrice], () => {
filteredGoods.value = goods.value.filter(...)
}, { immediate: true }) // 加上immediate: true,组件刚挂载的时候就执行一次,不然初始值是空的
乍一看好像也能实现?但对比一下computed的写法,这个手动watch的方式有几个明显的缺点:
- 代码量多了:要手动声明初始值、手动监听、手动赋值、还要加
immediate: true。 - 可维护性差:如果过滤逻辑变了,还要改watch的回调,而computed把逻辑都封装在一个地方,更清晰。
- 性能不一定更好:虽然表面上也是依赖变化才执行,但computed的缓存优化是Vue内部专门优化过的,手动赋值有时候可能会多触发不必要的更新(比如虽然你给filteredGoods.value赋了新数组,但新数组的内容和上次一模一样,computed会直接返回缓存,不会触发视图更新,手动ref赋值的话,不管内容一不一样,只要引用变了,视图就会重新渲染)。
那有没有“万不得已”必须用硬写watch+ref的情况?有,但非常少,比如计算逻辑里有异步操作,computed的getter是必须同步返回结果的,不能用async/await,比如如果过滤逻辑需要先调用一个接口拿到用户的会员等级,然后根据会员等级给商品打折扣再过滤价格,这时候就不能用computed了,必须用watch或者后面要讲的computed的setter配合其他异步方法?不对,setter也是同步的,所以异步的话还是老老实实watch。
进阶:computed的getter和setter,别只会写箭头函数
刚才的例子里,我们都是给computed传了一个箭头函数,这个箭头函数其实就是computed的getter,负责读取依赖并返回计算结果,但其实computed还可以传一个对象,里面有get和set两个方法,这种叫可写的computed。
可写的computed什么时候用?当你希望这个计算出来的新值,也能像普通ref一样被赋值,而且赋值的时候能触发一些逻辑,比如反向修改它的依赖数据。
举个常见的表单例子:比如用户注册的时候有“邮箱”和“确认邮箱”两个输入框,我们需要显示一个“合并后的邮箱状态”——如果两个邮箱一致,显示“邮箱确认成功”,不一致显示“请检查两次输入的邮箱”;如果我们直接修改这个“合并后的邮箱状态”对应的文本框(假设是个展示用但偶尔需要一键修改的场景),能同时把“邮箱”和“确认邮箱”都改成这个值。
用普通computed(只有getter)的话,这个“合并状态”是只读的,不能修改,用可写computed的话就可以:
// script setup
import { ref, computed } from 'vue'
const email = ref('')
const confirmEmail = ref('')
const emailStatus = computed({
get() {
// getter:生成状态值
if (!email.value || !confirmEmail.value) return '请输入邮箱'
return email.value === confirmEmail.value ? '邮箱确认成功' : '请检查两次输入的邮箱'
},
set(newValue) {
// setter:反向修改依赖
// 假设newValue就是我们要设的邮箱
email.value = newValue
confirmEmail.value = newValue
}
})
// 模拟一键修改场景
const fillDefaultEmail = () => {
emailStatus.value = 'test@example.com'
}
<!-- template --> <div> 邮箱:<input v-model="email" type="email"> 确认邮箱:<input v-model="confirmEmail" type="email"> 合并状态(可一键修改为默认邮箱):<input v-model="emailStatus"> <button @click="fillDefaultEmail">一键填入默认邮箱</button> </div>
这个例子里,点击“一键填入”按钮,或者直接在“合并状态”输入框里写内容,都会触发setter,同时更新email和confirmEmail;而修改email或confirmEmail,又会触发getter,重新生成合并状态的值。
避坑指南:别踩这几个Vue3 computed常见的坑
前面讲了用法和对比,现在说几个新手甚至有一定Vue2经验的人转Vue3时容易踩的坑:
坑1:在getter里修改依赖数据
Vue2的时候如果在computed里修改依赖数据,会报错“computed property was assigned to but it has no setter”或者产生死循环?Vue3虽然有时候不会直接报错,但绝对是违反设计原则的,而且大概率会导致死循环。
比如刚才的电商例子,要是在filteredGoods的getter里不小心加了一句minPrice.value = 0(比如手滑多写的),那会发生什么?
- 第一次调用getter,读取minPrice,发现minPrice.value被改成0了。
- minPrice变化,触发getter重新计算。
- 第二次调用getter,又读取minPrice,又改成0了。
- 无限循环下去,控制台会一直打印执行日志,页面直接卡死。
所以千万记住:computed的getter是纯函数,纯函数的意思是“输入相同,输出一定相同,而且没有任何副作用”,副作用包括修改依赖数据、调用接口、修改DOM、修改localStorage等等,这些都不能在getter里做。
坑2:依赖非响应式数据,导致computed不更新
刚才的例子里,computed的依赖都是ref或者reactive包裹的响应式数据,所以依赖一变化,computed就会重新计算,但如果依赖的是普通的JavaScript变量(比如没被ref/reactive包裹的数组、对象、数字、字符串),那不管你怎么改这个普通变量,computed都不会重新计算。
举个错误例子:
// script setup
import { computed } from 'vue'
// 别这样!count是普通变量,不是响应式的
let count = 0
const doubleCount = computed(() => count * 2)
const addCount = () => {
count++
console.log('count变成了', count)
console.log('doubleCount还是', doubleCount.value)
}
点击addCount按钮,count会变成1、2、3……但doubleCount.value永远是0,因为count不是响应式的,Vue的响应式系统追踪不到它的变化,所以不会触发computed的重新计算。
坑3:用索引或直接修改数组长度访问reactive数组的元素,导致computed不更新
这个坑主要是针对reactive包裹的数组的(ref包裹的数组其实没问题,因为ref包裹的基本类型或引用类型,都是通过.value访问修改,Vue3的Proxy代理能捕获到)。
Vue3用的是Proxy代理实现响应式,比Vue2的Object.defineProperty强很多,但reactive数组还是有两个小地方需要注意:
- 直接用索引修改数组元素:比如
reactiveArr[0] = '新值',这个Vue3的Proxy其实大部分现代浏览器都能捕获到,但为了兼容性(或者有时候你会遇到不生效的情况),还是建议用数组的方法,比如reactiveArr.splice(0, 1, '新值')。 - 直接修改数组长度:比如
reactiveArr.length = 0,这个Proxy有时候捕获不到,建议用reactiveArr.splice(0)来清空数组。
如果犯了这两个错误,而且你的computed依赖的是reactive数组的某个具体元素或者整个数组的长度相关的属性,那computed可能不会重新计算。
举个例子:
// script setup
import { reactive, computed } from 'vue'
const reactiveGoods = reactive([
{ name: '机械键盘', price: 399 },
{ name: '无线鼠标', price: 129 }
])
const goodsCount = computed(() => reactiveGoods.length)
const badClearGoods = () => {
// 别这样!Proxy可能捕获不到
reactiveGoods.length = 0
}
const goodClearGoods = () => {
// 这样才对!
reactiveGoods.splice(0)
}
点击badClearGoods按钮,goodsCount可能不会变成0;点击goodClearGoods按钮,肯定会变成0。
坑4:在模板里用computed的时候加了括号
这个坑是从method转过来的人最容易犯的!刚才讲了,computed是一个响应式数据,不是一个函数,所以在模板里直接写{{ filteredGoods }}或者v-for="item in filteredGoods"就行,别加。
如果加了括号会怎么样?Vue3会把computed当成一个普通的getter函数来调用,虽然第一次结果是对的,但缓存就失效了,和method一样,每次无关变量更新都会重新执行,完全失去了用computed的意义。
Vue3 computed的使用原则
我们把前面讲的内容总结成几个简单的使用原则,方便大家记住:
- 能用computed就不用method:只要是生成一个用于展示或其他计算的、依赖响应式数据的新值,优先用computed,利用它的缓存优化性能。
- 能用computed就不用硬写watch+ref:除非计算逻辑里有异步操作,不然别手动写watch和ref来模拟computed,computed代码更简洁、可维护性更好、性能更优。
- getter必须是纯函数:不能有副作用,不能修改依赖数据,必须同步返回结果。
- 需要反向修改依赖数据时用可写computed:给computed传一个带get和set的对象。
- 别踩坑:依赖必须是响应式数据、reactive数组别直接用索引或改长度、模板里别加括号、getter别写副作用。
希望这篇文章能帮你彻底搞懂Vue3的computed,以后写代码的时候再也不用纠结用哪个了。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


