孟凡的博客blog.mengfanyaoapp.com
课程资料库›Vue 3 + Element Plus 前端开发›第4次 · 计算属性与侦听器:computed 与 watch
模块总览

第4次 · 计算属性与侦听器:computed 与 watch

2026-09-10 · Vue 3 + Element Plus 前端开发 · 课程笔记
课堂练习资料 04-计算属性与侦听器computed与watch.zip
下载 ZIP 压缩包

第4次 · 计算属性与侦听器:computed 与 watch

第1/7页 · 先知道今天要做什么

上一课你已经能让数据“活起来”。但还有一个问题:总分、平均分、及格人数、等级这些数,并不是你输入的数据,而是从数据算出来的。今天要学两件事——怎么算(computed),以及算出来之后想顺手做点别的怎么办(watch)。

今天的场景

成绩录入页面。老师在表格里改一门课的分,页面上有四样东西要跟着变:总分、平均分、及格门数、等级徽章。这四样都不需要单独存,全都能从成绩数组算出来。算出来之后,如果等级从“良好”掉到“不及格”,我们还希望自动弹一句提醒。

下课时拿出这三样结果

做到什么 在哪里看 完成的证据
会用 computed 算派生数据 页面统计卡片 改任意一门成绩,总分/平均分/及格门数/等级四项同时更新。
能说清 computed 与函数调用的区别 演示与观察 能解释为什么用 computed 只在依赖变化时才算一次,而普通函数每次渲染都会重跑。
会用 watch 监听变化 页面提示 当平均分跌破 60 时,自动弹出红色警告提示,且只在跨过那一刻弹一次。

你只需要带着这些基础来

会写 ref 与 .value;知道数组的 map、filter、reduce 大概在做什么;上一课的工程还能跑。

本课时 45 分钟怎么上

时间 老师带着做 学生留下什么
0—6 分钟 复习 ref,抛出问题:总分要不要也做成一个 ref? 明白“派生数据不该单独存”的道理。
6—20 分钟 讲 computed:写法、自动追踪依赖、缓存机制、只读与可写两种。现场改成普通函数对比刷新次数。 能说出缓存带来的好处。
20—32 分钟 讲 watch 与 watchEffect:监听什么、回调拿到什么、immediate 与 deep 两个选项。 笔记里写下两者的区别。
32—42 分钟 粘贴本课 App.vue,改成绩观察四项统计联动;把平均分压到 60 以下看提示。 统计与提示都跑通。
42—45 分钟 抽查:问“为什么这里不能用普通函数”。 全员提交。

从哪里开始

继续用上一课的工程,或复制一份改名 lesson-vue-04。不需要新增依赖。

今天先掌握到这里

今天只讲“算”和“听”。条件渲染 v-if 与列表渲染 v-for 在第 5 课,事件与 v-model 的细节在第 6 课。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第2/7页 · 把新词说清楚

新词与本课含义

名字 用熟悉的话理解 本课里长什么样
派生数据 不是用户输入的,而是从已有数据算出来的数据。总分、平均分、及格数都属于这一类。 total、average、passCount、level
computed() 定义一个“会自己重算”的派生值。写法像函数,用起来像变量。 const total = computed(() => ...)
依赖追踪 Vue 自动记录这个 computed 里读了哪些响应式数据,只在这些数据变化时重算。 它读了 scores,所以只跟 scores 走。
缓存 computed 算过一次就记住结果;依赖没变时再读它,直接返回上一次的结果,不重算。 点了 10 次按钮,计算函数只跑了 1 次。
watch() 专门“盯着”某个数据,它一变就执行一段副作用代码(弹提示、发请求、存本地)。 平均分跌破 60 时弹警告。
副作用(side effect) 除了算出值之外,还要去“做点事”——写日志、弹框、改别的数据。 ElMessage.warning(...)
watchEffect() 不指定监听对象,自动把回调里读到的响应式数据都当成依赖。写起来更省事。 回调里读了 average,就自动监听它。

computed 与 watch 的分工

你想做的事 用哪个 为什么
从已有数据算出一个新值 computed 它返回结果,可以直接在模板里当变量用。
数据变了之后去做一件事 watch 它不返回结果,只负责执行一段代码。
算出来之后还想存一份到 localStorage 两者配合 computed 算,watch 存。

一句话记法:要“值”用 computed,要“动作”用 watch。

computed 为什么不能写成普通函数

写法 结果对不对 效率
computed(() => ...) 对 依赖没变就不重算,缓存住。
function getTotal() { ... } 模板里 {{ getTotal() }} 对 每次组件重渲染都会重新跑一遍,哪怕数据没变。

