在Vue开发中,响应式系统是核心基石——数据变化时,页面会自动同步更新,无需我们手动操作DOM。但在实际开发中,除了“数据驱动视图”的常规场景,我们还经常需要在响应式数据变化时,执行一些额外的“副作用”操作:比如发送异步请求、打印日志、修改DOM样式、清理资源等。这些操作无法通过计算属性实现,此时就需要用到Vue提供的侦听器(Watcher)。
侦听器的核心作用,就是监听响应式数据的变化,在数据发生改变时触发自定义回调函数,从而执行我们需要的副作用操作。它与之前讲解的计算属性、模板引用、生命周期钩子相辅相成,共同构成Vue响应式开发的完整体系。今天我们就来全面拆解侦听器的用法,从基础到进阶,结合全新实战示例,带你掌握响应式副作用的正确实现方式。
一、什么是侦听器?核心作用是什么?
简单来说,侦听器就是“监听数据变化,触发回调操作”的工具。这里的“副作用”,指的是那些不直接参与数据计算、但需要在数据变化时执行的操作——比如日志打印、异步请求、DOM操作、定时器控制等。
很多初学者会混淆计算属性和侦听器,其实二者的定位截然不同:
- 计算属性:用于声明式地派生新数据,依赖变化时自动重新计算,重点是“计算结果”,不能执行副作用;
- 侦听器:用于响应式地执行副作用,重点是“执行操作”,不直接返回数据,而是处理数据变化带来的额外逻辑。
举个简单的场景:当用户输入用户名时,我们需要实时校验用户名是否已存在(发送异步请求),这个校验操作就是“副作用”,无法用计算属性实现,只能通过侦听器来完成。
二、侦听器基础用法:watch函数的核心使用
在Vue组合式API中,我们通过watch函数来创建侦听器,它的使用非常灵活,支持多种数据源类型,最基础的用法是监听单个ref数据。
1. 基础示例:监听单个ref数据
当我们需要监听一个ref声明的响应式数据时,只需将该ref作为watch的第一个参数,第二个参数是数据变化时触发的回调函数,回调函数会接收两个参数:新值(newValue)和旧值(oldValue)。
<script setup>
import { ref, watch } from 'vue';
// 声明响应式数据
const username = ref('');
const tipText = ref('');
// 侦听username的变化,执行校验副作用
watch(username, (newName, oldName) => {
// 打印新旧值(副作用:日志打印)
console.log(`用户名从 ${oldName} 改为 ${newName}`);
// 简单的用户名校验(副作用:修改响应式数据)
if (newName.length < 3) {
tipText.value = '用户名长度不能少于3位';
} else if (newName.length > 10) {
tipText.value = '用户名长度不能超过10位';
} else {
tipText.value = '用户名格式合法';
}
});
</script>
<template>
<div class="form-item">
<label>用户名:</label>
<input
v-model="username"
placeholder="请输入用户名"
/>
<p class="tip" :style="{ color: tipText === '用户名格式合法' ? '#42b983' : '#e53e3e' }">
{{ tipText }}
</p>
</div>
</template>
<style>
.form-item {
margin: 20px 0;
}
.form-item input {
padding: 6px 12px;
margin-left: 8px;
border: 1px solid #eee;
border-radius: 4px;
}
.tip {
margin: 8px 0 0 60px;
font-size: 14px;
}
</style>
这个示例中,我们监听username的变化,触发回调函数执行用户名校验和日志打印,这两个都是典型的副作用操作,完美契合侦听器的使用场景。
2. 进阶:监听多种数据源类型
watch函数的第一个参数(数据源)支持多种类型,除了单个ref,还可以是getter函数、多个数据源组成的数组、响应式对象等,满足不同场景的需求。
(1)监听getter函数(适合监听响应式对象的单个属性)
当我们需要监听响应式对象(reactive声明)的单个属性时,不能直接传递属性名,必须使用getter函数返回该属性,否则侦听器无法正常触发。
<script setup>
import { reactive, watch } from 'vue';
// 声明响应式对象
const user = reactive({
name: '',
age: 18
});
// 监听user.name的变化(使用getter函数)
watch(
() => user.name,
(newName, oldName) => {
console.log(`用户姓名从 ${oldName} 改为 ${newName}`);
}
);
// 监听user.age的变化
watch(
() => user.age,
(newAge, oldAge) => {
console.log(`用户年龄从 ${oldAge} 改为 ${newAge}`);
}
);
</script>
<template>
<div>
<p>姓名:<input v-model="user.name" /></p>
<p>年龄:<input v-model.number="user.age" /></p>
</div>
</template>
(2)监听多个数据源(数组形式)
当我们需要监听多个数据源,且希望它们变化时触发同一个回调函数,可以将多个数据源组成一个数组,作为watch的第一个参数,回调函数的第一个参数会是一个数组,对应多个数据源的新值。
<script setup>
import { ref, watch } from 'vue';
// 声明多个ref数据
const password = ref('');
const confirmPassword = ref('');
const passwordTip = ref('');
// 监听password和confirmPassword两个数据源
watch(
[password, confirmPassword],
([newPwd, newConfirmPwd], [oldPwd, oldConfirmPwd]) => {
// 校验两次密码是否一致
if (newPwd && newConfirmPwd) {
passwordTip.value = newPwd === newConfirmPwd ? '两次密码一致' : '两次密码不一致';
} else {
passwordTip.value = '';
}
}
);
</script>
<template>
<div class="form-item">
<p>密码:<input v-model="password" type="password" /></p>
<p>确认密码:<input v-model="confirmPassword" type="password" /></p>
<p class="tip" :style="{ color: passwordTip === '两次密码一致' ? '#42b983' : '#e53e3e' }">
{{ passwordTip }}
</p>
</div>
</template>
(3)监听响应式对象(深层侦听)
直接将reactive声明的响应式对象作为数据源,会隐式创建一个深层侦听器,当对象的任何嵌套属性发生变化时,都会触发回调函数。但需要注意:此时回调函数的newValue和oldValue会指向同一个对象(因为对象是引用类型)。
<script setup>
import { reactive, watch } from 'vue';
// 声明嵌套的响应式对象
const userInfo = reactive({
basic: {
name: '张三',
age: 20
},
address: '北京市'
});
// 深层侦听userInfo对象
watch(userInfo, (newVal, oldVal) => {
console.log('用户信息发生变化', newVal, oldVal);
// 注意:newVal和oldVal是同一个对象,无法通过它们获取属性的旧值
});
// 修改嵌套属性,会触发侦听器
const changeName = () => {
userInfo.basic.name = '李四';
};
// 修改顶层属性,也会触发侦听器
const changeAddress = () => {
userInfo.address = '上海市';
};
</script>
<template>
<div>
<p>姓名:{{ userInfo.basic.name }}</p>
<p>地址:{{ userInfo.address }}</p>
<button @click="changeName">修改姓名</button>
<button @click="changeAddress">修改地址</button>
</div>
</template>
⚠️ 注意:深层侦听会遍历对象的所有嵌套属性,若对象结构复杂、数据量大,会产生一定的性能开销,建议仅在必要时使用。
三、侦听器的常用选项:优化副作用执行时机
watch函数的第三个参数是一个配置对象,包含多个实用选项,用于优化副作用的执行时机和行为,最常用的有immediate、once、deep、flush。
1. immediate:初始执行回调
默认情况下,侦听器是“懒执行”的——只有当数据源发生变化时,才会触发回调。如果我们希望侦听器在创建时就立即执行一次回调(比如初始化时发送请求),可以设置immediate: true。
<script setup>
import { ref, watch } from 'vue';
const userId = ref(1);
const userData = ref(null);
// 模拟异步请求
const fetchUserData = async () => {
userData.value = null;
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${userId.value}`);
userData.value = await res.json();
} catch (err) {
console.error('请求失败:', err);
}
};
// 侦听userId,设置immediate: true,初始就执行请求
watch(
userId,
fetchUserData,
{ immediate: true }
);
// 切换用户ID,触发重新请求
const changeUserId = () => {
userId.value = Math.floor(Math.random() * 10) + 1;
};
</script>
<template>
<div>
<button @click="changeUserId">切换用户</button>
<div v-if="!userData">加载中...</div>
<div v-else class="user-card">
<h3>{{ userData.name }}</h3>
<p>邮箱:{{ userData.email }}</p>
<p>地址:{{ userData.address.street }}</p>
</div>
</template>
<style>
.user-card {
margin-top: 16px;
padding: 12px;
border: 1px solid #eee;
border-radius: 4px;
}
</style>
2. once:只触发一次回调
如果我们希望侦听器只在数据源第一次变化时触发回调,之后不再触发,可以设置once: true(仅支持Vue 3.4及以上版本)。
<script setup>
import { ref, watch } from 'vue';
const count = ref(0);
const tip = ref('');
// 只在count第一次变化时触发回调
watch(
count,
(newCount) => {
tip.value = `计数第一次变化为:${newCount}`;
},
{ once: true }
);
</script>
<template>
<p>计数:{{ count }}</p>
<button @click="count++">计数+1</button>
<p>{{ tip }}</p>
</template>
3. flush:控制回调触发时机
默认情况下,侦听器回调会在父组件更新之后、当前组件DOM更新之前触发,此时无法访问到更新后的DOM。如果需要在DOM更新后执行回调(比如获取DOM尺寸),可以设置flush: 'post';如果需要同步触发(数据变化立即执行回调),可以设置flush: 'sync'。
<script setup>
import { ref, watch, ref as templateRef } from 'vue';
const count = ref(0);
const countDom = templateRef(null);
// 设置flush: 'post',在DOM更新后执行回调
watch(
count,
() => {
// 此时能获取到更新后的DOM尺寸
if (countDom.value) {
console.log('计数DOM宽度:', countDom.value.offsetWidth);
}
},
{ flush: 'post' }
);
</script>
<template>
<p ref="countDom" :style="{ fontSize: `${16 + count * 2}px` }">当前计数:{{ count }}</p>
<button @click="count++">计数+1</button>
</template>
四、简化版侦听器:watchEffect的使用
在很多场景中,我们侦听的数据源,会在回调函数中重复使用——比如前面的用户数据请求,我们既把userId作为数据源,又在回调中使用userId.value发送请求。这种情况下,我们可以使用watchEffect函数,它能自动追踪回调中的响应式依赖,无需手动指定数据源,代码更简洁。
1. 基础用法:自动追踪依赖
watchEffect会立即执行一次回调,然后自动追踪回调中使用的所有响应式数据,当这些数据发生变化时,会再次触发回调。
<script setup>
import { ref, watchEffect } from 'vue';
const keyword = ref('');
const searchResult = ref([]);
// 自动追踪keyword的变化,无需手动指定数据源
watchEffect(async () => {
// 回调立即执行,之后keyword变化时再次执行
searchResult.value = [];
if (keyword.value.trim()) {
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?title_like=${keyword.value}`);
searchResult.value = await res.json();
} catch (err) {
console.error('搜索失败:', err);
}
}
});
</script>
<template>
<div>
<input
v-model="keyword"
placeholder="输入关键词搜索文章"
/>
</div>
<div class="search-result">
<p v-if="searchResult.length === 0 && keyword.trim()">暂无搜索结果</p>
<ul v-else>
<li v-for="(item, index) in searchResult" :key="index">
{{ item.title }}
</li>
</ul>
</div>
</template>
<style>
.search-result {
margin-top: 16px;
}
.search-result ul {
padding: 0;
list-style: none;
}
.search-result li {
margin: 8px 0;
padding: 6px;
border-bottom: 1px solid #eee;
}
</style>
这个示例中,我们没有手动指定侦听keyword,但watchEffect会自动追踪回调中使用的keyword.value,当keyword变化时,自动触发搜索操作,比watch更简洁。
2. watch vs watchEffect:核心区别
虽然watch和watchEffect都能执行副作用,但二者的核心区别在于“依赖追踪方式”,选择哪种方式,取决于具体场景:
| 特性 | watch | watchEffect |
|---|---|---|
| 依赖追踪 | 手动指定数据源,只追踪明确的数据源 | 自动追踪回调中使用的响应式数据 |
| 初始执行 | 默认不执行,需设置immediate: true | 默认立即执行 |
| 新旧值获取 | 可以获取数据源的新值和旧值 | 无法获取旧值,只能获取当前值 |
| 使用场景 | 需要明确控制侦听数据源、需要获取新旧值 | 副作用依赖多个数据源、无需获取旧值、追求简洁 |
五、进阶技巧:副作用清理与侦听器停止
在侦听器中执行副作用时,有时会产生需要清理的资源——比如异步请求、定时器、事件监听等。如果不及时清理,可能会导致内存泄漏、数据错乱等问题。Vue提供了两种方式来实现副作用清理:onCleanup和onWatcherCleanup(Vue 3.5+)。
1. 副作用清理:onCleanup的使用
onCleanup是watch回调的第三个参数(也是watchEffect回调的第一个参数),用于注册清理函数,当侦听器即将重新触发或组件卸载时,会执行该清理函数。
<script setup>
import { ref, watch } from 'vue';
const searchKeyword = ref('');
watch(
searchKeyword,
(newKeyword, oldKeyword, onCleanup) => {
// 定时器ID,需要清理
const timer = setTimeout(() => {
console.log(`搜索关键词:${newKeyword}`);
}, 500);
// 注册清理函数:取消定时器
onCleanup(() => {
clearTimeout(timer);
console.log('定时器已清理');
});
}
);
</script>
<template>
<input
v-model="searchKeyword"
placeholder="输入搜索关键词"
/>
</template>
这个示例中,我们在侦听器中设置了一个延迟500ms的定时器,如果在500ms内用户再次输入(触发侦听器重新执行),会先执行清理函数,取消上一个定时器,避免多个定时器同时执行导致的数据错乱。
2. 手动停止侦听器
在<script setup>中同步创建的侦听器,会自动绑定到组件实例,组件卸载时会自动停止,无需手动处理。但如果是异步创建的侦听器(比如在setTimeout中创建),不会自动绑定到组件,需要手动停止,避免内存泄漏。
手动停止侦听器的方法很简单:调用watch或watchEffect返回的函数即可。
<script setup>
import { ref, watchEffect } from 'vue';
const isListening = ref(true);
let unwatch;
// 异步创建侦听器
setTimeout(() => {
unwatch = watchEffect(() => {
console.log('异步侦听器:', isListening.value);
});
}, 1000);
// 手动停止侦听器
const stopListening = () => {
if (unwatch) {
unwatch();
isListening.value = false;
console.log('侦听器已手动停止');
}
};
</script>
<template>
<button @click="stopListening">停止侦听器</button>
</template>
六、避坑指南:侦听器常见错误用法
侦听器的用法看似简单,但很多初学者会因为忽略细节而踩坑,这里总结几个最常见的误区,帮你避开陷阱。
误区1:直接侦听响应式对象的属性
用reactive声明的响应式对象,不能直接侦听其属性,必须使用getter函数,否则侦听器无法触发。
<!-- 错误示例 -->
<script setup>
import { reactive, watch } from 'vue';
const user = reactive({ name: '张三' });
// 错误:直接侦听响应式对象的属性
watch(user.name, (newName) => {
console.log('姓名变化:', newName); // 不会触发
});
</script>
<!-- 正确示例 -->
<script setup>
import { reactive, watch } from 'vue';
const user = reactive({ name: '张三' });
// 正确:使用getter函数侦听属性
watch(() => user.name, (newName) => {
console.log('姓名变化:', newName); // 正常触发
});
</script>
误区2:深层侦听滥用导致性能问题
深层侦听会遍历对象的所有嵌套属性,若对象结构复杂(比如嵌套多层、数据量大),会产生较大的性能开销,建议尽量避免使用,可改用getter函数侦听具体的嵌套属性。
误区3:异步注册侦听器导致无法自动停止
在异步操作(如setTimeout、Promise.then)中创建的侦听器,不会自动绑定到组件实例,组件卸载时不会自动停止,必须手动调用返回的unwatch函数,否则会导致内存泄漏。
误区4:混淆watch和watchEffect的依赖追踪逻辑
watch只追踪明确指定的数据源,回调中使用的其他响应式数据变化,不会触发侦听器;而watchEffect会追踪回调中所有使用的响应式数据,无需手动指定。
七、实战总结:侦听器的最佳实践
侦听器是Vue中处理响应式副作用的核心工具,结合前面的知识点,总结一下它的最佳实践,帮助你在实际开发中灵活运用:
- 明确使用场景:当需要执行副作用(异步请求、日志、DOM操作等)时使用侦听器,单纯的衍生数据用计算属性;
- 选择合适的侦听器类型:需要明确数据源、获取新旧值用
watch;依赖多个数据源、追求简洁用watchEffect; - 合理使用配置选项:初始化需要执行回调用
immediate: true,只触发一次用once: true,需要访问更新后DOM用flush: 'post'; - 及时清理副作用:在异步请求、定时器、事件监听等场景中,务必使用
onCleanup清理资源,避免内存泄漏; - 避免深层侦听滥用:尽量使用getter函数侦听具体属性,减少性能开销。
至此,我们已经掌握了Vue响应式开发的三大核心工具:计算属性(派生数据)、模板引用(手动DOM操作)、侦听器(响应式副作用),再结合生命周期钩子,就能应对绝大多数Vue开发场景。无论是简单的表单校验、复杂的异步请求,还是精细的DOM操作,都能通过这些工具实现高效、可维护的代码。
后续我们还会讲解这些工具的综合运用场景,带你解锁更复杂的Vue开发技巧,让你的代码更简洁、更高效、更具可维护性。