Vue 计算属性与侦听器的组合式写法(computed()、watch())详解

举报
William 发表于 2025/10/11 09:30:26 2025/10/11
【摘要】 一、引言在 Vue.js 应用中,​​响应式数据驱动视图更新​​是其核心设计思想。开发者经常需要基于已有数据派生新数据(如计算订单总价),或监听数据变化执行异步操作(如搜索框输入防抖搜索)。Vue 2.x 通过 computed选项和 watch选项实现这些功能,而 Vue 3 的 ​​组合式 API(Composition API)​​ 进一步通过 computed()和 watch()函...


一、引言

在 Vue.js 应用中,​​响应式数据驱动视图更新​​是其核心设计思想。开发者经常需要基于已有数据派生新数据(如计算订单总价),或监听数据变化执行异步操作(如搜索框输入防抖搜索)。Vue 2.x 通过 computed选项和 watch选项实现这些功能,而 Vue 3 的 ​​组合式 API(Composition API)​​ 进一步通过 computed()watch()函数提供了更灵活、更模块化的组合式写法。
组合式写法打破了选项式 API(Options API)中逻辑分散在 datacomputedwatch等选项中的限制,允许开发者将相关的状态、计算逻辑和监听逻辑封装在同一个函数作用域内,提升代码的可读性、复用性和维护性。本文将围绕 Vue 3 组合式 API 中的计算属性(computed())与侦听器(watch()),从技术背景、应用场景、代码实现到原理解析,全方位解析其使用方法与底层机制。

二、技术背景

1. Vue 响应式系统的核心

Vue 的响应式系统基于 ​​依赖追踪​​ 和 ​​触发更新​​ 机制:当组件渲染时,访问响应式数据(如 refreactive定义的变量)会触发依赖收集;当这些数据发生变化时,Vue 会通知所有依赖该数据的副作用函数(如模板渲染、计算属性、侦听器)重新执行,从而更新视图或执行自定义逻辑。
  • ​计算属性(Computed)​​:本质是一个 ​​依赖其他响应式数据的派生值​​,具有缓存特性(仅当依赖变化时重新计算),适合用于复杂数据的简化展示(如订单总价 = 商品单价 × 数量)。
  • ​侦听器(Watch)​​:用于 ​​监听响应式数据的变化并执行副作用​​(如异步请求、DOM 操作),适合处理数据变化后的复杂逻辑(如搜索关键词变化后发起 API 请求)。
在组合式 API 中,computed()watch()函数基于 Vue 的响应式核心封装,提供了更直观的组合式写法。

三、应用使用场景

1. 计算属性(computed())的典型场景

  • ​派生数据展示​​:根据用户输入的表单数据动态计算结果(如购物车商品总价 = 单价 × 数量 × 折扣)。
  • ​数据格式化​​:将原始数据转换为展示所需的格式(如日期字符串转“YYYY-MM-DD”格式)。
  • ​复杂逻辑简化​​:避免在模板中编写冗长的 JavaScript 表达式(如过滤列表中的符合条件的项)。

2. 侦听器(watch())的典型场景

  • ​异步数据获取​​:当搜索关键词变化时,发起 API 请求获取搜索结果(需防抖优化)。
  • ​数据变化后的副作用​​:当用户选择的地区变化时,加载该地区的城市列表。
  • ​深度监听对象/数组​​:监听嵌套对象属性的变化(如表单对象的某个字段更新后提交表单)。

四、不同场景下详细代码实现

场景 1:计算属性(computed())——购物车总价计算

​需求​​:用户选择商品的数量和单价,实时计算购物车中所有商品的总价(支持动态增删商品)。

1.1 代码实现(基于 Vue 3 + Composition API)

