Skip to content

侦听器 watch

基本示例

计算属性可以让我们声明性的计算衍生值,但也有不可以有副作用的约定,侦听器则允许我们执行异步操作或较大开销的操作,而不必考虑计算属性的限制

html
<script setup>
  import { ref, watch } from 'vue'

  const question = ref('')
  const answer = ref('Questions usually contain a question mark. ;-)')
  const loading = ref(false)

  // 可以直接侦听一个 ref
  watch(question, async (newQuestion, oldQuestion) => {
    if (newQuestion.includes('?')) {
      loading.value = true
      answer.value = 'Thinking...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        answer.value = (await res.json()).answer
      } catch (error) {
        answer.value = 'Error! Could not reach the API. ' + error
      } finally {
        loading.value = false
      }
    }
  })
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

侦听数据源类型

watch的第一个参数可以是不同形式的"数据源",包括:

  • 一个 ref
  • 一个响应式对象
  • 一个 getter 函数
  • 包含上述类型的一个数组
javascript
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`sum of x + y is: ${sum}`)
  },
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

但是不可以直接侦听响应式对象的属性值 ,因为这样侦听不到属性值的变化

javascript
const obj = reactive({ count: 0 })

// 错误,因为 watch() 得到的参数是一个 number
watch(obj.count, (count) => {
  console.log(`Count is: ${count}`)
})

为什么???

这里需要明白Vue3响应式的本质.响应式对象是通过Proxy实现的,这里声明的obj就是{count:0}这个对象的代理, 可以理解为 : obj = new Proxy({count:0}).在通过obj访问属性时,Vue会拦截这个操作,并返回属性值, 所以obj.count的值是0,而不是一个响应式的引用,因此无法侦听到变化.

如何解决???

可以通过getter函数来访问响应式对象的属性值,这样Vue就会将这个函数的返回值作为响应式数据,从而侦听到变化

javascript
const obj = reactive({ count: 0 })
watch(
  () => obj.count, // 通过getter函数访问响应式对象的属性值
  (count) => {
    console.log(`Count is: ${count}`)
  },
)

watch的上下文中,Getter函数就是传入watch的第一个参数的函数,Vue在执行这个函数时才会访问obj.count的值,内部通过 Proxy建立依赖关系 这里是对源码的一点分析

深层侦听器

直接给watch()传入一个响应式对象,会隐私的创建一个生成侦听器--该回调函数在所有嵌套变更时都会被处罚:

javascript
const obj = reactive({ count: 0 })

watch(obj, (newValue, oldValue) => {
  // 在嵌套的属性变更时触发 => count 属性的变化
  // 注意:`newValue` 此处和 `oldValue` 是相等的
  // 因为它们是同一个对象!
})

obj.count++

相比较之下,一个返回响应时对象的getter函数只会在该对象的引用发生变化时触发:

javascript
const obj = reactive({ count: 0 })
watch(
  () => obj.count,
  (newValue, oldValue) => {
    // 在嵌套的属性变更时不会触发
    // 只有当 obj.count发生变化时才会触发
  },
)
obj.count++

也可以在使用watch()时显式地启用深层侦听器,通过传入一个选项对象:

javascript
watch(
  () => state.someObject,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* state.someObject 被整个替换了
  },
  { deep: true },
)

Vue 3.5+中,deep选项还可以是一个数字,表示侦听的深度--既Vue应该遍历对象嵌套属性的级数

深度监听在用于大型数据结构时开销很大,因此请谨慎使用,并确保只在必要时使用

即时回调的侦听器

watch默认是懒执行的:仅当数据源变化时才会执行回调.如果需要在侦听器创建时立即执行回调,可以传入一个选项对象,并将 immediate设置为true

javascript
watch(
  () => state.someValue,
  (newValue, oldValue) => {
    // 回调将在侦听器创建时立即执行
  },
  { immediate: true },
)

一次性侦听

仅支持 3.4+

如果只需要侦听一次数据源的变化,可以使用once选项,它会在第一次变化时执行回调,然后自动取消侦听

javascript
watch(
  () => state.someValue,
  (newValue, oldValue) => {
    // 回调将在第一次变化时执行
  },
  { once: true },
)

watchEffect()

侦听器的回调使用与源完全相同的响应式状态是很常见的:

javascript
const todoId = ref(1)
const data = ref(null)

