主题
指令(下)、计算属性与侦听器(三)
本页对应 指令(下)、计算属性与侦听器,共 7 段视频,约 1 小时 17 分。
学习目标
- 能够完成成绩列表的添加与删除功能,包括表单双向绑定、分数数字类型转换、按 ID 删除并保持列表结构正确。
- 能够使用计算属性统计成绩总分与平均分,并在增删数据后自动更新结果,平均分可按需保留两位小数。
- 能够写出
watch侦听器的函数写法与对象完整写法,正确使用handler、deep、immediate配置并说明适用场景。 - 能够通过侦听对象属性变化触发翻译请求,处理空输入、重置翻译结果,并将接口返回结果显示到页面。
- 能够使用定时器实现输入防抖,在连续输入时清除并重建定时器,减少无效翻译请求。
- 能够对比计算属性与侦听器在缓存、异步代码、返回值要求和适用场景上的差异,并据此选择正确实现方式。
知识点
案例目标与整体思路

这个综合案例把列表渲染、事件绑定、双向绑定和计算属性串起来:页面要能渲染成绩列表、删除某条成绩、添加新成绩,并实时统计总分和平均分。
为什么需要:单独学指令容易忘,放到一个完整案例里才能理解“数据变化 → 视图更新”的 Vue 思路。
怎么想:先把成绩数组 list 设计好,每项包含 id、subject、score;添加用两个临时数据 subject、score 接输入框;删除按 id 过滤;统计交给 computed。
怎么做:按“渲染 → 删除 → 添加 → 统计”的顺序写,每完成一步就刷新调试。
易错点:不要一开始把所有代码堆上去;先保证 list 能渲染,再绑定事件。
渲染功能:v-for、:key 与动态 class
为什么需要:成绩是数组,不能手写多行;用 v-for 才能让数据驱动列表。
怎么想:循环 list,每一项输出序号、科目、分数和删除链接;:key 用唯一 id,不要用索引。笔记还要求用 v-bind:class 动态绑定样式,可按分数状态切换类名。
怎么做:
html
<tr v-for="(item, index) in list" :key="item.id">
<td>{{ index + 1 }}</td>
<td>{{ item.subject }}</td>
<td>{{ item.score }}</td>
<td><a href="#" @click.prevent="del(item.id)">删除</a></td>
</tr>易错点::key 写 index 在删除、插入时可能复用错行;v-bind:class 是绑定表达式,不是普通字符串。
删除功能:v-on 与阻止 a 标签默认行为
为什么需要:删除要由用户点击触发,并且只删当前行。
怎么想:删除链接是 a 标签,默认会跳转,所以要用 .prevent 阻止默认行为;删除时拿到当前 id,用 filter 保留 id 不相等的项。转写稿里反复调试后就是“this.list = this.list.filter(item => item.id !== id)”。
怎么做:
html
<a href="#" @click.prevent="del(item.id)">删除</a>js
methods: {
del(id) {
this.list = this.list.filter(item => item.id !== id)
}
}易错点:忘记 .prevent 页面会跳;用 splice 也能做,但要注意下标变化;删除后总分应自动重算,因为计算属性依赖 list。
添加功能:v-model 修饰符与清空输入
为什么需要:添加要把输入框里的科目、分数加入数组。
怎么想:准备 subject 和 score 两个数据,分别和输入框双向绑定。科目用 .trim 去空格;分数必须用 .number,否则输入的是字符串,后面相加会出问题。转写稿里明确提醒:字符串和数字相加容易拼接或报错。添加前判断是否为空,添加后清空输入框。
怎么做:
html
<input v-model.trim="subject" placeholder="科目">
<input v-model.number="score" placeholder="分数">
<button @click="add">添加</button>js
data() {
return {
list: [
{ id: 1, subject: '语文', score: 89 },
{ id: 2, subject: '数学', score: 100 }
],
subject: '',
score: ''
}
},
methods: {
add() {
if (!this.subject || this.score === '') return
this.list.unshift({
id: Date.now(),
subject: this.subject,
score: this.score
})
this.subject = ''
this.score = ''
}
}易错点:分数不加 .number 会导致总分错误;添加按钮外层没有 form 时不必强行阻止提交,但删除的 a 标签要阻止;添加后忘记清空,用户会误以为没添加成功。
统计总分与平均分:computed 与 reduce
为什么需要:总分、平均分会随添加删除变化,手算不现实。
怎么想:总分用 reduce 累加每项 score,初始值写 0;平均分用总分除以 list.length。转写稿里还提到小数保留两位,用 toFixed(2)。
怎么做:
js
computed: {
total() {
return this.list.reduce((sum, item) => sum + item.score, 0)
},
avg() {
return this.list.length
? (this.total / this.list.length).toFixed(2)
: '0.00'
}
}html
<p>总分:{{ total }},平均分:{{ avg }}</p>易错点:reduce 忘记给初始值 0 会出错;toFixed 返回字符串,只用于显示没问题;空数组直接相除会得到 NaN,实际可加长度判断。转写稿调试时删一条成绩,总分和平均分立即变化,说明计算属性会随依赖数据自动重新计算。
1. watch 的作用:数据一变就执行逻辑
为什么需要:模板插值负责“显示”,计算属性负责“由已有数据算出新值”,但有些逻辑是数据变化后要做事,比如发请求、存草稿、做校验。这类逻辑不适合硬塞进模板。
怎么想:把 watch 理解成“盯住某个数据”。被盯的数据一变,就执行对应函数。转写稿里用“监听器/侦听器”打比方:数据有风吹草动就触发。
怎么做:watch 与 data 同级;方法名就是要侦听的数据属性名;函数参数是 newValue、oldValue。
易错点:watch 不是用来替代 computed 的。由多个数据算一个结果,用 computed;数据变化后做业务或异步操作,用 watch。computed 里不要写异步,watch 里可以写异步请求。
2. 简单写法:数据属性名就是函数名
笔记中的语法代码要保留:
js
data: {
words: '苹果',
obj: {
words: '苹果'
}
},
watch: {
// 该方法会在数据变化时,触发执行
数据属性名 (newValue, oldValue) {
一些业务逻辑 或 异步操作。
},
'对象.属性名' (newValue, oldValue) {
一些业务逻辑 或 异步操作。
}
}为什么需要:简单类型数据直接监听最省事,比如监听 words。
怎么想:监听谁,就把谁写成函数名;如果监听对象里的某个属性,就写 'obj.words'。
怎么做:words(newValue, oldValue){} 会在 words 变化时执行。newValue 是变化后的值,oldValue 是变化前的值;一般只用 newValue。
易错点:'对象.属性名' 这种路径要加引号;watch 不要写到 methods 里;监听对象本身时,简单写法不一定能捕获对象内部属性变化,这时要用完整写法或直接监听具体路径。转写稿调试时,监听 obj 改输入框没有输出,就是因为对象内部属性变化需要深度侦听。
3. 完整写法:handler、deep、immediate
为什么需要:监听对象内部任意属性变化,或者希望页面刷新后立即执行一次,就要加配置项。
怎么想:完整写法是对象格式,核心是 handler,额外选项是 deep、immediate。
怎么做:
js
watch: {
obj: {
handler(newValue, oldValue) {
// 业务逻辑
},
deep: true, // 深度侦听对象内部属性变化
immediate: true // 页面刷新后立即执行一次
}
}转写稿里的调试过程很典型:先监听 obj,改输入框没反应;加上 deep: true 后,obj 里任意属性变化都能触发。加 immediate: true 后,刷新页面马上输出一次;去掉它,刷新后不执行,只有数据变化才执行。
易错点:handler 是固定名字,不能随意拼错;deep 用于对象/数组内部变化;immediate 不是“立即侦听所有变化”,而是初始化时先执行一次。若只监听对象中某一个属性,简单写法 'obj.words' 反而更直接。
4. 翻译案例准备:先双向绑定,再侦听
笔记中的页面准备代码保留如下:
html
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-size: 18px;
}
#app {
padding: 10px 20px;
}
.query {
margin: 10px 0;
}
.box {
display: flex;
}
textarea {
width: 300px;
height: 160px;
font-size: 18px;
border: 1px solid #dedede;
outline: none;
resize: none;
padding: 10px;
}
textarea:hover {
border: 1px solid #1589f5;
}
.transbox {
width: 300px;
height: 160px;
background-color: #f0f0f0;
padding: 10px;
border: none;
}
.tip-box {
width: 300px;
height: 25px;
line-height: 25px;
display: flex;
}
.tip-box span {
flex: 1;
text-align: center;
}
.query span {
font-size: 18px;
}
.input-wrap {
position: relative;
}
.input-wrap span {
position: absolute;
right: 15px;
bottom: 15px;
font-size: 12px;
}
.input-wrap i {
font-size: 20px;
font-style: normal;
}
</style>
<div id="app">
<!-- 条件选择框 -->
<div class="query">
<span>翻译成的语言:</span>
<select>
<option value="italy">意大利</option>
<option value="english">英语</option>
<option value="german">德语</option>
</select>
</div>
<!-- 翻译框 -->
<div class="box">
<div class="input-wrap">
<textarea v-model="words"></textarea>
<span><i>⌨️</i>文档翻译</span>
</div>
<div class="output-wrap">
<div class="transbox">mela</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// 接口地址:https://applet-base-api.t.itheima.net/api/translate
// 请求方式:get
// 请求参数:
// (1)words:需要被翻译的文本(必传)
// (2)lang: 需要被翻译成的语言(可选)默认值-意大利
// -----------------------------------------------
const app = new Vue({
el: '#app',
data: {
words: ''
},
// 具体讲解:(1) watch语法 (2) 具体业务实现
})
</script>为什么需要:翻译要实时响应输入内容、源语言、目标语言的变化。只监听输入框不够,下拉框变化也要重新翻译。
怎么想:转写稿把输入内容、源语言、目标语言放进一个对象,再对对象做 deep 侦听;任意属性变,handler 都执行。这样比写多个 watch 省事。实际案例中 textarea 用 v-model="words",select 也要加 v-model 绑定语言。
怎么做:先完成双向绑定,保证输入框和下拉框变化能进 data;再写 watch 监听相关数据;在 handler 里判断内容为空就不请求,然后发翻译请求,把结果存到数据项,右侧 transbox 用插值展示。
易错点:v-model 要写在表单元素上;空内容不要发请求,并重置结果;接口参数以接口文档/笔记注释为准。笔记注释里是 words、lang,转写稿演示中曾用 Q、from、to 命名,学习时不要混用。
5. 在 handler 中发请求并做防抖
为什么需要:输入框每敲一个字母都会触发 watch,如果立即请求,会发很多无效请求。转写稿里连续输入时观察到“翻译了好多次”,所以要防抖:等用户停一会儿再发。
怎么想:每次进入 handler 先清掉上一次定时器,再设一个新的定时器;如果用户继续输入,就不断重置计时,只有停下来后才真正请求。
怎么做:在 handler 里先判空;用 clearTimeout(this.timer) 清除旧定时器;再 this.timer = setTimeout(async () => { ...axios... }, 1000)。请求成功后把结果赋给 data,页面再渲染。
易错点:定时器要保存到 this.timer;await 要放在最近的 async 函数里;连续输入时只让最后一次请求生效;不要忘记空值重置结果。调试顺序应是:先确认 watch 能触发,再确认请求参数,最后加防抖减少请求次数。
先明确目标:输入变化就翻译
为什么需要:翻译案例的核心不是“点按钮才翻译”,而是输入框内容变化后自动请求接口,把结果显示出来。这样用户输入 book,右侧就能得到翻译结果。
怎么想:先准备两个数据:obj.words 存输入内容,result 存翻译结果。输入框和 obj.words 双向绑定;只要 obj.words 变化,就用 watch 侦听并发送请求。
怎么做:在 data 中写 obj: { words: '' } 和 result: '',在 watch 中监听 'obj.words'。注意这里监听的是对象中的属性,所以方法名要写成字符串 'obj.words',不能直接写 obj.words 当函数名。回调的第一个参数 newValue 就是变化后的新值。
易错点:watch 要写在 Vue 实例配置里,和 data 平级;newValue 是变化后的值,旧值一般不用;不要把它误写成计算属性。
接口与请求参数要先看清
为什么需要:不知道接口怎么传参,后面 axios 就会写错。
怎么想:笔记里已经给出接口说明:地址是 https://applet-base-api.t.itheima.net/api/translate,请求方式是 get;words 是必传的待翻译文本,lang 是可选的目标语言,默认意大利。当前代码只传了 words,所以先按笔记实现。
怎么做:用 axios({ url, params: { words: newValue } }),get 请求的参数放在 params 中。请求成功后,把 res.data.data 赋给 this.result。
易错点:get 参数不要写成 data;结果层级不要只写到 res.data,笔记代码取的是 res.data.data;lang 不传时使用接口默认值,不要自己编一个参数名。
防抖:输入很快时不要每次都发请求
为什么需要:如果每输入一个字母就发一次请求,输入 map 可能连续发三次请求,既浪费也可能结果错乱。
怎么想:转写稿里说的“黄抖/房抖”就是防抖:先等一等,一段时间内没有再次输入,才真正执行。这里设置 300ms,用户停手 300ms 后再请求。
怎么做:每次进入侦听回调,先 clearTimeout(this.timer) 清掉上一次定时器,再 this.timer = setTimeout(async () => { ... }, 300)。这样前两次输入产生的定时器会被清掉,只保留最后一次。调试时可以先在回调里 console.log(newValue),看到每次输入都触发;再加 setTimeout,发现仍会多次请求;最后补上 clearTimeout,才只发最后一次。
易错点:clearTimeout 要放在 setTimeout 前面;异步请求函数要用 async,里面才能 await;setTimeout 里用箭头函数,this 才指向 Vue 实例;timer 最好在 data 中声明,虽然临时挂到 this.timer 也能跑,但不规范。
空值与结果重置
为什么需要:转写稿调试时补了一个判断:输入框为空时不应该还发翻译请求,否则没有意义。
怎么想:进入回调先判断 newValue 是否为空。为空就清空 this.result,直接结束;不为空再走防抖和请求。
怎么做:可在回调开头写 if (!newValue) { this.result = ''; return }。这段是对笔记代码的调试补充,原笔记代码没有写,但实际业务中应当考虑。
易错点:清空输入后如果不清空结果,页面会残留上一次翻译;判断空值要放在发请求之前。
本节笔记代码
js
<script>
// 接口地址:https://applet-base-api.t.itheima.net/api/translate
// 请求方式:get
// 请求参数:
// (1)words:需要被翻译的文本(必传)
// (2)lang: 需要被翻译成的语言(可选)默认值-意大利
// -----------------------------------------------
const app = new Vue({
el: '#app',
data: {
//words: ''
obj: {
words: ''
},
result: '', // 翻译结果
// timer: null // 延时器id
},
// 具体讲解:(1) watch语法 (2) 具体业务实现
watch: {
// 该方法会在数据变化时调用执行
// newValue新值, oldValue老值(一般不用)
// words (newValue) {
// console.log('变化了', newValue)
// }
'obj.words' (newValue) {
// console.log('变化了', newValue)
// 防抖: 延迟执行 → 干啥事先等一等,延迟一会,一段时间内没有再次触发,才执行
clearTimeout(this.timer)
this.timer = setTimeout(async () => {
const res = await axios({
url: 'https://applet-base-api.t.itheima.net/api/translate',
params: {
words: newValue
}
})
this.result = res.data.data
console.log(res.data.data)
}, 300)
}
}
})
</script>最后检查
为什么需要:把流程串起来,避免只会抄代码。
怎么想:输入框变化 → obj.words 变化 → watch 触发 → 防抖等待 300ms → 发 get 请求 → 取 res.data.data → 赋值给 result → 页面展示。
易错点:监听对象属性用 'obj.words';防抖必须清上一次定时器;异步请求用 async/await;空输入要清结果;接口返回层级别取错。
watch 侦听器是做什么的
为什么需要:有些业务不是“点按钮才执行”,而是“数据一变就要执行”。例如翻译案例里,输入框内容变化、原语言变化、目标语言变化,任意一个变化都要重新翻译。用 input 事件只能盯住输入框,下拉框还得再写事件,麻烦且分散。watch 侦听器就是专门监听 data 中数据变化,并在变化后执行一段业务逻辑,甚至可以写异步请求。
怎么想:把要监听的“数据”当成侦听对象:它变了,我就执行 handler。简单值可以直接监听;对象里的属性变化,通常要监听整个对象并开启深度监听。
怎么做:简单写法:
js
watch: {
数据属性名 (newValue, oldValue) {
// 一些业务逻辑或异步操作
},
'对象.属性名' (newValue, oldValue) {
// 一些业务逻辑或异步操作
}
}易错点:watch 和 data、methods 平级;方法名要和被监听的数据名一致;监听对象属性时 '对象.属性名' 要加引号。
完整写法:deep 与 immediate
为什么需要:翻译案例中 obj 里有 words、lang,只写简单写法监听 obj 时,修改 obj.words 往往不会触发,因为默认只监听对象本身,不深入监听对象内部属性。另外,如果输入框有默认值,希望一进页面就翻译一次,就需要立即执行。
怎么想:完整写法把原来函数换成对象,用固定配置项控制行为:deep: true 深度监听复杂类型内部属性;immediate: true 让页面初始化时立刻执行一次 handler。真正执行逻辑的函数必须叫 handler。
怎么做:
js
data: {
obj: {
words: '苹果',
lang: 'italy'
},
},
watch: {// watch 完整写法
对象: {
deep: true, // 深度监视
immdiate:true,//立即执行handler函数
handler (newValue) {
console.log(newValue)
}
}
}注意:上面笔记中 immdiate 是拼写错误,正确应为 immediate。
易错点:handler 不能写成 hander;deep 只对对象/数组等复杂类型有意义;immediate 不是“每次变化都立即执行”,而是初始化时先执行一次;deep 不是“深拷贝”。
翻译需求拆解