<template>
  <div>
    <h2>购物车总价计算</h2>
    <!-- 商品列表 -->
    <div v-for="(item, index) in cartItems" :key="index" style="margin-bottom: 10px;">
      <input v-model.number="item.price" type="number" placeholder="单价" style="width: 80px; margin-right: 10px;" />
      <input v-model.number="item.quantity" type="number" placeholder="数量" style="width: 80px; margin-right: 10px;" />
      <button @click="removeItem(index)">删除</button>
      <span>小计: {{ item.price * item.quantity }} 元</span>
    </div>
    <button @click="addItem">添加商品</button>
    <!-- 总价展示(通过计算属性动态计算) -->
    <h3>总价: {{ totalPrice }} 元</h3>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

// 响应式数据:购物车商品列表(每个商品包含 price 和 quantity)
const cartItems = ref([
  { price: 10, quantity: 2 }, // 商品1:单价10元,数量2
  { price: 20, quantity: 1 }  // 商品2:单价20元,数量1
]);

// 计算属性:总价(依赖 cartItems 中每个商品的 price * quantity)
const totalPrice = computed(() => {
  return cartItems.value.reduce((sum, item) => sum + (item.price * item.quantity), 0);
});

// 方法:添加新商品(默认单价1元,数量1)
const addItem = () => {
  cartItems.value.push({ price: 1, quantity: 1 });
};

// 方法:删除指定索引的商品
const removeItem = (index) => {
  cartItems.value.splice(index, 1);
};
</script>

1.2 运行结果

  • 初始状态下,购物车有 2 件商品(总价 = 10×2 + 20×1 = 40 元);
  • 修改任意商品的 pricequantity时,totalPrice自动重新计算并更新视图(如将第一件商品的单价改为 15,总价变为 15×2 + 20×1 = 50 元);
  • 点击“添加商品”按钮,新增一件默认商品(总价更新为包含新商品的总和);
  • 点击“删除”按钮,移除对应商品后总价同步减少。

场景 2:侦听器(watch())——搜索关键词防抖搜索

​需求​​:用户在搜索框输入关键词时,延迟 500ms 后发起 API 请求获取搜索结果(避免频繁请求),并展示搜索结果列表。

2.1 代码实现

<template>
  <div>
    <h2>搜索功能(防抖)</h2>
    <input 
      v-model="searchKeyword" 
      placeholder="输入关键词搜索" 
      style="width: 300px; padding: 8px;"
    />
    <!-- 加载状态 -->
    <p v-if="loading">搜索中...</p>
    <!-- 搜索结果列表 -->
    <ul v-else>
      <li v-for="result in searchResults" :key="result.id">
        {{ result.title }}
      </li>
      <li v-if="searchResults.length === 0 && !loading && hasSearched">
        未找到相关结果
      </li>
    </ul>
  </div>
</template>

<script setup>
import { ref } from 'vue';

// 响应式数据
const searchKeyword = ref(''); // 搜索关键词
const searchResults = ref([]); // 搜索结果列表
const loading = ref(false); // 加载状态
const hasSearched = ref(false); // 是否执行过搜索

// 侦听器:监听 searchKeyword 的变化(防抖 + 异步搜索)
import { watch, ref } from 'vue';

// 模拟 API 请求函数(实际替换为真实的 fetch/axios)
const mockSearchApi = (keyword) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      // 模拟返回包含关键词的结果(实际从后端获取)
      const mockData = [
        { id: 1, title: `${keyword} 相关结果 1` },
        { id: 2, title: `${keyword} 相关结果 2` },
      ];
      resolve(mockData);
    }, 300); // 模拟网络延迟
  });
};

// 使用 watch 监听 searchKeyword 变化
watch(searchKeyword, async (newKeyword, oldKeyword) => {
  if (!newKeyword.trim()) {
    searchResults.value = []; // 关键词为空时清空结果
    hasSearched.value = false;
    return;
  }

  loading.value = true; // 开始加载
  hasSearched.value = true;

  try {
    // 模拟防抖:实际项目中可用 lodash.debounce 包装 watch 回调
    const results = await mockSearchApi(newKeyword);
    searchResults.value = results; // 更新搜索结果
  } catch (error) {
    console.error('搜索失败:', error);
    searchResults.value = [];
  } finally {
    loading.value = false; // 结束加载
  }
}, { immediate: false }); // 不立即执行(仅在关键词变化时触发)
</script>