watch(
  todoId,
  async () => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId.value}`)
    data.value = await response.json()
  },
  { immediate: true },
)

watchEffect()是一个更简洁的API,它会立即执行回调,并在回调中使用的任何响应式状态发生变化时重新执行回调

javascript
import { ref, watchEffect } from 'vue'

const todoId = ref(1)
const data = ref(null)
watchEffect(async () => {
  const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId.value}`)
  data.value = await response.json()
})

这个例子中,回调会立即执行,并在todoId变化时重新执行,因为todoId是响应式的. watchEffect()的这种特征在有多个依赖项的侦听器来说,可以消除手动维护依赖列表的负担.
此外,如果你需要侦听搞一个嵌套数据结构中的几个属性,watchEffect()可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归的跟踪所有属性

javascript
const user = reactive({
  id: 1,
  profile: {
    name: 'Alice',
    address: {
      city: 'Shanghai',
      district: 'Pudong',
    },
  },
  preferences: {
    theme: 'dark',
  },
})
  • 常规侦听
javascript
watch(
  () => user.profile,
  (newVal) => {
    console.log('城市变化:', newVal.address.city)
  },
  { deep: true }, // 会递归追踪整个 profile 对象
)

常规侦听中,如果修改的是address属性,也会触发回调

  • 使用 watchEffect()
javascript
watchEffect(() => {
  console.log('城市变化:', user.profile.address.city)
})

使用watchEffect()时,只会追踪回调中使用到的属性,如果修改的是preferences属性,则不会触发回调

最佳实践建议

TIP
watchEffect()仅会在其同步执行期间才追踪依赖.在使用会不会掉时,只有在第一个await正常工作前访问到的属性才会被追踪
1.精准追踪:对于嵌套对象优先使用watchEffect,只追踪实际用到的属性
2.简单路径:如果需要侦听简单路径,比如(user.id),可以使用watch而不是watchEffect,因为它更简洁
3.复杂场景:当需要显式控制侦听源时,在使用watch+deep

注意事项

  • watchEffect()中注意不要访问不需要追踪的属性
  • 对于需要出示自信的情况,watchEffect()会自动给你寄执行一次
  • 如果确实需要侦听整个对象更多结构变化,才使用deep

watchEffect()仅会在其同步执行期间才追踪依赖,

javascript
import { ref, watchEffect } from 'vue'

const count = ref(0)
const username = ref('')

watchEffect(async () => {
  console.log(count.value) // 会被追踪
  await fetch('/api') // 异步操作
  console.log(username.value) // 不会被自动追踪!
})
javascript
// 定时器中的依赖遗漏
const timer = ref(null)
const interval = ref(1000)

watchEffect(() => {
  // 在同步阶段清除旧定时器(会被追踪)
  clearTimeout(timer.value)

  setTimeout(() => {
    // 不会被追踪的访问
    console.log(interval.value)
  }, interval.value) // 只有这里的 interval.value 会被追踪
})

最佳实践建议
1.最小化异步操作:在watchEffect()中尽量避免异步操作,尽量将异步操作移除watchEffect
2.同步阶段捕获所有需要的值
3.考虑使用watch+immediate选项来确保在侦听器创建时立即执行回调,而不是依赖于watchEffect() 的自动执行4.使用组合式函数封装: 如useAsyncData等更高级的抽象

示例

1.基本拆分方式

javascript
import { ref, watchEffect } from 'vue'

const userId = ref(1)
const userData = ref(null)

// 同步部分处理依赖追踪
watchEffect(() => {
  const currentUserId = userId.value // 同步捕获响应式值

  // 异步操作封装到独立函数
  const fetchUser = async () => {
    userData.value = await fetch(`/api/users/${currentUserId}`).then((r) => r.json())
  }

  fetchUser()
})

2.使用 async/await的优化拆分

javascript
const searchQuery = ref('')
const searchResults = ref([])

watchEffect(() => {
  // 同步阶段:捕获所有需要的响应式值
  const query = searchQuery.value(
    // 立即执行异步函数(IIFE)
    async () => {
      if (query.length < 2) return
      searchResults.value = await fetchResults(query)
    },
  )()
})

3.使用组合式函数封装

javascript
// composables/useAsyncData.js
export function useAsyncData(getter, asyncFn) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  watchEffect(() => {
    const params = getter() // 同步获取所有依赖项

    loading.value = true
    asyncFn(params)
      .then((result) => (data.value = result))
      .catch((err) => (error.value = err))
      .finally(() => (loading.value = false))
  })

  return { data, loading, error }
}

