Skip to content

侦听器

计算属性侧重于监听响应式数据的变化,并派生出计算后的新值

而侦听器侧重于监听响应式数据的变化,并在状态变化之后执行一些“副作用”:例如更改 DOM,执行 Ajax 操作等。例如:实时侦听用户输入的账号名,并调用 Ajax 接口检测账号是否已被注册。

基本语法

侦听器的基本语法格式如下:

js
import { watch } from 'vue'

watch(响应式数据, fn)
watch(响应式数据, (新值旧值) => { /* 副作用操作 */ })

例如:实时监听 title 值的变化,动态设置网页的标题:

js
setup() {
  // 源数据
  const title = ref('')

  // 监听 title 值的变化,实时设置网页的标题
  // 参数1:新值; 参数2:旧值
  watch(title, (newTitle, oldTitle) => {
    document.title = newTitle
  })

  return {
    title
  }
}

页面的模板结构如下:

html
<div id="app">
  <input type="text" v-model="title" />
</div>

侦听对象上简单属性的变化

如果响应式对象中的属性值为简单类型(数字、字符串、布尔值),则需要使用 getter 函数指定要侦听的数据项:

js
const user = ref({
  name: 'zs',
  address: {
    province: '河北省',
    city: '邯郸市'
  }
})

// ❌错误写法
watch(user.value.name, (newName, oldName) => {
  console.log(newName, oldName)
})

// ✅正确写法
watch(
  () => user.value.name,
  (newName, oldName) => {
    console.log(newName, oldName)
  }
)

// ✅正确写法
watch(
  () => user.value.address.city,
  (newCity, oldCity) => {
    console.log(newCity, oldCity)
  }
)

侦听对象上复杂属性的变化

例如,在刚才的 user 对象中,如果想要侦听 user.address 的变化,不同的侦听方式会产生不同的侦听效果,请各位同学注意辨别:

js
const user = ref({
  name: 'zs',
  address: {
    province: '河北省',
    city: '邯郸市'
  }
})

watch(obj, fn)

TIP

  • 能够侦听到 obj 对象上属性的变化,例如:修改 address.city 的值被侦听到;
  • 无法侦听到 user.value.address 对象的替换操作。
js
// 仅在嵌套的属性变更时触发
watch(user.value.address, (newValue, oldValue) => {
  // 注意:`newValue` 此处和 `oldValue` 是相等的
  // 因为它们是同一个对象!
  console.log(newValue, oldValue)
})

const updateAddress = () => {
  user.value.address.city = '石家庄' // ✅可以正常侦听到对象中嵌套属性的变化
  user.value.address = { a: 1, b: 2 } // ❌无法侦听到对象的替换操作
}
html
<button @click="updateAddress">修改 address</button>

watch(() => obj, fn)

TIP

  • 能够侦听到 user.value.address 对象的替换操作;
  • 无法侦听到 obj 对象上属性的变化,例如:修改 address.city 的值不会被侦听到;
js
// 仅当 user.value.address 对象被替换时触发
watch(
  () => user.value.address,
  (newValue, oldValue) => {
    console.log(newValue, oldValue)
  }
)

const updateAddress = () => {
  user.value.address.city = '石家庄' // ❌无法侦听到对象中嵌套属性的变化
  user.value.address = { a: 1, b: 2 } // ✅可以侦听到对象的替换操作
}
html
<button @click="updateAddress">修改 address</button>

deep 深层侦听器

TIP

getter 配合 deep 选项一起使用时,既能够侦听到对象的替换操作,又能够侦听到对象属性的变化

js
watch(
  () => user.value.address,
  (newValue, oldValue) => {
    // 注意:`newValue` 此处和 `oldValue` 是相等的
    // *除非* user.value.address 被整个替换了
    console.log(newValue, oldValue)
  },
  { deep: true }
)

const updateAddress = () => {
  user.value.address.city = '石家庄' // ✅可以侦听到对象中嵌套属性的变化
  user.value.address = { a: 1, b: 2 } // ✅可以侦听到对象的替换操作
}
html
<button @click="updateAddress">修改 address</button>