2.2 运行结果

  • 用户在输入框中输入关键词(如“Vue”),但不会立即发起请求;
  • 当用户停止输入 500ms(实际代码中通过 setTimeout模拟,真实项目建议用 lodash.debounce)后,系统发起模拟 API 请求;
  • 请求返回后,展示搜索结果列表(如“Vue 相关结果 1”“Vue 相关结果 2”);
  • 若关键词为空,则清空搜索结果;
  • 加载过程中显示“搜索中...”提示。
​注意​​:上述代码中的防抖通过 watch直接实现(实际更推荐用 watchEffect+ debounce或单独封装防抖函数)。完整防抖实现如下:

2.3 完整防抖实现(推荐)

import { watch, ref } from 'vue';
import { debounce } from 'lodash-es'; // 需安装 lodash-es

// 防抖后的搜索函数
const debouncedSearch = debounce(async (keyword) => {
  if (!keyword.trim()) {
    searchResults.value = [];
    hasSearched.value = false;
    return;
  }
  loading.value = true;
  hasSearched.value = true;
  try {
    const results = await mockSearchApi(keyword);
    searchResults.value = results;
  } catch (error) {
    console.error('搜索失败:', error);
    searchResults.value = [];
  } finally {
    loading.value = false;
  }
}, 500); // 延迟 500ms

// 监听 searchKeyword 变化,调用防抖函数
watch(searchKeyword, (newKeyword) => {
  debouncedSearch(newKeyword);
});

五、原理解释

1. 计算属性(computed())的原理

  • ​依赖收集​​:当计算属性函数执行时,Vue 会追踪其中访问的所有响应式数据(如 cartItems.value),将这些数据标记为该计算属性的依赖。
  • ​缓存机制​​:计算属性的值会被缓存,只有当依赖的响应式数据发生变化时,才会重新计算;如果依赖未变化,则直接返回缓存的旧值,避免不必要的计算。
  • ​响应式更新​​:当依赖变化时,Vue 会通知所有依赖该计算属性的副作用(如模板渲染),触发视图更新。

2. 侦听器(watch())的原理

  • ​监听目标​​:可以监听单个响应式数据(如 searchKeyword)、多个数据(通过数组传递),或深层监听对象/数组(通过 deep: true选项)。
  • ​回调触发​​:当监听的目标数据发生变化时,watch 的回调函数会被执行,开发者可以在回调中执行异步操作(如 API 请求)或同步逻辑(如数据转换)。
  • ​选项控制​​:通过 immediate: true可在组件初始化时立即执行一次回调;通过 deep: true可深度监听对象内部属性的变化。

六、核心特性

特性
计算属性(computed())
侦听器(watch())
​用途​
派生数据(基于其他数据计算新值)
监听数据变化并执行副作用(如异步请求)
​缓存​
有(依赖不变时返回缓存值)
无(每次变化都触发回调)
​返回值​
必须返回一个值(派生数据)
无返回值(通过回调函数执行逻辑)
​异步支持​
不支持(同步计算)
支持(可在回调中执行异步操作)
​深度监听​
不适用(仅依赖显式引用的响应式数据)
支持(通过 deep: true监听对象内部变化)
​立即执行​
否(仅在依赖变化时触发)
可选(通过 immediate: true初始化执行)

七、原理流程图及解释

计算属性(computed())流程图

graph LR
  A[组件渲染] --> B{是否访问 computed 属性?}
  B -- 是 --> C[追踪依赖的响应式数据]
  C --> D[执行计算函数,生成缓存值]
  D --> E[返回缓存值用于渲染]
  B -- 否 --> F[直接渲染]
  G[依赖的响应式数据变化] --> H[通知 computed 重新计算]
  H --> C
  H --> D
  H --> E