为什么需要:这个案例要同时监听三个变化:文本框输入、原语言下拉框、目标语言下拉框。任意一个变化都要重新翻译,所以适合把相关数据放进一个对象里统一监听。
怎么想:输入框和下拉框通过 v-model 双向绑定到 obj 的属性上;只要其中任意属性变化,obj 内部就发生变化。于是监听 obj,开启 deep: true,在 handler 里发翻译请求。若文本框有默认值,再开启 immediate: true,让页面进来先翻译一次。
怎么做:在 handler 中拿到 newValue,作为请求参数;用 axios 发 GET 请求;把返回结果赋给 result,页面右侧显示 result。
易错点:接口参数名必须和接口文档一致;笔记示例中 obj 的字段是 words、lang,若接口要求 q、from、to,就要按文档调整字段,不能想当然。
代码实现与防抖调试
为什么需要:输入框每敲一个字母都发请求,会产生大量无效请求。转写稿中通过定时器实现防抖:用户连续输入时,前面的定时器被清除,只保留最后一次。
怎么想:每次进入 handler 先 clearTimeout(this.timer),再重新 setTimeout。例如输入 m、a、p:输入 m 创建定时器;输入 a 时清掉上一个定时器;输入 p 时再清掉上一个;停止输入后最后一个定时器保留,延迟结束才发请求。
怎么做:
js
<script>
const app = new Vue({
el: '#app',
data: {
obj: {
words: '小黑',
lang: 'italy'
},
result: '', // 翻译结果
},
watch: {
obj: {
deep: true, // 深度监视
immediate: true, // 立刻执行,一进入页面handler就立刻执行一次
handler (newValue) {
clearTimeout(this.timer)
this.timer = setTimeout(async () => {
const res = await axios({
url: 'https://applet-base-api-t.itheima.net/api/translate',
params: newValue
})
this.result = res.data.data
console.log(res.data.data)
}, 300)
}
}
}
})
</script>调试过程:先只写简单写法监听 obj,修改输入框或下拉框,发现不触发;改成完整写法并加 deep: true 后,修改 obj 任意属性都能触发。加 immediate: true 后刷新页面会立刻执行一次;去掉后刷新不执行。再把请求放进定时器,并先清除上一次定时器,输入过程中请求次数明显减少。
易错点:this.timer 未在 data 中声明也可以临时挂载,但更规范是提前声明;setTimeout 回调用箭头函数才能让 this 指向 Vue 实例;请求参数要传 newValue 或按接口要求传对象字段。
总结:两种写法怎么选
为什么需要:简单写法短,完整写法配置多。实际开发要知道两种写法,至少能看懂别人的代码。
怎么想:只监听一个简单值,用简单写法;要深度监听对象属性、要立即执行,用完整写法。转写稿也提到,实际工作中可以统一记完整写法,减少记混。
怎么做:
js
watch: {
数据属性名 (newValue, oldValue) {
// 一些业务逻辑或异步操作
},
'对象.属性名' (newValue, oldValue) {
// 一些业务逻辑或异步操作
}
}js
watch: {// watch 完整写法
数据属性名: {
deep: true, // 深度监视(针对复杂类型)
immediate: true, // 是否立刻执行一次handler
handler (newValue) {
console.log(newValue)
}
}
}易错点:watch 中可以写异步代码;计算属性中不要写异步;handler 是固定名字;deep、immediate 是选项,不是函数。
案例目标与整体思路