数据一多、循环里再套上它,普通函数会造成明显的卡顿。所以派生数据一律用 computed。

本课只用五个组件

组件 什么时候用 达到的效果
<el-input-number> 输入成绩 带加减按钮的数字输入框,可设上下限。
<el-progress> 显示平均分进度 彩色进度条,可按分数自动变色。
<el-tag> 显示等级 彩色徽章,等级越高越绿。
<el-descriptions> 展示统计项 成对展示“标签 — 值”。
<el-alert> 显示静态提示 带图标的提示条,可设置类型。

只看这几行:分怎么算出来的

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

const scores = ref([
  { subject: '语文', score: 88 },
  { subject: '数学', score: 92 },
  { subject: '英语', score: 55 }
])

// 派生数据:总分
const total = computed(() => scores.value.reduce((sum, s) => sum + s.score, 0))

// 派生数据:平均分(依赖 total 和 scores,两个都会自动被追踪)
const average = computed(() => total.value / scores.value.length)

// 派生数据:等级(依赖 average)
const level = computed(() => {
  if (average.value >= 85) return '优秀'
  if (average.value >= 70) return '良好'
  if (average.value >= 60) return '及格'
  return '不及格'
})
</script>

<template>
  <span>总分 {{ total }},平均 {{ average.toFixed(1) }},等级 {{ level }}</span>
  <!--            ↑ 模板里当变量用,不写括号、不写 .value -->
</template>

先口头预测

上面 average 里写了 total.value / scores.value.length。如果我只改一门课的分数,average 会重算几次?

一次。因为 total 先重算一次,average 依赖 total,所以跟着重算一次。Vue 会把同一轮里的重复计算合并,不会出现“算了三遍”的情况。这就是依赖链的意义。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第3/7页 · 先看一个小例子

完整代码:App.vue(成绩统计面板)

<script setup>
import { ref, computed, watch } from 'vue'
import {
  ElCard, ElInputNumber, ElProgress, ElTag, ElDescriptions,
  ElDescriptionsItem, ElAlert, ElMessage
} from 'element-plus'
import 'element-plus/dist/index.css'

/* ---------- 原始数据:成绩单 ---------- */
const scores = ref([
  { subject: '语文', score: 88 },
  { subject: '数学', score: 92 },
  { subject: '英语', score: 55 },
  { subject: '物理', score: 76 }
])

/* ---------- 派生数据:全部用 computed ---------- */
const total = computed(() => scores.value.reduce((sum, s) => sum + s.score, 0))

const average = computed(() =>
  scores.value.length ? total.value / scores.value.length : 0
)

const passCount = computed(() => scores.value.filter(s => s.score >= 60).length)

const level = computed(() => {
  if (average.value >= 85) return '优秀'
  if (average.value >= 70) return '良好'
  if (average.value >= 60) return '及格'
  return '不及格'
})

// 等级 → 徽章颜色
const levelType = computed(() => {
  if (level.value === '优秀') return 'success'
  if (level.value === '良好') return 'primary'
  if (level.value === '及格') return 'warning'
  return 'danger'
})

// 进度条颜色:按分数走
const progressColor = computed(() =>
  average.value >= 85 ? '#67c23a' : average.value >= 60 ? '#e6a23c' : '#f56c6c'
)

/* ---------- 侦听器:等级变化时提醒 ---------- */
watch(level, (newVal, oldVal) => {
  if (newVal === '不及格' && oldVal !== '不及格') {
    ElMessage.error('注意:平均分已跌破 60,等级变成“不及格”')
  }
})

// 也可以直接监听一个算出来的值:平均分
watch(average, (n, o) => {
  if (o > 0 && n > o + 5) {
    ElMessage.success('平均分上升了 5 分以上,进步明显!')
  }
})

/* ---------- 一个用来对比的普通函数(不是 computed) ---------- */
const highest = () => Math.max(...scores.value.map(s => s.score))
</script>