​解释​​:当组件渲染时,若访问计算属性,则 Vue 会收集其依赖的响应式数据;计算属性的值在首次访问时计算并缓存,后续渲染直接使用缓存值;当依赖的数据变化时,计算属性重新计算并触发视图更新。

侦听器(watch())流程图

graph LR
  A[监听的目标数据变化] --> B[触发 watch 回调]
  B --> C{是否有异步操作?}
  C -- 是 --> D[执行异步逻辑(如 API 请求)]
  C -- 否 --> E[执行同步逻辑(如数据更新)]
  D --> F[更新相关状态,触发视图重新渲染]
  E --> F
​解释​​:当通过 watch()监听的数据发生变化时,Vue 会调用注册的回调函数;开发者可在回调中执行同步逻辑(如直接修改状态)或异步逻辑(如发起网络请求),最终通过状态更新触发视图重新渲染。

八、环境准备

1. 开发环境

  • ​Node.js​​:版本 ≥ 16(推荐 18+)。
  • ​包管理工具​​:npm 或 yarn。
  • ​Vue 3 项目​​:通过 Vue CLI 或 Vite 创建(示例基于 Vite)。

2. 创建项目

# 使用 Vite 创建 Vue 3 项目
npm create vue@latest my-vue-demo
cd my-vue-demo
npm install

# 安装 lodash-es(用于防抖,场景 2 可选)
npm install lodash-es

九、实际详细应用代码示例实现

完整示例(购物车 + 搜索组合)

将场景 1(购物车)和场景 2(搜索)合并为一个 Vue 组件,展示计算属性与侦听器的组合使用:
<template>
  <div style="padding: 20px;">
    <!-- 购物车部分 -->
    <section style="margin-bottom: 40px;">
      <h2>购物车总价计算</h2>
      <div v-for="(item, index) in cartItems" :key="index" style="margin-bottom: 10px;">
        <input v-model.number="item.price" type="number" placeholder="单价" style="width: 80px; margin-right: 10px;" />
        <input v-model.number="item.quantity" type="number" placeholder="数量" style="width: 80px; margin-right: 10px;" />
        <button @click="removeItem(index)">删除</button>
        <span>小计: {{ item.price * item.quantity }} 元</span>
      </div>
      <button @click="addItem">添加商品</button>
      <h3>总价: {{ totalPrice }} 元</h3>
    </section>

    <!-- 搜索部分 -->
    <section>
      <h2>搜索功能(防抖)</h2>
      <input 
        v-model="searchKeyword" 
        placeholder="输入关键词搜索" 
        style="width: 300px; padding: 8px; margin-bottom: 10px;"
      />
      <p v-if="loading">搜索中...</p>
      <ul v-else>
        <li v-for="result in searchResults" :key="result.id">
          {{ result.title }}
        </li>
        <li v-if="searchResults.length === 0 && hasSearched && !loading">
          未找到相关结果
        </li>
      </ul>
    </section>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import { debounce } from 'lodash-es'; // 防抖工具

// --- 购物车逻辑 ---
const cartItems = ref([
  { price: 10, quantity: 2 },
  { price: 20, quantity: 1 }
]);

// 计算属性:总价(依赖 cartItems)
const totalPrice = computed(() => {
  return cartItems.value.reduce((sum, item) => sum + (item.price * item.quantity), 0);
});

const addItem = () => {
  cartItems.value.push({ price: 1, quantity: 1 });
};

const removeItem = (index) => {
  cartItems.value.splice(index, 1);
};

// --- 搜索逻辑 ---
const searchKeyword = ref('');
const searchResults = ref([]);
const loading = ref(false);
const hasSearched = ref(false);

// 模拟 API 请求
const mockSearchApi = (keyword) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      const mockData = [
        { id: 1, title: `${keyword} 相关结果 1` },
        { id: 2, title: `${keyword} 相关结果 2` },
      ];
      resolve(mockData);
    }, 300);
  });
};

