ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3响应式监听:watch/computed/watchEffect分工与避坑指南

Vue3响应式监听:watch/computed/watchEffect分工与避坑指南 1. 别急着写watch先搞清楚它和computed的分工先从一个特别常见的场景说起。你负责一个搜索页面搜索框每输入一个字符就要向后端拉一次数据但直接请求太频繁同事建议加个防抖。你第一反应是用computed——因为整天听人说“computed和watch都能监听数据变化”。结果发现computed根本做不到防抖它本质上是一个“计算值”你输入搜索词它顶多帮你派生出一个新值比如把字符串去空格、转大写但它不会在特定时间后主动帮你执行一段逻辑。这就是很多人刚接触Vue3时最糊涂的地方明明响应式数据变化都能感知为什么computed就“感知”不了、也“执行”不了异步操作因为你搞混了两件事——computed解决的是“我要什么”watch解决的是“我要做什么”。computed是声明式的你给我输入我同步给你算出输出过程中没有副作用也不存在延迟watch是命令式的你给我数据变化我立刻去做某件事比如发请求、操作DOM、写localStorage、调用别的组件方法。那为什么会把两者混在一起因为Vue2时代的滤镜搜索写法太容易让人产生错觉很多人用computed直接filter一个列表认为它就是“监听搜索词变化并更新结果”。确实能出结果但那是因为模板重新渲染时computed重新求值而不是watch“监视”到了变化。一旦你要做防抖、要等500ms再请求、要处理请求竞态computed就原形毕露了。真正干这事的是watch。所以你在选型时可以这样判断如果数据变化后的目标是“得到一个新值”用computed如果目标是“做一件事”用watch。一件事可以是更新另一个控件状态、上报埋点、拉取接口、切换路由行为、触发动画、打印日志……所有和“执行”沾边的场景都是watch的管辖范围。我在实际项目里听到最多的一句经验是能把状态计算出来的就别写watch能用watch别写一堆手动调用来调用去的事件。这话不完全对但方向是对的。新手阶段最容易写出“用watch去同步另一个ref值”的代码这种用computed更优雅而真正需要watch的地方——副作用、异步、批量操作——反而是新手不敢下笔的地方。1.1 computed解决的是“我要什么”咱们先说一个最简单的例子。你有单价ref(price)和数量ref(count)商品总价就是两者相乘。用computed写就是天生合适import { ref, computed } from vue const price ref(88) const count ref(2) const total computed(() price.value * count.value)你可以随时改price或counttotal自动更新。这个场景没有任何异步、没有任何DOM操作纯粹是“依赖其他状态的状态”直接用它没毛病。如果你非要用watch写就得再维护一个totalRef变量然后手动监听price和count再手动赋值代码量翻倍还容易漏监听。1.2 watch解决的是“我要做事”现在换成另一个场景你输入关键词后想等用户停下来了再调用接口搜索。此时computed就没有用武之地了。因为你不需要一个新值你要的是一个动作发网络请求。这时候watch就非常自然import { ref, watch } from vue const keyword ref() watch(keyword, (newVal, oldVal) { // 这里你要做什么都行防抖、请求、日志、埋点 debounceSearch(newVal) })1.3 组合使用的一个典型场景搜索框防抖Vue3里组合起来也不难代码不长但能很好说明两者分工。computed负责去掉首尾空格watch负责延迟请求const keyword ref() const trimmedKeyword computed(() keyword.value.trim()) let timer null watch(trimmedKeyword, (newVal) { clearTimeout(timer) timer setTimeout(() { fetchList(newVal) }, 400) })被测者的keyword变了computed先重新求值watch再拿到变化后的值去决定要不要发请求。这种各司其职的结构在后续维护时一眼能看懂。2. 从Vue2到Vue3watch的写法到底变了什么“Vue2的watch不是也能用吗Vue3到底改了什么”这是转型期最常见的疑问。答案是选项式API的watch基本没变但你一旦用组合式APIsetup就必须学会新的写法。两者的心智模型不一样很多人栽在这个过渡上。2.1 选项式API中watch其实没怎么变Vue3依旧支持Vue2时代的写法export default { data() { return { keyword: } }, watch: { keyword(newVal, oldVal) { console.log(newVal, oldVal) } } }这个确实没变化太多功能上兼容。但有个细节需要注意Vue3中watch内部实现是基于composition API的”watch选项“也不过是编译成了组合式API的调用。所以你现在学Vue3新写法并不是推翻旧知识而是往更底层理解。选项式的写法适合老项目迁移新项目建议直接上组合式。2.2 组合式API里三个最常见的调用姿势组合式API的watch基础用法记住三个形式就够了监听单个refimport { watch, ref } from vue const count ref(0) watch(count, (newVal, oldVal) { console.log(count从 ${oldVal} 变成了 ${newVal}) }) count.value重点提醒这里的回调在数据“变化”时触发页面加载时不触发。如果你希望首次就执行需要加配置项immediate后面会专门讲。监听多个refconst name ref() const age ref(0) watch([name, age], ([newName, newAge], [oldName, oldAge]) { console.log(某个字段变了, newName, newAge) })注意回调参数变成了数组用了数组解构。这种写法在表单联查的时候特别常用——比如名字变了要重新校验年龄变了也要重新校验。监听函数返回值const user reactive({ name: 张三, address: { city: 上海 } }) watch( () user.address.city, (newCity, oldCity) { console.log(城市变化了, newCity) } )这种getter函数写法是组合式API里最重要的技能之一。为什么不能直接watch(user.address.city)?因为Vue自动解包ref但无法智能跟踪一个属性路径内部的变化。传getter(() user.address.city)watch内部会通过effect追踪这个函数的依赖city一变就能感知。没getter的话一旦reactive对象的属性被重新赋值监听很可能不触发。2.3 配置项deep和immediate各自该在什么时候用组合式API的watch第三个参数是options最有价值的是两个deep和immediate。immediate: true需求场景其实特别多。比如详情页参数变化后要拉取数据页面初始化时也要拉一次。没有immediate就得在页面初始化的地方手动调用一次函数有了它watch回调在绑定监听后立刻执行一次。watch( () route.params.id, (newId) { fetchDetail(newId) }, { immediate: true } )这样无论首次进入还是id变化都会请求详情数据不用额外手动调用代码也简洁。实测中这个配置极大减少了重复函数调用或者漏掉首次初始化的bug强烈建议多用。deep: true场景是监听一个嵌套对象内部的变化。不加deep时如果你监听的是整个对象内部属性变化时回调不触发。比如const formData reactive({ user: { name: 李四, age: 25 } }) watch(() formData.user, (newVal, oldVal) { console.log(user变化了) }) formData.user.age 26 // 不会触发因为getter只追踪到了user这个引用age的变化发生在引用内部watch不知道。需要deep: true强制深层次遍历watch( () formData.user, (newVal, oldVal) { console.log(user内部属性变化了) }, { deep: true } )但这里有个坑加了deep之后新值和旧值拿到的都是同一个引用也就是新旧值其实指向同一个对象。紧接着的浅比较就会失效你没法直接通过oldVal和newVal对比出到底哪个字段变了。这是后续排错的大坑我放在第四部分细讲。2.4 多源监听与回调的参数细节组合式API的watch还支持回调接受一个onCleanup参数用来处理副作用。例如发起搜索请求后用户又快速输入了下一个词上一个请求可能还没返回这时候需要在每个新回调里取消上一次的请求或清理之前的定时器watch(keyword, async (newVal, oldVal, onCleanup) { let cancelled false // 记录取消标识 onCleanup(() { cancelled true }) const result await fetchList(newVal) if (!cancelled) { list.value result } })onCleanup的最大价值就是解决了请求竞态。实际项目中请求返回顺序颠倒会导致列表显示旧数据很多新手根本不知道watch还有这个参数只能手动用请求序号来规避。如果你用Vue3了多利用这个内置能力代码会干净很多。3. watchEffect自动追踪依赖的watch进化版凡是写Vue3的面试大概率会被问到watchEffect。很多人把它当成watch的兄弟但其实它的心智模型完全不同。简单说watchEffect是“自动收集依赖”的watch是“手动指定依赖”的。3.1 响应式依赖收集的执行时机watchEffect的典型写法import { watchEffect, ref } from vue const id ref(1) const userInfo ref(null) watchEffect(async () { userInfo.value await fetchUser(id.value) })在这里面使用了id.value所以watchEffect自己就知道要监听id。id一变回调立刻重新执行。你不用手动声明依赖写起来非常爽。但爽归爽有个问题不能忽略watchEffect默认在组件创建时就会执行一次回调用来收集依赖。这一点和watch形成鲜明对比——watch默认不立即执行。3.2 flush配置到底影响什么watchEffect还有一个隐藏配置叫flush三个值pre、post、sync。默认是pre表示回调会尽量在组件更新前执行。大多数需求用pre就够了但如果你需要在DOM更新后再执行操作比如获取某个元素的高度、调用第三方库做初始化就要用postimport { watchEffect } from vue watchEffect( (onCleanup) { // 当依赖变化后在组件更新后执行 initChart() }, { flush: post } )sync则意味着依赖变化后同步立即执行性能开销比较大且容易绕开Vue的批量更新机制99%的普通开发用不到性能敏感或特殊项目再说。这里不展开细节。绝大多数人只要记住拿不到DOM最新状态时看看是不是需要post。3.3 watchEffect的真正价值场景日志、状态持久化、数据预加载实际开发中watchEffect特别适合做三类事第一是日志和统计。比如用户操作时记录点击行为回调里读取各种state打日志依赖自动追踪新增字段后不用改监听列表少漏埋点。第二是状态持久化。比如用户设置项变更后要同步到localStorageconst settings reactive({ theme: dark, fontSize: 14 }) watchEffect(() { localStorage.setItem(settings, JSON.stringify(settings)) })这个代码会立刻执行一次把初始状态写进去之后任何设置变化都会自动保存非常省心。第三是数据预加载。详情页依赖多个query参数只要任一参数变化就需要重新请求。watchEffect配合axios/请求函数即可不用手动枚举依赖import { useRoute } from vue-router const route useRoute() watchEffect(() { const { id, tab } route.query fetchDetail(id, tab) })我自己项目里一个筛选面板用了六七个筛选条件如果用watch一个个监听会写得非常啰嗦watchEffect一次搞定。当然代价是不容易精确控制到底哪个变化触发的如果要对不同条件做差异化处理还是回watch。4. 那些年我们踩过的watch大坑这一节是重头戏。Vue3的watch看着简单但实际项目里翻车的大多都在下面几个点上。每个坑我都亲身踩过把完整排查链路写出来。4.1 深监听对象时旧值拿不到真“旧值”这个坑特别隐蔽。看代码const user reactive({ name: 张三, age: 30 }) watch( () user, (newVal, oldVal) { console.log(newVal oldVal) // 猜猜结果是什么 }, { deep: true } ) user.age 31输出结果让人太抓狂了true。因为加了deep监听后Vue内部传的新旧值其实是同一个引用——它只是把同一个对象深度追踪了一遍并没有做一份深拷贝。所以你想通过oldVal.age对比找出“从30变成31”这种需求直接做是拿不到正确的旧值的。解决办法是显式做深拷贝快照import { reactive, watch } from vue import { cloneDeep } from lodash-es const user reactive({ name: 张三, age: 30 }) watch( () cloneDeep(user), (newVal, oldVal) { console.log(旧age:, oldVal.age) console.log(新age:, newVal.age) } ) user.age 31这种方式每次触发都会拷贝一份完整对象数据量很大时会产生额外开销所以只适合小对象或低频场景。真要频繁做变更历史追踪建议在业务层自己维护版本记录别指望watch内建给你旧值。4.2 watch一个函数返回值避免直接看内部属性如果直接监听reactive对象某个多层属性有时候反而会莫名其妙不触发。看这段const form reactive({ detail: { address: { city: 北京 } } }) watch(form.detail.address, (newVal) { console.log(地址变了) })这代码在Vue3里执行时监听的其实是form.detail.address这个对象的引用。如果你做的是form.detail.address.city 上海理论上深层嵌套也能感知到因为引用本身没变监听失效。正确写法是监听getterwatch( () form.detail.address.city, (newVal) { console.log(city变了, newVal) } )原因很简单watch需要追踪依赖也就是要执行一次你给它的source函数看看它读到了哪些响应式数据。传一个对象引用给它它只知道“我监听这个对象”不知道你关心这个对象内部的哪个字段传getter给它它执行getter才知道city被读了city一变就能响应。4.3 在watch回调里修改被监听的数据循环触发的死循环这是一个令人头皮发麻的常见问题。比如你根据路由参数去更新表单时有个watch监听表单某个字段回调里又去修改这个字段const form reactive({ name: }) watch( () form.name, (newVal) { form.name newVal.toUpperCase() // 又一次修改触发下一次监听 } )执行过程form.name一变回调执行回调里又改form.name变更又触发回调……无限循环浏览器卡死控制台刷屏。Vue官方实际上对这种情况有一个内部保护机制但靠它兜底不靠谱。正确做法是在回调里永远不要修改正在被监听的数据源如果需要同步转换用computed如果确实要改就在改的时候暂停watch或在逻辑上判断let skipWatch false watch( () form.name, (newVal) { if (skipWatch) { skipWatch false return } skipWatch true form.name newVal.toUpperCase() } )这种“锁”在需要回写的时候很实用。还有一种策略是用{ flush: sync }再配合深度比较但我更推荐前者代码可读性好好维护。4.4 停不下来组件卸载后还在执行的副作用这是异步场景的大坑。watch监听了外部状态比如store里的数据或路由参数回调里发起异步请求结果组件已销毁回调仍然执行。轻则警告重则内存泄漏。官方给watchEffect和watch的回调提供了onCleanup清理机制但watch本身不会在组件卸载时自动停止——如果你在setup顶层创建watchVue在组件卸载时会自动停止但如果你在非setup环境比如事件回调里或动态创建创建watch就可能失控。稳妥做法是显式接收停止函数在组件卸载时手动调用import { onBeforeUnmount, watch } from vue const stopWatch watch(source, callback) onBeforeUnmount(() stopWatch())还有一个隐藏陷阱异步回调里拿到的newVal可能是旧值。因为watch的回调本身是在状态已经更新后再调用的如果你在回调里再发一个异步请求等到数据回来时可能已经又变了这时候要结合上面的onCleanup做竞态处理。我就是因为这个在页面快速切换筛选条件时多次出现列表数据错乱后来统一加上竞态控制才好。4.5 watch监听不到prop变化先检查是不是异步时机问题这个坑往往出现在父组件异步加载数据后传给子组件的情况。场景父页面打开时拉取列表第一项默认选中并传给子组件子组件watch这个prop要在变化时做初始化。问题来了第一次从空值变成实际值watch没触发。原因基本有二其一没有设置immediate: true子组件挂载时根本没监听过这个prop其二watch监听的是prop这个引用本身父组件拿到的异步数据和初始值属于不同引用但其实Vue能触发。更多时候是时机问题——子组件挂载时prop还是空等数据到达时watch早已绑定但回调没触发因为prop在子组件内根本没有被“读取”。排查思路先加console.log看prop在子组件里到底变没变再确认watch的source是不是getter函数最后确认watch有没有被意外停止。这里给一个通用建议监听props时永远用函数返回值且尽量设置immediate配合业务初始化const props defineProps({ rowData: Object }) watch( () props.rowData, (row) { if (row?.id) { initForm(row) } }, { immediate: true } )这样首屏渲染和后续更新都能覆盖不会出现“第一次不执行”的问题。5. 面试常客vue2和vue3的watch相关考点梳理每次简历上写“精通Vue3”面试官不可能不问你watch相关。90%的面试题其实就是四连问watch和computed什么区别watchEffect和watch什么区别deep监听要注意什么如何取消watch监听与其背答案不如理解后用自己的话讲。5.1 watch与computed的区别八股但常考面试官问这个不是想听你背“computed有缓存watch没有缓存”而是希望你讲出两者在职责和场景上的本质差异。我给一个标准但不会显得死板的回答思路computed必须返回一个值它是派生状态适合纯计算。watch不要求返回值它适合副作用也就是做事情。computed有缓存只有依赖变化才重新计算性能更好。watch可以执行异步代码computed里不应该做异步或DOM操作。实际开发中如果最终目的只是得到一个新值优先computed可维护性更高真要调用接口或操作外部状态才选择watch。可以再补一句在Vue3里computed本质上是基于响应式effect的缓存watch是基于scheduler的调度两者底层都是Effect但设计目标不一样。面试官听到这一层基本就会点头了。5.2 watchEffect与watch的区别这个最好用一张对比表来说面试时也可以画博客里我就用表格直接列维度watchwatchEffect依赖指定方式手动指定数据源自动收集回调里依赖的响应式数据初始化执行默认不执行可加immediate立即执行一次收集依赖获取新旧值回调参数直接提供新旧值拿不到旧值适用场景明确某个数据变化后需要做什么不关心哪个变化只要变了就重新同步多次依赖变更默认批量更新避免重复触发效果内部也会合并但语义上更偏向同步面试时还可以主动提一个细节watch的赋值是一个scheduler控制的过程同一轮状态改变中即使你连续改两次count回调可能只跑一次watchEffect则每次都跑所以如果你依赖多个状态watchEffect可能触发得比watch频繁。5.3 深监听的性能隐患与替代方案深拷贝一个巨大对象做deep监听性能成本很高。实际经验是能用getter就优先用getter只监听你真正关心的字段。如果必须监听整个对象可以监听多个getter再组合。如果对象特别大提升性能的关键是减少同时活跃的watch数量。用{ deep: true }时尽量减少回调里的重逻辑因为对象属性每次变化都会触发回调。项目里曾出现过一个表单对象有几十个字段用户在输入框每敲一个字deep watch的整个对象就会重新深比较导致卡顿。后来把watch拆成几个独立的getter监听只监听需要联动的那几个字段性能立刻恢复。这就是典型用watch要“精准”的经验依赖范围越小成本越低。最后分享一个我调试watch时的小习惯在回调里第一时间打印newVal和oldVal再结合{ flush: post }多验证DOM更新后状态。遇到“没触发”先看是不是getter写错“触发太多次”先看是不是副作用里改到监听源。“watch没反应”多半不是Vue3的问题而是你还没告诉它该看什么。这套路子捋顺了Vue3的watch就是最容易掌控的一块拼图。
返回列表