外观
第2章:让数据驱动页面
约 18236 字大约 61 分钟
Vue 3响应式指令计算属性
2026-07-28
第1章已经把 Vue 工程运行起来了。本章继续使用同一个工程,把只能显示固定文字的页面,逐步改造成可以输入、勾选、筛选、统计和保存的“Vue 学习计划表”。
本章成果
本章将完成“Vue 学习计划表”,并在章末独立完成“实训器材借用清单”。两个案例分别用于验证基础响应式能力和整章综合应用能力。
🎯 学习梯度指引(分层通关)
- 核心必学(保底通关 · 备忘录/计算器):2.1、2.4、2.5、2.6、2.7、2.8 节。重点是掌握
ref响应式数据声明、v-model双向绑定、@click事件、v-bind样式绑定、v-if/v-show条件判断、v-for循环列表以及基础computed计算。学完即可独立完成可增减、可勾选、能自动统计的简易任务备忘录或计算器! - 进阶选学(自主拓展探索):2.2 节的
v-text/v-html、2.3 节的reactive / toRefs响应式底层机制、2.9 节的watch与本地持久化、2.10 节的nextTickDOM 异步时机。供学有余力或想深入探索框架机制的同学自主选学,完全不挂钩任何考核分数。 - 练习分层:每节练习设【必做任务】与【选做挑战🌟】。必做任务面向全员通关;选做挑战完全由学生个人根据兴趣自主拓展,不包含也不挂钩任何分数。
上课前准备
本章示例工程位于:
resources/ch02/classroom-demo/第一次使用时,在终端执行:
cd resources/ch02/classroom-demo
npm install
npm run dev终端出现本地地址后,按住 Command 或 Ctrl 点击地址。在后面的课堂演示中,把代码粘贴到 src/App.vue,保存后浏览器会自动更新。
先确认再继续
如果浏览器没有打开页面,先检查终端是否仍在运行、当前目录是否正确,以及文件是否已经保存。不要在服务没有启动时反复修改代码。
2.1 单文件组件、ref、插值与基础点击
学习目标
掌握 Vue 单文件组件、ref、插值语法和最基本的点击事件,能够点击按钮让一段文字跟着数据变化。
语法/概念
一个 .vue 文件通常有三块:
| 区域 | 简单说明 |
|---|---|
<script setup> | 放数据和做事的函数,相当于页面的“大脑” |
<template> | 放页面上要出现的结构,相当于页面的“骨架” |
<style scoped> | 放当前组件的颜色、间距和外观 |
ref 可以理解为“给数据装一个提醒器”。数据改变后,Vue 会通知使用它的页面位置重新显示。
<script setup>
// 1. 从 vue 库中导入 ref 响应式函数
import { ref } from 'vue'
// 2. 使用 ref 定义响应式数据(相当于给数据装上变动提醒器)
const message = ref('初始内容')
// 3. 定义普通修改函数:在 JS 脚本中操作 ref 必须通过 .value 进行赋值
function changeMessage() {
message.value = '修改后的内容'
}
</script>
<template>
<!-- 4. 在模板中使用插值表达式 {{ }} 直接写变量名,Vue 会自动解包渲染最新值 -->
<p>{{ message }}</p>
</template>在 <script setup> 中修改 ref 要写 .value;在 <template> 中直接写变量名,Vue 会帮我们取出里面的值。
为了让页面上的按钮修改数据,本节先使用最简单的 @click:
<button @click="changeMessage">修改文字</button>它的意思只是“点击按钮时执行 changeMessage”。事件对象和修饰符会在 2.5 节详细学习。
课堂演示
步骤 1:打开组件文件
打开 resources/ch02/classroom-demo/src/App.vue,删除原内容。
保存后浏览器可能暂时显示空白,这是正常现象,因为组件还没有新内容。
步骤 2:粘贴最小示例
课程标题状态卡
src
App.vue
src/App.vue
<script setup>
// 1. 导入响应式 API:ref
import { ref } from 'vue'
// 2. 声明响应式变量:定义课程标题与当前学习状态
const courseTitle = ref('Web 前端框架开发')
const chapterMessage = ref('正在学习响应式状态')
// 3. 点击事件处理函数:更新响应式变量的值
function finishStudy() {
// 注意:在 <script> 中修改 ref 包装的数据,必须通过 .value 进行赋值
chapterMessage.value = '第一个响应式案例已完成'
}
</script>
<template>
<main class="card">
<p class="tag">CHAPTER 02</p>
<!-- 双大括号语法:将响应式数据直接插值渲染到模板中 -->
<h1>{{ courseTitle }}</h1>
<p>{{ chapterMessage }}</p>
<!-- @click 事件绑定:点击按钮触发 finishStudy 函数,进而触发视图响应式自动刷新 -->
<button type="button" @click="finishStudy">完成本案例</button>
</main>
</template>
<style scoped>
/* scoped 作用域:限制样式仅作用于当前组件 */
.card {
width: min(560px, calc(100% - 32px));
margin: 64px auto;
padding: 28px;
border: 1px solid #dce3f0;
border-radius: 18px;
font-family: system-ui, sans-serif;
}
.tag {
color: #3157d5;
font-weight: 800;
}
button {
padding: 10px 16px;
color: white;
border: 0;
border-radius: 8px;
background: #3157d5;
}
</style>保存后应该看到一张课程卡片。点击“完成本案例”,提示文字会从“正在学习响应式状态”变成“第一个响应式案例已完成”。
步骤 3:找到数据变化的证据
finishStudy 只修改了 chapterMessage.value,没有寻找页面上的 <p>。页面文字仍然自动变化,这就是“数据改变,页面跟着改变”的最小证据。
典型易错代码诊断:.value 的遗漏与误用
在初学 ref 响应式 API 时,最常见的语法失误发生在 .value 的使用边界上:
常见错误代码
<script setup>
import { ref } from 'vue'
const count = ref(10)
function increment() {
// 错误:在 <script setup> 中直接操作 ref 包装对象,遗漏 .value
// 会导致控制台抛出 "TypeError: Assignment to constant variable" 或破坏响应式
count = count + 1
}
</script>
<template>
<div>
<!-- 冗余:在模板中不需要手动书写 .value,Vue 会自动解包 -->
<p>当前数值:{{ count.value }}</p>
<button type="button" @click="increment">+1</button>
</div>
</template>规范修正代码
<script setup>
import { ref } from 'vue'
const count = ref(10)
function increment() {
// 正确:在 JavaScript 逻辑区中通过 .value 读写实际数据
count.value = count.value + 1
}
</script>
<template>
<div>
<!-- 正确:在模板中直接书写变量名即可,Vue 自动完成解包渲染 -->
<p>当前数值:{{ count }}</p>
<button type="button" @click="increment">+1</button>
</div>
</template>当堂练习
【必做任务(基础通关)】
- 在
<script setup>中使用ref增加一个studentName状态,初始值写你的真实姓名:const studentName = ref('你的姓名') - 在
<template>的标题下方用插值展示:“学习者:{{ studentName }}”; - 保存后,在浏览器中确认文字正确显示,验证基础响应式插值。
展开查看练习骨架代码提示
<script setup>
import { ref } from 'vue'
const courseTitle = ref('Web 前端框架开发')
const chapterMessage = ref('正在学习响应式状态')
// 1. 声明学生姓名状态
const studentName = ref('你的姓名')
function finishStudy() {
chapterMessage.value = '第一个响应式案例已完成'
}
</script>
<template>
<main class="card">
<p class="tag">CHAPTER 02</p>
<h1>{{ courseTitle }}</h1>
<!-- 2. 插值展示学习者姓名 -->
<p>学习者:{{ studentName }}</p>
<p>{{ chapterMessage }}</p>
<button type="button" @click="finishStudy">完成本案例</button>
</main>
</template>【选做挑战(🌟 自主拓展)】 在页面中再增加一个按钮“修改标题”,点击后将课程标题改成“Vue 3 响应式入门”,并在控制台通过 console.log 打印出修改后的 .value 值。
必做任务预计 10 分钟完成。
2.2 v-text、v-html 与内容安全
学习目标
掌握插值、v-text 和 v-html 的区别,能够安全显示普通文字和经过确认的 HTML。
🌟 进阶选学:模板文本指令与内容安全
在现代 Vue 3 开发中,99% 的文本展示直接使用普通插值 {{ 变量 }} 即可,它会自动对 HTML 标签进行字符转义,既简单又安全。v-text 和 v-html 了解其区别即可,初学阶段重点掌握普通插值,避免直接将不可信内容交给 v-html。
语法/概念
三种写法看起来都能放内容,但处理方式不同:
| 写法 | 页面怎样处理内容 | 适合什么内容 |
|---|---|---|
{{ message }} | 当作普通文字 | 绝大多数文字 |
v-text="message" | 替换整个元素的文字 | 需要明确绑定整个文本时 |
v-html="content" | 当作 HTML 标签解析 | 只允许可信且经过清洗的内容 |
<!-- 1. 普通插值:最常用的安全方式,HTML 标签会被转义为纯文本显示 -->
<p>{{ message }}</p>
<!-- 2. v-text 指令:将元素的整个文本内容替换为对应字符串(功能与插值类似) -->
<p v-text="message"></p>
<!-- 3. v-html 指令:将字符串解析为真实 HTML 标签渲染(仅限 100% 可信内容,谨防 XSS 攻击) -->
<div v-html="trustedContent"></div>不要把用户输入交给 v-html
用户输入、未知接口返回和未经审核的 AI 内容,都不能直接使用 v-html。普通插值会把危险标签当文字显示,通常更安全。
课堂演示
步骤 1:替换 src/App.vue
文字显示安全对比
src
App.vue
src/App.vue
<script setup>
// 1. 模拟不可信的外部输入(带有 HTML 标签的字符串)
const studentInput = '<strong>我想学习 Vue</strong>'
// 2. 开发者自己编写、100% 确认安全的固定提示内容
const trustedNotice = '<strong>安全提示:</strong>优先使用普通插值。'
</script>
<template>
<main class="page">
<h1>内容显示对比</h1>
<section>
<h2>普通插值</h2>
<!-- 安全:双大括号会把 <strong> 转义为纯字符显示在页面上,不会作为标签渲染 -->
<p>{{ studentInput }}</p>
</section>
<section>
<h2>v-text</h2>
<!-- 安全:v-text 同样会将内容作为纯文本替换元素中的文字 -->
<p v-text="studentInput"></p>
</section>
<section>
<h2>已经确认的固定内容</h2>
<!-- 注意:v-html 会将 <strong> 解析为真正的粗体标签进行渲染,切勿用于渲染不可信用户输入 -->
<p v-html="trustedNotice"></p>
</section>
</main>
</template>
<style scoped>
.page {
width: min(680px, calc(100% - 32px));
margin: 48px auto;
font-family: system-ui, sans-serif;
}
section {
display: grid;
gap: 8px;
margin: 16px 0;
padding: 16px;
border: 1px solid #dce3f0;
border-radius: 12px;
}
</style>保存后,前两块会把 <strong> 原样显示成文字;最后一块会把固定的 <strong> 解析成粗体。
步骤 2:修改显示内容
把脚本中的 studentInput 改成 <h1>测试</h1> 并保存。前两块会显示普通标签文字,不会凭空创建一个大标题。
当堂练习
【必做任务(基础通关)】
- 增加常量
studentNotice,值为'<button>领取奖励</button>'; - 在模板中直接使用双花括号插值
{{ studentNotice }}展示; - 保存后在浏览器中确认页面安全地显示为一段纯文本字符,而不会误渲染出真正的可点击按钮。
【选做挑战(🌟 自主拓展)】 尝试在下方增加一行使用 <p v-html="studentNotice"></p> 渲染,观察浏览器真实渲染出可点击按钮的效果,并思考为什么用户输入的内容绝不能直接传给 v-html。
必做任务预计 10 分钟完成。
2.3 reactive、v-model、toRef 与 toRefs
学习目标
掌握 reactive、v-model、toRef 和 toRefs,能够让一组表单字段与预览内容保持同步。
🌟 进阶选学:响应式对象的底层机制(reactive 与 toRefs)
在日常业务开发中,初学者声明数据推荐优先使用 ref(哪怕是对象表单,如 const form = ref({ subject: '', place: '实训室' }) 也能完美支持,而且不用担心解构丢失响应式)。reactive、toRef 与 toRefs 属于更底层的响应式原理机制。本节重点在于体验双向绑定 v-model,解构恢复机制属于拓展探索。
语法/概念
1. reactive 与 ref 的技术选型与适用场景
在 Vue 3 中,让数据具有响应式有两种常见方式:
ref:像把数据放进一个带提醒器的快递盒。在<script>中要通过.value访问;它既能包装简单数字、字符串,也能包装对象。reactive:直接给整个对象套上一个总代理。修改和读取它的属性时不需要写.value,直接form.subject = '...'。
import { reactive } from 'vue'
// 1. reactive 适合集中管理属于同一个业务的对象字段(例如表单)
const form = reactive({
subject: '',
place: '实训室',
})
// 2. 直接修改属性即可触发响应式更新,无需写 .value
form.subject = 'Vue 基础'💡 老师建议:初学阶段建议优先使用
ref(ref({ ... })同样支持对象表单,且不会踩解构陷阱)。遇到既有的reactive对象时,记住它只管整个对象,属性直接读写。
2. v-model:输入框和数据的“双向拉绳”
v-model 是初学阶段最常用的指令。它在表单控件和数据之间拴了一根“双向拉绳”:
- 代码里的数据改了,输入框里的文字自动更新;
- 用户在输入框敲字,代码里的数据也即时同步。
<!-- .trim 修饰符:自动去除用户输入内容的前后多余空格 -->
<input v-model.trim="form.subject" placeholder="请输入课程名称" />
<!-- v-model 双向绑定下拉选择菜单 -->
<select v-model="form.place">
<option>实训室</option>
<option>图书馆</option>
</select>常用表单修饰符:
| 修饰符 | 作用 | 典型生活场景 |
|---|---|---|
.trim | 自动去掉文字首尾的空格 | 用户复制内容时容易带前后空格,提交前自动过滤 |
.number | 将用户输入的内容尝试转为数字 | 年龄、金额、时长等数值输入框 |
.lazy | 失去焦点或按回车时才同步数据 | 长文本输入时减少高频更新 |
3. 避坑重点:解构赋值导致的响应式连接断开
很多同学习惯写 ES6 解构赋值:const { subject } = form。
- 💥 陷阱:解构出来的值会退化为普通的 JavaScript 静态字符串,彻底脱离 Vue 的监控!此后修改输入框,原对象再也不会联动。
- 🛠️ 修复工具
toRef/toRefs:toRef(form, 'subject'):把对象的单个属性单独拎出来,并重新包装成带有响应式连接的ref;toRefs(form):把对象的所有属性一次性批量转化为可解构的ref集合。
import { reactive, toRef, toRefs } from 'vue'
const form = reactive({ subject: '', place: '实训室' })
// 正确解构方式(保持与原响应式对象的数据双向连接):
const subject = toRef(form, 'subject') // 单个属性转换为 ref 保持连接
const { place } = toRefs(form) // 批量解构出所有属性并保持响应式连接4. 典型易错代码诊断:直接重新赋值 reactive 对象导致的代理断裂
常见错误代码
<script setup>
import { reactive } from 'vue'
let userProfile = reactive({
name: '李同学',
grade: '2024级'
})
function resetForm() {
// 错误:直接将普通新对象覆盖赋值给 reactive 变量
// 这会切断原有的 Proxy 代理指针,导致后续修改失去响应性
userProfile = {
name: '重置姓名',
grade: '2024级'
}
}
</script>规范修正代码
<script setup>
import { reactive, ref } from 'vue'
// 方式一:保持 reactive 引用不变,使用 Object.assign 批量更新内部属性
const userProfile = reactive({
name: '李同学',
grade: '2024级'
})
function resetForm() {
Object.assign(userProfile, {
name: '重置姓名',
grade: '2024级'
})
}
// 方式二(初学推荐):若整个表单对象需要频繁整体替换,优先使用 ref 包装对象
// const userProfile = ref({ name: '李同学', grade: '2024级' })
// userProfile.value = { name: '重置姓名', grade: '2024级' }
</script>日常编写主案例时,直接写 form.subject 最直观安全;只有当要把某个字段单独传递给外部时,才需要使用 toRef 或 toRefs。
课堂演示
步骤 1:替换 src/App.vue
学习计划实时预览
src
App.vue
src/App.vue
<script setup>
// 1. 从 vue 导入 reactive 和 toRef
import { reactive, toRef } from 'vue'
// 2. 使用 reactive 声明多字段表单对象
const form = reactive({
subject: '',
place: '实训室',
minutes: 30,
})
// 3. 使用 toRef 将表单中的 subject 属性单独解绑为一个独立 ref,保持响应式同步
const subject = toRef(form, 'subject')
</script>
<template>
<main class="layout">
<!-- 左侧:表单输入区 -->
<form class="card">
<h1>填写学习计划</h1>
<label>
学习科目
<!-- .trim 修饰符:输入时自动剔除首尾空白字符 -->
<input v-model.trim="subject" placeholder="例如:Vue 基础" />
</label>
<label>
学习地点
<!-- 下拉菜单双向绑定:选中项的值会自动同步到 form.place -->
<select v-model="form.place">
<option>实训室</option>
<option>图书馆</option>
<option>宿舍</option>
</select>
</label>
<label>
计划时长
<!-- .number 修饰符:将用户输入的字符串自动转为数字类型 -->
<input v-model.number="form.minutes" type="number" min="10" />
</label>
</form>
<!-- 右侧:实时数据预览区 -->
<article class="card preview">
<p>实时预览</p>
<!-- 使用逻辑或 || 提供内容为空时的默认占位提示 -->
<h2>{{ form.subject || '等待输入科目' }}</h2>
<p>地点:{{ form.place }}</p>
<p>时长:{{ form.minutes }} 分钟</p>
</article>
</main>
</template>
<style scoped>
.layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
width: min(820px, calc(100% - 32px));
margin: 48px auto;
font-family: system-ui, sans-serif;
}
.card {
display: grid;
align-content: start;
gap: 14px;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
}
label {
display: grid;
gap: 6px;
}
input,
select {
padding: 10px;
}
.preview {
background: #f3f5ff;
}
@media (max-width: 640px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>保存后应该看到左侧表单和右侧预览。输入科目、切换地点、修改时长,右侧三项内容会同时更新。
步骤 2:确认 toRef 没有复制第二份数据
输入框绑定的是 subject,预览读取的是 form.subject。输入文字后两边仍然一致,说明 subject 与原对象字段保持着连接,而不是复制了一份新数据。
当堂练习
【必做任务(基础通关)】
- 给
form增加level字段,初始值为'入门'; - 在左侧表单中增加一个难度下拉框,选项为“入门、熟练、挑战”,使用
v-model="form.level"双向绑定; - 在右侧实时预览卡片中增加一行:“难度:
{{ form.level }}”,确认切换下拉框时右侧实时变化。
展开查看练习骨架代码提示
<script setup>
import { reactive, toRef } from 'vue'
const form = reactive({
subject: '',
place: '实训室',
minutes: 30,
// 1. 新增 level 响应式属性
level: '入门',
})
const subject = toRef(form, 'subject')
</script>
<template>
<main class="layout">
<form class="card">
<h1>填写学习计划</h1>
<!-- 2. 增加难度下拉选择框绑定 form.level -->
<label>
学习难度
<select v-model="form.level">
<option>入门</option>
<option>熟练</option>
<option>挑战</option>
</select>
</label>
</form>
<article class="card preview">
<p>实时预览</p>
<h2>{{ form.subject || '等待输入科目' }}</h2>
<!-- 3. 在预览区渲染难度字段 -->
<p>难度:{{ form.level }}</p>
<p>地点:{{ form.place }}</p>
<p>时长:{{ form.minutes }} 分钟</p>
</article>
</main>
</template>【选做挑战(🌟 自主拓展)】 在 <script setup> 中尝试使用 const level = toRef(form, 'level') 解构出字段,并让下拉框改为 v-model="level",体验解构后仍能双向联动的特性。
必做任务预计 10 分钟完成。
2.4 v-bind、class 与 style 绑定
学习目标
掌握 v-bind、动态 class 和动态 style,能够让按钮状态、卡片外观和进度条跟着数据变化。
语法/概念
HTML 的固定属性只能写固定值。需要让属性读取 JavaScript 数据时,使用 v-bind,简写是冒号 :。
<!-- 1. 动态属性绑定:当 canSubmit 为 false 时禁用按钮 -->
<button :disabled="!canSubmit">提交</button>
<!-- 2. 对象语法动态绑定 Class:当 done 为 true 时自动添加 finished 类名 -->
<article :class="{ finished: done }"></article>
<!-- 3. 动态绑定 Style 行内样式:根据数字变量实时计算宽度百分比 -->
<div :style="{ width: progress + '%' }"></div>class 更适合“完成、警告、选中”等有明确含义的状态;style 更适合宽度、位置等连续变化的数字。
典型易错代码诊断:动态属性绑定遗漏冒号前缀
常见错误代码
<template>
<!-- 错误:遗漏冒号简写,disabled 会被当作原生 HTML 纯字符串 "!canSubmit" 处理 -->
<!-- 在 HTML 原生规范中,布尔属性只要存在即代表生效,导致按钮被永久禁用 -->
<button disabled="!canSubmit">提交</button>
</template>规范修正代码
<template>
<!-- 正确:加上冒号 : 作为 v-bind 简写,引号内部作为 JavaScript 表达式计算 -->
<button :disabled="!canSubmit">提交</button>
</template>状态不能只靠颜色
完成状态除了改变颜色,还要显示“已完成”文字或删除线。这样投影颜色不清楚时,仍然能看懂结果。
课堂演示
步骤 1:替换 src/App.vue
学习进度状态卡
src
App.vue
src/App.vue
<script setup>
import { ref } from 'vue'
// 1. 响应式状态:完成标记布尔值与进度百分比数字
const done = ref(false)
const progress = ref(35)
</script>
<template>
<main class="page">
<!-- 数组结合对象语法绑定 Class:'plan-card' 固定存在,'finished' 随着 done 状态动态切换 -->
<article :class="['plan-card', { finished: done }]">
<!-- 三元运算符动态展示状态文本 -->
<p>{{ done ? '已完成' : '进行中' }}</p>
<h1>复习 Vue 模板语法</h1>
<label>
<!-- 复选框双向绑定布尔值 -->
<input v-model="done" type="checkbox" />
标记为已完成
</label>
<label>
学习进度:{{ progress }}%
<!-- 滑块输入控件,使用 .number 确保同步为数字 -->
<input v-model.number="progress" type="range" min="0" max="100" />
</label>
<!-- 进度条轨道 -->
<div class="progress-track">
<!-- 动态 Style 绑定:进度条宽度随 progress 变量实时拉伸 -->
<div class="progress-bar" :style="{ width: progress + '%' }"></div>
</div>
<!-- 动态属性绑定:未完成时禁用提交按钮,完成后解除禁用 -->
<button :disabled="!done" type="button">提交学习记录</button>
</article>
</main>
</template>
<style scoped>
.page {
width: min(600px, calc(100% - 32px));
margin: 56px auto;
font-family: system-ui, sans-serif;
}
.plan-card {
display: grid;
gap: 16px;
padding: 28px;
border: 2px solid #dce3f0;
border-radius: 18px;
}
/* 完成态卡片样式:绿色边框与浅绿背景 */
.plan-card.finished {
border-color: #25936f;
background: #effaf6;
}
/* 完成态文字添加删除线 */
.finished h1 {
text-decoration: line-through;
}
label {
display: grid;
gap: 8px;
}
.progress-track {
height: 12px;
overflow: hidden;
border-radius: 999px;
background: #e5e9f2;
}
.progress-bar {
height: 100%;
background: #3157d5;
}
button {
padding: 10px;
}
</style>保存后拖动进度滑块,蓝色进度条宽度会改变;勾选“已完成”,卡片会出现完成样式和删除线,按钮也会变为可用。
步骤 2:区分三种绑定
:disabled改变按钮能不能使用;:class给卡片增加或移除finished类;:style根据具体百分比改变进度条宽度。
当堂练习
【必做任务(基础通关)】
- 体验动态状态与按钮禁用绑定:根据滑块数字判断,当进度小于 60 时显示“继续努力”,大于或等于 60 时显示“进度良好”;
- 勾选“已完成”时,通过
:disabled="!done"联动解除底部按钮的禁用状态。
展开查看练习骨架代码提示
<script setup>
import { ref } from 'vue'
const done = ref(false)
const progress = ref(35)
</script>
<template>
<main class="page">
<article :class="['plan-card', { finished: done }]">
<!-- 1. 根据进度滑块数值动态展示评价文字 -->
<p>{{ progress < 60 ? '继续努力' : '进度良好' }}</p>
<label>
学习进度:{{ progress }}%
<input v-model.number="progress" type="range" min="0" max="100" />
</label>
<label>
<input v-model="done" type="checkbox" /> 标记为已完成
</label>
<!-- 2. 动态控制禁用状态 -->
<button :disabled="!done" type="button">提交学习记录</button>
</article>
</main>
</template>【选做挑战(🌟 自主拓展)】 当进度达到 100 时,使用 :class="{ perfect: progress === 100 }" 为卡片绑定金黄色高亮边框并显示“全部完成”徽章,要求文字与边框同步变化。
必做任务预计 10 分钟完成。
课时综合演练:学员等级与成长经验卡
本实训综合运用 2.1 ~ 2.4 节所学的 ref、v-model.number、动态 :class、动态 :style 与 :disabled 状态控制,独立完成一个包含交互动画与等级晋升的成长看板:
学员等级与成长经验卡
src
App.vue
src/App.vue
<script setup>
import { ref } from 'vue'
// 1. 核心响应式状态
const exp = ref(30) // 经验值:0 ~ 100
const isVip = ref(false) // 是否晋升为 VIP 勋章状态
const studentName = ref('李同学')
</script>
<template>
<main class="page">
<!-- 综合绑定:固定类名 + 动态对象类名 -->
<article :class="['card', { 'vip-card': isVip }]">
<header class="card-header">
<h2>{{ studentName }} 的成长看板</h2>
<span class="badge">{{ isVip ? '👑 VIP 精英学员' : '🌱 预备学员' }}</span>
</header>
<label class="control-group">
<span>当前经验值:{{ exp }} / 100</span>
<!-- v-model.number 实时拉动数值 -->
<input v-model.number="exp" type="range" min="0" max="100" />
</label>
<!-- 动态 Style 控制进度条填充宽度 -->
<div class="bar-track">
<div class="bar-fill" :style="{ width: exp + '%' }"></div>
</div>
<footer class="card-footer">
<p v-if="exp < 60">状态:基础巩固阶段</p>
<p v-else-if="exp < 100">状态:即将达成满额</p>
<p v-else class="success-text">状态:经验已满额,可解锁勋章!</p>
<!-- 经验满 100 且未晋升时方可点击按钮 -->
<button
:disabled="exp < 100 || isVip"
type="button"
@click="isVip = true"
>
{{ isVip ? '已获得 VIP 勋章' : '点击晋升 VIP 学员' }}
</button>
</footer>
</article>
</main>
</template>
<style scoped>
.page {
width: min(580px, calc(100% - 32px));
margin: 50px auto;
font-family: system-ui, sans-serif;
}
.card {
display: grid;
gap: 18px;
padding: 26px;
border: 2px solid #dce3f0;
border-radius: 18px;
background: #ffffff;
transition: all 0.3s ease;
}
.card.vip-card {
border-color: #f59e0b;
background: #fffbeb;
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.15);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.badge {
padding: 4px 10px;
font-size: 13px;
font-weight: 700;
border-radius: 999px;
background: #eef2f6;
}
.vip-card .badge {
background: #fde68a;
color: #92400e;
}
.bar-track {
height: 14px;
border-radius: 999px;
background: #e5e9f2;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #3157d5;
transition: width 0.2s ease;
}
.vip-card .bar-fill {
background: #f59e0b;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.success-text {
color: #d97706;
font-weight: 700;
}
button {
padding: 8px 16px;
border-radius: 8px;
border: 0;
background: #3157d5;
color: #fff;
cursor: pointer;
}
button:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
.vip-card button:not(:disabled) {
background: #f59e0b;
}
</style>2.5 v-on、事件对象与事件修饰符
学习目标
掌握 v-on、事件对象和常用事件修饰符,能够处理按钮、表单和键盘操作。
语法/概念
v-on 用来说明“用户做了什么以后,调用哪个函数”,简写是 @:
<!-- 1. 基础事件绑定:点击按钮触发 addPlan 函数 -->
<button @click="addPlan">添加</button>
<!-- 2. 事件修饰符 .prevent:阻止原生表单提交引起的页面整页刷新 -->
<form @submit.prevent="addPlan"></form>
<!-- 3. 按键修饰符 .esc:仅当用户按下 Esc 键时触发清空操作 -->
<input @keyup.esc="clearForm" />浏览器触发事件时会带来一个事件对象。需要知道事件类型或当前元素时,可以接收 event;同时传业务数据和事件对象时使用 $event:
<!-- 显式传递当前业务 ID 与原生浏览器事件对象 $event -->
<button @click="removePlan(plan.id, $event)">删除</button>常用修饰符:
| 修饰符 | 简单说明 |
|---|---|
.prevent | 阻止表单提交后刷新页面等默认动作 |
.stop | 不让事件继续向外层冒泡 |
.once | 只响应一次 |
.enter | 只处理回车键 |
.esc | 只处理 Esc 键 |
课堂演示
步骤 1:替换 src/App.vue
新增学习计划事件
src
App.vue
src/App.vue
<script setup>
import { ref } from 'vue'
// 1. 状态定义:计划输入框内容与操作反馈提示语
const planTitle = ref('')
const message = ref('输入内容后提交。')
// 2. 添加计划:包含基础非空校验与添加后清空输入框
function addPlan() {
if (!planTitle.value.trim()) {
message.value = '计划内容不能为空。'
return
}
message.value = `已添加:${planTitle.value.trim()}`
planTitle.value = ''
}
// 3. 按键处理函数:按下 Esc 时清空输入框
function clearForm() {
planTitle.value = ''
message.value = '输入内容已清空。'
}
// 4. 事件对象接收演示:读取原生的 event.type
function showEvent(event) {
message.value = `刚才触发的是 ${event.type} 事件。`
}
</script>
<template>
<main class="card">
<h1>新增学习计划</h1>
<!-- @submit.prevent:拦截默认表单整页刷新行为,交由 addPlan 函数处理 -->
<form @submit.prevent="addPlan">
<label>
计划内容
<!-- @keyup.esc:监听按键抬起,当按下 Esc 键时触发 clearForm 清空文本 -->
<input
v-model="planTitle"
placeholder="例如:复习 v-model"
@keyup.esc="clearForm"
/>
</label>
<button type="submit">添加计划</button>
</form>
<!-- 显式传入 $event 原生事件对象 -->
<button type="button" @click="showEvent($event)">查看事件类型</button>
<p aria-live="polite">{{ message }}</p>
</main>
</template>
<style scoped>
.card {
display: grid;
gap: 16px;
width: min(560px, calc(100% - 32px));
margin: 56px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
form,
label {
display: grid;
gap: 10px;
}
input,
button {
padding: 10px;
}
</style>保存后输入计划并提交,页面不会刷新,提示文字会显示刚添加的内容。输入一半时按 Esc,输入框会被清空。点击“查看事件类型”,页面会显示 click。
步骤 2:验证 .prevent
观察浏览器地址和页面状态。提交表单后页面没有重新加载,说明 .prevent 已经阻止了表单的默认刷新。
当堂练习
【必做任务(基础通关)】
- 反向对比体验:尝试将表单上的
.prevent临时删除(改为<form @submit="addPlan">),点击提交,观察浏览器地址栏变化以及整页白屏刷新的现象;体验完成后重新加回.prevent,亲身理解修饰符的作用; - 快捷键盘操作:在文本输入框上增加键盘事件修饰符
@keyup.enter="addPlan",测试在输入框敲击回车键也能快捷提交内容。
【选做挑战(🌟 自主拓展)】 增加一个“重置提示”按钮,使用修饰符 @click.once 实现“仅生效一次”:第一次点击把提示文字改回“输入内容后提交”,后续再次点击不再触发任何操作。
必做任务预计 10 分钟完成。
2.6 v-if、v-else 与 v-show
学习目标
掌握 v-if、v-else 和 v-show,能够根据状态选择创建内容或只是隐藏内容。
语法/概念
v-if 为假时,这一段页面不会创建;v-show 为假时,元素仍在页面中,只是使用 CSS 隐藏。
| 情况 | 优先选择 |
|---|---|
| 很少切换,内容较多 | v-if |
| 经常展开和收起,内容较轻 | v-show |
<!-- 1. v-if / v-else:条件为真时才创建 DOM 节点,为假时 DOM 节点完全不存在 -->
<p v-if="hasPlan">已经有学习计划</p>
<p v-else>还没有学习计划</p>
<!-- 2. v-show:元素始终保留在 DOM 树中,仅通过 CSS display: none 控制显隐 -->
<aside v-show="showGuide">操作说明</aside>课堂演示
步骤 1:替换 src/App.vue
计划空状态与操作说明
src
App.vue
src/App.vue
<script setup>
import { ref } from 'vue'
// 1. 响应式布尔状态:控制是否已有计划、是否展示操作说明
const hasPlan = ref(false)
const showGuide = ref(true)
</script>
<template>
<main class="card">
<h1>今日学习计划</h1>
<!-- 点击切换计划的存在状态 -->
<button type="button" @click="hasPlan = !hasPlan">
{{ hasPlan ? '删除示例计划' : '添加示例计划' }}
</button>
<!-- v-if / v-else:互斥条件渲染,同一时刻仅渲染其中一个 DOM 分支 -->
<article v-if="hasPlan" class="plan">
<strong>复习 Vue 条件渲染</strong>
<span>地点:实训室</span>
</article>
<p v-else class="empty">还没有计划,请先添加一条。</p>
<label>
<!-- 复选框切换说明显隐 -->
<input v-model="showGuide" type="checkbox" />
显示操作说明
</label>
<!-- v-show:适合高频切换显隐,节点不销毁,仅切换 display: none -->
<aside v-show="showGuide">点击上方按钮可以反复观察两种状态。</aside>
</main>
</template>
<style scoped>
.card {
display: grid;
gap: 14px;
width: min(560px, calc(100% - 32px));
margin: 56px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
.plan,
.empty,
aside {
display: grid;
gap: 6px;
padding: 16px;
border-radius: 10px;
background: #f3f5ff;
}
</style>保存后先看到空状态。点击“添加示例计划”,空状态会被计划卡片替换;反复勾选“显示操作说明”,说明会频繁显示和隐藏。
步骤 2:用开发者工具观察
在浏览器开发者工具的 Elements 面板中查看:v-if 为假时计划节点不存在;v-show 为假时说明节点仍在,只是多了隐藏样式。
当堂练习
【必做任务(基础通关)】
- 增加
progress进度状态(默认50)与数字输入框; - 使用
v-if、v-else-if和v-else实现进度状态分级:- 小于 60 时显示“待推进”;
- 60~84 时显示“推进良好”;
- 85 及以上时显示“接近完成”。
- 在页面中修改数字,确认文字分级即时切换。
【选做挑战(🌟 自主拓展)】 为当前页面的帮助提示增加一个折叠开关,使用 v-show 控制提示内容的显隐,并在浏览器 F12 Elements 中观察它只是被添加了 display: none 样式,节点并未被从 DOM 树移除。
必做任务预计 10 分钟完成。
2.7 v-for 与稳定 key
学习目标
掌握 v-for 和稳定 key,能够根据数组生成可新增、可删除的学习计划列表。
语法/概念
1. 列表渲染:根据数组“复印”页面结构
v-for 可以理解为一台自动化复印机:数组里有几个元素,它就照着模板结构复印出几个 HTML 节点。
<ul>
<!-- plan: 当前遍历的每一项;plans: 数据源数组;:key: 必须绑定唯一稳定的身份标识(如 id) -->
<li v-for="plan in plans" :key="plan.id">
{{ plan.title }}
</li>
</ul>如果需要获取循环的序号,可以使用括号语法:v-for="(plan, index) in plans"。
2. 核心命脉:绑定稳定 key 的底层就地复用机理
Vue 在渲染列表时,为了节省浏览器性能,会尽量就地复用现有的 DOM 节点。:key 就是给每一条数据贴上的唯一身份标签。
💡 老师打个比方(身份证号 vs 排队叫号):
- 数据自带的
plan.id是终身不变的“身份证号”;- 循环生成的
index(0, 1, 2)只是去银行办业务的“排队叫号”。
3. 避坑重点:使用 index 作为 key 引发的就地复用缺陷
想象一个列表里有 3 个计划,每个计划前面都有一个输入框或复选框:
- 如果你写了
:key="index"(0, 1, 2),当你删除了第 1 项(原 0 号); - 原本的第 2 项(原 1 号)就会往前顶上来,变成新的 0 号;
- Vue 按照排队号去复用 DOM 时,就会把原本已经被勾选的状态,错误地保留在新顶上来的 0 号元素上!造成严重的“勾选状态错位、输入框内容串门” Bug。
4. 对比实验:不同 key 策略引发的输入框状态错位现象
通过下方代码可直观复现就地复用缺陷:
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 101, name: '实验项目 A' },
{ id: 102, name: '实验项目 B' },
{ id: 103, name: '实验项目 C' },
])
function removeFirst() {
items.value.shift() // 移除第一项
}
</script>
<template>
<main>
<h2>对比实验:测试就地复用</h2>
<ul>
<!-- 测试要点:故意使用 :key="index",并在输入框输入文字后点击删除 -->
<li v-for="(item, index) in items" :key="index">
<span>{{ item.name }}:</span>
<input placeholder="请输入该项专属备注" />
</li>
</ul>
<button type="button" @click="removeFirst">删除第一项(实验项目 A)</button>
</main>
</template>实验观察:在“实验项目 A”后方的输入框输入“A的备注”,点击删除按钮。此时项目 A 文本被删除了,但“A的备注”却错误地留在了顶上来的“实验项目 B”输入框中。若将
:key="index"改为:key="item.id",错位现象立刻消除。
5. 典型易错代码诊断:v-for 与 v-if 同级混用的优先级陷阱
常见错误代码
<template>
<ul>
<!-- 错误:v-if 与 v-for 书写在同一节点上 -->
<!-- Vue 3 中 v-if 优先级高于 v-for,执行时 item 尚未被 v-for 循环声明,导致变量未定义报错 -->
<li v-for="item in plans" :key="item.id" v-if="item.done">
{{ item.title }}
</li>
</ul>
</template>规范修正代码
<template>
<ul>
<!-- 规范写法一:使用 <template> 容器层实现逻辑解耦 -->
<template v-for="item in plans" :key="item.id">
<li v-if="item.done">
{{ item.title }}
</li>
</template>
<!-- 规范写法二(生产推荐):使用 computed 提前过滤出目标数组,模板直接渲染 -->
<!-- <li v-for="item in finishedPlans" :key="item.id">{{ item.title }}</li> -->
</ul>
</template>稳定 key 的三条黄金法则:
- ✅ 唯一性:同一个列表中,每个节点的
key必须独一无二; - ❌ 绝不用
index下标:只要列表涉及新增、删除或排序,严禁用下标当 key; - ❌ 绝不用随机数:不能写
:key="Math.random()",否则每次页面微小变动都会导致整个列表所有 DOM 被全部销毁重建。
课堂演示
步骤 1:替换 src/App.vue
可增删学习计划列表
src
App.vue
src/App.vue
<script setup>
import { ref } from 'vue'
// 1. 响应式状态:输入框新任务文本与任务计划数组
const newTitle = ref('')
const plans = ref([
{ id: 1, title: '复习 ref', done: true },
{ id: 2, title: '练习 v-for', done: false },
])
// 2. 添加计划:生成自增唯一 ID 并追加到数组末尾
function addPlan() {
const title = newTitle.value.trim()
if (!title) return
plans.value.push({
id: Math.max(0, ...plans.value.map(item => item.id)) + 1,
title,
done: false,
})
newTitle.value = ''
}
// 3. 删除计划:根据唯一稳定的 id 过滤数组
function removePlan(id) {
plans.value = plans.value.filter(item => item.id !== id)
}
</script>
<template>
<main class="card">
<h1>学习计划列表</h1>
<!-- 提交时阻止整页刷新并触发 addPlan -->
<form @submit.prevent="addPlan">
<input v-model.trim="newTitle" placeholder="输入新计划" />
<button type="submit">添加</button>
</form>
<!-- v-if 判断数组是否有数据 -->
<ul v-if="plans.length">
<!-- v-for 列表渲染:必须绑定唯一稳定的 :key="plan.id" -->
<li v-for="plan in plans" :key="plan.id">
<label>
<!-- 复选框双向绑定每条计划的完成状态 -->
<input v-model="plan.done" type="checkbox" />
<span :class="{ done: plan.done }">{{ plan.title }}</span>
</label>
<!-- 将计划唯一的 id 作为参数传递给删除函数 -->
<button type="button" @click="removePlan(plan.id)">删除</button>
</li>
</ul>
<p v-else>当前没有学习计划。</p>
</main>
</template>
<style scoped>
.card {
width: min(640px, calc(100% - 32px));
margin: 48px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
form,
li {
display: flex;
gap: 10px;
}
form input {
flex: 1;
}
input,
button {
padding: 10px;
}
ul {
display: grid;
gap: 10px;
padding: 0;
list-style: none;
}
li {
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #e2e7f0;
border-radius: 10px;
}
.done {
color: #667085;
text-decoration: line-through;
}
</style>保存后应该看到两条计划。新增一条计划后出现第三行;删除其中一条后,其他行仍然对应原来的勾选状态。
步骤 2:检查身份
每条数据都有自己的 id,模板使用 :key="plan.id"。删除时也按 id 查找,不依赖当前位于第几行。
当堂练习
【必做任务(基础通关)】
- 给数组中的每条计划增加
place字段(如'实训室'、'图书馆'); - 在
v-for循环模板中展示为“计划名称 · 学习地点”; - 新增表单增加地点下拉框,练习新增一条计划,并点击删除按钮删除对应计划,确认列表渲染正常。
展开查看练习骨架代码提示
<script setup>
import { ref } from 'vue'
const newTitle = ref('')
const newPlace = ref('实训室')
// 1. 每项数据包含 place 字段
const plans = ref([
{ id: 1, title: '复习 ref', place: '实训室', done: true },
{ id: 2, title: '练习 v-for', place: '图书馆', done: false },
])
function addPlan() {
const title = newTitle.value.trim()
if (!title) return
plans.value.push({
id: Math.max(0, ...plans.value.map(item => item.id)) + 1,
title,
place: newPlace.value,
done: false,
})
newTitle.value = ''
}
function removePlan(id) {
plans.value = plans.value.filter(item => item.id !== id)
}
</script>
<template>
<main class="card">
<h1>学习计划列表</h1>
<form @submit.prevent="addPlan">
<input v-model.trim="newTitle" placeholder="输入新计划" />
<select v-model="newPlace">
<option>实训室</option>
<option>图书馆</option>
</select>
<button type="submit">添加</button>
</form>
<ul v-if="plans.length">
<!-- 2. 展示计划名称与学习地点 -->
<li v-for="plan in plans" :key="plan.id">
<label>
<input v-model="plan.done" type="checkbox" />
<span :class="{ done: plan.done }">{{ plan.title }} · {{ plan.place }}</span>
</label>
<button type="button" @click="removePlan(plan.id)">删除</button>
</li>
</ul>
<p v-else>当前没有学习计划。</p>
</main>
</template>【选做挑战(🌟 自主拓展)】 增加一个“防空校验”:在用户点击添加按钮时,若输入内容为空,阻止向 plans 数组追加新数据,并在输入框下方提示“请输入计划名称”。
必做任务预计 15 分钟完成。
2.8 computed:根据现有数据算结果
学习目标
掌握 computed,能够根据计划数组计算完成数量、完成率和筛选结果。
语法/概念
1. 生活中的比喻:Excel 的“自动求和公式”
想象你在用 Excel 记录消费账单:
- 每一笔账目是原始数据;
- 底部的“总支出”单元格,你绝不会每次手动算好再敲一个死数字上去,而是会填入一个公式:
=SUM(A1:A10)。 - 只要上面的账目有任何变动,总支出自动跳变更新;如果账目没变,总支出就直接显示当前结果(这就是缓存机制)。
Vue 的 computed(计算属性)就是前端代码里的“自动公式”。它根据已有的响应式数据,派生出新的只读结果。
2. 标准语法拆解(务必写出 return)
import { computed } from 'vue'
// 1. 声明一个计算属性 = computed(() => { 必须 return 计算出的新结果 })
const completedCount = computed(() => {
// 2. 依赖收集:函数内部用到的响应式数据发生改变时,Vue 会自动重新计算并缓存新结果
return plans.value.filter(item => item.done).length
})- 在
<script>中使用:它是一个派生出来的 Ref,读取其值需要写completedCount.value; - 在
<template>中使用:和普通变量一样,直接写{{ completedCount }}即可。
3. 计算属性依赖缓存与普通函数调用的性能对比
很多同学会问:“我写个 function getCount() { return ... },然后在模板里写 {{ getCount() }} 不行吗?”
- 核心区别在于“依赖缓存”:
- 如果在模板中直接调用方法
{{ getCount() }},页面上只要有任何不相干的数据变动导致重新渲染,这个方法都会被盲目地重复执行,极度浪费计算性能; computed具有智能缓存:它会牢牢记住依赖的数据(plans)。只要plans没有变,页面刷新 100 次它也是直接拿上次算好的缓存结果,零多余开销!
- 如果在模板中直接调用方法
4. 对比实验:验证计算属性缓存性能与普通方法执行频次
通过下方代码可直观通过浏览器控制台对比二者的调用频次:
<script setup>
import { computed, ref } from 'vue'
const count = ref(1)
const otherText = ref('') // 无关文本输入
// 1. 计算属性:具有依赖缓存
const computedSquare = computed(() => {
console.log('⚡ [计算属性执行]:重新计算平方!')
return count.value * count.value
})
// 2. 普通方法:无缓存机制
function methodSquare() {
console.log('🔥 [普通方法执行]:重新计算平方!')
return count.value * count.value
}
</script>
<template>
<main>
<h2>数字:{{ count }}</h2>
<button type="button" @click="count++">数字 + 1</button>
<p>计算属性结果:{{ computedSquare }}</p>
<p>普通方法结果:{{ methodSquare() }}</p>
<!-- 实验验证点:在下方无关输入框中输入文字,打开 F12 控制台观察日志 -->
<input v-model="otherText" placeholder="在此输入无关文字测试重渲染" />
</main>
</template>实验观察:在输入框中连续打字时,虽然
count没有任何变化,但普通方法methodSquare会随着每次键盘输入被盲目重复调用,导致控制台日志持续刷新;而computedSquare依托依赖缓存保持静默,零多余开销。
5. 典型易错代码诊断:计算属性遗漏 return 与试图手动赋值
错误代码一:遗漏 return
// 错误:计算属性内部没有使用 return 语句返回值
// 导致在模板中渲染时得到的是 undefined
const completedCount = computed(() => {
const total = plans.value.filter(item => item.done).length
})错误代码二:试图手动赋值
const completedCount = computed(() => {
return plans.value.filter(item => item.done).length
})
function updateCount() {
// 错误:计算属性是基于依赖自动派生的只读 Ref,不能手动对其 .value 赋值
// 控制台会输出警告:"Write operation failed: computed value is readonly"
completedCount.value = 5
}规范修正代码
// 正确:必须具有 return,且仅通过修改它所依赖的原始数据(plans)来间接驱动变化
const completedCount = computed(() => {
return plans.value.filter(item => item.done).length
})计算属性的三大铁律:
- ✅ 必须有
return:计算属性的职责就是算出并返回一个值; - ❌ 切勿手动赋值:它是只读的推导结果,不能写
completedCount.value = 10; - ❌ 切勿执行副作用:它只负责纯算账,绝不能在里面发送网络请求、修改其他变量或保存本地缓存。
| 业务需求 | 是否使用 computed | 原因说明 |
|---|---|---|
| 统计已完成任务数量 | 是 | 由 plans 纯推导得出的数字 |
| 根据关键字/状态筛选列表 | 是 | 由列表和筛选条件派生出的新数组 |
| 点击按钮把新任务存入数组 | 否 | 这是用户操作,应该写普通事件函数 |
| 数据变动时把数据备份到浏览器 | 否 | 这是有外部影响的动作,应该使用 watch |
课堂演示
步骤 1:替换 src/App.vue
自动统计与筛选
src
App.vue
src/App.vue
<script setup>
import { computed, ref } from 'vue'
// 1. 原始响应式状态:当前筛选类型与计划数据源
const filter = ref('all')
const plans = ref([
{ id: 1, title: '复习 ref', done: true },
{ id: 2, title: '练习 v-for', done: false },
{ id: 3, title: '理解 computed', done: false },
])
// 2. 计算属性 1:自动统计已完成数量(依赖 plans 数组)
const completedCount = computed(() => {
return plans.value.filter(item => item.done).length
})
// 3. 计算属性 2:自动计算完成百分比(依赖 completedCount 和 plans.length)
const progress = computed(() => {
if (plans.value.length === 0) return 0
return Math.round((completedCount.value / plans.value.length) * 100)
})
// 4. 计算属性 3:根据当前选中的 filter 标签自动过滤可见列表(不破坏原始 plans 数组)
const visiblePlans = computed(() => {
if (filter.value === 'done') {
return plans.value.filter(item => item.done)
}
if (filter.value === 'todo') {
return plans.value.filter(item => !item.done)
}
return plans.value
})
</script>
<template>
<main class="card">
<header>
<h1>学习进度</h1>
<!-- 读取计算属性:模板中无需加 .value,直接插值使用 -->
<strong>{{ progress }}%</strong>
<span>已完成 {{ completedCount }} / {{ plans.length }}</span>
</header>
<!-- 筛选导航:点击修改 filter 响应式变量,驱动 visiblePlans 重新推导 -->
<nav aria-label="筛选学习计划">
<button type="button" @click="filter = 'all'">全部</button>
<button type="button" @click="filter = 'todo'">未完成</button>
<button type="button" @click="filter = 'done'">已完成</button>
</nav>
<!-- 遍历计算属性派生出的可见列表 -->
<ul v-if="visiblePlans.length">
<li v-for="plan in visiblePlans" :key="plan.id">
<label>
<!-- 复选框切换 done 状态,计算属性会自动刷新统计结果 -->
<input v-model="plan.done" type="checkbox" />
{{ plan.title }} · {{ plan.done ? '已完成' : '进行中' }}
</label>
</li>
</ul>
<p v-else>当前筛选下没有计划。</p>
</main>
</template>
<style scoped>
.card {
display: grid;
gap: 16px;
width: min(640px, calc(100% - 32px));
margin: 48px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
header,
nav {
display: flex;
align-items: center;
gap: 12px;
}
header strong {
color: #3157d5;
font-size: 28px;
}
button {
padding: 9px 12px;
}
ul {
display: grid;
gap: 10px;
padding: 0;
list-style: none;
}
</style>保存后勾选任意计划,完成数量和百分比会自动变化。点击三个筛选按钮,列表会显示对应数据;筛选后原数组没有被删除。
步骤 2:分清原始数据和计算结果
plans 与 filter 是原始状态;completedCount、progress 和 visiblePlans 都能从原始状态算出来,所以使用 computed。
当堂练习
【必做任务(基础通关)】
- 使用
computed声明todoCount,自动计算未完成的计划数量:const todoCount = computed(() => plans.value.filter(item => !item.done).length) - 在界面统计区域展示“剩余未完成:
{{ todoCount }}项”; - 勾选任意一项计划,确认未完成数量立刻自动减少,无需手动维护第二份计数变量。
展开查看练习骨架代码提示
<script setup>
import { computed, ref } from 'vue'
const plans = ref([
{ id: 1, title: '复习 ref', done: true },
{ id: 2, title: '练习 v-for', done: false },
{ id: 3, title: '理解 computed', done: false },
])
const completedCount = computed(() => {
return plans.value.filter(item => item.done).length
})
// 1. 声明 todoCount 计算属性:统计未完成计划数量
const todoCount = computed(() => {
return plans.value.filter(item => !item.done).length
})
</script>
<template>
<main class="card">
<h1>学习进度统计</h1>
<p>已完成:{{ completedCount }} 项</p>
<!-- 2. 在模板中插值展示未完成数量 -->
<p>剩余未完成:{{ todoCount }} 项</p>
<ul>
<li v-for="plan in plans" :key="plan.id">
<label>
<input v-model="plan.done" type="checkbox" />
{{ plan.title }}
</label>
</li>
</ul>
</main>
</template>【选做挑战(🌟 自主拓展)】 为计划列表增加一个“只看实训室”筛选按钮,利用计算属性根据当前地点进行过滤,在不破坏原始 plans 数组的前提下呈现过滤后的列表。
必做任务预计 10 分钟完成。
2.9 watch:数据变化后执行保存动作
学习目标
掌握 watch,能够在状态变化后把非敏感课堂数据保存到浏览器。
🌟 进阶选学:数据侦听与本地持久化(watch & localStorage)
在数据驱动开发中,界面的计算展示优先使用 computed。而 watch 专注于“数据变动后执行特定动作”(例如将数据备份存进浏览器的 localStorage 中实现刷新不丢)。本节的数据侦听与本地持久化属于进阶拓展内容,初学者建议先吃透计算属性,学有余力时再深入探索。
语法/概念
1. 生活中的比喻:站岗值守的“保安与行动员”
如果说 computed 是只动脑算账的“军师”,那 watch 就是拿着对讲机的保安(行动员):
- 平时一言不发,两眼死死盯住指定的目标数据;
- 一旦目标数据发生风吹草动(比如草稿内容变了),他立刻拿起对讲机去干具体的脏活累活(例如把数据存进本地存储、发送网络请求或打印日志)。
2. 标准语法三要素(主谓宾结构)
import { watch } from 'vue'
// watch(盯住谁, (变化后的新值, 变化前的旧值) => { 变了之后干什么 })
watch(draft, (newVal, oldVal) => {
console.log('旧内容是:', oldVal)
console.log('新内容变成了:', newVal)
// 执行具体动作:保存到浏览器的本地存储 localStorage
localStorage.setItem('plan-draft', newVal)
})- 参数说明:
- 第 1 个参数:要盯住的目标数据(直接传入 Ref 对象,不需要写
.value); - 第 2 个参数:回调函数。Vue 会自动送入两个参数:变化后的新值
newVal和变化前的旧值oldVal; - 核心特征:不需要
return,它专门用来执行产生外部影响的“副作用”动作。
- 第 1 个参数:要盯住的目标数据(直接传入 Ref 对象,不需要写
3. 深度侦听配置 deep 的必要性与对象内部变更监控
- 监听普通字符串或数字(如
draft)时,直接侦听即可; - 但如果监听的是一个包含很多任务对象的数组或复杂对象(如
plans),当用户只是勾选了内部某一项(plan.done = true)时,数组本身的引用地址并没有变,普通侦听会漏掉这个动作; - 加上
{ deep: true },相当于给保安配了透视眼镜,连数组内部每一层对象属性的细微改动都能精准捕捉:
// 深度侦听:配置 { deep: true },即使只是修改了数组内部某一项的属性,也能被捕捉
watch(plans, (newPlans) => {
savePlans(newPlans)
}, { deep: true })4. localStorage:浏览器里的小抽屉
localStorage 是浏览器提供给网站的本地存储空间:
- 存入:
localStorage.setItem(键名, 字符串内容); - 读取:
localStorage.getItem(键名); - 刷新页面或重新打开浏览器后,存入的数据仍然保留。它只适合保存学习草稿或界面偏好,绝不能保存密码、令牌、身份证号等敏感信息。
5. 典型易错代码诊断:侦听器内部自我修改导致的死循环
常见错误代码
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal) => {
// 错误:在 watch 回调中直接修改正在被监听的变量自身
// 数据变动 -> 触发 watch -> 再次修改数据 -> 再次触发 watch ...
// 最终导致死循环,浏览器抛出 "RangeError: Maximum call stack size exceeded"
count.value = newVal + 1
})规范修正方案
import { ref, watch } from 'vue'
const count = ref(0)
const doubledCount = ref(0)
// 规范方案一:侦听 count,修改外部关联的另一个状态
watch(count, (newVal) => {
doubledCount.value = newVal * 2
})
// 规范方案二(推荐):单纯由变量派生出的新值,直接使用 computed,严禁滥用 watch
// const doubledCount = computed(() => count.value * 2)计算属性 vs 侦听器 终极对比表:
| 维度 | computed(计算属性) | watch(侦听器) |
|---|---|---|
| 形象比喻 | Excel 求和公式 / 自动计算器 | 带对讲机的站岗保安 / 行动员 |
| 核心职责 | 根据已有数据,推导出一个新值并展示 | 数据变动后,去执行一件具体动作 |
| 返回值 | 必须有 return | 不需要 return |
| 缓存特性 | 有缓存(依赖没变就直接读缓存) | 无缓存(变动一次就执行一次动作) |
| 典型场景 | 统计未完成数量、过滤列表、算进度百分比 | 自动保存草稿、表单验证报错、搜索框防抖发请求 |
课堂演示
步骤 1:替换 src/App.vue
自动保存学习草稿
src
App.vue
src/App.vue
<script setup>
import { ref, watch } from 'vue'
// 1. 定义本地存储使用的键名
const STORAGE_KEY = 'vue-ch02-draft'
// 2. 初始化数据:优先从 localStorage 读取历史草稿,若无则使用空字符串兜底
const draft = ref(localStorage.getItem(STORAGE_KEY) || '')
const saveMessage = ref('输入内容后会自动保存。')
// 3. 侦听器:当 draft 发生变动时,自动将最新草稿持久化到 localStorage
watch(draft, (value) => {
localStorage.setItem(STORAGE_KEY, value)
saveMessage.value = '草稿已保存。'
})
</script>
<template>
<main class="card">
<h1>学习计划草稿</h1>
<!-- 文本域双向绑定草稿数据 -->
<textarea
v-model="draft"
rows="6"
placeholder="写下今天准备完成的内容"
></textarea>
<!-- 无障碍提示信息展示保存反馈 -->
<p aria-live="polite">{{ saveMessage }}</p>
</main>
</template>
<style scoped>
.card {
display: grid;
gap: 14px;
width: min(600px, calc(100% - 32px));
margin: 56px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
textarea {
padding: 12px;
font: inherit;
}
</style>保存后在文本框中输入一段内容,看到“草稿已保存”。刷新浏览器,刚才的内容仍然存在。
步骤 2:检查浏览器存储
打开开发者工具的 Application(应用)面板,找到 Local Storage,可以看到键名 vue-ch02-draft 和对应内容。
解析数组时要防止白屏
数组通常使用 JSON.stringify 保存、JSON.parse 恢复。外部数据可能损坏,因此恢复时应使用 try...catch。完整学习计划表会演示这一步。
当堂练习
【必做任务(基础通关)】
- 在组件中定义响应式变量
draft与message; - 使用
watch监听draft的变化:每当内容变动时,更新提示文字为“草稿内容已侦听到变动”; - 在页面中输入字符,确认提示文字实时响应更新。
展开查看练习骨架代码提示
<script setup>
import { ref, watch } from 'vue'
const draft = ref('')
const message = ref('等待输入草稿...')
// 1. 侦听草稿变动
watch(draft, (newVal) => {
message.value = '草稿内容已侦听到变动'
})
</script>
<template>
<main class="card">
<h1>草稿侦听练习</h1>
<textarea v-model="draft" placeholder="输入任意内容观察侦听效果"></textarea>
<!-- 2. 展示侦听状态提示 -->
<p>{{ message }}</p>
</main>
</template>【选做挑战(🌟 自主拓展)】 增加一个“清空草稿”按钮,点击后将输入内容与 localStorage 中的缓存一并清空,并在界面提示“草稿已清空”,刷新页面确认历史记录不再恢复。
必做任务预计 10 分钟完成。
2.10 nextTick:等待页面完成本轮更新
学习目标
掌握 Vue 批量更新页面的特点和 nextTick 的基础用法,能够在页面更新完成后读取新的 DOM 结果。
🌟 进阶选学:DOM 异步更新与时机控制(nextTick)
在数据驱动模式下,绝大多数业务直接操作响应式数据即可(例如要展示计划数量直接使用 plans.length,界面会自动更新),不需要手动去获取真实 DOM 元素。nextTick 仅用于极少数“修改数据后必须立刻读取真实 DOM 最新高度、尺寸或行数”的高阶场景,作为拓展选学即可。
语法/概念
1. 核心机制:Vue 的“异步批处理更新”
当你在代码里写下:
count.value = 100很多同学以为:页面上的数字在这一毫秒内立刻就画好了。
实际上并不是!
Vue 为了保证性能,采用了异步批处理策略:它就像一位聪明的快递员,绝不会因为你每买一件小物品就立刻单独跑一趟送货,而是把同一瞬间的所有包裹(所有的响应式数据变动)先统一攒起来,然后在下一轮时机(微任务队列)一次性合并渲染更新页面。这样可以彻底避免浏览器频繁重排重绘导致的卡顿。
2. 最经典的实战痛点:数据变动后即时读取 DOM 失败的根因分析
最典型的场景就是“点击按钮显示输入框,并希望输入框立刻自动获得焦点(.focus())”:
const showInput = ref(false)
const inputRef = ref(null)
function handleClick() {
showInput.value = true // 1. 修改数据,要求显示输入框
// 💥 报错!此时 Vue 仅仅是把数据变了,DOM 树中还没来得及长出 input 节点
// inputRef.value 此时还是 null,调用 focus() 会直接报错抛异常!
inputRef.value.focus()
}3. nextTick:等 DOM 真正长出来后再行动
nextTick 的作用非常纯粹:“等待 Vue 把本轮积攒的 DOM 更新彻底渲染到页面中”。
import { nextTick } from 'vue'
async function handleClick() {
showInput.value = true // 1. 修改数据
await nextTick() // 2. 暂停等待,等 Vue 把真实 DOM 节点插入页面
inputRef.value.focus() // 3. 此时 DOM 已经就绪,安全调用 focus()!
}⚠️ 铁律与使用边界:
- 99% 的日常数据驱动开发中(例如用
{{ plans.length }}展示条数),Vue 会自动完成界面同步,完全不需要写nextTick; - 只有极少数必须在改动数据后立刻操作真实 DOM 物理属性(例如:聚焦输入框
.focus()、读取容器实际滚动高度scrollHeight、初始化第三方图表)的高阶场景下,才需要请出nextTick。
课堂演示
步骤 1:替换 src/App.vue
新增后读取列表行数
src
App.vue
src/App.vue
<script setup>
// 1. 从 vue 导入 nextTick 和 ref
import { nextTick, ref } from 'vue'
// 2. 数据状态与 DOM 元素引用
const plans = ref([{ id: 1, title: '复习响应式' }])
const listElement = ref(null) // 绑定模板中 ul 元素的 ref
const message = ref('当前页面有 1 条计划。')
// 3. 异步新增函数:演示修改数据后等待 DOM 渲染完成
async function addPlan() {
// 向响应式数组追加新数据
plans.value.push({
id: plans.value.length + 1,
title: `新计划 ${plans.value.length + 1}`,
})
// 关键:await nextTick() 暂停等待,直到 Vue 将新 li 节点真实插入 DOM 树
await nextTick()
// 此时读取真实 DOM 子节点数量,能够准确拿到最新的行数
message.value = `页面现在有 ${listElement.value.children.length} 条计划。`
}
</script>
<template>
<main class="card">
<h1>页面更新时间</h1>
<!-- 点击触发异步新增函数 -->
<button type="button" @click="addPlan">新增计划</button>
<p>{{ message }}</p>
<!-- ref="listElement":建立模板元素与 script 中同名 ref 变量的绑定 -->
<ul ref="listElement">
<li v-for="plan in plans" :key="plan.id">{{ plan.title }}</li>
</ul>
</main>
</template>
<style scoped>
.card {
width: min(560px, calc(100% - 32px));
margin: 56px auto;
padding: 24px;
border: 1px solid #dce3f0;
border-radius: 16px;
font-family: system-ui, sans-serif;
}
button {
padding: 10px 14px;
}
</style>保存后连续点击“新增计划”,列表和提示中的数量始终一致。await nextTick() 后读取到的是本轮更新完成后的列表行数。
步骤 2:理解使用边界
如果只需要显示 plans.length,模板直接写 {{ plans.length }} 即可,不需要 nextTick。本例使用它,是为了演示“必须读取新 DOM”这一种特殊情况。
当堂练习
【必做任务(基础通关)】
- 体验数据驱动的天然响应性:直接在模板中使用
{{ plans.length }}展示计划总条数; - 连续点击“新增计划”按钮,观察界面上的数量无需手动查找 DOM 元素即刻同步更新最新数字。
【选做挑战(🌟 自主拓展)】 尝试结合 await nextTick(),在新增后获取列表中最后一个 <li> 的文本内容并提示在页面上,体验 DOM 异步微任务更新完成的时机。
必做任务预计 10 分钟完成。
2.11 串联案例:完成“Vue 学习计划表”
学习目标
掌握本章知识的组合方式,能够完成一张可新增、勾选、筛选、统计并自动保存的学习计划表。
语法/概念
完整案例只有一份业务真源:
plans保存学习计划;form保存正在填写的表单;filter保存当前筛选条件;computed负责统计和筛选,不复制第二份结果;watch负责保存,不负责计算;- 指令把这些状态连接到页面。
交互演示
一条学习计划如何进入页面
从表单输入到列表与样式更新,观察各条指令的职责。
- 1读取输入v-model
- 2提交动作@submit.prevent
- 3更新列表ref + push
- 4渲染身份v-for + key
- 5状态样式:class
当前发生
输入框的值与响应式表单字段保持同步,不需要手工读取 DOM。
观察证据
Vue Devtools 中 form 字段跟随输入变化。
课堂演示
案例阶梯式实现路径
本案例综合运用了响应式数据、表单双向绑定、列表渲染、计算属性和数据侦听,建议在编码实训时遵循“由简入深、分步验证”的递进路线:
阶段一:数据源与基础表单新增
- 建立响应式表单对象
form与计划数组plans; - 使用
v-model绑定输入框,编写addPlan函数; - 使用
v-for渲染列表,绑定稳定:key="plan.id",验证能否正常新增与删除。
- 建立响应式表单对象
阶段二:计算属性融入与条件筛选
- 引入
computed声明completedCount与progress百分比; - 声明
visiblePlans计算属性,根据当前filter('all' / 'done' / 'todo')动态派生出可见列表; - 将模板中
v-for的遍历源从plans切换为visiblePlans,验证筛选按钮与数据统计联动。
- 引入
阶段三:数据持久化与体验优化
- 使用
watch深度侦听plans,配置{ deep: true },在数据变动时同步写入localStorage; - 编写初始恢复逻辑并加入
try...catch防御异常数据; - 在新增计划后调用
await nextTick(),实现更新后读取最新行数,完成全功能闭环。
- 使用
步骤 1:恢复完整课堂工程
如果前面一直在替换 src/App.vue,把下面的完整代码粘贴进去;也可以直接使用仓库中的课堂工程。
Vue 学习计划表完整案例
src
App.vue
src/App.vue
<script setup>
// 1. 导入 Vue 核心组合式 API
import { computed, nextTick, reactive, ref, watch } from 'vue'
// 2. 本地持久化缓存的键名
const STORAGE_KEY = 'vue-ch02-learning-plans'
// 3. 表单响应式状态:管理新增计划的输入字段
const form = reactive({
subject: '',
content: '',
place: '实训室',
})
// 4. 核心数据源:学习计划数组
const plans = ref([
{ id: 1, subject: 'Vue 基础', content: '复习 ref 与 v-model', place: '实训室', done: true },
{ id: 2, subject: '模板指令', content: '完成列表与筛选练习', place: '图书馆', done: false },
])
// 5. 辅助状态:筛选类别、操作反馈提示与 DOM 列表引用
const filter = ref('all')
const message = ref('填写表单后添加一条学习计划。')
const listElement = ref(null)
// 6. 数据恢复:初始化时从 localStorage 安全读取并解析历史数据
if (typeof localStorage !== 'undefined') {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null')
if (Array.isArray(saved)) plans.value = saved
}
catch {
localStorage.removeItem(STORAGE_KEY)
message.value = '本地数据异常,已恢复初始计划。'
}
}
// 7. 计算属性:表单是否满足提交条件(科目与内容均不能为空)
const canSubmit = computed(() =>
form.subject.trim() !== '' && form.content.trim() !== '',
)
// 8. 计算属性:自动统计已完成条数
const completedCount = computed(() =>
plans.value.filter(item => item.done).length,
)
// 9. 计算属性:自动计算总体完成进度百分比
const progress = computed(() => {
if (plans.value.length === 0) return 0
return Math.round((completedCount.value / plans.value.length) * 100)
})
// 10. 计算属性:根据 filter 条件派生出当前可见的计划列表
const visiblePlans = computed(() => {
if (filter.value === 'todo') return plans.value.filter(item => !item.done)
if (filter.value === 'done') return plans.value.filter(item => item.done)
return plans.value
})
// 11. 添加计划处理函数:组装新计划对象、清空表单并等待 DOM 更新统计行数
async function addPlan() {
if (!canSubmit.value) {
message.value = '学习科目和学习内容不能为空。'
return
}
plans.value.push({
id: Math.max(0, ...plans.value.map(item => item.id)) + 1,
subject: form.subject.trim(),
content: form.content.trim(),
place: form.place,
done: false,
})
// 重置表单输入字段
form.subject = ''
form.content = ''
form.place = '实训室'
// 等待 Vue 将新 li 节点插入 DOM 树
await nextTick()
const rowCount = listElement.value?.children.length ?? 0
message.value = `计划已添加,当前筛选下显示 ${rowCount} 条。`
}
// 12. 删除计划:限制只有勾选“已完成”的计划才能删除
function removePlan(id) {
const plan = plans.value.find(item => item.id === id)
if (!plan?.done) {
message.value = '完成计划后才能删除。'
return
}
plans.value = plans.value.filter(item => item.id !== id)
message.value = '已删除完成的计划。'
}
// 13. 深度侦听:plans 数组有任何增删或内部状态修改时,自动序列化写入 localStorage
watch(
plans,
(value) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
}
},
{ deep: true },
)
</script>
<template>
<main class="plan-app">
<!-- 顶部概览栏:展示标题与完成进度统计 -->
<header class="hero">
<div>
<p class="eyebrow">CHAPTER 02 · CLASSROOM DEMO</p>
<h1>Vue 学习计划表</h1>
<p>修改数据,观察表单、列表、筛选和统计一起变化。</p>
</div>
<div class="summary">
<!-- 响应式渲染计算属性计算出的百分比与数字 -->
<strong>{{ progress }}%</strong>
<span>已完成 {{ completedCount }} / {{ plans.length }}</span>
</div>
</header>
<section class="workspace">
<!-- 左侧:录入表单(@submit.prevent 阻止整页刷新) -->
<form class="panel" @submit.prevent="addPlan">
<h2>新增学习计划</h2>
<label>
学习科目
<!-- .trim 自动剔除首尾空白 -->
<input v-model.trim="form.subject" placeholder="例如:Vue 模板语法" />
</label>
<label>
学习内容
<textarea v-model.trim="form.content" rows="3" placeholder="写清楚准备完成什么"></textarea>
</label>
<label>
学习地点
<select v-model="form.place">
<option>实训室</option>
<option>图书馆</option>
<option>宿舍</option>
</select>
</label>
<!-- 动态属性绑定:表单验证不通过时禁用添加按钮 -->
<button :disabled="!canSubmit" type="submit">添加学习计划</button>
</form>
<!-- 右侧:计划看板管理区 -->
<section class="panel">
<div class="board-title">
<h2>计划列表</h2>
<!-- 筛选下拉菜单:双向绑定 filter 驱动 visiblePlans 变化 -->
<select v-model="filter" aria-label="筛选学习计划">
<option value="all">全部</option>
<option value="todo">未完成</option>
<option value="done">已完成</option>
</select>
</div>
<p class="message" aria-live="polite">{{ message }}</p>
<!-- 列表渲染:v-if 判空,v-for 遍历 visiblePlans,绑定稳定唯一 :key="plan.id" -->
<ul v-if="visiblePlans.length" ref="listElement" class="plan-list">
<li
v-for="plan in visiblePlans"
:key="plan.id"
:class="{ completed: plan.done }"
>
<label class="plan-check">
<!-- 复选框切换 plan.done,自动触发样式与统计计算属性刷新 -->
<input v-model="plan.done" type="checkbox" />
<span>
<strong>{{ plan.subject }}</strong>
<small>{{ plan.content }} · {{ plan.place }} · {{ plan.done ? '已完成' : '进行中' }}</small>
</span>
</label>
<!-- 仅允许删除已完成的任务 -->
<button
type="button"
:disabled="!plan.done"
@click="removePlan(plan.id)"
>
删除
</button>
</li>
</ul>
<p v-else class="empty">当前筛选下没有计划。</p>
</section>
</section>
</main>
</template>
<style scoped>
.plan-app,
.plan-app * {
box-sizing: border-box;
}
.plan-app {
width: min(1080px, calc(100% - 32px));
margin: 0 auto;
padding: 48px 0;
color: #172033;
font-family: system-ui, sans-serif;
}
.hero,
.workspace,
.board-title,
.plan-list li,
.plan-check {
display: flex;
}
.hero {
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.eyebrow {
color: #3157d5;
font-weight: 800;
letter-spacing: .1em;
}
.summary {
min-width: 180px;
padding: 18px;
color: white;
border-radius: 16px;
background: #3157d5;
}
.summary strong,
.summary span {
display: block;
}
.summary strong {
font-size: 30px;
}
.workspace {
align-items: flex-start;
gap: 20px;
}
.panel {
flex: 1 1 0;
min-width: 0;
padding: 22px;
border: 1px solid #dce3f0;
border-radius: 16px;
background: white;
}
form.panel,
form label {
display: grid;
gap: 12px;
}
input,
textarea,
select,
button {
padding: 10px;
font: inherit;
}
form button {
color: white;
border: 0;
border-radius: 8px;
background: #3157d5;
}
button:disabled {
cursor: not-allowed;
opacity: .45;
}
.board-title {
align-items: center;
justify-content: space-between;
gap: 12px;
}
.message,
.empty {
color: #667085;
}
.plan-list {
display: grid;
gap: 10px;
padding: 0;
list-style: none;
}
.plan-list li {
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 1px solid #e1e6ef;
border-radius: 10px;
}
.plan-check {
align-items: center;
gap: 10px;
}
.plan-check span {
display: grid;
gap: 4px;
}
.plan-check small {
color: #667085;
}
.completed {
background: #effaf6;
}
.completed strong {
text-decoration: line-through;
}
@media (max-width: 760px) {
.hero,
.workspace {
align-items: stretch;
flex-direction: column;
}
}
</style>保存后应该看到新增表单、学习计划列表、筛选框和完成统计。
步骤 2:按顺序验收课堂案例
- 输入科目和内容,选择地点,点击“添加学习计划”;页面应新增一行并清空表单。
- 勾选刚添加的计划;状态文字、卡片样式、完成数和百分比应一起变化。
- 切换“未完成”和“已完成”;列表应只显示对应计划。
- 尝试删除未完成计划;按钮不可用。完成计划后应可以删除。
- 刷新浏览器;已经保存的计划应恢复。
- 在终端执行
npm run build;应看到构建成功,不应出现红色错误。
CHAPTER 02 · CLASSROOM DEMO
Vue 学习计划表
修改数据,观察表单、列表、筛选和统计一起变化。
50%已完成 1 / 2
计划列表
填写表单后添加一条学习计划。
当堂练习
【必做任务(基础通关)】
- 在完整案例上增加“学习难度”字段,选项为“入门、熟练、挑战”;
- 新增时保存选中的难度,并在列表中展示“难度:xxx”,测试成功新增一条记录。
【选做挑战(🌟 自主拓展)】 增加一个筛选按钮“只看挑战任务”,点击后利用计算属性过滤出难度为“挑战”的计划展示;测试刷新浏览器后,新加难度的计划依然能从 localStorage 中完整恢复。
必做任务预计 15 分钟完成。
2.12 课后任务:实训器材借用清单
任务目标
使用 resources/ch02/after-class-starter/ 骨架工程,完成一张可以登记借用、勾选归还并实时统计的“实训器材借用清单”。本任务包含“基础通关要求(全员必做)”与“进阶拓展任务(自主选做)”。
基础通关要求(全员必做)
- 包含借用人、器材分类和器材名称输入表单,点击“添加借用记录”按钮能将新数据添加进列表;
- 基础输入校验:若借用人或器材名称为空,不能新增并提示“内容不能为空”;
- 使用
v-for与稳定:key="item.id"循环渲染借用记录列表; - 每条记录包含一个勾选框,勾选后状态变为“已归还”,并展示完成划线样式;
- 使用
computed计算属性自动统计当前借用记录总数与已归还数量; - 项目能通过
npm run dev正常启动,在浏览器中交互流畅、无红色报错。
进阶拓展任务(自主选做)
以下任务完全由个人根据学习兴趣自主选择探索,不与任何考核分数绑定:
- 拓展一【分类与状态筛选】:实现“全部 / 借用中 / 已归还”下拉切换,利用
computed过滤出对应状态的记录; - 拓展二【未归还防误删保护】:增加安全约束,只有已勾选“已归还”的器材才允许点击删除,未归还点击提示“不可删除”;
- 拓展三【本地数据自动保存】:尝试使用
watch将器材数组序列化存入localStorage,实现刷新浏览器后借用记录自动恢复。
必须使用的知识
| 知识 | 在任务中的用途 |
|---|---|
ref | 借用记录列表、筛选状态与提示文字 |
v-model | 表单输入框、下拉框与复选框的双向绑定 |
v-bind / class 绑定 | 按钮禁用状态控制与已归还划线样式 |
v-if / v-show | 空状态提示与说明显示 |
v-for 与稳定 key | 循环生成借用记录列表 |
computed | 自动计算总借用数、已归还数与筛选列表 |
watch(选做) | 尝试将借用记录自动同步到本地存储 |
| 事件处理 | 表单提交与记录删除 |
完成效果
页面包含借用登记表单、归还统计卡片与借用记录列表。新增、勾选归还后,相关文字、统计数字和样式同步变化;修改代码支持热更新。
CHAPTER 02 · EXPECTED RESULT
实训器材借用清单
登记借用信息,标记归还状态,并按状态查看记录。
50%已归还 1 / 2
新增借用记录
当前借用记录
新增、归还、筛选和刷新恢复都应有可见结果。
这是完成效果示意,只用于确认信息、状态和布局;作业实现仍需根据任务书独立完成。
验收步骤
- 在项目根目录执行
npm install与npm run dev,成功打开页面; - 输入借用人“张明”,选择“摄影设备”,填写“移动三脚架”,点击添加,确认列表中新增一条记录;
- 提交一次空借用人,确认不会新增并弹出提示;
- 勾选新记录为“已归还”,确认归还统计数量自动加 1,卡片显示删除线样式;
- (选做)测试切换“借用中”和“已归还”筛选器;
- (选做)测试刷新浏览器后数据是否恢复。
提交内容
- 基础提交(全员必做):
- 完整项目源码(不包含
node_modules/和dist/); - 一张新增与勾选归还成功的正常运行效果图。
- 完整项目源码(不包含
- 拓展提交(自选提交):
- 若尝试了筛选或本地持久化拓展,可附带对应功能效果截图。
2.13 应用判断
学习自检
停一下:状态真源清楚吗
确认页面更新、派生值和副作用没有混成多份状态。
选择学习计划表中的一个状态,用自己的话说明:它从哪里来、由哪个动作修改、页面哪一处可以证明修改成功。
2.14 本章小测
章节测评
7 题第2章小测:让数据驱动页面
检查响应式状态、列表身份和派生逻辑。
2.15 本章知识自检
| 能力 | 达标标准 |
|---|---|
| 单文件组件 | 能说明 script setup、template 和 style scoped 的分工 |
| 响应式状态 | 能区分 ref、reactive、toRef/toRefs 的常见用途 |
| 内容显示 | 能区分插值、v-text 和 v-html,知道用户输入不能直接使用 v-html |
| 表单与事件 | 能使用 v-model、@submit.prevent 和键盘修饰符 |
| 条件与列表 | 能选择 v-if/v-show,并为列表提供稳定 key |
| 动态外观 | 能使用 v-bind、class 和 style 表达业务状态 |
| 派生结果 | 能用 computed 计算统计和筛选结果 |
| 保存动作 | 能用 watch 保存非敏感数据,并安全恢复 JSON |
| 更新时间 | 只在必须读取新 DOM 时使用 nextTick |
| 综合应用 | 能独立完成学习计划表的改造要求 |
本章小结
ref和reactive让 Vue 能追踪会变化的数据;- 插值和指令把数据、属性、事件、表单、条件、列表与样式连接到页面;
computed根据现有状态算结果,watch在变化后执行保存等外部动作;- 稳定
key表达列表项的业务身份,不能使用会变化的位置代替; nextTick只用于修改状态后必须读取新 DOM 的情况;- 下一章会把学习计划表拆成多个组件,让不同组件分工协作。