本节用购物车把前面的渲染、事件、计算属性、侦听器串起来。需求共六项:渲染、删除、修改个数、全选反选、统计选中总价和总数量、持久化到本地。转写稿里“镇定器/真听器/今听器”应统一理解为“侦听器”;“山竹”是“删除”;“黄抖”是“防抖”;“定时期”是“定时器”;“excel”是“axios”。
为什么需要:单个知识点会写,不等于能组织一个完整案例。怎么想:先让列表能显示,再让每一行能操作,最后做汇总和存储。怎么做:数据放在 list 中,每项至少包含 id、checked、数量、单价等字段,页面由数据驱动。易错点:不要先堆样式再补逻辑;不要直接操作 DOM 改页面,应改数据让视图自动更新。
1. 基本渲染
为什么需要:把数组数据变成购物车列表,是后续删除、修改、统计的基础。怎么想:用 v-for 遍历列表,用 :class 根据 checked 动态绑定选中样式。怎么做:循环时写 v-for="item in list" :key="item.id",选中样式可用对象语法,如 :class="{ active: item.checked }"。易错点:key 优先用唯一 id,不要用索引;:class 绑定的是布尔值,不要写成字符串;选中样式要和 checked 同步,否则全选后页面不亮。
2. 删除功能
为什么需要:购物车要能移除不想要的商品。怎么想:点击删除时拿到当前行 id,再从数组中过滤掉该项。怎么做:用 v-on 绑定事件,如 @click="del(item.id)";方法中用 filter 保留 id 不相等的项,再赋回 list。易错点:不要用错索引导致删错行;id 类型要一致;转写稿中“山竹”实际是“删除”,删除后统计应自动重算,调试时可删一条看总价是否变化。
3. 修改个数
为什么需要:同一商品可能买多件,数量必须可增减。怎么想:事件要同时知道“哪一行”和“加还是减”,所以传 id 和增量。怎么做:点击加号/减号绑定事件,获取当前行 id,筛选出对应项后修改数量;统计依赖数量,修改后总价自动变。易错点:不要直接改错数组项;数量按需求处理下限,避免出现负数;不要把统计结果当普通数据直接改,统计应由计算属性推导。
4. 全选反选
为什么需要:批量选择能提高操作效率。怎么想:全选状态不是孤立的,它由所有小选框共同决定:所有小选框都选中,全选才选中,可用 every;全选按钮改变时,再遍历设置每个小选框。怎么做:声明计算属性判断 list.every(item => item.checked);全选点击时遍历 list,把每项 checked 设为目标状态。易错点:空数组 every 返回 true,要结合业务判断;计算属性默认只读,若用 v-model 绑计算属性需写 set,否则用事件处理;取消全选必须全部取消。转写稿讲计算属性完整写法时提到 get/set,这里正要注意“只读计算”和“可写计算”的区别。
5. 统计选中总价和总数量
为什么需要:结算区只应统计“选中的”商品。怎么想:先用 filter 过滤 checked,再用 reduce 累加。总数量累加数量,总价累加“单价×数量”。怎么做:写成计算属性,依赖 list 自动重算。转写稿算总分时用 reduce,初始值为 0,累加当前项分数,再用总分除以数组长度得平均分;购物车同理,只是先过滤选中项,再累加。易错点:忘记过滤会把未选中商品算进去;总价不是只加单价;计算属性里不要写异步代码;小数可用 toFixed 处理。转写稿调试时删一条数据,总分自动变,说明计算属性会随依赖变化重新计算。
6. 持久化到本地
为什么需要:刷新页面后购物车不能丢。怎么想:数据变化时写入 localStorage,初始化时再读回来。怎么做:用 watch 监听购物车数据,handler 中把数据 JSON.stringify 后写入本地;初始化时读取并 JSON.parse。转写稿讲侦听器完整写法时强调:handler 是固定写法,监听对象属性变化要加 deep,页面刷新立即执行可加 immediate。易错点:localStorage 只能存字符串;解析要容错;监听对象不加 deep 可能监听不到内部变化;初始空数组不要覆盖已有本地数据。
小结
为什么需要:综合案例检验“数据驱动视图”的完整链路。怎么想:渲染→行操作→全选→统计→持久化,顺序推进。怎么做:删除用 filter,修改用 id 定位,全选用 every,统计用计算属性,持久化用 watch。易错点:id 要唯一、统计只算选中、侦听对象注意 deep、本地存储注意序列化。
课件图示