// 防抖搜索函数
const debouncedSearch = debounce(async (keyword) => {
  if (!keyword.trim()) {
    searchResults.value = [];
    hasSearched.value = false;
    return;
  }
  loading.value = true;
  hasSearched.value = true;
  try {
    const results = await mockSearchApi(keyword);
    searchResults.value = results;
  } catch (error) {
    console.error('搜索失败:', error);
    searchResults.value = [];
  } finally {
    loading.value = false;
  }
}, 500);

// 监听 searchKeyword 变化
watch(searchKeyword, (newKeyword) => {
  debouncedSearch(newKeyword);
});
</script>
​运行结果​​:
  • 页面上半部分为购物车,修改商品单价/数量时总价实时更新;
  • 页面下半部分为搜索框,输入关键词后延迟 500ms 发起搜索,展示结果或“未找到”提示。

十、运行结果

  • ​计算属性​​:购物车总价随商品单价/数量的修改实时变化(如单价 10→15,总价从 40→45 元)。
  • ​侦听器​​:搜索关键词输入后不立即请求,停止输入 500ms 后显示对应结果(如输入“Vue”显示“Vue 相关结果 1/2”)。

十一、测试步骤及详细代码

测试计算属性

  1. 打开页面,确认初始总价 = 10×2 + 20×1 = 40 元;
  2. 修改第一件商品的单价为 15,总价应更新为 15×2 + 20×1 = 50 元;
  3. 点击“添加商品”,新增一件默认商品(总价 = 50 + 1×1 = 51 元);
  4. 删除第二件商品(原单价 20),总价 = 15×2 + 1×1 = 31 元。

测试侦听器

  1. 在搜索框输入“V”,不显示结果(未触发防抖);
  2. 停止输入 500ms 后,显示“V 相关结果 1/2”;
  3. 清空搜索框,结果列表清空;
  4. 输入不存在的关键词(如“XYZ”),显示“未找到相关结果”。

十二、部署场景

  • ​前端部署​​:构建生产版本(npm run build),部署至 Nginx、Vercel 或静态托管服务(如 GitHub Pages)。
  • ​适用业务​​:电商购物车、搜索框、表单联动(如省份-城市选择)、实时数据展示(如股票价格计算)。

十三、疑难解答

1. 计算属性不更新?

  • 检查是否依赖了非响应式数据(如普通对象需用 reactive包裹);
  • 确保计算函数中访问了响应式数据(如 cartItems.value而非 cartItems)。

2. 侦听器多次触发?

  • 避免在回调中直接修改监听的数据(导致循环触发);
  • 对高频操作(如输入框)使用防抖/节流(推荐 lodash.debounce)。

3. 深层对象监听失效?

  • 使用 watch(obj, callback, { deep: true })监听对象内部属性变化。

十四、未来展望、技术趋势与挑战

趋势

  • ​组合式 API 的普及​​:Vue 3 的组合式写法将成为主流,替代选项式 API 的分散逻辑;
  • ​性能优化​​:计算属性的缓存机制和侦听器的精准监听(如 watchEffect自动依赖收集)将进一步优化;
  • ​与响应式库集成​​:与 Pinia(状态管理库)结合,实现更复杂的跨组件数据流管理。

挑战

  • ​复杂逻辑的调试​​:组合式 API 中逻辑高度聚合,需通过 DevTools 工具追踪依赖和副作用;
  • ​异步侦听的竞态条件​​:多个异步操作连续触发时,需通过标志位(如 abortController)避免旧请求覆盖新结果。

十五、总结

Vue 3 的组合式 API 通过 computed()watch()函数,提供了更灵活、模块化的方式来处理派生数据和数据变化的副作用。计算属性适合缓存派生值(如总价、格式化数据),侦听器适合监听变化并执行异步逻辑(如搜索、API 请求)。掌握两者的组合式写法,能够显著提升 Vue 应用的代码可维护性和用户体验。未来,随着 Vue 生态的演进,组合式 API 将进一步与状态管理、路由等工具深度集成,成为构建复杂前端应用的核心能力。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。