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-bind、v-on、v-if、v-for 和基础 v-model 之后,已经可以完成简单的 Vue 页面,但真正进入业务开发,还会连续遇到几个问题:按下回车时怎样精准触发搜索?表单提交时怎样阻止页面刷新?复选框为什么有时得到布尔值、有时得到数组?页面样式怎样跟随状态切换?总数量、总金额这类派生结果应该存起来还是实时计算?数据变化后又该怎样执行本地存储、网络请求等副作用?
这些问题分别对应 指令修饰符、表单控件绑定、动态 class/style、计算属性 computed 和侦听器 watch 。它们看起来是分散的语法,实际都围绕同一条主线展开:用响应式状态描述页面,让 Vue 负责把状态变化同步到视图,再在确有必要时处理副作用。
本文使用 Vue 3、组合式 API 与 <script setup>。阅读时不要只记语法,重点关注每段代码中的数据来源、依赖关系和触发时机。
示例会反复使用 ref 和 reactive。二者都用于创建响应式数据: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.prevent 和 v-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 事件修饰符:处理冒泡与默认行为
浏览器事件有两类容易混淆的行为:事件传播 和默认行为。它们可能在同一次点击中发生,但解决的是两个完全不同的问题。
先理解冒泡。假设 p 在 div 内部,二者都监听 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.prevent。prevent 会调用 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.prevent。prevent 让浏览器不跳转,stop 让事件不到达外层 div。由于链接本身没有另外绑定处理函数,最终表现是 页面不跳转,控制台也不打印 onDivClick。
| 点击元素 | 当前元素处理函数 | 外层 onDivClick |
页面跳转 |
|---|---|---|---|
@click.prevent 的链接 |
未绑定 | 执行 | 阻止 |
@click.stop 的 p |
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.5,goods.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 |
选中 option 的 value |
字符串 |
单选框 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 的匹配。select 的 v-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。
| 操作 | isAgree 或 hobby 的变化 |
|---|---|
| 勾选"同意用户协议" | 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>