<template>
  <div class="page">
    <h1 class="title">成绩统计面板</h1>

    <el-card class="block">
      <template #header>录入各科成绩(直接改数字试试)</template>
      <div class="score-grid">
        <div v-for="item in scores" :key="item.subject" class="score-item">
          <span class="subj">{{ item.subject }}</span>
          <el-input-number v-model="item.score" :min="0" :max="100" size="small" />
          <el-tag v-if="item.score < 60" type="danger" size="small">不及格</el-tag>
          <el-tag v-else type="success" size="small">已及格</el-tag>
        </div>
      </div>
    </el-card>

    <el-card class="block">
      <template #header>统计结果(全部由 computed 算出,没有单独存)</template>
      <el-progress
        :percentage="Math.round(average)"
        :color="progressColor"
        :stroke-width="16"
      >
        <span class="prog-text">平均分 {{ average.toFixed(1) }}</span>
      </el-progress>

      <el-descriptions :column="3" border style="margin-top: 18px">
        <el-descriptions-item label="总分">{{ total }}</el-descriptions-item>
        <el-descriptions-item label="平均分">{{ average.toFixed(1) }}</el-descriptions-item>
        <el-descriptions-item label="及格门数">{{ passCount }} / {{ scores.length }}</el-descriptions-item>
        <el-descriptions-item label="等级">
          <el-tag :type="levelType">{{ level }}</el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="最高分">{{ highest() }}(普通函数,每次渲染都重算)</el-descriptions-item>
        <el-descriptions-item label="科目数">{{ scores.length }}</el-descriptions-item>
      </el-descriptions>
    </el-card>

    <el-alert
      title="把任意一门成绩改到 60 以下,观察等级徽章颜色;把平均分压到 60 以下,会弹出红色警告。"
      type="info"
      :closable="false"
      show-icon
    />
  </div>
</template>

<style>
html, body, #app {
  margin: 0; padding: 0; max-width: none; width: auto; height: auto;
  display: block; place-items: initial; text-align: left; background: #f5f7fa;
}
</style>

