第4次 · 计算属性与侦听器:computed 与 watch
第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>
预期看见什么
- 第一张卡片里四行科目,每行有科目名、数字输入框、一个及格徽章。
- 第二张卡片里有一条彩色进度条显示平均分,下面三列统计表:总分、平均分、及格门数 / 科目数、等级徽章、最高分。
- 把“英语”从 55 改成 85:进度条变长变绿、平均分上涨、及格门数从 3/4 变 4/4、等级可能升到“良好”、徽章换色。
- 把“数学”改成 40:平均分压到 60 以下时,屏幕上方弹出红色错误提示“平均分已跌破 60”。
- 再改回来,等级回到更高档,不会再次弹红色警告(因为已经不是“从非不及格变成不及格”了)。
先猜一猜,读完代码再回答
代码里有两处:
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页 · 换成自己的代码
你的任务
- 换成自己的成绩:把
scores改成你自己的 5 门课(含至少一门不及格),科目名用你真实学的课程。 - 增加一个派生数据:
lowest(最低分),用computed写。再在统计表里加一项显示它。 - 增加一个派生数据:
rangeText(分数跨度),文案形如最高 92 — 最低 55,跨度 37,要求全部由computed拼出来。提示:可以在computed里用模板字符串。 - 加一条 watch:当及格门数从少于一半变成超过一半(或反过来)时,弹一条提示。例如“已过半科目及格” / “过半数科目不及格”。
- 可写的 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 能正确触发副作用,并且能说出它的监听对象是什么。 |
在本地写练习记录
- 画出本课数据的依赖链:
scores → total → average → level,标出哪几个是computed、哪几个是原始数据。 - 记录缓存的实验数据:
computed打印了几次、普通函数打印了几次。 - 抄下你写的
lowest与rangeText代码。 - 写下任务 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一进来就跑。
不要用这些办法“修复”
- 不要为了“让统计更新”而在
watch里手动改ref存的总分——那是把派生数据又落地了,一定会不同步。 - 不要在
computed里写副作用(弹框、发请求、改别的数据)。computed应该“纯”——只算不改。副作用交给watch。 - 不要为了省事给所有
watch都加deep: true,它会让监听成本变大且容易重复触发。
需要老师帮忙时交这些
出错那一行 computed/watch 的完整代码;Console 报错原文;你改了什么数据、期望哪几个统计项变化、实际哪一个没变。
老师巡视时只问三句话
- 总分在代码里是怎么来的?有没有单独存一份?(能指出
computed且确认没有ref存) level依赖了谁?改一门成绩它会重算几次?(能说出依赖average,重算一次)- 你的
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 或生命周期里。 |
一分钟现场演示指南
- 展示联动:改一门成绩,指着总分、平均分、及格数、等级徽章说“四处同时变了,代码里一行同步逻辑都没写”。
- 演示缓存:预先打开 Console,点几个无关按钮,说明没有新的打印 —— “依赖没变,它没重算”。
- 演示 watch:把平均分压到 60 以下,弹出红色警告;改回来,说明只在跨越那一刻弹一次。
- 收尾一句:“要值用
computed,要动作用watch。”
交作业前最后检查
src/App.vue与练习记录.md已保存。- 确认统计项里没有用
ref存派生数据。 - 确认 Console 无红色错误。
课后再做一个小对照
把一个 computed(例如 total)改写成普通函数 getTotal(),在模板里用 {{ getTotal() }} 调用。在函数里加一句 console.log,然后反复点页面上无关的按钮,数一数打印次数。写下两组的对比数据,用一句话总结 computed 的价值。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(计算属性 / 侦听器章节)· 组件依据 Element Plus 2.8 官方文档