第 122 章:computed 与 watch
学习目标
- 深入理解 computed 的缓存机制
- 掌握 watch 的各种用法与配置
- 区分 computed、watch、watchEffect 的使用场景
- 学会组合式 API 中的响应式设计模式
一、computed 计算属性
1.1 为什么需要 computed
typescript
// ❌ 模板里写复杂逻辑
<template>
<p>{{ list.filter(item => item.active).reduce((sum, item) => sum + item.price, 0) }}</p>
</template>
// ✅ 提取为计算属性
<script setup>
const totalPrice = computed(() =>
list.value.filter(item => item.active)
.reduce((sum, item) => sum + item.price, 0)
);
</script>
<template>
<p>{{ totalPrice }}</p>
</template>1.2 computed vs 方法
typescript
// ❌ 方法:每次渲染都重新计算
function totalPrice() {
return list.value.filter(...).reduce(...);
}
// ✅ computed:有缓存,只在依赖变化时重算
const totalPrice = computed(() => {
return list.value.filter(...).reduce(...);
});缓存机制:
typescript
const count = ref(0);
const doubled = computed(() => {
console.log('计算 doubled');
return count.value * 2;
});
// 多次访问,只计算一次
console.log(doubled.value); // 计算 doubled, 0
console.log(doubled.value); // 不计算,返回缓存
console.log(doubled.value); // 不计算,返回缓存
count.value = 5; // 依赖变了
console.log(doubled.value); // 重新计算, 101.3 可写 computed
typescript
const firstName = ref('Tom');
const lastName = ref('Jerry');
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`;
},
set(newValue: string) {
const parts = newValue.split(' ');
firstName.value = parts[0];
lastName.value = parts[1];
}
});
fullName.value = 'Alice Cooper';
console.log(firstName.value); // 'Alice'
console.log(lastName.value); // 'Cooper'1.4 链式 computed
typescript
const price = ref(100);
const quantity = ref(2);
// 第一个 computed
const subtotal = computed(() => price.value * quantity.value);
// 第二个 computed 依赖第一个
const tax = computed(() => subtotal.value * 0.1);
const total = computed(() => subtotal.value + tax.value);
// total.value = 100 * 2 + 100 * 2 * 0.1 = 2201.5 异步 computed(伪需求)
注意
computed 不支持异步,这是 Vue 的设计。如需异步请用 watch。
typescript
// ❌ 错误:computed 是同步的
const user = computed(async () => {
return await fetch('/api/user').then(r => r.json());
});
// ✅ 正确:用 ref + watch
const user = ref<User | null>(null);
watchEffect(async () => {
user.value = await fetch('/api/user').then(r => r.json());
});二、watch 侦听器
2.1 基本用法
typescript
import { ref, watch } from 'vue';
const count = ref(0);
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`);
});
count.value = 1; // 输出: count: 0 -> 12.2 监听多个源
typescript
const firstName = ref('Tom');
const lastName = ref('Jerry');
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`${oldFirst} ${oldLast} -> ${newFirst} ${newLast}`);
}
);2.3 监听 reactive 对象
typescript
const state = reactive({ count: 0, name: 'Tom' });
// ❌ 直接监听 reactive 对象无效
// watch(state, ...) // ❌ 不会触发
// ✅ 监听 reactive 的某个属性(getter 形式)
watch(
() => state.count,
(newVal, oldVal) => {
console.log(newVal);
}
);
// ✅ 整个 reactive 对象(加 deep)
watch(state, (newVal) => {
console.log(newVal);
}, { deep: true });2.4 监听对象/数组的 deep
typescript
const state = reactive({
user: { name: 'Tom', age: 18 },
list: [1, 2, 3]
});
// deep: true 会深度监听
watch(
() => state.user,
(newVal) => {
console.log('user 变了');
},
{ deep: true }
);
// 修改属性触发
state.user.name = 'Jerry'; // ✅ 触发2.5 immediate 选项
typescript
const count = ref(0);
// 默认不立即执行
watch(count, (val) => {
console.log(val);
});
// 立即打印?不会
// immediate: true 立即执行一次
watch(count, (val) => {
console.log(val); // 立即打印 0
}, { immediate: true });2.6 flush 选项
typescript
// 默认:pre(组件更新前)
watch(source, callback, { flush: 'pre' });
// post:组件更新后
watch(source, callback, { flush: 'post' });
// sync:同步触发(很少用)
watch(source, callback, { flush: 'sync' });2.7 once 选项(3.4+)
typescript
// 只触发一次后自动停止
watch(source, callback, { once: true });2.8 停止监听
typescript
const stop = watch(count, (val) => {
console.log(val);
});
// 停止监听
stop();三、watchEffect 自动依赖收集
3.1 基本用法
typescript
import { ref, watchEffect } from 'vue';
const count = ref(0);
// 自动追踪函数内的响应式依赖
watchEffect(() => {
console.log('count is:', count.value);
});
count.value = 1; // 输出: count is: 13.2 立即执行
typescript
// watchEffect 默认立即执行
watchEffect(() => {
console.log('立即执行');
});3.3 清理副作用
typescript
watchEffect((onCleanup) => {
const timer = setInterval(() => {
console.log(count.value);
}, 1000);
// 下次执行或停止时调用
onCleanup(() => {
clearInterval(timer);
});
});3.4 watch vs watchEffect
| 维度 | watch | watchEffect |
|---|---|---|
| 监听源 | 显式指定 | 自动收集 |
| 初始执行 | 默认不执行 | 立即执行 |
| 访问新旧值 | ✅ | ❌ |
| 多个源 | 数组 | 多个引用 |
| 使用场景 | 已知依赖 | 自动追踪 |
3.5 选择建议
四、实战案例
4.1 搜索防抖
typescript
import { ref, watch } from 'vue';
const search = ref('');
const debouncedSearch = ref('');
let timer: number;
watch(search, (val) => {
clearTimeout(timer);
timer = window.setTimeout(() => {
debouncedSearch.value = val;
}, 300);
});
// 监听 debouncedSearch 触发请求
watch(debouncedSearch, async (val) => {
if (!val) return;
const res = await fetch(`/api/search?q=${val}`);
results.value = await res.json();
});4.2 自动同步到 localStorage
typescript
const settings = reactive({ theme: 'light', lang: 'zh' });
watch(
settings,
(val) => {
localStorage.setItem('settings', JSON.stringify(val));
},
{ deep: true }
);4.3 路由参数变化重请求
vue
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const article = ref<Article | null>(null);
watch(
() => route.params.id,
async (id) => {
const res = await fetch(`/api/articles/${id}`);
article.value = await res.json();
},
{ immediate: true }
);
</script>4.4 表单实时验证
vue
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
const email = ref('');
const emailError = ref('');
watch(email, (val) => {
if (!val) {
emailError.value = '';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
emailError.value = '邮箱格式错误';
} else {
emailError.value = '';
}
});
const isValid = computed(() => email.value && !emailError.value);
</script>
<template>
<input v-model="email" />
<p v-if="emailError" class="error">{{ emailError }}</p>
<button :disabled="!isValid">提交</button>
</template>4.5 联动下拉框
vue
<script setup lang="ts">
import { ref, watch } from 'vue';
const province = ref('');
const city = ref('');
const areas = ref<Area[]>([]);
watch(province, async (val) => {
if (!val) {
areas.value = [];
return;
}
const res = await fetch(`/api/areas?parent=${val}`);
areas.value = await res.json();
// 清空已选城市
city.value = '';
});
</script>4.6 全屏监听 Esc
vue
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue';
const isFullscreen = ref(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isFullscreen.value) {
isFullscreen.value = false;
}
}
watch(isFullscreen, (val) => {
if (val) {
document.addEventListener('keydown', handleKeydown);
} else {
document.removeEventListener('keydown', handleKeydown);
}
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
</script>五、性能优化
5.1 避免不必要的 watch
typescript
// ❌ 不必要的 watch
const count = ref(0);
const doubled = ref(0);
watch(count, (val) => {
doubled.value = val * 2;
});
// ✅ 用 computed
const doubled = computed(() => count.value * 2);5.2 深度监听优化
typescript
const state = reactive({ user: { name: 'Tom' } });
// ❌ deep: true 监听整个对象
watch(state, () => { /* ... */ }, { deep: true });
// ✅ 只监听需要的属性
watch(() => state.user.name, (val) => { /* ... */ });5.3 watchEffect 精确依赖
typescript
// ❌ 函数内引用了不必要的响应式数据
watchEffect(() => {
console.log(count.value, otherState.value); // otherState 变化也会触发
});
// ✅ 只在必要时引用
watchEffect(() => {
console.log(count.value);
});5.4 停止不需要的 watch
typescript
const stop = watch(source, callback);
// 当不再需要时停止,避免内存泄漏
stop();六、常见错误
6.1 直接监听 reactive 对象
typescript
const state = reactive({ count: 0 });
// ❌ 不触发
watch(state, () => { /* ... */ });
// ✅ getter 形式
watch(() => state.count, () => { /* ... */ });6.2 watch 内修改响应式数据导致循环
typescript
const count = ref(0);
// ❌ 无限循环
watch(count, (val) => {
count.value++;
});
// ✅ 加判断
watch(count, (val) => {
if (val < 10) count.value++;
});6.3 异步 computed 错误
typescript
// ❌ 不支持
const data = computed(async () => {
return await fetch('/api');
});
// ✅ 用 ref + watch
const data = ref(null);
watchEffect(async () => {
data.value = await fetch('/api').then(r => r.json());
});七、本章小结
| API | 特性 | 用途 |
|---|---|---|
| computed | 同步、有缓存、可读写 | 派生数据 |
| watch | 显式监听源、可访问新旧值 | 副作用、异步 |
| watchEffect | 自动收集依赖、立即执行 | 自动追踪副作用 |
动手练习
- computed:实现一个购物车,显示总价和节省金额
- watch:实现一个监听路由参数变化重新请求数据的页面
- 防抖:用 watch 实现搜索框防抖
- watchEffect:实现一个自动同步表单到 localStorage 的组件
推荐阅读
- 📖 Vue 3 computed — 官方文档
- 📖 Vue 3 watch — 官方文档
- 🌐 VueUse watch utilities — watch 工具库
下一章:第 123 章:watch 高级用法 →