Code前端首页关于Code前端联系我们

一、watchEffect到底是个啥?

terry 3小时前 阅读数 6 #Vue
文章标签 watchEffect;Vue

p>不少刚开始学Vue3的同学,一碰到watchEffect就犯迷糊:这玩意儿到底是干啥的?和之前熟悉的watch有啥区别?啥时候该用它?别慌,今天咱们就把watchEffect掰开揉碎了讲,从基础用法到实战场景,再到和watch的对比,一次性搞明白~


“先搞懂概念:watchEffect是Vue3响应式系统里专门处理副作用的工具,啥叫‘副作用’?比如发请求、操作DOM、修改全局状态这些和‘计算数据’无关的操作,都算副作用。

watchEffect的核心特点是自动收集依赖,举个例子,你在回调里用了某个响应式数据(比如ref、reactive包装的变量),Vue会自动盯住这些数据——只要它们变了,回调就重新执行,不需要像watch那样,手动指定要监听哪个数据,省心不少~

举个简单代码例子感受下:

import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => {
  console.log(`当前计数是:${count.value}`)
})
// 初始化时,回调会立即执行,打印“当前计数是:0”
count.value = 1 // 数据变化,回调自动执行,打印“当前计数是:1”

这里没手动写“我要监听count”,但因为回调里用了count.value,Vue自动把count当成依赖,数据一变就触发。

另外得区分下和computed的区别:computed是“计算并返回一个值”,侧重有返回值的响应式计算;而watchEffect侧重执行副作用,没有返回值,更像“只要依赖变了,就去做件事”~”

watchEffect的基本用法咋上手?

“知道是干啥的了,接下来得会用,这部分拆成「基础语法」「停止监听」「清理副作用」三个小知识点讲~

基础语法:简单到极致

只要从vue里导入watchEffect,然后传一个回调函数就行:

import { watchEffect } from 'vue'
watchEffect(() => {
  // 这里写副作用逻辑,比如操作DOM、发请求、修改全局变量...
})

回调会立即执行第一次(这和watch默认“惰性执行”不一样,后面讲区别时会细说),而且Vue会在执行过程中,自动收集里面用到的响应式数据作为依赖,之后只要依赖变了,回调就重新跑~

停止监听:手动切断依赖

有时候你不想让watchEffect一直监听(比如组件销毁后还监听会内存泄漏),这时候可以用停止函数,因为watchEffect调用后会返回一个stop函数,调用它就能停止监听:

const stop = watchEffect(() => {
  console.log('我在监听哦~')
})
// 某个时机(比如按钮点击、组件卸载)执行stop
stop() // 执行后,后续依赖变化也不会触发回调了

Vue组件卸载时,会自动停止所有没手动停止的watchEffect,但如果是在非组件环境(比如纯JS逻辑)里用,记得手动stop更安全~

清理副作用:处理异步/重复操作

如果回调里有异步操作(比如定时器、发请求),多次触发时可能产生“竞态问题”(旧请求还没完成,新请求又发了),这时候得用onInvalidate来清理旧的副作用~

onInvalidate是watchEffect回调的第一个参数,它接收一个“清理函数”,这个清理函数会在回调重新执行前被调用,用来销毁旧的副作用(比如清定时器、取消请求)。

举个“防抖搜索”的例子(用户输入时延迟发请求,避免频繁请求):

import { ref, watchEffect } from 'vue'
const searchKeyword = ref('')
watchEffect((onInvalidate) => {
  const timer = setTimeout(() => {
    console.log(`发送请求搜索:${searchKeyword.value}`)
  }, 500)
  // 注册清理函数:每次回调重新执行前,清掉旧定时器
  onInvalidate(() => {
    clearTimeout(timer)
  })
})

当用户快速输入时,每次输入都会触发watchEffect重新执行,这时候旧的定时器会被onInvalidate清理掉,保证只有最后一次输入的请求会发出去~

再比如用axios发请求时,取消未完成的请求(需要axios支持取消功能):

watchEffect((onInvalidate) => {
  const source = axios.CancelToken.source()
  axios.get('/api/data', { cancelToken: source.token })
    .then(res => { /* 处理响应 */ })
  onInvalidate(() => {
    source.cancel('请求被取消,因为依赖变化了')
  })
})

这样每次依赖变化,旧的请求会被主动取消,避免无效响应干扰页面~”

watchEffect和watch核心区别在哪?

“很多同学学完watchEffect,还是分不清和watch的区别,这部分直接对比三个核心点:依赖指定方式触发时机使用场景

依赖指定:主动声明 vs 自动收集

watch需要手动指定要监听的依赖,而且能同时拿到旧值和新值,语法长这样:

watch(
  () => count.value, // 明确指定监听count.value
  (newVal, oldVal) => { // 能拿到新旧值
    console.log(`count从${oldVal}变到${newVal}`)
  }
)

而watchEffect不需要手动指定依赖,Vue自动收集回调里用到的响应式数据,但代价是拿不到旧值(因为它只关心“依赖变了要执行副作用”,不记录旧状态)。

