Vue中computed与watch到底怎么选?从响应式原理到实战区分
如果你在 Vue 项目里待过一段时间大概率遇到过这样的场景需求来了要根据某个数据的变化去更新另一个数据、发请求或者操作 DOM然后你翻开文档看到computed和watch两个选项脑子一嗡——这俩到底有什么区别更常见的是明明用computed能解决的问题同事用watch写了一堆代码结果页面还卡了。或者反过来应该用watch去处理异步逻辑的有人强行在computed里写了setTimeout直接报错。实话说这两个 API 的边界问题是 Vue 开发里最容易被混淆、也最能体现对响应式系统理解深度的一关。面试会被问代码 review 会被提线上出了诡异的 bug 排查半天发现也是它俩用错了。这篇文章我打算从 Vue 的响应式原理开始讲起把这些年的实战经验、踩过的坑和思考逻辑一起梳理一遍希望能帮你把这两个 API 彻底搞清楚。适合谁来读如果你是 Vue 新手刚写完几个页面正在进阶这篇文章帮你建立正确的底层心智模型如果你是写了两三年 Vue 的老手可以当成一次查漏补缺看看有没有你忽略的细节。不管你是用 Options API 还是 Composition API底层机制是相通的我会把二者的写法差异也一起讲掉。1. 先搞懂心智模型依赖收集与副作用很多时候分不清computed和watch不是因为你不知道它们的 API 用法而是因为你还没有在脑子里建立一个清晰的 Vue 响应式工作模型。这就像开手动挡车你光知道哪个挡位在哪儿没用你得理解发动机转速和车速之间的关系才能在恰当的时候挂进恰当的挡位。1.1 响应式数据是怎么通知视图更新的Vue 的响应式核心说白了就两步数据变化时通知谁谁需要重新执行这两个问题依赖依赖收集和派发更新来完成。你可以把 Vue 的响应式数据想象成一个广播电台数据一变电台就发信号而所有正在收听的人——我们叫它订阅者或Watcher——收到信号后会执行自己注册的回调。在 Vue 3 里这个机制通过Proxy实现。当你读取一个响应式对象的属性时get陷阱会记录当前正在运行的 Watcher依赖了这个属性当你修改这个属性的值时set陷阱就会触发通知所有依赖它的 Watcher 去更新。这个当前正在运行的 Watcher是一个全局变量Vue 在准备执行一个 Watcher 之前会把它设好执行完再恢复。这段机制是理解computed和watch一切区别的地基。因为computed会在读取响应式数据时被记成依赖相当于我在用这个数据数据变了记得叫我而watch则是显式地告诉 Vue 我要盯住这个数据它一变就执行我给你的回调函数。1.2 通知之后的动作才是关键区别关键就在通知之后做什么。computed收到更新通知后做的是重新计算——它要重新执行 getter算出新的值然后等着渲染函数来读取。渲染函数读取了它的新值视图就更新了。这个过程里computed本身不产生额外的动作。watch收到更新通知后做的是执行你写的回调——这个回调里你可以做任何事发请求、改别的数据、操作 DOM、console.log、跳转路由。这是完全开放的动作不是简单的重新求值。所以你看computed是被动派生数据watch是主动响应变化。一个追求数据正确一个追求动作执行。这个心智模型一旦建立很多技术选型问题就会迎刃而解。2. computed它是如何做到懒和缓存的computed最容易被误解的地方在于缓存机制。很多文章会告诉你computed 有缓存watch 没有缓存但这个缓存到底缓存的是什么、什么时候生效、什么时候失效、为什么需要这个缓存很少有人讲透。2.1 从一次购物车算价聊起假设你有一个购物车页面需求是实时计算商品总价。用户每勾选一件商品、修改一次数量总价就要变。如果不用computed直接在模板里写方法调用比如template div总价{{ totalPrice() }}/div /template script setup import { ref } from vue const cartItems ref([ { name: T恤, price: 99, count: 1, checked: true }, { name: 牛仔裤, price: 199, count: 2, checked: true } ]) function totalPrice() { let sum 0 for (const item of cartItems.value) { if (item.checked) { sum item.price * item.count } } return sum } /script这样写每次渲染都会执行一次totalPrice()函数。如果购物车里有几十件商品或者模板里在别的地方还引用了totalPrice()很多次那就会做大量重复的计算。更重要的是当页面里其他响应式数据变化引起重新渲染时totalPrice()也会白白执行一遍——虽然计算结果没变但函数不会记得上次的结果它只会老老实实地重新算一遍。换成computed之后同样的逻辑就变成了惰性求值 缓存script setup import { ref, computed } from vue const cartItems ref([...]) const totalPrice computed(() { let sum 0 for (const item of cartItems.value) { if (item.checked) { sum item.price * item.count } } return sum }) /script这段代码里的totalPrice是一个ComputedRef在模板里使用时会自动解包。它内部维护了一个脏值标记只有当它依赖的cartItems发生变化后重新读取时才会执行 getter如果依赖没有变化即使模板重新渲染了一百次它也会直接返回上一次缓存的结果。2.2 缓存和脏值标记是怎么工作的从源码角度看computed的实现核心是一个ComputedRefImpl类它内部维护了_dirty这个布尔值。初次读取时_dirty为true执行 getter 后把结果缓存到_value再把_dirty设为false。当依赖的响应式数据变化时Vue 不会立刻重算只是把_dirty改回true然后通知依赖computed的渲染 Watcher我这边数据可能变了请刷新页面。等到页面真的重新渲染、模板再次读取这个 computed 值时它才会执行真正的计算。为什么这么设计因为 Vue 要把真正执行计算这件事推迟到最后一刻。如果你在某个组件里定义了 20 个 computed 属性每个都依赖不同的数据它们不求值就不会有性能开销。这种机制在大型项目中价值很大——很多数据可能在一个渲染周期里根本不会被用到但 watch 就做不到这种懒执行它一有变动就必须立刻跑回调。注意computed 依赖的数据没变但它自己没有缓存——这话听着绕解释一下就明白了。computed 的缓存是基于依赖的版本管理它不会在你修改了其他无关数据时重新计算但它也没有时间过期一说。你给它依赖的 ref 重新赋值一个新对象注意是赋值不是修改属性它也照样能感知到变化因为 ref 的value变化时会触发依赖通知。2.3 给 computed 写 setter 的正确姿势很多人不知道computed还能写 setter。它默认是只读的如果你直接赋值比如totalPrice.value 100在开发模式下 Vue 会给你一个警告Write operation failed: computed value is readonly。但有的时候你的数据流确实需要双向都通。比如一个搜索页地址栏 URL 参数可以驱动搜索框的值同时用户在搜索框输入时又要更新 URL——这就是一个计算属性可写的场景。正确写法是const keyword ref() const searchText computed({ get() { // 从 URL 参数或者其他数据源读取 return route.query.keyword || }, set(value) { // 写入 URL 参数同时更新 keyword router.replace({ query: { ...route.query, keyword: value } }) keyword.value value } })我知道你可能会问这不是把简单问题复杂化了吗——确实如果你只是在一个组件内部同步两个数据直接用普通数据加上手动赋值就够了。但当你面对外部驱动 内部交互这种双向数据流场景computed 的 setter 模式可以让这个双向同步的规则变得集中、可预测而不是在事件处理函数里东改一下西改一下。这是我在几个中后台项目里反复验证过的值得推荐给被数据流混乱困扰的朋友。3. watch监听这件事到底该怎么用如果说computed的核心是派生那watch的核心就是副作用。它天生就是为那些数据变了之后需要做些什么的场景设计的。这个定位决定了它的用法和computed完全不同。3.1 watch 的定位是副作用观察watch做的事情非常简单你要我盯着某个数据源可以是 ref、reactive 对象、getter 函数、甚至一个数组它一变我就执行你给我的回调并且把新旧值都传给你。听起来和computed有点像但有一个根本区别computed的回调getter必须是一个纯函数——你不能在 computed 的 getter 里发请求、改文件、弹窗提示。而watch的回调恰恰就是给你做这些事的。比如这种场景用户填写一个表单选中了某个省份接下来要动态加载该省份的城市列表然后重置城市选择。这是典型的一个数据变化后引发一系列后续操作的场景const selectedProvince ref() const cityOptions ref([]) const selectedCity ref() watch(selectedProvince, async (newProvince) { // 重置下级选项 selectedCity.value cityOptions.value [] // 发请求拉取城市列表 if (newProvince) { const res await fetch(/api/cities?province${newProvince}) cityOptions.value await res.json() } })这段逻辑如果硬塞进computed里会非常别扭。computed的目的永远只有一个算出需要显示的值。你让它去发请求它没法缓存请求结果也没法管理请求的时序更违反纯函数的语义。各位记住一个判断口诀如果这个计算过程不需要修改外部状态不需要耗时操作纯粹输入响应式数据、输出数据就用 computed只要掺杂了请求、DOM 操作、定时器、其他数据修改就移到 watch 里。3.2 deep、immediate 和 flush 三个参数的实战意义watch的三件套deep、immediate、flush读文档是一回事真正用对是另一回事。我逐一说说在什么场景下它们会派上用场。先看deep。当你监听一个 reactive 对象时默认就是深度监听。但当你监听的是ref创建的引用类型时情况就不一样了const state ref({ user: { name: 张三, age: 18 } }) // 直接这样监听只会监听 state.value 这个引用本身 watch(state, (newVal, oldVal) { console.log(state 引用变了, newVal) }) // 只有变成这样才能监听嵌套属性的变化 watch(() state.value.user.age, (newAge, oldAge) { console.log(年龄从, oldAge, 变成, newAge) })我见过不下十次这样的场景后端返回一个userInfo对象前端用一个ref去承接。需求是userInfo.value.name变化时做点事于是同事直接写了watch(userInfo, callback)结果数据变了回调死活不执行。这就是典型的没搞清楚watch在监听 ref 时实际上是在监听ref.value这个整体的引用变化。如果确实需要深度监听有两种选择要么监听一个 getter 返回你关心的具体属性要么给 watch 加上{ deep: true }选项。再说immediate。默认情况下watch 的回调只有在数据变化后才会触发。但有时候你需要进入页面的那一瞬间就执行一次回调。比如一个列表页进入时按默认条件加载数据之后用户调整筛选条件再重新加载const filters reactive({ category: all, keyword: }) watch( () [filters.category, filters.keyword], async ([newCategory, newKeyword], [oldCategory, oldKeyword]) { // 拉取列表数据 await fetchList({ category: newCategory, keyword: newKeyword }) }, { immediate: true, deep: true } )带上immediate: true之后页面初始化时就会按默认筛选条件拉取一次数据后续条件变化再重新拉取。这样比在onMounted里单独写一次请求、又在 watch 里写一遍请求要优雅得多不会出现逻辑重复。至于flush多数人可能没用过。它控制的是回调执行的时机默认是pre即组件渲染前调用还有post组件渲染后调用sync数据变化后同步调用。如果你需要在 watch 回调里去操作更新后的 DOM或者访问组件的新尺寸就可以把flush设为post。比如你要根据一段文本内容的变化把页面上某个区域的scrollTop滚到底部那就必须等 DOM 更新完再执行这时候flush: post就是正确的选择。3.3 watchEffect自动收集依赖的轻量监听watchEffect经常和watch被并列提及因为它俩的名字太像了。但实际用起来它们俩的思考方式完全相反。watch是先指定监听什么再定义回调。watchEffect是不指定监听谁直接把回调函数扔进去回调里用到哪些响应式数据它就会自动监听哪些数据。拿上文的搜索场景举个例子const keyword ref() const results ref([]) // 写法一watch watch(keyword, async (val) { if (val) { results.value await searchApi(val) } }) // 写法二watchEffect watchEffect(async () { if (keyword.value) { results.value await searchApi(keyword.value) } })watchEffect的优点是心智负担小不需要想我该监听哪些依赖只要把要做的事情写清楚就行。但它有两个明显的坑第一回调里用到多少个响应式数据它就会监听多少个有可能监听过宽第二处理async函数时需要小心如果请求返回的时序出现乱序竞态问题需要自己加控制逻辑。相比之下watch虽然写起来繁琐但依赖明确、时序可控。我的习惯是在有明确单一数据源、且需要异步回调的场景优先用watch在初始化时就要执行一次 依赖数据不算多的场景用watchEffect会比较省事。4. 实战选型什么时候该用谁以及如何改造成正确写法现在到了最实用的一节。道理讲了那么多落到代码上其实就是一行行的选择。我给你总结几个我用过的判断标准再加几个从错误用法改到正确用法的实例这比背框架文档有用得多。4.1 一张表看清核心差异先把computed和watch的关键差异列表对比一下方便你以后查阅。对比维度computedwatch核心定位派生数据根据已有数据计算新数据副作用响应数据变化后执行操作是否有返回值必须有返回值供模板或逻辑使用不要求回调返回有意义的值缓存机制有依赖不变则不会重新计算无每次变化都会执行回调惰性求值是访问时才计算否数据一变立即执行适用场景数据联动、格式化、过滤、求和异步请求、DOM 操作、消息通知、路由跳转对异步操作不支持getter 内不可有副作用支持回调内可放心写异步逻辑组件初始化行为只有被访问时才会计算默认不执行需配合 immediate监听多个数据依赖多个属性自动追踪可显式传入数组或 getter修改其他数据不推荐应在 getter 外做回调内可以修改任意数据这个表格里的重点我给各位标一标computed一定要有返回值且适合纯计算watch简直就是为异步而生的。拿不准的时候先问自己一句这行代码我要的是拿到一个值还是做一个动作前者用 computed后者用 watch基本不会错。4.2 高频反模式把数据加工写成 watch 的连环赋值这可能是 Vue 项目里最泛滥的一种问题代码。需求很简单firstName和lastName变化时需要拼一个fullName。于是有人这样写const firstName ref(张) const lastName ref(三) const fullName ref() watch([firstName, lastName], () { fullName.value firstName.value lastName.value })功能上完全正确但我每次 review 看到这种代码都会皱眉头。为什么因为它把简单的事情复杂化了。这个场景的本质是用已有数据派生出新数据是computed的主场。用 watch 手动赋值等于绕了一大圈去演奏一首本可以用钢琴简单弹出的曲子——还引入了不必要的中间状态。更糟的是如果你在多个地方都写了这种 watch 赋值数据流会变得混乱谁先更新谁后更新会不会出现某一个 watch 还没触发、中间状态下组件读取到的不是最新值的情况正确写法一行搞定const fullName computed(() firstName.value lastName.value)记住这个规律当你发现自己在 watch 里只是给另一个数据赋值时停下来想想能不能换成 computed。这两者的代码量差距不算大但数据模型的清晰度天差地别。看一个项目里 watch 被怎么用你大概就能判断出这个项目的代码质量了。4.3 反过来的反模式computed 里塞副作用还有一种错误方向。有人想实现数量变化时打印日志并更新总额于是写成const total computed(() { console.log(数量变化了) return count.value * price.value })运行起来他可能觉得一切正常log 也打印了。但问题在于computed的 getter 在依赖不变时是会被缓存的也就是说这个 log 并不会每次数据变化都打印而且在响应式系统内部computed 的 getter 被调用的时机可能比你预期的多比如模板里别的数据更新触发了渲染导致 getter 被重新读取log 的触发时机就会很不可控。更严重的是如果你在 getter 里改了一个响应式数据可能直接触发infinite update loop detected的警告因为每次重算你的 getter 都会再次修改数据导致无限循环。正确的做法是拆开const total computed(() count.value * price.value) watch(total, (newTotal) { console.log(总额变化了, newTotal) // 这里可以做任何副作用比如上报埋点、更新服务端数据等等 })别嫌麻烦。这个拆分看起来多写了几行但它把计算和响应计算结果的副作用两个关注点彻底分开。计算逻辑是纯粹的给定输入返回输出副作用逻辑是开放的由计算值驱动去做外部操作。这样做两个 part 都可以独立测试、独立维护。这是我在大型项目中特别注重的写法要求。4.4 选型决策的几个实操问题为了让你拿起来就能用我把自己平时做技术选型时会问自己的几个问题列出来你写代码之前可以对着想一想。我的这段代码最终是要在北单调一个值出来给模板显示吗如果是——computed。我需要数据一变就自动做点事情吗而且是那种事情本身较复杂请求、调用其他函数、修改多个数据如果是——watch。我处理的是异步请求并且希望在参数变化时重新执行吗如果是——watch配合immediate做初始化。这个计算结果要经过很复杂的数据加工且加工结果会被多次使用吗如果是——computed可以省掉重复计算的开销。我要在多个数据源任意变化后统一做一件事吗如果是——watch监听一个 getter 函数或数组而不是到处写computed。这些问题没有一个会同时指向两个答案。所以多练习几次选型速度就会上去。5. 那些年一起踩过的坑报错、陷阱与 Vue 3 的变化学一个知识点最快的方式就是从别人的 bug 里学。这节我把近几年收集到的、尤其是围绕computed报错和watch失效的坑梳理一遍。这些坑有些是官方文档提到过的更多是实际开发中踩出来的。5.1 computed 报错常见原因与现场还原第一个高频报错给只读 computed 赋值。前面已经说过没有显式提供 setter 的 computed 是不能被赋值的。我见过一个很典型的场景用户写了一个编辑页把表单数据通过 computed 从 store 里取出来然后直接在子组件里formData.value.xx xxx控制台立刻给你打出一长串警告。原因是 computed 返回的对象即使内容可变它本身也被视为只读引用。这个问题的解法要么是给 computed 提供 setter 来触发 store 的更新要么就从 store 里取一个可写的副本出来。第二个高频报错叫循环依赖。你写了一个computed它的 getter 又依赖另一个computed而另一个computed的 getter 又反过来依赖它。于是系统会在计算链中打转最终报 Maximum recursive updates exceeded 或把堆栈打爆。这类问题往往是因为数据流设计反了两个派生数据不应该互相依赖如果要互相联动说明它们的公共根应该是同一个原始数据源。第三个报错是 Vue 2 时代留下的经典——拿不到闭包里的最新值。在 Vue 2 的 Options API 里如果你在 computed 里引用了外部变量但这个变量没有放在data里它就不是响应式的computed 自然也不会更新。迁移到 Vue 3 的 Composition API 之后这个问题大幅减少因为只要你在setup里用 ref 或 reactive 声明响应式连接就会建立。但如果你用的是普通变量又想要响应式那还是会原地打转——这不是 API 的问题是该上没上的问题。5.2 watch 失效的经典陷阱引用没变对象属性变了watch失效也是一个高频 bug。最常见的场景就是我之前提到的监听一个 ref 类型的对象但监听的是基本引用而非深度属性。举个例子const profile ref({ nickname: 小明, email: xiaomingexample.com }) // 这样监听是无效的因为 profile 的引用没有变化 watch(profile, (newVal) { console.log(profile 更新了) }) // 修改的是对象的属性不是对象的引用 profile.value.nickname 小红回调不会执行。很多同事会在这里一脸懵数据明明变了啊。没错数据确实变了但profile.value这个对象本身的引用还是同一个而 ref 的依赖追踪逻辑基于它的.value是否可以触发版本更新。在 Vue 3 中对profile.value.nickname赋值虽然会触发嵌套响应式系统内部的更新但对于监听profile这个 ref 的 watcher 来说它收到的通知被设计为引用级的。那怎么办三个方向如果你关注的对象中只有少数属性改成watch(() profile.value.nickname, ...)如果你关注的是整个对象深度的变化加上{ deep: true }如果你其实想知道对象整体被替换为另一个对象那保持监听引用即可。这三种需求对应三种写法我建议你别无脑deep因为深度监听是有开销的——要递归遍历对象的所有属性数据规模一大性能就是隐患。5.3 对 watch 的文件管理式使用数据很多就建一个监听集watch 文件管理这个词在搜索里热度不低我猜用户想找的是那种有一堆数据源如何统一管理监听逻辑的方法。这就有点像操作系统里管理很多文件你不会每个文件单独折腾而是建一个目录结构统一处理。在代码里你可以用一个集中式的 watch 管理函数function createWatchManager() { const tasks [] function addWatch(source, callback, options) { tasks.push({ source, callback, options }) } function install() { for (const { source, callback, options } of tasks) { watch(source, callback, options) } } return { addWatch, install } } // 使用 const manager createWatchManager() manager.addWatch(() state.page, handlePageChange, { immediate: true }) manager.addWatch(() state.filters, handleFilterChange, { deep: true }) manager.install()这种做法适合多源数据联动的复杂页面比如报表工作台、配置后台等。把 watch 当成待办任务统一注册方便统一管理、统一销毁、代码可读性也会高很多。对于绝大多数简单页面我仍然推荐直接写 watch引入这个工具反而显得过度设计。记住工具是服务于场景的逼格是服务于可维护性的。5.4 Vue 3 中 computed 与 watch 的关键变化最后聊一下从 Vue 2 迁移到 Vue 3 时这两个 API 的主要差异这部分对新老玩家都很关键因为网上大量旧教程还在用 Options API 教你而实际项目早就切到 Composition API 了。在 Vue 3 的 Composition API 中computed和watch都需要从vue包导入。computed返回的是一个ComputedRef模板里使用会自动解包脚本中必须通过.value访问。watch变更为可以监听一个 getter 函数、一个 ref、一个 reactive 对象或者一个包含多种数据源的数组。此外 Vue 3 新增了watchEffect和watchPostEffect前者我们已经聊过后者自带flush: post逻辑。另一个容易被忽略的细节是 Vue 3 对watchEffect和async函数组合时的警告。如果你在watchEffect的回调里使用了async/await并在后面又访问了响应式数据Vue 会提示你watchEffect callbacks run inline async functions may lose track of dependencies because you await—this is expected, but please ensure your async effect tracks only the synchronous dependencies. 简单说watchEffect的依赖收集只在同步阶段完成await之后访问的响应式数据不会被追踪。如果你有先等一个请求再根据结果访问另一个响应式数据的需求建议用watch加 getter或者用watchPostEffect并保持回调尽量同步。还有一个 Vue 3 的性能细节在script setup中直接用const total computed(...)它在每个组件实例中都是独立的。如果你在多个组件中都要用同一个计算逻辑建议提取到composables目录里比如useCartTotal()返回一个 computed 实例。这样既复用逻辑也保持每个组件实例的缓存独立。6. 写在最后的一点个人使用心得如果你花时间读完上面这些我相信你已经掌握了computed和watch的底层区别前者是数据派生的精算器后者是数据变化的触发器。但掌握了原理和用法不等于写代码时就能舒适地落地。按我的经验最终决定项目代码质量的往往是你在复盘中反复打磨出的那条直觉。这种直觉没法看文档获得只能靠一次次重构和 review 去练。如果只让我留一条建议我会说这样一句写出一个 watch 或 computed 之前先试着用一句话回答这数据是从哪来的、去哪里的。如果能一句话说清楚是由 A 和 B 算出来给页面显示用 computed如果这句话变成当 A 变化时要去请求接口、更新 C、操作 DOM用 watch。回答清楚了代码组织就已经成功一大半了。另外从工程角度再给你一个实操技巧在代码 review 时如果有人写了watch 里赋值另一个响应式数据的模式别急着打回。先看看数据流的方向是否清晰、是否真的需要异步参与。如果只是同步的派生关系改成 computed 会更好如果中间确实存在异步请求或不可控的时机watch 是合理选择。不要一刀切关键在于场景的适配度。最后分享一个小习惯我在写新组件时会先检查这个组件里有多少个 watch如果超过 3 个就会反思数据模型是否设计得太复杂能否通过提升状态层级或者用 computed 简化。因为 watch 本身是命令式的、隐式的副作用集合watch 越多数据流的透明性越差。把这个当作代码健康度的体检指标之一你会在长期维护中发现这个小小习惯带来的巨大收益。