执行过程可以按一次"点击问答"完整追踪:
- 首次渲染时
currentIndex为0。v-for依次产生四个按钮,只有首页的index也是0,因此首页得到active。 - 用户点击"问答",这一项的
index是2,模板语句currentIndex = index把 ref 内部值改为2。模板能自动处理赋值时的 ref 解包,所以这里不写currentIndex.value。 - Vue 发现响应式值变化,安排组件更新。四个按钮重新计算
index === currentIndex:首页的0 === 2为假,问答的2 === 2为真。 - 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 只更新第一个 p 的 color;如果执行 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 让正文可见,按钮文字是"收起",borderBottomColor 取 transparent,标题与正文连接处不会出现两条重叠边框。
点击按钮后的执行顺序如下:
@click执行visible = !visible,把true改为false。- 模板中的三个位置都依赖
visible,Vue 在同一次更新中重新计算它们。 v-show给正文节点添加display: none,节点仍在 DOM 中,只是暂时不显示。- 三元表达式把按钮文字改为"展开",动态
style把标题底边颜色改为#ccc。 - 再点击一次,
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 收集为依赖。
可以把它的运行过程拆成四步:
- 组件第一次读取
result时,Vue 执行 getter。 - getter 执行过程中读到哪些
ref.value或响应式对象属性,Vue 就记录哪些依赖。 - getter 返回值被缓存;同一轮中再次读取
result,依赖没变就直接复用结果。 - 某个依赖变化后,Vue 先把缓存标记为"已失效"。下一次视图或代码读取
result时才重新执行 getter 并缓存新结果。
因此,"依赖变化会重新计算"并不等于每次随便修改一个响应式变量都会执行 getter。只有 getter 真正读取过的依赖才有关,而且计算属性采用按需求值。
| API 要点 | 说明 |
|---|---|
| 参数 | getter 函数,或包含 get、set 的对象 |
| 返回值 | 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 被写入,应使用包含 get 和 set 的完整写法:
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 |
被侦听的数据源,可以是 ref、reactive 对象、getter 等 |
callback |
数据变化后执行,常用参数是新值和旧值 |
options |
可选配置,例如 deep、immediate |
返回值 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 那一刻取出的普通字符串,无法继续响应后续变化。默认情况下,侦听器创建时不会立刻执行回调,而是在值第一次变化后执行。
假设用户依次输入字母 v、u、e:
| 操作 | 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 变化,同一个回调也会执行。这里侦听的是整个表单对象,所以任意内部字段变化都能触发。
但深度侦听发生嵌套修改时,newValue 和 oldValue 可能指向 同一个对象引用 。例如把 username 从空字符串改为 'admin' 后,控制台展开两个对象时都可能看到当前的 { username: 'admin', password: '' }。这不是侦听失败,而是对象在原位置被修改,Vue 没有自动复制一份历史快照。若业务确实需要修改前后的完整对象,应自行在合适时机复制所需字段。
ref 包裹数组或对象时,直接替换整个 .value 能被观察到;如果要观察数组内部 push、splice 或对象内部字段变化,需要启用 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,防止本地数据被破坏后解析报错;这里保留基础案例的主体结构。
填写并新增一件商品时,执行链路如下:
- 三个
v-model把输入分别写入goodsItem.name、goodsItem.price和goodsItem.num。trim清理名称首尾空白,number尝试把价格、数量转成数字。 - 点击表单内的"新增"按钮本来会触发表单默认提交,
@click.prevent先阻止刷新,再调用add。 add先做非空校验。任一字段为空就弹出提示并return,后面的入列逻辑不会执行。- 校验通过后,
...goodsItem把三个表单字段复制到新对象,Date.now()生成当前案例所需的唯一标识,然后push到goodsList.value。 - 链式赋值把三个表单字段清空,响应式绑定让三个输入框同时清空。
数组 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 推导。prev 和 next 只负责让索引在合法范围内循环。
| 交互 | 状态变化 | 视图结果 |
|---|---|---|
| 点击上一张 | 索引减一,越过开头则回到末尾 | 前一张图片激活 |
| 点击下一张 | 索引加一,越过末尾则回到开头 | 后一张图片激活 |
| 点击指示器 | 索引直接等于指示器索引 | 跳转到指定图片 |
| 鼠标进入 | 清除定时器 | 暂停自动播放 |
| 鼠标离开 | 重新创建定时器 | 恢复自动播放 |
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"><</button>
<button class="btn next" type="button" @click="next">></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 定时器的两个关键边界
next 把 i 加一,到达 images.length 时回到 0;prev 把 i 减一,到达 -1 时跳到 images.length - 1。假设共有三张图,索引循环分别是 0 -> 1 -> 2 -> 0 和 0 -> 2 -> 1 -> 0,永远不会拿一个不存在的下标读取图片。
首次渲染时 i = 0,第一张 li 和第一个 span 都满足 index === i,同时得到 active。CSS 让所有图片默认 opacity: 0,只让 li.active 变为 opacity: 1,所以页面只显示第一张。三秒后定时器调用 next,i 变为 1:第一张和第一个指示器移除 active,第二张和第二个指示器添加 active,过渡样式形成淡入淡出。点击第三个指示器时,模板赋值 i = 2,也会经过同一套更新过程。
鼠标进入 banner 时,mouseenter 调用 stop 清除定时器,当前 i 不再自动变化;鼠标离开时,mouseleave 调用 play,重新每三秒执行一次 next。play 创建新定时器前先调用 stop,避免多次移入移出后同时运行多个定时器,出现轮播越来越快的问题。
组件被卸载后,页面已经不存在,但定时器属于浏览器外部资源,不会自动理解组件消失了,所以用 onUnmounted(stop) 做最后清理。onUnmounted 接收卸载后执行的回调,没有需要使用的业务返回值。
图片列表还应保证非空。若数据来自接口,应在开始自动播放前判断 images.length > 0,否则上一张逻辑会计算出 -1。示例采用本地资源路径,实际项目应把图片放入 public/images,或通过模块导入交给 Vite 处理。
8. 选型复盘与常见错误
8.1 状态、派生值和副作用如何选择
面对一段需求,可以先问三个问题:它是需要独立保存的原始状态吗?它能否完全由其他状态计算出来?它是否要在变化后执行系统外部操作?答案分别对应 ref/reactive、computed 和 watch。
| 需求 | 推荐方案 | 典型例子 |
|---|---|---|
| 保存可被用户或程序直接修改的状态 | 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 根据控件类型在表单状态与视图之间建立联系;动态 class 和 style 把状态转换为视觉表现;computed 用依赖追踪和缓存描述派生数据;watch 则在响应式数据变化后承接本地存储、异步请求等副作用。
把这些能力组合起来后,导航切换只需要一个当前索引,折叠面板只需要一个布尔值,全选状态无需额外手动同步,商品总数和总金额也不必反复赋值。开发时始终坚持 原始状态单一、派生结果计算、外部操作侦听、用户动作函数化,代码的数据流会更稳定,也更容易调试和扩展。