触发时机:立即执行 vs 惰性执行

watch默认是惰性的——只有依赖变化时才执行回调,初始化时不执行,如果想让watch初始化时也执行,得加immediate: true选项:

watch(
  () => count.value,
  (newVal) => { ... },
  { immediate: true } // 初始化时执行一次
)

但watchEffect是强制立即执行第一次的,而且之后依赖变化时再执行,所以如果你需要“初始化时就执行副作用,之后依赖变化再执行”,用watchEffect更顺手~

使用场景:精准监控 vs 批量副作用

watch适合关注“特定数据的变化细节”(比如只关心user.name的变化,还要拿新旧值做对比)。

watchEffect适合“只要相关数据变了,就执行一堆副作用”(比如页面里多个数据变了,都要触发同一个弹窗隐藏、请求刷新、DOM更新的逻辑,这时候用watchEffect自动收集依赖,不用一个个列出来)。

举个场景对比的例子:

  • 需求:用户修改姓名后,记录修改日志(要拿旧姓名和新姓名)→ 用watch,因为要精准监控user.name,还要新旧值。
  • 需求:用户姓名、年龄、头像任意一个变了,就自动刷新用户信息卡片(不需要知道具体哪个变了,只要变了就刷新)→ 用watchEffect,把刷新逻辑丢进回调,自动收集name、age、avatar这些依赖~”

实际项目里哪些场景适合用watchEffect?

“光讲理论不够,得结合真实场景才好懂,分享三个高频场景,看完就知道啥时候该用它~

自动响应的搜索/筛选逻辑

比如后台管理系统里的表格筛选:用户改了筛选条件(时间范围、状态、关键词),表格数据自动刷新,这时候用watchEffect特别爽——不用一个个监听每个筛选条件,只要回调里用了这些条件,自动追踪变化。

代码示例(简化版):

const searchForm = reactive({
  keyword: '',
  status: 'all',
  startDate: '',
  endDate: ''
})
watchEffect(async (onInvalidate) => {
  // 模拟请求前清理旧请求(如果有的话)
  let canceled = false
  onInvalidate(() => { canceled = true })
  // 发请求拿数据
  const res = await fetch('/api/tableData', {
    method: 'POST',
    body: JSON.stringify(searchForm)
  })
  if (!canceled) {
    tableData.value = res.data
  }
})

只要searchForm里的任意属性变了,请求就会重新发,表格数据自动更新~

响应式数据驱动的DOM操作

有时候需要根据响应式数据动态修改DOM(比如弹窗的显示隐藏、进度条宽度),用watchEffect可以自动跟踪数据变化,执行DOM操作。

举个“动态调整进度条”的例子:

const progress = ref(0)
watchEffect(() => {
  const progressBar = document.getElementById('progress-bar')
  if (progressBar) {
    progressBar.style.width = `${progress.value}%`
  }
})
// 模拟进度变化
setInterval(() => {
  progress.value += 10
  if (progress.value > 100) progress.value = 0
}, 1000)

progress变化时,watchEffect自动执行,更新DOM的宽度,完全不用手动监听progress~

复杂状态下的副作用聚合

比如用户登录状态(isLogin)变化时,要做一堆事:隐藏登录按钮、显示用户头像、拉取用户权限、刷新菜单… 这些操作依赖的核心数据是isLogin,用watchEffect把所有副作用塞到一个回调里,自动跟踪isLogin的变化:

const isLogin = ref(false)
watchEffect(() => {
  if (isLogin.value) {
    // 显示头像
    userAvatar.value = getAvatar()
    // 拉取权限
    fetchPermissions()
    // 刷新菜单
    refreshMenu()
    // 隐藏登录按钮
    loginButtonVisible.value = false
  } else {
    // 反之,显示登录按钮,隐藏头像等
    loginButtonVisible.value = true
    userAvatar.value = ''
  }
})

这里只要isLogin变了,所有关联的副作用自动执行,不用给watch一个个列依赖,也不用写多个watch语句,代码更聚合~”

用watchEffect容易踩哪些坑?怎么避?

“再好的工具用不对也会出问题,总结四个常见坑,附避坑思路~

依赖过度收集,触发太频繁

因为watchEffect自动收集依赖,有时候回调里不小心用了很多无关数据,导致一点小变化就触发副作用。

比如下面这个反例:

const user = reactive({ name: '张三', age: 18 })
const theme = ref('light')
watchEffect(() => {
  console.log(`用户姓名:${user.name},当前主题:${theme.value}`)
})
// 这里修改theme,其实和user.name没关系,但因为回调里用了theme,也会触发
theme.value = 'dark' 

如果只是想在user.name变化时打印,结果theme变化也触发了,就会导致不必要的执行。

避坑法:尽量让回调里的依赖“精准”,可以把逻辑拆分,或者用watch代替(如果只关心特定数据),比如上面的场景,改成watch更合适:

watch(() => user.name, (newName) => {
  console.log(`用户姓名:${newName}`)
})

忘记清理副作用,导致内存泄漏/逻辑冲突

