Vue3中Watch与watchEffect的核心差异及适用场景是什么?

0 阅读17分钟

一、Vue3 侦听器(Watch)核心概念与基本示例

1.1 什么是侦听器?

在Vue3中,侦听器(Watch)是一种强大的工具,用于在响应式状态变化时执行副作用操作。当你需要在数据变化时执行异步操作、更新DOM或者修改其他状态时,侦听器就派上用场了。它与计算属性不同,计算属性主要用于声明式地计算衍生值,而侦听器更适合处理副作用逻辑。

1.2 基本示例(选项式API)

export default {
  data() {
    return {
      question: '',
      answer: 'Questions usually contain a question mark. ;-)',
      loading: false
    }
  },
  watch: {
    // 每当 question 改变时,这个函数就会执行
    question(newQuestion, oldQuestion) {
      if (newQuestion.includes('?')) {
        this.getAnswer()
      }
    }
  },
  methods: {
    async getAnswer() {
      this.loading = true
      this.answer = 'Thinking...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer
      } catch (error) {
        this.answer = 'Error! Could not reach the API. ' + error
      } finally {
        this.loading = false
      }
    }
  }
}
<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

1.3 基本示例(组合式API)

<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>

1.4 侦听数据源类型

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

  • 单个ref(包括计算属性)
  • 响应式对象
  • getter函数
  • 多个数据源组成的数组
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}`)
})

二、深层侦听器

2.1 什么是深层侦听器?

watch默认是浅层的:被侦听的属性仅在被赋新值时才会触发回调函数,而嵌套属性的变化不会触发。如果想侦听所有嵌套的变更,你需要使用深层侦听器。

2.2 深层侦听器示例(选项式API)

export default {
  watch: {
    someObject: {
      handler(newValue, oldValue) {
        // 注意:在嵌套的变更中,只要没有替换对象本身,newValue和oldValue相同
      },
      deep: true
    }
  }
}

2.3 深层侦听器示例(组合式API)

const obj = reactive({ count: 0 })

// 直接传入响应式对象,隐式创建深层侦听器
watch(obj, (newValue, oldValue) => {
  // 在嵌套的属性变更时触发
  // 注意:newValue和oldValue是相等的,因为它们是同一个对象!
})

obj.count++

2.4 注意事项

深度侦听需要遍历被侦听对象中的所有嵌套属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。在Vue 3.5+中,deep选项还可以是一个数字,表示最大遍历深度。

三、即时回调的侦听器

3.1 什么是即时回调的侦听器?

watch默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时立即执行一遍回调,比如请求初始数据。

3.2 即时回调示例(选项式API)

export default {
  watch: {
    question: {
      handler(newQuestion) {
        // 在组件实例创建时会立即调用
      },
      // 强制立即执行回调
      immediate: true
    }
  }
}

3.3 即时回调示例(组合式API)

watch(
  source,
  (newValue, oldValue) => {
    // 立即执行,且当source改变时再次执行
  },
  { immediate: true }
)

四、一次性侦听器

4.1 什么是一次性侦听器?

一次性侦听器仅在被侦听源第一次变化时触发回调,之后自动停止侦听。这个特性在Vue 3.4及以上版本支持。

往期文章归档
免费好用的热门在线工具

4.2 一次性侦听器示例(选项式API)

export default {
  watch: {
    source: {
      handler(newValue, oldValue) {
        // 当source变化时,仅触发一次
      },
      once: true
    }
  }
}

4.3 一次性侦听器示例(组合式API)

watch(
  source,
  (newValue, oldValue) => {
    // 当source变化时,仅触发一次
  },
  { once: true }
)

五、watchEffect的使用

5.1 什么是watchEffect?

watchEffect允许我们自动跟踪回调的响应式依赖,它会立即执行,不需要指定immediate: true。在执行期间,它会自动追踪所有能访问到的响应式属性,每当这些属性变化时,回调会再次执行。

5.2 watchEffect示例

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()
})

5.3 注意事项

watchEffect仅会在其同步执行期间追踪依赖。在使用异步回调时,只有在第一个await正常工作前访问到的属性才会被追踪。

六、watch vs watchEffect

特性watchwatchEffect
追踪方式只追踪明确侦听的数据源在副作用发生期间自动追踪所有能访问到的响应式属性
执行时机懒执行,仅在数据源变化时触发立即执行,之后在依赖变化时再次执行
回调参数可以获取新旧值无法直接获取新旧值
使用场景需要精确控制触发时机,或者需要获取新旧值时多个依赖项的侦听器,或者不需要关心具体变化值时

七、副作用清理

7.1 什么是副作用清理?

当我们在侦听器中执行异步操作时,可能会出现竞态问题。例如,在请求完成之前数据源发生了变化,当上一个请求完成时,它仍会使用已经过时的数据触发回调。这时我们需要清理这些过时的副作用。

7.2 副作用清理示例

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

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

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

八、回调的触发时机

8.1 默认触发时机

默认情况下,侦听器回调会在父组件更新之后、所属组件的DOM更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的DOM,那么DOM将处于更新前的状态。

8.2 后置刷新的侦听器

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

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

// 或者使用watchPostEffect
import { watchPostEffect } from 'vue'

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

8.3 同步侦听器

你还可以创建一个同步触发的侦听器,它会在Vue进行任何更新之前触发:

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

// 或者使用watchSyncEffect
import { watchSyncEffect } from 'vue'

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

九、this.$watch的使用

9.1 什么是this.$watch?

我们也可以使用组件实例的$watch方法来命令式地创建一个侦听器。这在特定条件下设置侦听器,或者只侦听响应用户交互的内容时很有用。

9.2 this.$watch示例

export default {
  created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }
}

十、停止侦听器

10.1 自动停止

用watch选项或者$watch()实例方法声明的侦听器,会在宿主组件卸载时自动停止。在setup()或script setup中用同步语句创建的侦听器,也会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。

10.2 手动停止

在少数情况下,你需要在组件卸载之前就停止一个侦听器,这时可以调用$watch() API返回的函数:

const unwatch = this.$watch('foo', callback)

// ...当该侦听器不再需要时
unwatch()

如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏:

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

// 这个需要手动停止
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()
</script>

十一、课后Quiz

问题1:如何在Vue3中侦听响应式对象的嵌套属性变化?

答案解析: 有两种方式可以侦听响应式对象的嵌套属性变化:

  1. 使用getter函数:
watch(
  () => obj.nestedProperty,
  (newValue) => {
    console.log(newValue)
  }
)
  1. 使用深层侦听器:
watch(obj, (newValue) => {
  console.log(newValue.nestedProperty)
}, { deep: true })

注意:深层侦听器会遍历对象的所有嵌套属性,性能开销较大,建议优先使用getter函数的方式。

问题2:watch和watchEffect的主要区别是什么?

答案解析:

  • watch是懒执行的,仅在数据源变化时触发回调,而watchEffect会立即执行,之后在依赖变化时再次执行。
  • watch只追踪明确侦听的数据源,而watchEffect会自动追踪回调中访问到的所有响应式属性。
  • watch可以获取新旧值,而watchEffect无法直接获取新旧值。

问题3:如何清理侦听器中的副作用?

答案解析: 可以通过onCleanup函数来清理副作用,它作为第三个参数传递给watch回调函数,或者作为第一个参数传递给watchEffect的作用函数:

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

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

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

十二、常见报错解决方案

报错1:Cannot read property 'xxx' of undefined

产生原因: 在侦听器回调中访问了未初始化的响应式属性。

解决办法: 在访问属性之前先进行判断:

watchEffect(() => {
  if (data.value) {
    console.log(data.value.xxx)
  }
})

报错2:Maximum call stack size exceeded

产生原因: 在侦听器回调中修改了被侦听的数据源,导致无限循环。

解决办法: 避免在侦听器回调中直接修改被侦听的数据源,或者使用条件判断来终止循环:

watch(count, (newCount) => {
  if (newCount < 10) {
    count.value++
  }
})

报错3:Invalid watch source: xxx

产生原因: watch的第一个参数不是有效的数据源类型,比如直接传递了响应式对象的属性值。

解决办法: 使用getter函数来返回响应式对象的属性:

// 错误写法
watch(obj.count, (count) => {
  console.log(count)
})

// 正确写法
watch(
  () => obj.count,
  (count) => {
    console.log(count)
  }
)

参考链接:

cn.vuejs.org/guide/essen…