代码示例
示例一:成绩案例(渲染 / 删除 / 添加 / 计算总分与平均分)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>成绩案例</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
#app { padding: 20px; width: 600px; }
.row { display: flex; border: 1px solid #ccc; border-top: none; }
.row:first-child { border-top: 1px solid #ccc; }
.row span { flex: 1; text-align: center; padding: 8px; }
.row.head { background-color: #f0f0f0; font-weight: bold; }
.add { margin-top: 15px; }
.add input { width: 100px; margin-right: 10px; }
.statistics { margin-top: 15px; font-weight: bold; }
</style>
</head>
<body>
<div id="app">
<!-- 1. 渲染功能:v-for + :key -->
<div class="row head">
<span>序号</span><span>科目</span><span>分数</span><span>操作</span>
</div>
<div class="row" v-for="(item, index) in list" :key="item.id">
<span>{{ index + 1 }}</span>
<span>{{ item.subject }}</span>
<span>{{ item.score }}</span>
<!-- 2. 删除功能:v-on 绑定事件,.prevent 阻止 a 标签默认行为 -->
<span><a href="#" @click.prevent="del(item.id)">删除</a></span>
</div>
<!-- 3. 添加功能:.trim 去空格 .number 转数字 -->
<div class="add">
科目:<input type="text" v-model.trim="subject">
分数:<input type="text" v-model.number="score">
<button @click="add">添加</button>
</div>
<!-- 4. 统计总分、平均分:计算属性 -->
<div class="statistics">
总分:{{ total }} 平均分:{{ avg }}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
list: [
{ id: 1, subject: '语文', score: 100 },
{ id: 2, subject: '数学', score: 90 },
{ id: 3, subject: '英语', score: 80 }
],
subject: '', // 科目(双向绑定)
score: '' // 分数(双向绑定)
},
methods: {
add() {
// 判断数据是否为空,为空不添加
if (!this.subject || this.score === '') return
this.list.unshift({
id: +new Date(),
subject: this.subject,
score: this.score
})
// 添加后清空文本框
this.subject = ''
this.score = ''
},
del(id) {
this.list = this.list.filter(item => item.id !== id)
}
},
computed: {
total() {
return this.list.reduce((sum, item) => sum + item.score, 0)
},
avg() {
return (this.total / this.list.length).toFixed(2)
}
}
})
</script>
</body>
</html>关键行与易错点
v-model.number="score":分数必须转成数字,否则字符串和数字相加会得到拼接结果('100' + 10 = '10010'),总分就错了。@click.prevent="del(item.id)":a标签默认会跳转刷新页面,必须用.prevent阻止默认行为。del中必须重新赋值this.list = this.list.filter(...),filter不改变原数组,直接调用不赋值页面不会更新。:key="item.id":用 id 而不是 index,删除中间行时 index 会错位。- 计算属性
total里reduce第二个参数0是初始值,必须传,否则空数组会报错。 avg用toFixed(2)保留两位小数;数组为空时会出现NaN,实际项目可加if (!this.list.length) return 0判断。
示例二:watch 侦听器简单写法 —— 翻译案例(防抖)
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>翻译案例 - 简单写法</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-size: 18px; }
#app { padding: 10px 20px; }
.query { margin: 10px 0; }
.box { display: flex; }
textarea {
width: 300px; height: 160px; font-size: 18px;
border: 1px solid #dedede; outline: none;
resize: none; padding: 10px;
}
textarea:hover { border: 1px solid #1589f5; }
.transbox {
width: 300px; height: 160px;
background-color: #f0f0f0; padding: 10px; border: none;
}
.input-wrap { position: relative; }
.input-wrap span { position: absolute; right: 15px; bottom: 15px; font-size: 12px; }
</style>
</head>
<body>
<div id="app">
<div class="query">
<span>翻译成的语言:</span>
<select>
<option value="italy">意大利</option>
<option value="english">英语</option>
<option value="german">德语</option>
</select>
</div>
<div class="box">
<div class="input-wrap">
<textarea v-model="obj.words"></textarea>
<span>⌨️文档翻译</span>
</div>
<div class="output-wrap">
<div class="transbox">{{ result }}</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// 接口地址:https://applet-base-api-t.itheima.net/api/translate
// 请求方式:get
// 请求参数:(1)words:需要被翻译的文本(必传)(2)lang:需要被翻译成的语言(可选)默认值-意大利
const app = new Vue({
el: '#app',
data: {
obj: {
words: ''
},
result: '' // 翻译结果
},
watch: {
// 简单写法:方法名就是被监视的数据属性名
'obj.words'(newValue) {
// 防抖:延迟执行 —— 一段时间内没有再次触发,才执行
clearTimeout(this.timer)
this.timer = setTimeout(async () => {
const res = await axios({
url: 'https://applet-base-api-t.itheima.net/api/translate',
params: {
words: newValue
}
})
this.result = res.data.data
console.log(res.data.data)
}, 300)
}
}
})
</script>
</body>
</html>关键行与易错点
watch与data、methods、computed同级,写在 Vue 实例配置对象里。- 侦听对象中的某个属性要写成 字符串
'obj.words'(newValue);侦听普通数据直接写words(newValue)。 - 第一个参数
newValue是变化后的值,第二个oldValue是旧值(一般用不上)。 - 防抖:每次进来先
clearTimeout(this.timer)清掉上一次的定时器,再重新setTimeout,这样连续输入只会发最后一次请求。 await必须写在最近的 async 函数里,即setTimeout(async () => {...})这个箭头函数,不能直接写在外层'obj.words'(newValue)中。this.timer未在data中声明也能用,但为了清晰建议在data里加上timer: null。- 计算属性里不能写异步代码,而 watch 里可以(本案例的 axios 请求就是异步操作)。
示例三:watch 完整写法 —— deep 深度监听 + immediate 立即执行
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>翻译案例 - 完整写法</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; font-size: 18px; }
#app { padding: 10px 20px; }
.query { margin: 10px 0; }
.box { display: flex; }
textarea {
width: 300px; height: 160px; font-size: 18px;
border: 1px solid #dedede; outline: none;
resize: none; padding: 10px;
}
textarea:hover { border: 1px solid #1589f5; }
.transbox {
width: 300px; height: 160px;
background-color: #f0f0f0; padding: 10px; border: none;
}
.input-wrap { position: relative; }
.input-wrap span { position: absolute; right: 15px; bottom: 15px; font-size: 12px; }
</style>
</head>
<body>
<div id="app">
<!-- 原语言 / 目标语言:双向绑定到 obj 的属性上 -->
<div class="query">
<span>翻译成的语言:</span>
<select v-model="obj.lang">
<option value="italy">意大利</option>
<option value="english">英语</option>
<option value="german">德语</option>
</select>
</div>
<div class="box">
<div class="input-wrap">
<textarea v-model="obj.words"></textarea>
<span>⌨️文档翻译</span>
</div>
<div class="output-wrap">
<div class="transbox">{{ result }}</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
const app = new Vue({
el: '#app',
data: {
obj: {
words: '小黑',
lang: 'italy'
},
result: '', // 翻译结果
timer: null // 延时器 id
},
watch: {
// 完整写法:监视整个对象,对象任意属性变化都能监听到
obj: {
deep: true, // 深度监视
immediate: true, // 立刻执行,一进入页面 handler 就执行一次
handler(newValue) {
clearTimeout(this.timer)
this.timer = setTimeout(async () => {
const res = await axios({
url: 'https://applet-base-api-t.itheima.net/api/translate',
params: newValue
})
this.result = res.data.data
console.log(res.data.data)
}, 300)
}
}
}
})
</script>
</body>
</html>关键行与易错点
- 完整写法是对象格式:
数据属性名: { deep, immediate, handler },其中handler是固定名字,写成hander、handlers都不会生效。 deep: true才能监听到对象内部属性的变化;不加deep时,改obj.words不会触发 handler(示例二中能监听到是因为直接写了'obj.words'这个路径)。immediate: true让页面刷新后立刻执行一次 handler;如果words默认是空字符串,加上它会去翻译空内容,所以要么给默认值(如'小黑'),要么不加immediate。params: newValue直接把变化后的整个obj传给 axios,因为接口参数恰好是words和lang,与obj的属性名一致。- 防抖逻辑必须在 handler 内部第一行先
clearTimeout,否则下拉框和输入框连续变化会发出多次无效请求。 - 简单写法与完整写法可以混用,但只监视一个属性用简单写法更省事,监视对象多个属性时完整写法更划算。
常见错误
现象:成绩案例里输入
100,总分却变成字符串拼接。→ 原因:分数输入框只用了v-model,拿到的是字符串。→ 修法:改为v-model.number,添加前再确认分数为数字。现象:点击删除超链接后页面地址变了/刷新了。→ 原因:
a标签默认跳转行为未阻止。→ 修法:事件处理中调用e.preventDefault(),或改用@click.prevent。现象:成绩添加后输入框还有旧内容,或空科目也能加进去。→ 原因:只做了
push/unshift,没有判空和清空。→ 修法:添加前判断科目和分数是否为空,添加成功后把subject、score重置为空。现象:平均分出现一长串小数。→ 原因:直接相除后未处理精度。→ 修法:在计算属性返回值上使用
toFixed(2),并注意返回的是字符串。现象:翻译案例中修改输入框或下拉框,侦听器不触发。→ 原因:监听的是对象整体却用了简单写法,或监听路径写错。→ 修法:监听对象属性用
'obj.words',监听对象内多个属性变化用完整写法加deep: true。现象:页面一刷新没有立即翻译默认内容。→ 原因:完整写法缺少
immediate: true,或拼写错误。→ 修法:确认选项名为immediate,并只在有默认翻译内容时开启。现象:翻译输入过程中连续发很多次请求。→ 原因:每次数据变化都直接发 axios 请求,没有防抖。→ 修法:进入 handler 先
clearTimeout(this.timer),再setTimeout延迟发请求;确保timer可挂到实例上。现象:购物车全选状态不对,或总价把未选中商品也算进去。→ 原因:全选未用
every判断所有checked,统计未先过滤选中项。→ 修法:全选计算属性用every;总价/总数量先filter(item => item.checked)再累加。
练习
成绩案例补强:在现有添加/删除/总分/平均分基础上,加入非空判断、分数
.number、平均分保留两位小数,并验证删除后总分和平均分自动更新。翻译侦听器改造:用
obj: { words, from, to }完成双向绑定,监听对象属性变化重新翻译;加入空值不发请求、清空结果、300ms 防抖。watch 写法排错:给出若干
watch片段,分别判断监听对象属性、监听对象多属性、立即执行、深度监听是否写对,并说明应改成哪种写法。购物车综合:完成渲染、删除、修改数量、全选反选、选中总价/总数量统计,并用
watch深度监听购物车数据持久化到localStorage。computed 与 watch 选型:为“总分/平均分”“全选状态”“输入翻译内容后请求接口”三个场景分别选择
computed或watch,并写出关键代码片段。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 案例-成绩列表删除和添加(19-21) | 07:37 |
| 2 | 案例-成绩列表统计综合和平均分(19-21) | 07:14 |
| 3 | watch侦听器-基本语法(22-23) | 11:06 |
| 4 | watch侦听器-完整写法(24-26) | 17:51 |
| 5 | watch侦听器-实现翻译功能 | 10:41 |
| 6 | 19-17-50-29-237 | 14:56 |
| 7 | 1918-09-21-397 | 08:27 |
小结
- 表单收集数字分数必须优先想到
.number,否则总分统计容易被字符串拼接坑掉。 - 删除类
a标签要处理默认行为,添加类操作要同时做判空、追加和清空。 - 只读计算结果用
computed函数写法即可;需要被赋值修改时才考虑get/set。 - 监听对象单个属性用路径写法,监听对象内多个属性联动用完整写法加
deep。 immediate只解决“刷新后立即执行一次”,不能替代防抖和空值判断。- 翻译请求要加防抖,进入 handler 先清定时器,再延迟发请求。
- 购物车全选用
every,总价和总数量只统计checked为真的项。 - 需要持久化的购物车数据,用
watch深度监听后写入本地存储,避免每个修改点都手写保存。