前面讲过onInvalidate的用法,但很多同学写异步逻辑时容易忘,比如定时器没清,重复发请求没取消,都会出问题。

反例(定时器没清理):

watchEffect(() => {
  setTimeout(() => {
    console.log('1秒后执行')
  }, 1000)
})
// 每隔1秒,回调重新执行,旧的定时器还在,导致控制台疯狂打印

避坑法:只要回调里有异步操作(定时器、Promise、事件监听等),都要通过onInvalidate清理,上面的例子改成:

watchEffect((onInvalidate) => {
  const timer = setTimeout(() => {
    console.log('1秒后执行')
  }, 1000)
  onInvalidate(() => clearTimeout(timer))
})

混淆“立即执行”的适用场景

watchEffect强制立即执行第一次,如果你希望“初始化时不执行,只在依赖变化时执行”,那watchEffect就不合适了,得用watch并关掉immediate(watch默认就是惰性的)。

比如需求是“用户点击按钮后才开始监听,且只在count变化时执行”,这时候用watch更合适:

const startWatch = ref(false)
watch(
  () => (startWatch.value ? count.value : null), // 控制是否监听
  (newVal) => { ... },
  { immediate: false } // 默认就是false,初始化不执行
)

对响应式数据的“深层跟踪”理解错

有些同学以为watchEffect能自动深层跟踪对象,但其实要看数据是不是响应式的:

  • 如果是reactive包装的对象,修改深层属性(比如obj.a.b.c),watchEffect会触发,因为reactive的所有属性都是响应式的。
  • 如果是ref包装的对象(比如obj = ref({ a: 1 })),修改obj.value.a,watchEffect也会触发,因为ref的.value是响应式的。
  • 但如果是普通对象(非响应式),修改它的属性,watchEffect不会触发,因为没被Vue的响应式系统拦截。

举个容易踩坑的例子:

const normalObj = { name: '普通对象' }
watchEffect(() => {
  console.log(normalObj.name)
})
normalObj.name = '改了' // 不会触发watchEffect,因为normalObj不是响应式的

避坑法:要监听的对象/数组,必须用reactive或ref包装成响应式数据,才能被watchEffect跟踪到变化~”

进阶技巧,让watchEffect用得更溜

“掌握基础后,学几个进阶技巧,应对复杂场景更顺手~

自定义调度器(scheduler),控制执行时机

默认情况下,watchEffect的回调在依赖变化时立即执行,但有时候你想延迟执行、批量执行,或者放到微任务队列里执行,这时候可以用scheduler选项自定义调度逻辑。

调度器是一个函数,接收一个run参数(run就是要执行的回调),比如把回调放到微任务里执行:

watchEffect(() => {
  console.log('执行副作用')
}, {
  scheduler: (run) => {
    queueMicrotask(run) // 放到微任务队列,等同步代码跑完再执行
  }
})

再比如做“防抖执行”(依赖变化后,等500ms再执行,期间有新变化就重置定时器):

let timer = null
watchEffect(() => {
  console.log('执行副作用')
}, {
  scheduler: (run) => {
    clearTimeout(timer)
    timer = setTimeout(run, 500)
  }
})

这样依赖变化后,不会立即执行,而是等500ms内没新变化了才执行,实现类似防抖的效果~

结合组件生命周期,更精细控制

虽然Vue组件卸载时会自动停止watchEffect,但如果想在特定生命周期执行/停止,可以主动控制,比如在onMounted里启动,onBeforeUnmount里停止:

import { onMounted, onBeforeUnmount, watchEffect } from 'vue'
let stop = null
onMounted(() => {
  stop = watchEffect(() => {
    // 组件挂载后开始监听
  })
})
onBeforeUnmount(() => {
  stop && stop() // 组件卸载前停止监听
})

不过一般情况下,Vue自动处理卸载时的停止,手动停止更适合“提前停止”的场景(比如用户点击按钮后停止监听)~

处理多个watchEffect的执行顺序

当页面里有多个watchEffect时,默认是按注册顺序执行的,但如果它们之间有依赖关系,想控制执行顺序,可以用调度器。

比如有两个watchEffect,A依赖数据x,B依赖数据x和A的执行结果,这时候可以让A的调度器里触发B的执行,或者用队列控制顺序,不过这种场景比较少见,了解即可~

利用watchEffect做“响应式调试”

开发时,想快速看某个响应式数据的变化,可以用watchEffect临时打印:

watchEffect(() => {
  console.log('user数据变化了:', user)
})

只要user(响应式数据)有变化,控制台就会打印,快速定位问题超方便~”

到这,关于Vue3 watchEffect的核心知识就讲得差不多啦~ 总结下:它是自动收集依赖、侧重副作用执行的工具,和watch在依赖指定、触发时机、场景上有明显区别,实际项目里处理自动响应的副作用、DOM操作、异步清理这些场景特别好用,但也要注意依赖收集、副作用清理这些坑,多写多练,自然就熟啦~ 如果还有疑问,评论区随时喊我~

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门