<style scoped>
.page { max-width: 900px; margin: 28px auto; padding: 0 16px; }
.title { font-size: 22px; color: #303133; margin: 0 0 18px; }
.block { margin-bottom: 18px; }
.score-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px 24px; }
.score-item { display: flex; align-items: center; gap: 10px; }
.subj { width: 46px; color: #606266; }
.prog-text { color: #303133; font-weight: 600; }
@media (max-width: 720px) {
  .score-grid { grid-template-columns: 1fr; }
}
</style>

预期看见什么

  1. 第一张卡片里四行科目,每行有科目名、数字输入框、一个及格徽章。
  2. 第二张卡片里有一条彩色进度条显示平均分,下面三列统计表:总分、平均分、及格门数 / 科目数、等级徽章、最高分。
  3. 把“英语”从 55 改成 85:进度条变长变绿、平均分上涨、及格门数从 3/4 变 4/4、等级可能升到“良好”、徽章换色。
  4. 把“数学”改成 40:平均分压到 60 以下时,屏幕上方弹出红色错误提示“平均分已跌破 60”。
  5. 再改回来,等级回到更高档,不会再次弹红色警告(因为已经不是“从非不及格变成不及格”了)。

先猜一猜,读完代码再回答

代码里有两处:

const level = computed(() => { ... })        // 等级
const highest = () => Math.max(...)          // 最高分

模板里一个是 {{ level }}(不带括号),另一个是 {{ highest() }}(带括号)。为什么?

因为 computed 返回的是一个“值”(Vue 包装过的 ref),模板里直接用变量名即可;而 highest 是一个真正的函数,必须用 () 调用才会执行。 更关键的区别在效率:level 只在 scores 变化时重算;highest() 每次组件重新渲染都会被调用一遍,即使 scores 一点没变。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第4/7页 · 一步一步做出来

第1步:先证明“派生数据不该单独存”

在 App.vue 里先写这样一段:

<script setup>
import { ref } from 'vue'
const a = ref(10)
const b = ref(20)
const sum = ref(30)          // 手动存的总和
</script>

<template>
  <p>a={{ a }} b={{ b }} sum={{ sum }}</p>
  <button @click="a++">a +1</button>
</template>

点按钮。预期:a 变了,sum 纹丝不动。因为没有任何东西负责维护 sum。

结论:能被算出来的值,不要单独存——一定会忘记同步。

第2步:换成 computed,再看一次

<script setup>
import { ref, computed } from 'vue'
const a = ref(10)
const b = ref(20)
const sum = computed(() => a.value + b.value)
</script>

点按钮。预期:sum 立刻跟着变。

第3步:体验缓存

在 computed 里加一句打印:

const sum = computed(() => {
  console.log('computed 执行了一次', Date.now())
  return a.value + b.value
})

打开 Console,然后点 10 次页面上的其他按钮(不碰 a、b)。

预期:Console 里只出现一次打印。这就是缓存——依赖没变,不重算。

再改成普通函数调用 {{ getSum() }},同样操作,预期:每次重渲染都会打印。

第4步:整段替换为本课完整版

把第 3 页的完整代码整段覆盖 src/App.vue,保存。确认四行成绩都能改、统计能联动。

第5步:让 watch 真的“只弹一次”

把“英语”从 55 改成 85,再改回 55,再改回 85。

预期:只有第一次从 ≥60 掉到 <60 时弹红色警告;反复横跳时,只在跨越那一刻弹。这说明 watch(level, (n, o) => ...) 里比较新旧值的写法是有意义的——如果不加 oldVal 判断,每次重算都会弹。

第6步:认识 watchEffect

在 <script setup> 末尾加一段:

import { watchEffect } from 'vue'
watchEffect(() => {
  console.log('当前等级:', level.value)
})

预期:页面首次加载就打印一次(不需要 immediate: true),之后每次等级变化都会打印。区别在于:watchEffect 会自动把回调里读到的响应式数据都当成依赖,不用你手写监听对象。

本课文件速查

位置 本次作用
<script setup> 的 scores 唯一的原始数据,其余全部由它派生。
total / average / passCount / level 四个 computed,构成一条依赖链。
watch(level, ...) 等级变化时的副作用:弹提示。
highest() 故意保留的普通函数,用来对比 computed。

完成修改后检查

操作或输入 预期观察
把一门成绩从 90 改到 50 总分下降、平均分下降、及格门数 -1、等级可能降低、进度条颜色变橙/红。
把四门都改成 90 以上 等级显示“优秀”,徽章绿色,进度条绿色。
把平均分压到 60 以下 弹出红色提示“平均分已跌破 60”。
连续点页面上别的按钮 Console 里 computed 的打印不重复出现。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第5/7页 · 换成自己的代码

你的任务

  1. 换成自己的成绩:把 scores 改成你自己的 5 门课(含至少一门不及格),科目名用你真实学的课程。
  2. 增加一个派生数据:lowest(最低分),用 computed 写。再在统计表里加一项显示它。
  3. 增加一个派生数据:rangeText(分数跨度),文案形如 最高 92 — 最低 55,跨度 37,要求全部由 computed 拼出来。提示:可以在 computed 里用模板字符串。
  4. 加一条 watch:当及格门数从少于一半变成超过一半(或反过来)时,弹一条提示。例如“已过半科目及格” / “过半数科目不及格”。
  5. 可写的 computed:把“等级”改造成一个可写的 computed——写值时自动调整分数。做法:
const levelWritable = computed({
  get: () => level.value,
  set: (val) => {
    // 传入 '优秀' 时把所有成绩改成 90
    if (val === '优秀') scores.value.forEach(s => { s.score = 90 })
    if (val === '及格') scores.value.forEach(s => { s.score = 65 })
  }
})

然后加一个 <el-select v-model="levelWritable">,切换时观察所有成绩被改写。这一条只要求跑通并说清原理,不要求背下来。

按这张操作单完成

步骤 改哪里 运行后应该看到什么
1 换成绩 scores 数组 5 门自己的课,统计随之更新。
2 加最低分 新增 lowest computed + 统计项 统计表出现“最低分”。
3 加跨度文案 新增 rangeText computed 页面出现“最高 X — 最低 Y,跨度 Z”。
4 过半提示 新增 watch(passCount, ...) 及格门数跨过一半时弹出提示。
5 可写 computed levelWritable + el-select 选中“优秀”,所有成绩立刻变 90。

最低完成标准:先把这三项做实

检查项 通过条件
派生数据不落地 总分、平均分、及格数、等级这四项在代码里都是 computed,没有任何一个用 ref 存。
会看依赖链 能指出“改一门成绩 → total 重算 → average 重算 → level 重算”这条链。
会写 watch 至少有一条 watch 能正确触发副作用,并且能说出它的监听对象是什么。

在本地写练习记录

  1. 画出本课数据的依赖链:scores → total → average → level,标出哪几个是 computed、哪几个是原始数据。
  2. 记录缓存的实验数据:computed 打印了几次、普通函数打印了几次。
  3. 抄下你写的 lowest 与 rangeText 代码。
  4. 写下任务 5 的体会:可写 computed 的 get 和 set 分别在什么时候被调用?

最后交什么

提交 src/App.vue 与 练习记录.md。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第6/7页 · 出错时从哪里查

把现象和检查动作对上

看到什么现象 / 报错 先检查哪里 修好后怎样确认
模板里写成 {{ total() }},报 total is not a function computed 返回的是值,模板里不要加括号。 去掉括号,正常显示。
页面上显示 [object Object] 或奇怪的英文 在 computed 里忘了返回东西,或者返回了包装对象。检查 () => { } 有没有 return。 加上 return 后显示数字。
改了数据统计不更新 computed 里读的是普通变量而不是 .value;或者数据本身不是响应式的。 补上 .value 后联动正常。
computed 里报 Cannot read properties of undefined 数组为空时访问了 [0] 或做了除法。加一个空值保护,例如 scores.value.length ? ... : 0。 空数组时不再报错。
watch 回调不执行 监听的不是响应式数据,或监听的是一个对象但只改了内部属性(需要 deep: true 或直接监听 .value 的具体字段)。 改对监听对象后能触发。
watch 一进页面就执行了 用了 watchEffect,它会立即执行一次。要避免就用 watch 并不要加 immediate: true。 行为符合预期。
watch 每次输入都触发很多次 监听了整个对象且加了 deep: true,对象内部每个字段变化都会触发。 改成只监听需要的那个字段。
循环里用普通函数导致卡顿 把派生数据写成了函数调用。 改成 computed。

排错口诀

模板里 computed 不加括号,函数才加括号; 不返回就显示 undefined,先看有没有 return; watch 不触发查依赖,watchEffect 一进来就跑。

不要用这些办法“修复”

  1. 不要为了“让统计更新”而在 watch 里手动改 ref 存的总分——那是把派生数据又落地了,一定会不同步。
  2. 不要在 computed 里写副作用(弹框、发请求、改别的数据)。computed 应该“纯”——只算不改。副作用交给 watch。
  3. 不要为了省事给所有 watch 都加 deep: true,它会让监听成本变大且容易重复触发。

需要老师帮忙时交这些

出错那一行 computed/watch 的完整代码;Console 报错原文;你改了什么数据、期望哪几个统计项变化、实际哪一个没变。

老师巡视时只问三句话

  1. 总分在代码里是怎么来的?有没有单独存一份?(能指出 computed 且确认没有 ref 存)
  2. level 依赖了谁?改一门成绩它会重算几次?(能说出依赖 average,重算一次)
  3. 你的 watch 在监听什么?回调里的两个参数分别是什么?(能说出 (newVal, oldVal))

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档


第7/7页 · 用自己的话讲一遍

回到第 3 页:你刚才猜对了吗?

为什么 {{ level }} 不写括号,{{ highest() }} 要写括号?

参考解释:level 是一个 computed,它本身就是一个“值”(类似 ref),模板里当变量用;highest 是一个普通函数,必须调用才得到结果。更重要的是性能差异:computed 有缓存,依赖不变就不重算;普通函数在每次重渲染时都会被调用。

回答本课的两个问题

问题 参考解释
computed 的缓存是怎么生效的? 它记录了这次计算读了哪些响应式数据。再次读取时,如果这些数据都没变,就直接返回上次的结果,不执行计算函数。
什么时候必须用 watch 而不能用 computed? 当你要做的不是“算一个值”,而是“做一件事”——弹提示、发请求、写本地存储、打印日志。computed 里面不该有副作用。

五个必须能答上来的问题

考考自己 可以这样通俗回答
派生数据为什么不能单独用 ref 存? 因为你会忘记在原始数据变化时同步它,一定会不同步。
computed 能不能在模板里写括号? 不能。它是值不是函数。
watch 回调的第三个参数有什么? 一个对象,里面的 immediate 控制是否立即执行一次,deep 控制是否深度监听。
watchEffect 和 watch 最大的区别? watchEffect 自动收集依赖、立即执行一次;watch 要你明确指定监听对象,默认不立即执行。
computed 里能发请求吗? 不应该。computed 要“纯”,请求属于副作用,应放在 watch 或生命周期里。

一分钟现场演示指南

  1. 展示联动:改一门成绩,指着总分、平均分、及格数、等级徽章说“四处同时变了,代码里一行同步逻辑都没写”。
  2. 演示缓存:预先打开 Console,点几个无关按钮,说明没有新的打印 —— “依赖没变,它没重算”。
  3. 演示 watch:把平均分压到 60 以下,弹出红色警告;改回来,说明只在跨越那一刻弹一次。
  4. 收尾一句:“要值用 computed,要动作用 watch。”

交作业前最后检查

  • src/App.vue 与 练习记录.md 已保存。
  • 确认统计项里没有用 ref 存派生数据。
  • 确认 Console 无红色错误。

课后再做一个小对照

把一个 computed(例如 total)改写成普通函数 getTotal(),在模板里用 {{ getTotal() }} 调用。在函数里加一句 console.log,然后反复点页面上无关的按钮,数一数打印次数。写下两组的对比数据,用一句话总结 computed 的价值。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档