Skip to content

指令(下)、计算属性与侦听器(三) ​

本页对应 指令(下)、计算属性与侦听器,共 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、本地存储注意序列化。

课件图示 ​

课件第26页:总结 小结 watch侦听器的语法有几种? ①简单写法→ 监视简单类型的变化 ②

课件第23页:watch 侦听器(监视器) 作用:监视数据变化,执行一些业务逻辑或异步操作。

课件第25页:watch 侦听器(监视器) ②完整写法→ 添加额外配置项 (1) deep:

课件第28页:综合案例- 水果购物车 需求说明: 1. 渲染功能 2. 删除功能 3. 修改个

课件第29页:总结 总结 业务技术点总结: 1. 渲染功能:v-if/v-else v-for

课件第5页:通过 "." 指明一些指令后缀,不同后缀封装了不同的处理操作 → 简化代码 ①按

![课件第14页:计算属性 data: { list: { id: 1, name: '篮球'

课件第18页:计算属性完整写法 computed: { 计算属性名: { get() { 一段

代码示例 ​

示例一:成绩案例(渲染 / 删除 / 添加 / 计算总分与平均分) ​

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,否则下拉框和输入框连续变化会发出多次无效请求。
  • 简单写法与完整写法可以混用,但只监视一个属性用简单写法更省事,监视对象多个属性时完整写法更划算。

常见错误 ​

  1. 现象:成绩案例里输入 100,总分却变成字符串拼接。→ 原因:分数输入框只用了 v-model,拿到的是字符串。→ 修法:改为 v-model.number,添加前再确认分数为数字。

  2. 现象:点击删除超链接后页面地址变了/刷新了。→ 原因:a 标签默认跳转行为未阻止。→ 修法:事件处理中调用 e.preventDefault(),或改用 @click.prevent。

  3. 现象:成绩添加后输入框还有旧内容,或空科目也能加进去。→ 原因:只做了 push/unshift,没有判空和清空。→ 修法:添加前判断科目和分数是否为空,添加成功后把 subject、score 重置为空。

  4. 现象:平均分出现一长串小数。→ 原因:直接相除后未处理精度。→ 修法:在计算属性返回值上使用 toFixed(2),并注意返回的是字符串。

  5. 现象:翻译案例中修改输入框或下拉框,侦听器不触发。→ 原因:监听的是对象整体却用了简单写法,或监听路径写错。→ 修法:监听对象属性用 'obj.words',监听对象内多个属性变化用完整写法加 deep: true。

  6. 现象:页面一刷新没有立即翻译默认内容。→ 原因:完整写法缺少 immediate: true,或拼写错误。→ 修法:确认选项名为 immediate,并只在有默认翻译内容时开启。

  7. 现象:翻译输入过程中连续发很多次请求。→ 原因:每次数据变化都直接发 axios 请求,没有防抖。→ 修法:进入 handler 先 clearTimeout(this.timer),再 setTimeout 延迟发请求;确保 timer 可挂到实例上。

  8. 现象:购物车全选状态不对,或总价把未选中商品也算进去。→ 原因:全选未用 every 判断所有 checked,统计未先过滤选中项。→ 修法:全选计算属性用 every;总价/总数量先 filter(item => item.checked) 再累加。

练习 ​

  1. 成绩案例补强:在现有添加/删除/总分/平均分基础上,加入非空判断、分数 .number、平均分保留两位小数,并验证删除后总分和平均分自动更新。

  2. 翻译侦听器改造:用 obj: { words, from, to } 完成双向绑定,监听对象属性变化重新翻译;加入空值不发请求、清空结果、300ms 防抖。

  3. watch 写法排错:给出若干 watch 片段,分别判断监听对象属性、监听对象多属性、立即执行、深度监听是否写对,并说明应改成哪种写法。

  4. 购物车综合:完成渲染、删除、修改数量、全选反选、选中总价/总数量统计,并用 watch 深度监听购物车数据持久化到 localStorage。

  5. computed 与 watch 选型:为“总分/平均分”“全选状态”“输入翻译内容后请求接口”三个场景分别选择 computed 或 watch,并写出关键代码片段。

原视频对照 ​

#视频时长
1案例-成绩列表删除和添加(19-21)07:37
2案例-成绩列表统计综合和平均分(19-21)07:14
3watch侦听器-基本语法(22-23)11:06
4watch侦听器-完整写法(24-26)17:51
5watch侦听器-实现翻译功能10:41
619-17-50-29-23714:56
71918-09-21-39708:27

小结 ​

  1. 表单收集数字分数必须优先想到 .number,否则总分统计容易被字符串拼接坑掉。
  2. 删除类 a 标签要处理默认行为,添加类操作要同时做判空、追加和清空。
  3. 只读计算结果用 computed 函数写法即可;需要被赋值修改时才考虑 get/set。
  4. 监听对象单个属性用路径写法,监听对象内多个属性联动用完整写法加 deep。
  5. immediate 只解决“刷新后立即执行一次”,不能替代防抖和空值判断。
  6. 翻译请求要加防抖,进入 handler 先清定时器,再延迟发请求。
  7. 购物车全选用 every,总价和总数量只统计 checked 为真的项。
  8. 需要持久化的购物车数据,用 watch 深度监听后写入本地存储,避免每个修改点都手写保存。