Skip to content
第 122 / 250 章前端⏱ 10 分钟阅读

第 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);  // 重新计算, 10

1.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 = 220

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

2.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: 1

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

维度watchwatchEffect
监听源显式指定自动收集
初始执行默认不执行立即执行
访问新旧值
多个源数组多个引用
使用场景已知依赖自动追踪

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自动收集依赖、立即执行自动追踪副作用

动手练习

  1. computed:实现一个购物车,显示总价和节省金额
  2. watch:实现一个监听路由参数变化重新请求数据的页面
  3. 防抖:用 watch 实现搜索框防抖
  4. watchEffect:实现一个自动同步表单到 localStorage 的组件

推荐阅读


下一章第 123 章:watch 高级用法

本站基于 VitePress 构建 · 由 Codebook 团队维护