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-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>

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

  1. 首次渲染时 currentIndex 为 0。v-for 依次产生四个按钮,只有首页的 index 也是 0,因此首页得到 active。
  2. 用户点击"问答",这一项的 index 是 2,模板语句 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 只更新第一个 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,标题与正文连接处不会出现两条重叠边框。

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

  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 函数,或包含 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,防止本地数据被破坏后解析报错;这里保留基础案例的主体结构。

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

  1. 三个 v-model 把输入分别写入 goodsItem.name、goodsItem.price 和 goodsItem.num。trim 清理名称首尾空白,number 尝试把价格、数量转成数字。
  2. 点击表单内的"新增"按钮本来会触发表单默认提交,@click.prevent 先阻止刷新,再调用 add。
  3. add 先做非空校验。任一字段为空就弹出提示并 return,后面的入列逻辑不会执行。
  4. 校验通过后,...goodsItem 把三个表单字段复制到新对象,Date.now() 生成当前案例所需的唯一标识,然后 push 到 goodsList.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 推导。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">&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 定时器的两个关键边界

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 则在响应式数据变化后承接本地存储、异步请求等副作用。

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

相关推荐
bmxy小明同学9 分钟前
2026-10-09-token与成本
java·前端·数据库
neoooo38 分钟前
🚀Claude Code 小技巧(二):找回会话,撤销修改
前端·人工智能·后端
今年下半年43 分钟前
【文件上传】记录一次大附件上传的处理方案
前端·文件上传·内存溢出·分段上传
溪语流沙1 小时前
【每天一个CSS | Day21】落地页Hero动画的分镜表
前端·css
neoooo1 小时前
🚀Claude Code 小技巧(一):一键进入危险模式
前端·人工智能·后端
bmxy小明同学2 小时前
2026-10-11-幻觉治理
服务器·前端·javascript
Coder_张三3 小时前
iOS 26 适配
前端·ios·swift
m0_587383003 小时前
无人自助拼豆系统落地开发实战指南 核心逻辑实现与部署经验分享
前端·javascript·vue.js·人工智能·经验分享·需求分析
zhaiyy3 小时前
HTML Popover API 实践
前端·javascript·html
OpsEye3 小时前
企业 AI 管理新需求:不仅要接入模型,还要看懂每一次调用
javascript·ai编程