// 组件中使用
import { useAsyncData } from './composables/useAsyncData'

const userId = ref(1)
const { data: user, loading } = useAsyncData(
  () => ({ userId: userId.value }), // 同步getter
  async (params) => {
    return fetch(`/api/users/${params.userId}`).then((r) => r.json())
  },
)

4.响应式参数对象模式

javascript
const filters = reactive({
  category: 'books',
  query: '',
  page: 1,
})

watchEffect(() => {
  // 同步创建请求参数快照
  const params = { ...toRefs(filters) }

  fetchResults(params).then((data) => {
    // 处理结果
  })
})

5.使用Promise链替代 async/await

javascript
const sessionToken = ref('')
const permissions = ref([])

watchEffect(() => {
  const token = sessionToken.value

  getSessionInfo(token)
    .then((info) => checkPermissions(info.role))
    .then((perms) => (permissions.value = perms))
    .catch(console.error)
})

watch vs watchEffect

两者都能响应式的执行有副作用的会掉,主要区别是追踪响应式依赖的方式

  • watch只能追踪明确侦听的数据源.仅在数据源发生改变时出发回调.会避免在发生副作用是追踪回调,因此能更加精确的控制回调函数的触发时机
  • watchEffect会在副作用发生期间追踪依赖,会在同步执行过程中自动追踪所有能访问的响应式属性,这个特性会使代码更简洁,但响应式依赖的关系不太明确

副作用清理

有时我们可能会在侦听器中执行副作用,比如异步请求:

javascript
watch(id, (newId) => {
  fetch(`/api/${newId}`).then(() => {
    // 回调逻辑
  })
})

但是如果在请求过程中,id发生了变化,当请求完成时,它还会使用已经过期的id触发回调,正常徐秋霞,应该在id发生变化时取消过时请求 这里我们可以使用onWatcherCleanup()API注册一个清理函数,当侦听器失效并准备重新运行时会被调用

javascript
import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })

  onWatcherCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

注意 > onWatcherCleanup仅在Vue 3.5+中支持,并且必须在watchEffect效果函数或watch回调函数的同步期间执行,不能在 await语句之后调用

作为替代,onClean函数可以作为第三个参数传递给监听器回调,以及watchEffect作用函数的第一个参数 :

javascript
watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // 清理逻辑
  })
})

这在3.5之前的版本有效,此外,通过函数参数传递的onCleanup与侦听器实例绑定,因此不受onWatcherCleanup的同步限制

回调的触发时机

当响应式状态发生改变时,可能同时触发Vue组件的更新和侦听器的回调

类似于组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用.默认情况下,侦听器回调会在父组件更新(如果有)之后,所属组件的 DOM更新之前调用,也就是说 如果你在侦听器中访问所属组件的DOM,那么你拿到的DOM将处于更新之前的状态

Post Watcher

如果想在侦听器的回调中能访问被Vue更新之后的所属组件DOM,需要知名flush:'post' :

javascript
watch(source, callback, {
  flush: 'post',
})

watchEffect(callback, {
  flush: 'post',
})

针对watchEffect还有个更方便的别名watchPostEffect() :

javascript
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

同步侦听器

同步侦听器会在Vue进行更新之前出发,通过指定配置flush:'sync' :

javascript
watch(source, callback, {
  flush: 'sync',
})

watchEffect(callback, {
  flush: 'sync',
})

同样也有个别名watchSyncEffect() :

javascript
import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* 在响应式数据变化时同步执行 */
})

谨慎使用
同步侦听器不会进行批处理,每当响应式数据发生变化时就会出发.可以用它来监视简单的布尔值,但应该避免在可能多次同步修改的数据源上使用

停止侦听器

setupscript setup中使用同步语句创建的监听器会自动绑定到宿主组件的实力上,并会在组件卸载的时候自动停止

一个关键点时:侦听器必须用同步语句创建,否则不会自动停止,这时就需要手动停止,防止内存泄露:

html
<script setup>
  import { watchEffect } from 'vue'

  // 它会自动停止
  watchEffect(() => {})

  // ...这个则不会!
  setTimeout(() => {
    watchEffect(() => {})
  }, 100)
</script>

通过watchwatchEffect返回的函数来停止侦听器:

javascript
// 需要异步请求得到的数据
const data = ref(null)
watchEffect(() => {
  if (data.value) {
    //数据加载后执行某些操作...
  }
})