TIP

深度侦听需要遍历被侦听对象中的所有嵌套的属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。

侦听器的高级用法

即时回调的侦听器

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

我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

js
// 源数据
const title = ref('哇哈哈')

// 侦听器
watch(
  title,
  (newTitle, oldTitle) => {
    document.title = newTitle
  },
  {
    immediate: true
  }
)

一次性侦听器

每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,请使用 once: true 选项。

js
// 监听 title 值的变化,实时设置网页的标题
watch(
  title,
  (newTitle, oldTitle) => {
    document.title = newTitle
  },
  {
    immediate: true,
    once: true
  }
)

侦听多个数据的变化

watch 还可以通过数组的形式,侦听多个数据源的变化,只要被侦听的任何一个数据源发生了变化,就会触发 watch 回调函数的执行。语法格式如下:

js
// 通过数组的形式,同时侦听多个数据源的变化
watch([value1, value2], ([newValue1, newValue2], [oldValue1, oldValue2]) => {
  // watch 的回调函数
})

例如:

js
setup() {
  // 源数据
  const title = ref('哇哈哈')
  const suffix = ref('dev')

  // 同时侦听 title 和 suffix 的变化
  watch([title, suffix], ([newTitle, newSuffix]) => {
    document.title = `${newTitle} - ${newSuffix}`
  }, {
    immediate: true
  })

  return {
    title,
    suffix
  }
}

页面的模板结构如下:

html
<input type="text" v-model="title" />
<br />
<input type="text" v-model="suffix" />

watchEffect()

使用 watch 侦听器时需要程序员手动指定依赖项,再通过回调函数的形参接收变化前后的新旧数据,最后才能在回调函数中使用变化后的数据,用法比较繁琐:

js
watch(依赖项, (新数据, 旧数据) => {
  // 使用形参中接收到的新/旧数据
})

为了简化 watch 侦听器的用法,vue 提供了 watchEffect API,它不需要手动指定依赖项,而是在运行时自动收集依赖

js
// watchEffect(fn)

// 在页面首次渲染时,watchEffect 中的回调函数会立即运行一次,
// 从而收集“响应式数据”和“副作用函数”的依赖关系
watchEffect(() => {
  document.title = `${title.value} - ${suffix.value}`
})

watch vs watchEffect

相同点

都能侦听响应式数据的变化,并执行副作用函数。

不同点

  1. watch 需要显示指定依赖项,而 watchEffect 不需要显示指定依赖项。watchEffect 会在首次执行时自动收集依赖关系。
  2. watch 的回调函数在首次渲染时默认不执行(可配置 immediate: true 选项改为立即执行),而 watchEffect 会在首次渲染时执行。

使用场景

  1. 如果想提高代码的可读性精确控制回调函数的触发时机:推荐使用 watch 侦听器,因为 watch 必须明确指定依赖项,且只在依赖项变化时才会触发回调。
  2. 如果想追求代码的简洁性,对响应性的依赖关系要求不高:推荐使用 watchEffect,因为它会在首次执行回调函数时自动收集依赖关系,能极大提高代码的简洁性。

回调的触发时机

默认情况下,watch 和 watchEffect 的回调函数会在数据变更后DOM 更新前执行。因此在回调函数中获取到的是 DOM 更新前的旧状态,而非更新后的新状态。例如:

js
setup() {
  const count = ref(0) // 初始值 0

  // 侦听 count 值的变化,输出 h1 的文本内容
  watch(count, (newValue) => {
    const h1DOM = document.querySelector('h1')
    // 输出 count 的值是:0,而不是更新后的 1
    console.log(h1DOM.textContent)
  })

  return {
    count
  }
}
html
<div id="app">
  <h1>count 的值是:{{ count }}</h1>
  <button @click="count++">+1</button>
</div>

TIP

默认情况下,回调函数会在 DOM 更新之前调用。

Post Watchers

如果想在侦听器的回调函数中获取到更新后的 DOM,则需要把回调函数延迟到 DOM 更新完毕之后执行。此时可以使用 flush: 'post' 选项。语法格式如下:

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

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

