Vue3进阶基础:指令修饰符、v-model表单绑定、动态样式、计算属性与侦听器实战

Vue3进阶基础:指令修饰符、v-model表单绑定、动态样式、计算属性与侦听器实战

  • 前言
  • [1. 指令修饰符:给指令补充行为细节](#1. 指令修饰符:给指令补充行为细节)
    • [1.1 修饰符是什么](#1.1 修饰符是什么)
    • [1.2 按键修饰符:只响应目标按键](#1.2 按键修饰符:只响应目标按键)
    • [1.3 事件修饰符:处理冒泡与默认行为](#1.3 事件修饰符:处理冒泡与默认行为)
    • [1.4 v-model 修饰符:控制同步和类型转换](#1.4 v-model 修饰符:控制同步和类型转换)
  • [2. v-model 与不同表单控件](#2. v-model 与不同表单控件)
    • [2.1 v-model 根据控件类型选择属性](#2.1 v-model 根据控件类型选择属性)
    • [2.2 文本域、下拉框和单选框](#2.2 文本域、下拉框和单选框)
    • [2.3 单个复选框与复选框组](#2.3 单个复选框与复选框组)
  • [3. 动态 class 与 style:让状态驱动样式](#3. 动态 class 与 style:让状态驱动样式)
    • [3.1 class 的三元表达式和对象语法](#3.1 class 的三元表达式和对象语法)
    • [3.2 导航栏切换案例](#3.2 导航栏切换案例)
    • [3.3 style 的对象绑定](#3.3 style 的对象绑定)
    • [3.4 折叠面板案例](#3.4 折叠面板案例)
  • [4. 计算属性 computed:描述派生状态](#4. 计算属性 computed:描述派生状态)
    • [4.1 为什么需要计算属性](#4.1 为什么需要计算属性)
    • [4.2 礼物数量统计案例](#4.2 礼物数量统计案例)
    • [4.3 计算属性与普通函数的区别](#4.3 计算属性与普通函数的区别)
    • [4.4 可写计算属性的 get 与 set](#4.4 可写计算属性的 get 与 set)
    • [4.5 全选、取消全选与反选](#4.5 全选、取消全选与反选)
  • [5. 侦听器 watch:在数据变化后执行副作用](#5. 侦听器 watch:在数据变化后执行副作用)
    • [5.1 watch 的职责、参数与返回值](#5.1 watch 的职责、参数与返回值)
    • [5.2 侦听 ref 简单值](#5.2 侦听 ref 简单值)
    • [5.3 侦听 reactive 对象与深层变化](#5.3 侦听 reactive 对象与深层变化)
  • [6. 综合案例:商品管理与本地持久化](#6. 综合案例:商品管理与本地持久化)
    • [6.1 业务拆解与 API 分工](#6.1 业务拆解与 API 分工)
    • [6.2 完整代码](#6.2 完整代码)
    • [6.3 数据流与执行过程](#6.3 数据流与执行过程)
  • [7. 综合案例:响应式轮播图](#7. 综合案例:响应式轮播图)
    • [7.1 用一个索引驱动图片、指示器和切换按钮](#7.1 用一个索引驱动图片、指示器和切换按钮)
    • [7.2 完整代码](#7.2 完整代码)
    • [7.3 定时器的两个关键边界](#7.3 定时器的两个关键边界)
  • [8. 选型复盘与常见错误](#8. 选型复盘与常见错误)
    • [8.1 状态、派生值和副作用如何选择](#8.1 状态、派生值和副作用如何选择)
    • [8.2 高频问题检查表](#8.2 高频问题检查表)
  • 总结

前言

学会插值表达式、v-bindv-onv-ifv-for 和基础 v-model 之后,已经可以完成简单的 Vue 页面,但真正进入业务开发,还会连续遇到几个问题:按下回车时怎样精准触发搜索?表单提交时怎样阻止页面刷新?复选框为什么有时得到布尔值、有时得到数组?页面样式怎样跟随状态切换?总数量、总金额这类派生结果应该存起来还是实时计算?数据变化后又该怎样执行本地存储、网络请求等副作用?

这些问题分别对应 指令修饰符、表单控件绑定、动态 class/style、计算属性 computed 和侦听器 watch 。它们看起来是分散的语法,实际都围绕同一条主线展开:用响应式状态描述页面,让 Vue 负责把状态变化同步到视图,再在确有必要时处理副作用。

本文使用 Vue 3、组合式 API 与 <script setup>。阅读时不要只记语法,重点关注每段代码中的数据来源、依赖关系和触发时机。

示例会反复使用 refreactive。二者都用于创建响应式数据:ref(initialValue) 接收一个初始值并返回带 .value 的 ref 对象,适合基本类型,也能保存数组或对象;reactive(target) 接收对象或数组并返回响应式代理,适合把一组关联字段放在一起。在 <script setup> 的 JavaScript 中读写 ref 必须使用 .value,因为 JavaScript 需要明确访问 ref 容器里的值;模板编译器会自动解包,所以模板中直接写变量名。

API 参数 返回值 本文中的用途
ref(initialValue) 任意初始值 .value 的响应式 ref 当前索引、关键词、列表
reactive(target) 对象或数组 原对象的响应式代理 商品表单、账号表单

例如 const currentIndex = ref(0) 中,currentIndex 不是数字本身,而是保存数字的响应式容器。脚本里递增要写 currentIndex.value++;模板表达式 index === currentIndex 则由 Vue 自动取出内部的 0 参与比较。

说明: 由于 CSDN 对 Vue 代码块的语法高亮支持存在一定限制,本文中的 Vue 模板代码统一使用 html 类型进行代码块标注,仅用于优化代码高亮显示效果,不影响代码内容及实际运行结果。

1. 指令修饰符:给指令补充行为细节

1.1 修饰符是什么

修饰符是跟在指令后面的点语法,例如 @click.stop@submit.preventv-model.trim。它不是一个独立指令,而是告诉 Vue:在执行原指令时,再附加一项标准化处理。

指令修饰符的价值,是把常见的 DOM 事件判断、事件对象处理和输入值转换直接写进模板,使模板能够更准确地表达交互意图。

如果完全依赖原生 JavaScript,阻止默认行为通常要接收事件对象并调用方法:

javascript 复制代码
const submit = (event) => {
  event.preventDefault()
  // 提交逻辑
}

在 Vue 模板中可以写成:

html 复制代码
<script setup>
const submit = () => {
  console.log('执行提交业务')
}
</script>

<template>
  <form @submit.prevent="submit">
    <button type="submit">提交</button>
  </form>
</template>

点击按钮时,type="submit" 会触发表单的 submit 事件。浏览器原本会提交表单并刷新或跳转页面;Vue 先根据 .prevent 调用 event.preventDefault(),随后执行 submit,因此控制台会打印"执行提交业务",页面却不会刷新。处理函数不再需要为了阻止默认行为而接收 event,事件规则留在模板,submit 只关注业务。

1.2 按键修饰符:只响应目标按键

输入框经常需要"按回车搜索"。如果只监听 keydown,处理函数会被所有按键触发,再由开发者手动判断 event.key。Vue 提供 .enter 把过滤过程放在事件绑定阶段:

html 复制代码
<script setup>
const search = () => {
  console.log('执行搜索')
}
</script>

<template>
  <input type="text" @keydown.enter="search" />
</template>

运行这段代码后,按字母键、方向键或空格键,输入框都会产生 keydown 事件,但 Vue 检查到按键不是 Enter,不会调用 search;按下 Enter 时条件通过,控制台才打印"执行搜索"。因此 .enter 不是让输入框只能输入回车,而是对送往处理函数的键盘事件做过滤。

@keydown.enter 表示 Enter 键按下时 执行 search。也可以改成:

vue 复制代码
<input type="text" @keyup.enter="search" />

此时会在 Enter 键抬起时 触发。假设用户按住 Enter 一秒,keydown 可能因为键盘重复机制连续触发,而 keyup 通常只在最后松开时触发一次。两者没有绝对优劣:keydown 反馈更早,keyup 更适合"一次回车执行一次"的提交或搜索场景。

写法 触发时机 常见场景
@keydown.enter="fn" Enter 按下 需要更快反馈的键盘交互
@keyup.enter="fn" Enter 抬起 搜索、确认、提交

常见错误是把修饰符写到处理函数后面,例如 @keyup="search.enter"。修饰符属于事件指令,必须紧跟事件名写成 @keyup.enter

1.3 事件修饰符:处理冒泡与默认行为

浏览器事件有两类容易混淆的行为:事件传播默认行为。它们可能在同一次点击中发生,但解决的是两个完全不同的问题。

先理解冒泡。假设 pdiv 内部,二者都监听 click

html 复制代码
<script setup>
const onPClick = () => {
  console.log('onPClick')
}

const onDivClick = () => {
  console.log('onDivClick')
}
</script>

<template>
  <div @click="onDivClick">
    <p @click="onPClick">点击文字</p>
  </div>
</template>

用户点击的是 p,所以先执行 onPClick。处理完目标元素后,同一个点击事件会沿着 DOM 层级从 p 向父元素 div 传播,于是 onDivClick 也被执行。控制台顺序是:

text 复制代码
onPClick
onDivClick

第二行并不表示用户又点击了一次 div,而是 p 的点击事件通知了它的父元素。这个从目标元素逐级向祖先元素传播的过程就是 事件冒泡 。如果用户点击 div 内没有被 p 覆盖的空白区域,事件目标一开始就是 div,控制台只会打印 onDivClick

默认行为不是传播。链接的默认行为是跳转到 href 指定的地址,表单的默认行为是提交并可能刷新页面。即使链接没有绑定任何点击函数,浏览器照样会跳转。可以这样区分:

概念 它回答的问题 例子
事件冒泡 当前点击还会不会通知父元素 点击 p 后,父级 div 的点击函数也执行
默认行为 浏览器会不会执行元素自带动作 点击 a 后跳转,提交 form 后刷新

下面的代码与这一节的核心示例保持一致,只把外部网址换成站内占位地址:

html 复制代码
<script setup>
const onPClick = () => {
  console.log('onPClick')
}

const onDivClick = () => {
  console.log('onDivClick')
}
</script>

<template>
  <div class="panel" @click="onDivClick">
    <a href="https://baidu.com" @click.prevent>
      只阻止默认跳转
    </a>

    <p @click.stop="onPClick">
      只阻止事件冒泡
    </p>

    <a href="https://baidu.com" @click.stop.prevent>
      同时阻止冒泡和默认跳转
    </a>
  </div>
</template>

不要一次性扫过这三个修饰符,应该逐个点击观察结果。

第一步,点击"只阻止默认跳转"。 这条链接使用 @click.preventprevent 会调用 event.preventDefault(),因此浏览器不会进入 https://baidu.com。但是它没有阻止事件传播,点击仍会从 a 冒泡到外层 div,所以 onDivClick 会执行,控制台打印:

text 复制代码
onDivClick

这个结果最能说明:.prevent 只管默认行为,不管冒泡。

第二步,点击"只阻止事件冒泡"。 p 使用 @click.stop="onPClick"。当前元素自己的 onPClick 会正常执行,随后 stop 调用 event.stopPropagation(),阻止事件继续传给 div。控制台只有:

text 复制代码
onPClick

stop 不是"禁止点击",更不是"禁止当前函数执行";它只是不让这个已经发生的事件继续向父级传播。p 本身没有链接跳转之类的默认行为,所以这里也不需要 prevent

第三步,点击"同时阻止冒泡和默认跳转"。 这条链接使用 @click.stop.preventprevent 让浏览器不跳转,stop 让事件不到达外层 div。由于链接本身没有另外绑定处理函数,最终表现是 页面不跳转,控制台也不打印 onDivClick

点击元素 当前元素处理函数 外层 onDivClick 页面跳转
@click.prevent 的链接 未绑定 执行 阻止
@click.stopp onPClick 执行 不执行 无默认跳转
@click.stop.prevent 的链接 未绑定 不执行 阻止

三个规则最终可以归纳为:

  • .stop 对应 event.stopPropagation(),阻止事件继续冒泡,但不会取消元素的默认行为。
  • .prevent 对应 event.preventDefault(),取消链接跳转、表单提交等默认行为,但不会阻止冒泡。
  • 修饰符可以链式使用,@click.stop.prevent 同时完成两项处理。

不要为了"保险"给所有事件都加 .stop。例如列表把点击处理统一绑定在父元素上时,本来就需要子元素事件冒泡;滥用 .stop 会让这种事件委托失效。只有父级确实不应该响应当前子元素事件时才阻止。

1.4 v-model 修饰符:控制同步和类型转换

文本输入默认在 input 事件发生时同步数据,而且输入值通常是字符串。v-model 的三个常用修饰符分别解决同步时机、空白和数字类型问题:

html 复制代码
<script setup>
import { reactive } from 'vue'

const goods = reactive({
  name: '',
  price: ''
})
</script>

<template>
  <!-- 测试 .lazy 时启用这一行,并暂时注释下面的 .trim 输入框 -->
  <!-- <input v-model.lazy="goods.name" type="text" placeholder="商品名称" /> -->
  <input v-model.trim="goods.name" type="text" placeholder="商品名称" />
  <input v-model.number="goods.price" type="number" placeholder="商品价格" />
</template>
修饰符 作用 关键细节
.trim 去掉字符串首尾空白 不会删除字符串中间的空格
.number 尝试把输入转换成数字 无法转换时仍可能保留原值,空输入也可能得到空字符串
.lazy 从监听 input 改为监听 change 文本框通常在失焦或确认变更后同步,不再逐字符更新

分别测试时可以观察到具体效果:

  • .trim 输入框中键入首尾带空格的"显示器",goods.name 最终只保留"显示器"。修饰符只去掉开头和结尾的空白,"机械 键盘"中间的空格仍会保留。
  • .number 输入框中键入 12.5goods.price 得到数字 12.5,而不是字符串 "12.5"。这会直接影响后续加法:数字可以正确求和,字符串使用加号可能变成拼接。
  • 测试 .lazy 时,先取消注释带 v-model.lazy 的输入框,并暂时注释 v-model.trim 的输入框。输入字符期间 goods.name 保持旧值;输入框触发 change 后才同步新值。文本框常在失去焦点或按回车确认后触发 change

.number 的关键词是"尝试":Vue 会使用类似 parseFloat() 的规则转换输入;无法解析时保留原值,清空输入时也可能得到空字符串。它并不保证绑定值永远是数字,因此金额、数量进入核心业务前仍应进行合法性校验。.lazy 也不能简单理解为所有控件都只在失焦时更新;准确说法是改用原生 change 事件,而不同控件触发 change 的方式可能不同。

2. v-model 与不同表单控件

2.1 v-model 根据控件类型选择属性

v-model 经常被描述为"双向绑定",但它不是不加区分地绑定 value。Vue 会识别表单控件类型,选择合适的 DOM 属性和事件组合。用户操作控件时,数据被更新;代码修改数据时,控件状态也会重新渲染。

表单控件 主要关联内容 绑定值的典型类型
文本框 input value 字符串
文本域 textarea value 字符串
下拉框 select 选中 optionvalue 字符串
单选框 radio 被选项的 value 字符串或其他绑定值
单个复选框 checkbox checked 布尔值
一组复选框 checkbox 各选项的 value 数组

同样是 v-model,最终操作的可能是 value,也可能是 checked。理解控件与数据类型的对应关系,比死记模板写法更重要。

2.2 文本域、下拉框和单选框

下面用一份表单同时收集个人简介、城市和血型:

html 复制代码
<script setup>
import { ref } from 'vue'

const intro = ref('')
const city = ref('SH')
const blood = ref('ab')
</script>

<template>
  <textarea
    v-model="intro"
    rows="4"
    placeholder="请输入简介"
  ></textarea>

  <select v-model="city">
    <option value="BJ">北京</option>
    <option value="SH">上海</option>
    <option value="SZ">深圳</option>
    <option value="HZ">杭州</option>
  </select>

  <label>
    <input v-model="blood" type="radio" value="a" />
    A
  </label>
  <label>
    <input v-model="blood" type="radio" value="b" />
    B
  </label>
  <label>
    <input v-model="blood" type="radio" value="ab" />
    AB
  </label>
  <label>
    <input v-model="blood" type="radio" value="o" />
    O
  </label>
</template>

代码运行后,不同控件按下面的顺序完成第一次渲染和后续同步:

控件 初始数据 首次渲染结果 用户操作后的数据
textarea intro = '' 文本域为空 输入"前端开发"后,intro 变为该字符串
select city = 'SH' Vue 找到 value="SH",所以上海被选中 选择深圳后,city 变为 'SZ'
radio blood = 'ab' value="ab" 的 AB 单选框被选中 点击 O 后,blood 变为 'o'

这里最关键的不是中文显示文字,而是 value 的匹配。selectv-model 写在 select 元素上,Vue 遍历选项并让数据值与某个 option.value 对应;单选框则让多个 radio 共享同一个 blood,哪个 value 等于 blood,哪个就处于选中状态。

多个单选框要绑定到 同一个响应式变量 ,并为每项设置不同的 value。如果四个单选框分别绑定四个布尔变量,它们就不再是互斥的一组;若忘记写 value,浏览器默认值通常是字符串 on,业务也无法判断用户究竟选中了哪一项。

2.3 单个复选框与复选框组

复选框必须根据业务区分"一个开关"和"多个选项"。单个复选框表达是否同意协议,应该绑定布尔值;多个兴趣选项表达集合,应该绑定数组:

html 复制代码
<script setup>
import { ref } from 'vue'

const isAgree = ref(false)
const hobby = ref(['ZQ', 'PB'])
</script>

<template>
  <label>
    <input v-model="isAgree" type="checkbox" />
    同意用户协议
  </label>

  <fieldset>
    <legend>兴趣爱好</legend>
    <label><input v-model="hobby" type="checkbox" value="LQ" />篮球</label>
    <label><input v-model="hobby" type="checkbox" value="ZQ" />足球</label>
    <label><input v-model="hobby" type="checkbox" value="YMQ" />羽毛球</label>
    <label><input v-model="hobby" type="checkbox" value="PB" />跑步</label>
  </fieldset>
</template>

同样是 type="checkbox",Vue 会根据绑定值类型决定工作模式。

isAgree 初始值是 false,因此协议复选框首次渲染为未选中。用户勾选时,Vue 读取复选框的 checked = true 并把 isAgree 改为 true;取消勾选时再写回 false。这里表达的是一个"是否同意"的开关,不需要 value

hobby 初始值是数组 ['ZQ', 'PB']。Vue 渲染每个复选框时,会检查该项的 value 是否存在于数组:足球的 ZQ 和跑步的 PB 已存在,所以二者默认选中。随后点击篮球,Vue 把 LQ 加入数组;取消足球,Vue 把 ZQ 从数组移除。最终数组记录的不是勾选数量,而是所有被选项的 value

操作 isAgreehobby 的变化
勾选"同意用户协议" false -> true
勾选"篮球" ['ZQ', 'PB'] -> ['ZQ', 'PB', 'LQ']
取消"足球" 数组移除 'ZQ'

常见错误是让复选框组绑定字符串,或者忘记给每个选项设置 value。判断方法很直接:业务结果是"是/否"就用布尔值,结果是"选中了哪些项"就用数组。

3. 动态 class 与 style:让状态驱动样式

3.1 class 的三元表达式和对象语法

静态 class 适合永远存在的基础样式,动态 :class 则决定某个状态类是否出现。最基础的两种写法如下:

html 复制代码
<script setup>
import { ref } from 'vue'

const isActive = ref(true)
</script>

<template>
  <p :class="isActive ? 'active' : ''">三元表达式</p>
  <p :class="{ active: isActive }">对象语法</p>
  <p class="item" :class="{ active: isActive }">静态与动态 class 共存</p>
</template>

<style scoped>
.item {
  padding: 8px;
}

.active {
  color: red;
}
</style>

三元表达式会在两个结果之间选择,适合状态对应两个明确类名的场景;对象语法中,键是类名,值会按真假决定是否添加该类,更适合切换一个或多个状态类。class="item":class="{ active: isActive }" 不会相互覆盖,Vue 会把它们合并为最终类名集合。

代码中的 isActive 初始值是 true。模板渲染时 Vue 自动取出 ref 内部的布尔值,三个表达式都判定为真,因此浏览器最终看到的类名大致如下:

模板写法 isActive = true isActive = false
三元表达式 class="active" 不包含 active
对象语法 class="active" 不包含 active
静态与动态共存 class="item active" class="item"

如果脚本中的某次操作把 isActive.value 改为 false,Vue 会重新计算三处 :class。前两段文字失去 active 后不再显示红色;第三段只移除动态的 active,静态 item 仍保留,所以 padding 不会消失。动态绑定只控制它负责的类名,不会粗暴覆盖整个 class 属性。

写法 含义 适用场景
:class="condition ? 'a' : 'b'" 条件成立用 a,否则用 b 两种样式二选一
:class="{ active: condition }" 条件成立时添加 active 状态类开关
class="item" :class="..." 静态类与动态类合并 基础样式加状态样式

对象键如果包含连字符,需要写成字符串,例如 :class="{ 'is-active': isActive }",否则它不是合法的 JavaScript 属性写法。

3.2 导航栏切换案例

导航高亮的核心不是直接操作每个 DOM,而是保存"当前项索引"。点击导航时修改索引,每一项再根据自己的索引判断是否激活:

html 复制代码
<script setup>
import { ref } from 'vue'

const currentIndex = ref(0)
const navList = [
  { id: 101, name: '首页' },
  { id: 102, name: '课程' },
  { id: 103, name: '问答' },
  { id: 104, name: '关于' }
]
</script>

<template>
  <nav class="nav">
    <button
      v-for="(item, index) in navList"
      :key="item.id"
      type="button"
      :class="{ active: index === currentIndex }"
      @click="currentIndex = index"
    >
      {{ item.name }}
    </button>
  </nav>
</template>

<style scoped>
.nav {
  display: flex;
  gap: 20px;
}

.nav button {
  padding: 10px 4px;
  border: 0;
  border-bottom: 2px solid transparent;
  background: transparent;
  color: #666;
  cursor: pointer;
}

.nav button.active {
  border-bottom-color: #409eff;
  color: #222;
}
</style>

执行过程可以按一次"点击问答"完整追踪:

  1. 首次渲染时 currentIndex0v-for 依次产生四个按钮,只有首页的 index 也是 0,因此首页得到 active
  2. 用户点击"问答",这一项的 index2,模板语句 currentIndex = index 把 ref 内部值改为 2。模板能自动处理赋值时的 ref 解包,所以这里不写 currentIndex.value
  3. Vue 发现响应式值变化,安排组件更新。四个按钮重新计算 index === currentIndex:首页的 0 === 2 为假,问答的 2 === 2 为真。
  4. Vue 只更新有差异的类名:首页移除 active,问答添加 active,CSS 随即显示新的底边和文字颜色。

:key="item.id" 给每个导航项稳定身份,列表重排时 Vue 可以正确复用对应 DOM。整个过程只维护一个 currentIndex,不需要 querySelectorAll、手动遍历类名,也不需要为四个按钮分别保存四个布尔值。

3.3 style 的对象绑定

当样式值本身来自数据时,可以用 :style 绑定对象。对象键是 CSS 属性,值是最终样式值:

html 复制代码
<script setup>
import { reactive, ref } from 'vue'

const textColor = ref('red')
const backgroundColor = ref('pink')
const styleObj = reactive({
  color: 'blue',
  backgroundColor: 'yellowgreen'
})
</script>

<template>
  <p :style="{ color: textColor, backgroundColor }">行内对象</p>
  <p :style="styleObj">响应式样式对象</p>
</template>

模板会自动解包 ref,因此对象中可直接写 textColor,不写 .value。CSS 属性既可以采用 JavaScript 常见的驼峰形式 backgroundColor,也可以写成带引号的短横线形式 'background-color'。样式项较少且只用一次时适合内联对象;样式项较多或需要复用时,应在脚本中抽成对象。

第一次渲染时,第一个 p 得到的实际行内样式相当于 style="color: red; background-color: pink";第二个 p 读取整个 styleObj,得到蓝色文字和黄绿色背景。如果之后执行 textColor.value = 'green',Vue 只更新第一个 pcolor;如果执行 styleObj.background = 'orange' 并不会生效,因为对象里真正的属性名是 backgroundColor,必须修改同一个键。

这里的"对象绑定"不是把对象转成一段随意字符串。Vue 会逐项读取属性并更新 DOM 的 style 对象,因此属性名拼错、值缺少必要单位都会导致对应样式无效。例如宽度通常要写 width: '200px',不能想当然地给所有 CSS 属性传一个无单位字符串。

动态 style 适合数值、颜色等真正由数据计算出的值。若只是切换预定义的一组视觉状态,优先用动态 class,这样 CSS 规则更集中,也更容易维护伪类和媒体查询。

3.4 折叠面板案例

折叠面板同时使用 v-show、动态文本和动态 style。页面只需维护一个 visible

html 复制代码
<script setup>
import { ref } from 'vue'

const visible = ref(true)
</script>

<template>
  <section class="panel">
    <header
      class="panel-title"
      :style="{ borderBottomColor: visible ? 'transparent' : '#ccc' }"
    >
      <h3>面板标题</h3>
      <button type="button" @click="visible = !visible">
        {{ visible ? '收起' : '展开' }}
      </button>
    </header>

    <div v-show="visible" class="panel-content">
      <p>这里是可折叠的正文内容。</p>
    </div>
  </section>
</template>

<style scoped>
.panel {
  width: 400px;
}

.panel-title {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 16px;
  border: 1px solid #ccc;
}

.panel-content {
  padding: 0 16px;
  border: 1px solid #ccc;
}
</style>

初始时 visible = true,所以页面呈现三项一致结果:v-show 让正文可见,按钮文字是"收起",borderBottomColortransparent,标题与正文连接处不会出现两条重叠边框。

点击按钮后的执行顺序如下:

  1. @click 执行 visible = !visible,把 true 改为 false
  2. 模板中的三个位置都依赖 visible,Vue 在同一次更新中重新计算它们。
  3. v-show 给正文节点添加 display: none,节点仍在 DOM 中,只是暂时不显示。
  4. 三元表达式把按钮文字改为"展开",动态 style 把标题底边颜色改为 #ccc
  5. 再点击一次,visible 回到 true,三个结果同时恢复。

这里使用 v-show 是因为折叠面板可能频繁开关。v-show 不会反复创建和销毁正文 DOM,只切换 display;如果改用 v-if,隐藏时节点会被移除,重新展开时再创建。一个状态同时驱动显示、文案和边框,避免三份状态互相不同步。

4. 计算属性 computed:描述派生状态

4.1 为什么需要计算属性

商品总数量、购物车总金额、筛选后的列表、是否全选,都不是独立的原始状态,而是由现有状态计算得到的 派生状态 。如果把派生结果也存进 ref,就必须在每一次原始数据变化后手动同步,很容易出现数据不一致。

计算属性用于声明"某个结果怎样由现有响应式数据推导出来"。依赖变化时 Vue 会让结果失效,并在下次读取时重新计算;依赖不变时则复用缓存。

只读计算属性的基本形式如下:

javascript 复制代码
import { computed } from 'vue'

const result = computed(() => {
  return /* 基于响应式依赖得到的结果 */
})

computed 接收一个 getter 函数,getter 必须返回计算结果;它返回一个 计算属性 ref 。在 JavaScript 中读取 result.value,在模板中直接写 result,因为模板会自动解包。getter 执行期间读取到的响应式数据会被 Vue 收集为依赖。

可以把它的运行过程拆成四步:

  1. 组件第一次读取 result 时,Vue 执行 getter。
  2. getter 执行过程中读到哪些 ref.value 或响应式对象属性,Vue 就记录哪些依赖。
  3. getter 返回值被缓存;同一轮中再次读取 result,依赖没变就直接复用结果。
  4. 某个依赖变化后,Vue 先把缓存标记为"已失效"。下一次视图或代码读取 result 时才重新执行 getter 并缓存新结果。

因此,"依赖变化会重新计算"并不等于每次随便修改一个响应式变量都会执行 getter。只有 getter 真正读取过的依赖才有关,而且计算属性采用按需求值。

API 要点 说明
参数 getter 函数,或包含 getset 的对象
返回值 ComputedRef;只有 getter 时默认只读
重新计算条件 被 getter 读取的响应式依赖发生变化
适用场景 基于已有数据得到新值,且读取结果时不应产生副作用

计算 getter 中不要请求接口、写本地存储或修改它所依赖的数据。getter 应保持"读取输入、返回结果"的纯计算特征,副作用交给事件函数或 watch

4.2 礼物数量统计案例

html 复制代码
<script setup>
import { computed, ref } from 'vue'

const goodsList = ref([
  { id: 1, name: '篮球', num: 1 },
  { id: 2, name: '玩具', num: 3 },
  { id: 3, name: '书籍', num: 2 }
])

const totalNum = computed(() => {
  return goodsList.value.reduce((sum, item) => sum + item.num, 0)
})
</script>

<template>
  <table>
    <thead>
      <tr><th>名称</th><th>数量</th></tr>
    </thead>
    <tbody>
      <tr v-for="item in goodsList" :key="item.id">
        <td>{{ item.name }}</td>
        <td>{{ item.num }}</td>
      </tr>
    </tbody>
  </table>
  <p>礼物总数:{{ totalNum }} 个</p>
</template>

第一次渲染到 {``{ totalNum }} 时,模板读取计算属性,getter 开始执行 reduce。累计过程如下:

遍历项 进入本轮前的 sum 当前 item.num 本轮返回值
篮球 0 1 1
玩具 1 3 4
书籍 4 2 6

reduce 的第二个参数 0 是累计初始值。每轮回调返回的新数字会成为下一轮的 sum,最终 6 成为 totalNum 的值,页面显示"礼物总数:6 个"。

getter 读取了 goodsList.value 以及列表项的 num,这些就是依赖。若执行 goodsList.value[0].num = 2,篮球数量从 1 变成 2,缓存失效,下一次渲染重新累计并显示 7;若只修改一个与列表无关的标题,依赖没有变化,totalNum 会直接复用缓存。

这里不能忘记 return。若使用带花括号的箭头函数却不返回,计算结果就是 undefined。模板中也不应写 totalNum(),因为它是计算属性而不是普通函数。

4.3 计算属性与普通函数的区别

普通函数同样能返回总数量:

javascript 复制代码
const getTotalNum = () => {
  return goodsList.value.reduce((sum, item) => sum + item.num, 0)
}

模板中需要调用 {``{ getTotalNum() }}。只要组件发生更新,模板里的函数调用就可能再次执行;计算属性则会基于响应式依赖缓存结果,依赖不变时重复读取不会重复计算。

假设同一个模板在顶部和底部显示两次总数量。普通函数写成两次 getTotalNum(),渲染时就会执行两次遍历;两处都读取 totalNum 时,第一次读取完成计算,第二次读取直接使用缓存。列表只有三项时差异很小,但列表很大、计算复杂或结果被多处使用时,缓存能避免没有意义的重复工作。

反过来,点击"删除"应该调用普通函数,因为删除是一项由用户主动触发、会修改列表的动作,而不是一个需要被读取的派生值。不要为了追求缓存把所有逻辑都塞进 computed

对比项 计算属性 computed 普通函数
模板使用 {``{ totalNum }} {``{ getTotalNum() }}
JavaScript 使用 totalNum.value getTotalNum()
缓存 有,依赖不变时复用 无,每次调用都执行
核心用途 描述派生数据 封装可主动调用的业务步骤
是否适合点击处理 通常不适合 适合

选择标准不是"哪个语法更短",而是语义:需要一个随依赖变化而变化的值,用计算属性;需要在特定时机执行一段动作,用函数。

4.4 可写计算属性的 get 与 set

只传 getter 创建的是只读计算属性。若计算结果需要配合 v-model 被写入,应使用包含 getset 的完整写法:

html 复制代码
<script setup>
import { computed, ref } from 'vue'

const sourceName = ref('张三')

const uname = computed({
  get() {
    return sourceName.value
  },
  set(val) {
    sourceName.value = val
  }
})
</script>

<template>
  <input v-model="uname" type="text" />
  <p>当前姓名:{{ sourceName }}</p>
</template>

代码首次渲染时,输入框通过 v-model 读取 uname,于是触发 get()get 返回 sourceName.value,所以输入框显示"张三"。

用户把内容改为"李四"时,v-model 试图给 uname 赋新值,于是触发 set('李四')。setter 把新值写回真正的源数据 sourceName.value;源数据变化后,getter 再次求值得到"李四",输入框和下方文字保持一致。完整链路是:

text 复制代码
输入框修改 -> v-model 给 uname 赋值 -> set(val)
-> 修改 sourceName -> get() 重新读取 -> 页面更新
完整写法成员 触发时机 职责
get() 读取计算属性 根据源数据返回结果
set(val) 给计算属性赋值 把新值转换并写回源数据

如果按照演示骨架让 get 永远返回固定字符串,而 set 只写 console.log(val)、不修改任何源数据,确实能观察到 setter 被触发,但输入并不能稳定保存:下一次 getter 仍返回固定字符串。这种代码适合验证调用时机,不是完整业务实现。真正使用时,setter 必须像上例一样修改计算属性所依赖的源数据。

4.5 全选、取消全选与反选

"是否全选"由列表每一项的 done 共同决定,但点击全选框又需要反向修改每一项。这正适合可写计算属性:getter 负责从列表汇总,setter 负责批量回写。

html 复制代码
<script setup>
import { computed, ref } from 'vue'

const planList = ref([
  { id: 12, name: '跑步', done: false },
  { id: 76, name: '看书', done: false },
  { id: 31, name: '编程', done: false },
  { id: 49, name: '观影', done: false }
])

const isAll = computed({
  get() {
    return planList.value.length > 0 &&
      planList.value.every((item) => item.done)
  },
  set(checked) {
    planList.value.forEach((item) => {
      item.done = checked
    })
  }
})

const onToggle = () => {
  planList.value.forEach((item) => {
    item.done = !item.done
  })
}
</script>

<template>
<p>
<span>
<input
v-model="isAll"
type="checkbox"
id="all" />
<label for="all">全选</label>
</span>
<button @click="onToggle">反选</button>
</p>
<ul>
<li
v-for="item in planList"
:key="item.id">
<input
type="checkbox"
v-model="item.done" />
<span :class="{ completed: item.done }">{{ item.name }}</span>
</li>
</ul>
</template>

<style scoped>
.completed {
  color: #999;
  text-decoration: line-through;
}
</style>

every 只有在所有项都满足 item.done 时才返回 true。额外判断 planList.value.length > 0,是因为空数组调用 every 会返回 true,而空列表通常不应显示为"已全选"。

点击全选框时,v-model 把复选框的新状态交给 setter,setter 让每一项与该状态保持一致;单独勾选任务时,列表项变化会使 getter 重新计算;点击反选按钮则逐项取反,之后 isAll 自然得到正确结果。这里不存在需要手动维护的第二份布尔状态。

把三种操作展开看会更清楚:

用户操作 首先修改谁 后续计算结果
勾选顶部"全选" set(true) 把每项 done 改为 true getter 检查后仍返回 true
取消某一个任务 该任务的 item.done 变为 false every 得到 false,顶部全选框取消
点击"反选" onToggle 逐项执行 item.done = !item.done getter 根据反选后的数据重新判断

getter 不负责修改列表,setter 不负责判断是否全选,onToggle 也不直接修改 isAll。三段逻辑各自只做一件事,数据变化后由计算属性自然汇总。

5. 侦听器 watch:在数据变化后执行副作用

5.1 watch 的职责、参数与返回值

计算属性负责"从数据得到数据",watch 负责"数据变化后做一件事"。保存到 localStorage、根据关键词请求接口、记录日志、调用第三方 DOM API,都属于副作用,更适合放进侦听回调。

javascript 复制代码
import { watch } from 'vue'

const stop = watch(source, (newValue, oldValue) => {
  // 数据变化后的操作
}, options)
参数或返回值 含义
source 被侦听的数据源,可以是 refreactive 对象、getter 等
callback 数据变化后执行,常用参数是新值和旧值
options 可选配置,例如 deepimmediate
返回值 stop 停止当前侦听的函数,调用 stop() 后不再触发

创建侦听器时,Vue 先记录 source 当前值。默认没有配置 immediate,所以不会立刻调用回调;等数据真正变化后,Vue 才把变化后的结果作为 newValue、上一次结果作为 oldValue 传入。若配置 { immediate: true },回调会在侦听建立后立即执行一次,此时旧值通常是 undefined。回调没有必须使用的业务返回值,它的重点是执行操作;watch 自身返回的停止函数则用于提前解除侦听。

不要用 watch 维护本可由 computed 得到的结果。 否则会多出一份状态和同步逻辑,增加不一致风险。

5.2 侦听 ref 简单值

html 复制代码
<script setup>
import { ref, watch } from 'vue'

const keyword = ref('')

watch(keyword, (newValue, oldValue) => {
  console.log('旧关键词:', oldValue)
  console.log('新关键词:', newValue)
  // 可在这里发起搜索请求
})
</script>

<template>
  <input v-model="keyword" type="search" placeholder="请输入关键词" />
</template>

source 直接传 keyword,而不是 keyword.value。前者是 Vue 可以持续跟踪的 ref,后者只是调用 watch 那一刻取出的普通字符串,无法继续响应后续变化。默认情况下,侦听器创建时不会立刻执行回调,而是在值第一次变化后执行。

假设用户依次输入字母 vue

操作 oldValue newValue 回调结果
页面刚打开 '' 默认不执行
输入 v '' 'v' 打印一次,可搜索 v
再输入 u 'v' 'vu' 再执行一次
再输入 e 'vu' 'vue' 再执行一次

v-model 先把输入框的新内容同步给 keyword,随后侦听器观察到变化并执行回调。因此回调中的 newValue 已经是最新数据,不需要再从 DOM 输入框手动取值。

若把接口请求直接放进示例,实际项目还应考虑输入防抖和请求竞态;但无论如何,请求是副作用,职责仍属于事件处理或侦听器,而不是计算属性。

5.3 侦听 reactive 对象与深层变化

html 复制代码
<script setup>
import { reactive, watch } from 'vue'

const obj = reactive({
  username: '',
  password: ''
})

watch(obj, (newValue, oldValue) => {
  console.log('表单发生变化:', newValue, oldValue)
})
</script>

<template>
  <input v-model="obj.username" type="text" placeholder="用户名" />
  <input v-model="obj.password" type="password" placeholder="密码" />
</template>

直接侦听 reactive 对象 obj 时,Vue 会深度观察其嵌套属性。用户在用户名框输入字符,v-model 修改 obj.username,回调执行;用户再输入密码,obj.password 变化,同一个回调也会执行。这里侦听的是整个表单对象,所以任意内部字段变化都能触发。

但深度侦听发生嵌套修改时,newValueoldValue 可能指向 同一个对象引用 。例如把 username 从空字符串改为 'admin' 后,控制台展开两个对象时都可能看到当前的 { username: 'admin', password: '' }。这不是侦听失败,而是对象在原位置被修改,Vue 没有自动复制一份历史快照。若业务确实需要修改前后的完整对象,应自行在合适时机复制所需字段。

ref 包裹数组或对象时,直接替换整个 .value 能被观察到;如果要观察数组内部 pushsplice 或对象内部字段变化,需要启用 deep

javascript 复制代码
watch(
  goodsList,
  (newList) => {
    localStorage.setItem('goods', JSON.stringify(newList))
  },
  { deep: true }
)

深度侦听会遍历嵌套数据,数据结构很大时需要关注成本。若只关心某个字段,可以传 getter 缩小范围:

javascript 复制代码
watch(
  () => obj.username,
  (newUsername, oldUsername) => {
    console.log(newUsername, oldUsername)
  }
)

这个 getter 数据源只在执行时读取 obj.username,所以 Vue 只跟踪用户名。修改 obj.password 不会触发回调;修改用户名才会传入两个独立字符串,新旧值也更容易比较。这通常比无差别侦听整个大对象更精准。

需求 推荐数据源写法 是否需要 deep
侦听一个 ref 字符串 watch(keyword, callback)
侦听整个 reactive 对象 watch(obj, callback) 默认深度
侦听 ref 数组内部增删改 watch(list, callback, { deep: true })
只侦听对象某个属性 watch(() => obj.username, callback) 通常不需要

6. 综合案例:商品管理与本地持久化

6.1 业务拆解与 API 分工

商品管理案例把前面的知识串在一起:表单收集商品名称、价格和数量;列表支持新增、删除和空状态;每行计算小计,总区域计算数量与金额;高金额小计动态高亮;列表变化后自动写入浏览器本地存储。

业务需求 实现方式 原因
收集商品表单 reactive + v-model.trim/.number 多字段属于同一表单对象
新增、删除 普通函数 由提交或点击主动触发
总数量、总金额 computed 都是由商品列表派生的数据
高金额标记 动态 :class 类名由当前行小计决定
空列表提示 v-if / v-else 两种结构互斥
自动保存 watch + deep 列表变化后执行本地存储副作用

6.2 完整代码

html 复制代码
<script setup>
import { computed, reactive, ref, watch } from 'vue'

const KEY = 'goodsSystem__key'

const goodsList = ref(
  JSON.parse(localStorage.getItem(KEY)) || [
    { id: 190, name: '笔记本', price: 10, num: 2 },
    { id: 237, name: '签字笔', price: 11, num: 3 },
    { id: 163, name: '文件夹', price: 3, num: 2 }
  ]
)

const goodsItem = reactive({
  name: '',
  price: '',
  num: ''
})

const add = () => {
  if (!goodsItem.name || !goodsItem.price || !goodsItem.num) {
    return window.alert('商品名称、价格和数量不能为空')
  }

  goodsList.value.push({
    ...goodsItem,
    id: Date.now()
  })

  goodsItem.name = goodsItem.price = goodsItem.num = ''
}

const del = (index) => {
  if (window.confirm('确认删除这件商品吗?')) {
    goodsList.value.splice(index, 1)
  }
}

const totalNum = computed(() => {
  return goodsList.value.reduce((sum, item) => sum + item.num, 0)
})

const totalAmount = computed(() => {
  return goodsList.value.reduce((sum, item) => {
    return sum + item.price * item.num
  }, 0)
})

watch(
  goodsList,
  (newVal) => {
    localStorage.setItem(KEY, JSON.stringify(newVal))
  },
  { deep: true }
)
</script>

<template>
  <div class="goods-container">
    <table>
      <thead>
        <tr>
          <th>序号</th>
          <th>名称</th>
          <th>价格</th>
          <th>数量</th>
          <th>小计</th>
          <th>操作</th>
        </tr>
      </thead>

      <tbody v-if="goodsList.length">
        <tr v-for="(item, index) in goodsList" :key="item.id">
          <td>{{ index + 1 }}</td>
          <td>{{ item.name }}</td>
          <td>{{ item.price }}</td>
          <td>{{ item.num }}</td>
          <td :class="{ active: item.price * item.num >= 30 }">
            {{ item.price * item.num }}
          </td>
          <td>
            <a href="#" @click.prevent="del(index)">删除</a>
          </td>
        </tr>
      </tbody>

      <tbody v-else>
        <tr>
          <td class="empty" colspan="6">暂无数据</td>
        </tr>
      </tbody>

      <tfoot>
        <tr>
          <td colspan="3">总数量:{{ totalNum }}</td>
          <td colspan="3">总金额:{{ totalAmount }} 元</td>
        </tr>
      </tfoot>
    </table>

    <form>
      <div class="form-item">
        <label>名称:</label>
        <input
          v-model.trim="goodsItem.name"
          type="text"
          placeholder="请输入名称"
        />
      </div>
      <div class="form-item">
        <label>价格:</label>
        <input
          v-model.number="goodsItem.price"
          type="number"
          placeholder="请输入价格"
        />
      </div>
      <div class="form-item">
        <label>数量:</label>
        <input
          v-model.number="goodsItem.num"
          type="number"
          placeholder="请输入数量"
        />
      </div>
      <div class="form-item">
        <button class="btn-add" @click.prevent="add">新增</button>
      </div>
    </form>
  </div>
</template>

<style scoped>
.goods-container {
  width: 700px;
  margin: 40px auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 10px;
  border: 1px solid #ccc;
  text-align: center;
}

.active {
  color: #d93025;
  font-weight: 700;
}

.empty {
  height: 80px;
  color: #888;
}

form {
  margin-top: 24px;
}

.form-item {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}

.form-item label {
  width: 60px;
  text-align: right;
}
</style>

6.3 数据流与执行过程

页面初始化时,localStorage.getItem(KEY) 先读取上次保存的 JSON 字符串,JSON.parse 把它还原为数组;第一次访问没有缓存时得到 null|| 右侧的默认商品数组生效,随后由 ref 把列表变成响应式数据。实际生产项目还应给 JSON.parse 增加 try...catch,防止本地数据被破坏后解析报错;这里保留基础案例的主体结构。

填写并新增一件商品时,执行链路如下:

  1. 三个 v-model 把输入分别写入 goodsItem.namegoodsItem.pricegoodsItem.numtrim 清理名称首尾空白,number 尝试把价格、数量转成数字。
  2. 点击表单内的"新增"按钮本来会触发表单默认提交,@click.prevent 先阻止刷新,再调用 add
  3. add 先做非空校验。任一字段为空就弹出提示并 return,后面的入列逻辑不会执行。
  4. 校验通过后,...goodsItem 把三个表单字段复制到新对象,Date.now() 生成当前案例所需的唯一标识,然后 pushgoodsList.value
  5. 链式赋值把三个表单字段清空,响应式绑定让三个输入框同时清空。

数组 push 后会连锁产生多项更新:

依赖位置 变化效果
v-for 根据新数组多渲染一行
行小计 item.price * item.num 显示新商品小计
动态 :class 小计大于等于 30 时添加 active,文字变红
totalNum 重新 reduce,更新总数量
totalAmount 重新 reduce,更新总金额
深度 watch 把完整新数组序列化并写入 localStorage

点击"删除"链接时,.prevent 阻止 href="#" 改变地址或让页面跳到顶部,del(index) 在确认后用 splice(index, 1) 删除对应项。删除同样改变数组内部结构,因此计算属性、列表渲染和深度侦听会再次更新。总数量和总金额没有另外存一份可修改状态,也就不会出现"商品已删除,总金额却忘了同步"的问题。

7. 综合案例:响应式轮播图

7.1 用一个索引驱动图片、指示器和切换按钮

轮播图真正需要维护的核心状态只有 当前索引 i 。图片是否可见、哪个指示器高亮,都可以通过 index === i 推导。prevnext 只负责让索引在合法范围内循环。

交互 状态变化 视图结果
点击上一张 索引减一,越过开头则回到末尾 前一张图片激活
点击下一张 索引加一,越过末尾则回到开头 后一张图片激活
点击指示器 索引直接等于指示器索引 跳转到指定图片
鼠标进入 清除定时器 暂停自动播放
鼠标离开 重新创建定时器 恢复自动播放

7.2 完整代码

html 复制代码
<script setup>
import { onUnmounted, ref } from 'vue'

const images = [
  '/images/banner-1.jpg',
  '/images/banner-2.jpg',
  '/images/banner-3.jpg'
]

const i = ref(0)

const prev = () => {
  i.value--
  if (i.value <= -1) {
    i.value = images.length - 1
  }
}

const next = () => {
  i.value++
  if (i.value >= images.length) {
    i.value = 0
  }
}

let timer = null

const stop = () => {
  if (timer !== null) {
    clearInterval(timer)
    timer = null
  }
}

const play = () => {
  stop()
  timer = setInterval(() => {
    next()
  }, 3000)
}

play()
onUnmounted(stop)
</script>

<template>
  <div class="banner" @mouseenter="stop" @mouseleave="play">
    <ul>
      <li
        v-for="(url, index) in images"
        :key="url"
        :class="{ active: index === i }"
      >
        <img :src="url" :alt="`第 ${index + 1} 张轮播图`" />
      </li>
    </ul>

    <div class="indicator">
      <span
        v-for="(n, index) in images.length"
        :key="n"
        :class="{ active: index === i }"
        @click="i = index"
      ></span>
    </div>

    <div class="ctrl">
      <button class="btn prev" type="button" @click="prev">&lt;</button>
      <button class="btn next" type="button" @click="next">&gt;</button>
    </div>
  </div>
</template>

<style scoped>
.banner {
  position: relative;
  width: min(900px, 100%);
  aspect-ratio: 16 / 5;
  overflow: hidden;
}

.banner ul {
  height: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

.banner ul li {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.6s;
  pointer-events: none;
}

.banner ul li.active {
  opacity: 1;
  pointer-events: auto;
}

.banner ul li img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.banner .indicator {
  position: absolute;
  bottom: 16px;
  left: 50%;
  display: flex;
  gap: 8px;
  transform: translateX(-50%);
}

.banner .indicator span {
  width: 28px;
  height: 4px;
  padding: 0;
  border: 0;
  background: rgba(255, 255, 255, 0.55);
  cursor: pointer;
}

.banner .indicator span.active {
  background: #fff;
}

.banner .ctrl {
  position: absolute;
  inset: 50% 12px auto;
  display: flex;
  justify-content: space-between;
  transform: translateY(-50%);
}
</style>

7.3 定时器的两个关键边界

nexti 加一,到达 images.length 时回到 0previ 减一,到达 -1 时跳到 images.length - 1。假设共有三张图,索引循环分别是 0 -> 1 -> 2 -> 00 -> 2 -> 1 -> 0,永远不会拿一个不存在的下标读取图片。

首次渲染时 i = 0,第一张 li 和第一个 span 都满足 index === i,同时得到 active。CSS 让所有图片默认 opacity: 0,只让 li.active 变为 opacity: 1,所以页面只显示第一张。三秒后定时器调用 nexti 变为 1:第一张和第一个指示器移除 active,第二张和第二个指示器添加 active,过渡样式形成淡入淡出。点击第三个指示器时,模板赋值 i = 2,也会经过同一套更新过程。

鼠标进入 banner 时,mouseenter 调用 stop 清除定时器,当前 i 不再自动变化;鼠标离开时,mouseleave 调用 play,重新每三秒执行一次 nextplay 创建新定时器前先调用 stop,避免多次移入移出后同时运行多个定时器,出现轮播越来越快的问题。

组件被卸载后,页面已经不存在,但定时器属于浏览器外部资源,不会自动理解组件消失了,所以用 onUnmounted(stop) 做最后清理。onUnmounted 接收卸载后执行的回调,没有需要使用的业务返回值。

图片列表还应保证非空。若数据来自接口,应在开始自动播放前判断 images.length > 0,否则上一张逻辑会计算出 -1。示例采用本地资源路径,实际项目应把图片放入 public/images,或通过模块导入交给 Vite 处理。

8. 选型复盘与常见错误

8.1 状态、派生值和副作用如何选择

面对一段需求,可以先问三个问题:它是需要独立保存的原始状态吗?它能否完全由其他状态计算出来?它是否要在变化后执行系统外部操作?答案分别对应 ref/reactivecomputedwatch

需求 推荐方案 典型例子
保存可被用户或程序直接修改的状态 ref / reactive 当前索引、表单对象、商品列表
根据已有状态得到新值 computed 总数量、总金额、是否全选
数据变化后执行副作用 watch 本地存储、请求、日志
用户主动触发一段操作 普通函数 + 事件绑定 新增、删除、反选、上一张
根据状态切换预设样式 动态 class 激活项、完成态、高金额
样式值本身由数据产生 动态 style 动态颜色、边框色、尺寸

8.2 高频问题检查表

问题 原因 修正方式
脚本中修改 ref 没有生效 忘记访问 ref 容器的 .value 脚本写 count.value++,模板仍直接写 count
@keyup="fn.enter" 不生效 修饰符位置错误 写成 @keyup.enter="fn"
点击子元素同时触发父级 事件发生冒泡 确认业务后使用 .stop
提交表单后页面刷新 浏览器执行默认提交 form 上使用 @submit.prevent
复选框组得不到数组 初始绑定值不是数组或缺少 value 使用数组并为每项设置 value
模板中写 total() 报错 把计算属性当成函数 {``{ total }}
计算属性得到 undefined getter 没有返回值 明确 return 计算结果
给只读计算属性赋值出现警告 只有 getter,没有 setter 改源数据或提供合理的 get/set
watch(keyword.value, ...) 无法侦听 传入的是普通值 keyword 或 getter
数组内部修改后未持久化 ref 对象未做深度侦听 配置 { deep: true }
深度侦听的新旧对象看起来相同 二者可能是同一引用 需要历史值时自行保存快照
轮播越切越快 重复创建定时器 创建前清理,并在卸载时清理

总结

Vue 的核心不是把所有逻辑都写进模板,而是合理划分职责。修饰符负责给事件和输入同步补充明确规则;v-model 根据控件类型在表单状态与视图之间建立联系;动态 classstyle 把状态转换为视觉表现;computed 用依赖追踪和缓存描述派生数据;watch 则在响应式数据变化后承接本地存储、异步请求等副作用。

把这些能力组合起来后,导航切换只需要一个当前索引,折叠面板只需要一个布尔值,全选状态无需额外手动同步,商品总数和总金额也不必反复赋值。开发时始终坚持 原始状态单一、派生结果计算、外部操作侦听、用户动作函数化,代码的数据流会更稳定,也更容易调试和扩展。

相关推荐
csdn2015_1 小时前
vue 前端运行命令
前端·javascript·vue.js
swipe1 小时前
02|从 `pnpm dev` 到 Spring Boot 启动:后端服务到底怎么跑起来?
前端·后端·全栈
阿懂在掘金2 小时前
做命令式弹窗工具应该考虑什么——我为什么把 vue-layerx 设计成这样
前端·前端框架·函数式编程
太平洋月光2 小时前
AI 快捷指令:Cursor Rules · Commands · Skills
前端·ai编程
用户83134859306982 小时前
Cesium 实现行政区反向遮罩镂空效果(自定义暗色蒙层+矢量/影像底图切换)
vue.js·webgl·cesium
默_笙2 小时前
😁 LLM 不知道的事它不会说"不知道",而是会"编"——RAG 就是让它闭嘴先查资料
前端·javascript
stringwu2 小时前
用 LangGraph 落地飞书 Bug 自动修复 Agent 实践
前端
Revolution612 小时前
变量提升到底提升了什么:为什么 var 是 undefined,let 却直接报错
前端·javascript
labixiong2 小时前
ES2026 落地了什么?两个真上线的特性 + 两个被毙的,一次讲清楚
javascript·ecmascript 6·ecmascript 8