前阵子重构一个后台列表页面光筛选条件就五个关键词、分类、状态、排序方式、日期范围。用watch写的话五个条件得开五个watch或者小心翼翼地把它们包进一个computed里再统一监听——折腾半天总觉得哪里不对。后来我干脆全删了换成watchEffect三十多行的联动逻辑缩成了十来行还不用操心漏掉哪个依赖。这不是说watch不行。作为一个在 Vue2 时代用惯了watch的人我刚看到watchEffect的时候也觉得这不就是watch加了个immediate: true吗实际用下来才发现它俩是完全不同的两种思维方式。这篇就把我的理解、踩过的坑、以及现在搭项目时怎么分工的一次说清楚。1. watch 和 watchEffect一对被反复比较的兄弟1.1 一个立即执行的差别决定了场景分水岭先看最直观的区别watchEffect创建后会立刻执行一次回调watch默认不执行要等数据变化才触发除非手动加immediate: true。这个立即执行太关键了。很多场景下你本来就需要在组件初始化时拉一次数据、算一次状态、同步一次外部系统。用watch的惯性写法是const keyword ref() watch(keyword, (val) { fetchSearchList(val) }, { immediate: true })每次都要记得加immediate忘了的话页面首次进入就不会触发请求bug 就藏在细节里。而watchEffect天生就是先跑一次之后自动跟踪再跑不需要额外配置const keyword ref() watchEffect(() { fetchSearchList(keyword.value) })看起来只是少了配置项但实际上反映了两者的设计定位watch是监听变化后执行回调它默认假设你关心的是变化之后的事watchEffect是运行一个会访问响应式数据的函数并在依赖变化时重新运行它不区分首次和之后把初始化逻辑和更新逻辑统一了。1.2 自动收集依赖 vs 手动指定数据源这是两者最本质的差异也是大多数教程没讲透的地方。watch要求你显式指定要监听的数据源可以是一个 ref、一个 reactive 对象、一个 getter 函数或者一个由它们组成的数组。依赖列表是你自己维护的数据源之外的东西变了回调一概不认。watchEffect不要求你指定任何数据源。它把你的回调函数当作一个副作用函数执行执行过程中凡是访问到的响应式数据都会被自动注册为依赖。之后任何一个被访问过的数据变化都会触发整个回调重新执行。看个实际对比。假设有一个筛选联动关键词、分类、页码任何一个变化都要刷新列表// watch 写法手动维护依赖列表 const keyword ref() const category ref(all) const page ref(1) watch([keyword, category, page], ([kw, cate, pg]) { fetchList({ keyword: kw, category: cate, page: pg }) }) // watchEffect 写法访问即依赖 watchEffect(() { fetchList({ keyword: keyword.value, category: category.value, page: page.value }) })一眼看过去差不多但维护性差异很大。项目迭代过程中如果新增了一个sortOrder条件watch写法需要改两处依赖数组里加一项、解构参数里加一项。watchEffect写法只需要在回调函数里加一行sortOrder: sortOrder.value依赖自动被追踪。漏改的概率后者低得多。1.3 没有旧值是真的缺点吗watch回调能拿到(newVal, oldVal)watchEffect拿不到。很多人据此判断watchEffect是阉割版我倒觉得这是刻意为之。什么时候需要旧值最常见的几个场景统计某个值从旧状态变成新状态的差值、判断某个数据是否发生了特定方向的改变、做表单的脏检查。这些场景watchEffect确实做不了你只能自己额外保存一份旧值。但换个角度想大部分联动场景你根本不关心之前是什么只关心现在变成什么了我该做什么。列表查询就是典型——用户改了关键词不管你之前搜的是什么只要重新拉一次新结果就行了。为了拿到一个用不上的旧值多维护一份依赖列表其实不划算。我现在的判断标准很简单需要对比前后状态差异的就用watch只需要每当状态变了就同步做某事的就用watchEffect。一个偏事件型一个偏同步型。2. 从源码看 watchEffect 的调度机制2.1 一次依赖收集的完整链路想真正用好watchEffect得先搞明白它是怎么工作的。Vue3 里它和computed、watch共用同一套响应式核心——ReactiveEffect。watchEffect的实现本质上是创建一个ReactiveEffect实例传入你的回调函数。这个实例有个run方法执行时会创建一个当前正在执行的 effect的上下文。你的回调函数里只要访问响应式数据的 getter就能通过全局上下文找到当前 effect把自己注册进这个数据的依赖集合里。数据变化时setter 触发依赖集合中的 effecteffect 再决定何时重新执行回调。所以watchEffect的依赖追踪是运行时动态的。这意味着一个很容易被忽略的细节依赖收集是执行时实时发生的。如果你的回调里有条件分支watchEffect(() { if (showDetail.value) { console.log(detailData.value) } })showDetail为false时detailData不会被访问也就不会被收集为依赖。只有showDetail变为true、回调重新执行、detailData被真正读取之后它才进入依赖列表。反过来如果某次执行后某个依赖不再被访问它也会自动从依赖列表中被移除Vue3 的cleanup机制会在每次执行前清空上一次收集的依赖。这个机制的好处是足够精确坏处是如果你依赖的数据是动态挂载的比如在异步回调里才访问那依赖收集就会漏。后面踩坑部分细说。2.2 触发时机pre、post 与 sync 三种调度模式watchEffect有个容易忽略的配置项flush它决定了副作用函数的执行时机默认是pre。pre默认在组件更新之前执行。Vue 内部会把这个 effect 放进一个调度队列等当前组件的更新任务统一处理。好处是如果你的副作用会修改组件内部状态可以在本轮渲染前完成避免额外触发一次渲染。post在组件更新之后执行。当你的副作用需要读取更新后的 DOM 时比如访问元素的高度、宽度、或获取某个子组件实例的引用必须用post。sync数据变化时同步执行不进队列。性能最差一般不推荐除非你确切知道自己在做什么。一个典型的post场景监听某个数据变化后需要测量 DOM 节点的新尺寸。const list ref([1, 2, 3]) const listRef ref(null) watchEffect( () { list.value.forEach((item) { // 注意这里没有访问 listRef 的响应式数据 }) // 组件更新后listRef 对应的 DOM 才会渲染出新内容 }, { flush: post } ) // 配合 onMounted 等钩子获取更新后的 DOM我早期用watchEffect处理过类似内容变化后滚动到底部的需求默认pre模式下取到的scrollHeight总是旧值改成{ flush: post }就正常了。遇到明明数据变了DOM 测量值却没变的问题第一反应就该检查是不是flush配置不对。2.3 onInvalidate 清理函数最容易忽略却最关键的能力watchEffect还有一个watch的常规用法里不太直观的能力清理函数。回调函数接收一个onInvalidate参数你可以在每次副作用执行前注册一个清理逻辑这个清理逻辑会在以下两个时机被调用下一次副作用重新执行之前组件卸载、effect 被停止时。这有什么用最常见的是处理竞态用户在搜索框快速输入上一次请求还没返回下一次就发出去了最后展示的结果可能是旧请求返回的。用onInvalidate加一个过期标记就能解决watchEffect((onInvalidate) { let cancelled false fetchSearchList(keyword.value).then((res) { if (!cancelled) { result.value res.data } }) onInvalidate(() { cancelled true }) })每次keyword变化时上一次回调注册的onInvalidate会先执行把上一次的cancelled标记为true这样旧请求返回后就不会更新result从根上消灭竞态。清理函数还能用来清除定时器、断开 WebSocket 连接、取消 IntersectionObserver 等。它弥补了在组件卸载时反向清理资源的需求而传统写法里这些逻辑经常被塞进onUnmounted里和watch逻辑分离维护起来很割裂。3. 哪些场景我劝你换成 watchEffect3.1 多条件联动的列表查询这是我最推荐使用watchEffect的场景也是它收益最明显的地方。后台管理系统的列表页往往是表单筛选 分页 列表的组合。筛选条件可能有五六个每个变化都可能涉及不同参数的组合。用watch的痛点在于条件一多依赖数组写起来像流水账新增条件时容易漏改依赖数组多个条件变化想要合并成一次请求还得自己处理nextTick或Promise.resolve()合并。watchEffect的写法天然就是一条链路const filters reactive({ keyword: , category: all, status: , dateRange: [], sortBy: createdAt, page: 1, pageSize: 20 }) const loading ref(false) const list ref([]) watchEffect(async () { loading.value true const params { keyword: filters.keyword, category: filters.category, status: filters.status, dateRange: filters.dateRange, sortBy: filters.sortBy, page: filters.page, pageSize: filters.pageSize } const { data } await fetchList(params) list.value data.list loading.value false })这里有个细节值得注意watchEffect的依赖追踪对异步代码是无效的await fetchList(params)里的params是在同步阶段就读取好的所以只要你在进入异步之前把所有需要的值都读出来追踪就是完整的。这个写法把任何筛选条件变化就重新拉数据这件事表达得非常直观。3.2 显隐联动多个状态共同决定一个结果另一个常见场景是多个状态共同决定一个结果。比如购物车页面商品选中状态、数量、优惠码、会员等级都会影响最终结算金额和下单按钮的可用性。以前用watch的写法要监听所有相关状态然后逐个判断。写起来很长而且漏一个就会出 bug。用watchEffect直接把判断逻辑写成结算状态的派生过程const selectedItems ref([]) const couponCode ref() const memberLevel ref(normal) const canCheckout ref(false) const totalPrice ref(0) watchEffect(() { const items selectedItems.value const hasItems items.length 0 const allValid items.every((item) item.quantity 0 item.stock 0) const hasValidCoupon couponCode.value ? checkCoupon(couponCode.value) : true const levelOk memberLevel.value ! banned totalPrice.value computeTotal(items, couponCode.value, memberLevel.value) canCheckout.value hasItems allValid hasValidCoupon levelOk })注意我在这里把派生结果放到了ref里而不是用computed。为什么因为这里的需求不是我想计算一个值而是每当输入状态变化我想要同步执行一段逻辑这段逻辑会更新多个输出。如果只用computed单个computed只能返回一个值想同时更新canCheckout和totalPrice要么包成对象要么分开写两个computed显然不如watchEffect来得自然。3.3 数据持久化与外部系统同步还有一类场景是数据变了要同步到 Vue 之外的地方。比如把用户操作同步到localStorage或者把某个响应式状态推送到第三方 SDK。const userPreferences reactive({ theme: dark, fontSize: 14, language: zh-CN }) watchEffect(() { localStorage.setItem( user-preferences, JSON.stringify({ theme: userPreferences.theme, fontSize: userPreferences.fontSize, language: userPreferences.language }) ) })如果换成watch要么监听整个userPreferences对象加deep: true要么监听每个属性。前者会有多余的触发后者写起来繁琐。watchEffect在这里的优势恰好在我只管把状态序列化存起来至于这些状态是从哪个路径被修改的不关我的事。如果想去掉深层监听的开销这里还有一个进阶技巧用flush: post合并同一次事件循环中的多次修改让localStorage的写入次数降到最低。默认的pre模式在同一个 tick 内修改多个属性可能会触发多次执行加上post后Vue 的调度器会自动把它们合并到组件更新后的同一轮。4. 哪些场景必须老老实实用 watchwatchEffect虽好但绝不是watch的替代品。下面这几个场景我试过强行用watchEffect最后都老老实实换回了watch。4.1 新旧值对比只有 watch 能给你watchEffect不提供oldVal。所有需要对比前后状态的逻辑它都做不了。举两个实际例子表单脏检查判断某个字段是否从初始值发生了变化以便开启保存按钮。const form reactive({ name: , email: , role: user }) // 必须用 watch因为需要对比初始值 const initialForm { ...form } const isDirty ref(false) watch( () [form.name, form.email, form.role], (newVals, oldVals) { isDirty.value newVals.some((val, i) val ! initialForm[[name, email, role][i]]) } )值变化方向的判断比如监控某个指标只有从0变成非0时才触发某段逻辑。watch(counter, (newVal, oldVal) { if (newVal 0 oldVal 0) { // 从无到有发送埋点 } })这种场景用watchEffect碰都碰不了因为每次执行拿到的都是最新的counter你无法知道上一次的值。虽然可以手动声明一个let prevValue来保存但这就退化成了自己实现watch没必要。4.2 监听那些不是直接响应式的数据源watchEffect的依赖收集依赖于访问响应式数据。如果你需要监听的数据源本身不是 Vue 响应式系统的一部分watchEffect就无法感知它的变化。最常见的是路由对象。Vue Router 4 的useRoute()返回的route对象虽然是 reactive 的但有个小坑如果你直接解构const { query } useRoute()然后监听query由于解构出来的query是一个普通对象引用watchEffect里的query访问并不会建立正确的依赖关系。// 错误解构后的 query 不是稳定的响应式引用 const { query } useRoute() watchEffect(() { fetchByKeyword(query.keyword) // 不会正确触发 }) // 正确使用 getter 访问 route const route useRoute() watchEffect(() { fetchByKeyword(route.query.keyword) // 正确的依赖收集 }) // 或者用 watch watch( () route.query.keyword, (kw) fetchByKeyword(kw) )类似的情况还有第三方全局状态、SDK 内部状态、某个 DOM 元素的 MutationObserver 变更等。这些非响应式数据源watchEffect感知不到必须显式watch某个桥接它们的响应式载体。4.3 深度监听与精细控制watch监听对象时默认就是深度的Vue3 对 reactive 对象监听 getter 函数时需要在deep: true的情况下才深度遍历。watchEffect没有deep选项的概念它只能依赖你实际访问的路径。这意味着如果你想监控一个复杂嵌套对象内部任何一层的变化watchEffect并不合适。比如const settings reactive({ layout: { header: true, sidebar: true, width: 240 }, theme: { color: #333, mode: light } }) // 我想在 settings 任何变化时都执行逻辑 // watch 可以这样写 watch(settings, () { saveSettings(settings) }, { deep: true }) // watchEffect 只能这样写必须手动访问所有用到的字段 watchEffect(() { saveSettings({ layout: settings.layout, theme: settings.theme }) })单看上面watchEffect的写法其实也能工作因为settings.layout返回的是对象访问它就会触发这个对象 getterVue3 的响应式系统会把这个 effect 挂到settings.layout这个 proxy 对象的依赖集合里这样layout.header变化时settings.layout这个 getter 同样会被触发。但如果某个深层字段比如settings.theme.color变化而你的回调里访问的是settings.theme整体它同样能感知到因为 proxy 的 get 触发了。不过这里有个边界如果你访问的是settings.layout.header这种具体路径那settings.layout.sidebar变化时就不会触发回调。watchEffect的追踪粒度是访问了什么就依赖什么非常精确但如果你想无论哪里变了都执行就得自己访问整棵依赖树或者干脆用watch的deep: true更省心。5. 用 watchEffect 踩坑实录每一个都值得记下来5.1 异步代码里的依赖追踪会漏这个坑我最早踩过写出来的代码看着没问题但就是不触发。const userId ref(1) const userInfo ref(null) watchEffect(async () { // 先访问了 userId依赖收集正常 const id userId.value // 但这里用了 await之后的代码不在同步执行上下文里 const res await fetchUser(id) // 下面访问 userInfo 以外的响应式数据不生效 if (res.data.name admin) { console.log(permission.value) // permission 变化不会触发 effect } userInfo.value res.data })原因在前面源码部分提过依赖收集只发生在同步执行阶段。await之后的代码已经跳出当前 effect 的执行上下文访问任何响应式数据都不会被收集。所以permission变化了这个watchEffect不会重新执行。解决办法有两个一是把异步需要的所有响应式值在进入异步前全部读取完确保它们是同步阶段的依赖二是不要依赖异步代码中访问的响应式数据来触发更新。// 推荐把同步阶段需要的依赖都显式读取一遍 watchEffect(() { const id userId.value // 主动访问 permission让它在同步阶段被收集 const perm permission.value fetchUser(id).then((res) { if (res.data.name admin perm limited) { // ... } }) })虽然多打一行访问但这保证了依赖追踪的完整。记住一个口诀watchEffect回调里await之前访问的是依赖await之后访问的是空气。5.2 死循环的成因与排查思路watchEffect最常见的运行时问题是死循环。本质原因是回调里访问了某个响应式数据同时又修改了这个数据。修改导致依赖变化变化导致回调重新执行执行又修改无限循环。举例const count ref(0) watchEffect(() { console.log(count.value) // 访问了 count count.value // 又修改了 count —— 死循环 })console.log触发 count 的 gettercount 成为依赖紧接着count.value触发 setter通知 effect 重新执行重新执行再访问再修改……浏览器直接卡死。实际项目中这种裸奔式死循环不太会出现常见的死循环藏在更隐蔽的地方。我遇到过的典型模式是**读取 → 修改 → 读取 → 修改**的间接循环const products ref([]) const totolPrice ref(0) watchEffect(() { // 读取 products totolPrice.value products.value.reduce((sum, p) sum p.price, 0) // 修改了 totalPrice —— 但 totalPrice 不在依赖里没事 }) watchEffect(() { // 读取 totalPrice if (totalPrice.value 100) { // 修改 products 里某个 item —— 而 products 是上一个 effect 的依赖 products.value[0].price 99 } })第二个watchEffect访问了totalPrice又修改了products而products正好是第一个 effect 的依赖。第一个 effect 重新执行后更新totalPricetotalPrice又触发第二个 effect……同样死循环。排查经验遇到watchEffect引起的死循环第一时间把所有在回调里赋值过的响应式数据列出来看有没有被同一个回调先读后写或者跨回调读写。用console.trace()打印调用栈或者把几个 effect 的日志打出来很快能定位。5.3 和组件生命周期配合的时间差watchEffect默认pre调度创建时会立即执行一次。如果在这个立即执行的过程中访问了尚未初始化的 DOM 或组件状态可能拿到undefined。我踩过一个比较经典的坑watchEffect里访问了props中传入的一个对象这个对象在父组件里是异步获取的子组件创建时它是null。// 父组件 const data ref(null) // 异步获取 data... // 子组件 const props defineProps({ detail: { type: Object, default: null } }) watchEffect(() { // 首次执行props.detail 是 null这里会报错 console.log(props.detail.title) })首次执行时props.detail是null直接访问.title就会抛 TypeError。解决方案是加个空值守卫并利用依赖追踪仅在访问时建立的特性让数据可用后再执行逻辑watchEffect(() { if (!props.detail) return // 此时 return 没有访问 .titlenull 不会被收集为依赖但 detail 本身被访问了 console.log(props.detail.title) })注意if (!props.detail)访问了props.detail所以detail本身会成为依赖。当父组件把detail从null更新为对象时effect 重新执行这次非空继续往后走——逻辑就顺了。这种先判断空再干活的写法在watchEffect里非常常见它天然实现了等数据就绪的效果比watchimmediate 空判断要自然得多。6. 项目里我是怎么搭这套响应式逻辑的6.1 watch、watchEffect、computed 的分工表用了两年 Vue3 之后我给自己定了一套分工规则。不一定适合所有人但至少它能让我从每次都在纠结用哪个中解脱出来。需求类型推荐方案原因纯粹的值派生一个值从另外几个值计算而来computed缓存、懒计算、只读语义清晰多个状态联动后需要执行一段逻辑拉数据、存缓存、推SDKwatchEffect自动收集依赖省去手动维护数据源列表需要拿到新旧值做对比或决策watch唯一能拿oldVal的方案监听子组件emit或路由变更等非直接响应式事件watch getter需要明确的触发条件和数据源组件挂载后需要基于 DOM 测量结果做逻辑watchEffect{ flush: post }省去手动处理时序深层次对象任意属性变化都要响应watchdeep: true语义清晰避免手写整个对象遍历这张表是我每次评审同事代码时会对照的清单。大部分场景watchEffect更适合副作用型需求computed更适合纯计算型需求watch则留在那些真正需要精确控制的事件型场景里。6.2 一个封装示例把搜索逻辑抽成可复用组合式函数最后分享一个我在多个项目里复用的写法把watchEffect和组合式函数结合起来做一个通用的搜索逻辑封装。它同时解决了自动触发请求和竞态清理两个问题// useSearch.js import { watchEffect, ref, unref } from vue export function useSearch(fetcher, getParams) { const data ref([]) const loading ref(false) const error ref(null) watchEffect(async (onInvalidate) { // 同步阶段读取所有依赖确保追踪完整 const params unref(getParams()) let cancelled false onInvalidate(() { cancelled true }) loading.value true error.value null try { const result await fetcher(params) if (!cancelled) { data.value result } } catch (e) { if (!cancelled) { error.value e } } finally { if (!cancelled) { loading.value false } } }) return { data, loading, error } }组件里用起来很干净// 列表页组件 const filters reactive({ keyword: , status: all }) const { data, loading, error } useSearch( (params) api.fetchGoods(params), () ({ keyword: filters.keyword, status: filters.status }) )每一次filters里的字段变化useSearch内部的watchEffect都会自动重新执行旧的请求结果不会再覆盖新结果loading 状态也能正确切换。这个封装把watchEffect最擅长的自动联动和清理竞态都利用上了代码量不多但避免了在每个页面里重复写这套逻辑。6.3 一个小习惯给 watchEffect 起个看得懂的名字最后说个经验之外的小习惯。Vue3 的watchEffect调试起来不直观尤其是组件里多个 effect 并存时很难一眼看出哪个 effect 对应哪段逻辑。我一般把watchEffect抽成具名变量或者在回调里打标记const stopSyncCart watchEffect(() { // 同步购物车到服务端 api.syncCart(cartItems.value) }) // 某个场景下手动停止 stopSyncCart()watchEffect会返回一个停止函数需要时能手动取消监听。在页面销毁时 Vue 会自动清理但如果你在某个交互里启用了它后续不需要了别忘了手动调用停止函数。我见过一些项目里watchEffect在onActivated里创建、但onDeactivated里没停掉导致页面切走再切回来时重复执行。这类问题排查起来很隐蔽写代码时留个心眼能省不少事。说回最开始那个重构。我把五个watch换成watchEffect之后列表页的代码量降了三分之一后续加筛选条件只需要在filters里加字段、在请求参数里加一行完全不用想着去同步哪个watch的依赖数组。这种少操心的感觉大概就是watchEffect最香的地方。当然该用watch的时候也别硬换工具没有高下场景对了才是王道。