后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect()

js
import { watchPostEffect } from 'vue'

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

代码示例

watch + post 的用法示例:

js
// 侦听 count 值的变化,输出 h1 的文本内容
watch(
  count,
  (newValue) => {
    const h1DOM = document.querySelector('h1')
    console.log(h1DOM.textContent)
  },
  {
    flush: 'post'
  }
)

watchEffect + post 的用法示例:

js
watchEffect(
  () => {
    console.log(count.value)
    const h1DOM = document.querySelector('h1')
    console.log(h1DOM?.textContent)
  },
  {
    flush: 'post'
  }
)

watchPostEffect 的用法示例:

js
watchPostEffect(() => {
  console.log(count.value)
  const h1DOM = document.querySelector('h1')
  console.log(h1DOM?.textContent)
})

同步侦听器*

TIP

同步侦听器可能存在性能上的问题,因此在实际开发中请谨慎使用!

默认情况下,侦听器的“回调函数”会被批量处理以避免重复调用(这种批处理的处理方式是高效的、性能友好的)。如果我们同步的连续修改被侦听的数据多次,出于性能考虑,我们不希望连续触发多次侦听器的回调函数:

html
<div id="app">
  <h1>count 的值是:{{ count }}</h1>
  <button @click="addThreeTimes">循环3次+1</button>
</div>
js
setup() {
  const count = ref(0) // 初始值 0

  // 点击按钮,循环让 count 自增 +1 三次
  const addThreeTimes = () => {
    for (let i = 0; i < 3; i++) {
      count.value++
    }
  }

  // 侦听 count 值的变化
  watch(count, (newVal) => {
    // 点击1次按钮,只输出了“监听到了 count 值的改变:3”
    // 更新期间的中间状态(1和2)被忽略了,能够提高性能
    console.log('监听到了 count 值的改变:' + newVal)
  })

  return {
    count,
    addThreeTimes
  }
}

如果您想侦听到中间状态的变化,并在每次状态变化后都触发回调函数,vue 提供了 flush: 'sync' 选项来创建同步触发的侦听器。语法格式如下:

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

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

同步触发的 watchEffect() 有个更方便的别名 watchSyncEffect()

js
import { watchSyncEffect } from 'vue'

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

示例代码如下:

js
watch(
  count,
  (newVal) => {
    // 点击1次按钮,输出了三次,中间状态的变化也会触发侦听器的回调函数:
    // 监听到了 count 值的改变:1
    // 监听到了 count 值的改变:2
    // 监听到了 count 值的改变:3
    console.log('监听到了 count 值的改变:' + newVal)
  },
  {
    flush: 'sync'
  }
)

// --------

watchEffect(
  () => {
    console.log('监听到了 count 值的改变:' + count.value)
  },
  {
    flush: 'sync'
  }
)

// --------

watchSyncEffect(() => {
  console.log('监听到了 count 值的改变:' + count.value)
})

停止侦听器

要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

js
const unwatch = watchEffect(() => {})

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

示例的代码如下:

html
<div id="app">
  <input type="text" v-model="title" />
  <hr />
  <button @click="stop">停止侦听器</button>
  <button @click="start">开启侦听器</button>
</div>
js
setup() {
  // 响应式数据
  const title = ref('论')

  // 变量,用来存储侦听器返回的 unwatch 函数
  let unwatch = null

  // 点击按钮停止侦听器
  const stop = () => {
    unwatch && unwatch()
    unwatch = null
    console.log('停止了')
  }

  // 点击按钮开启侦听器
  const start = () => {
    if (unwatch) return // 防止开启多余的侦听器

    // watch
    unwatch = watch(title, (newVal) => {
      document.title = newVal
    }, {
      immediate: true
    })

    // watchEffect
    // unwatch = watchEffect(() => document.title = title.value)
  }

  // 首次渲染时,自动启动侦听器
  start()

  return {
    title,
    stop,
    start
  }
}

天不生夫子,万古长如夜