---
url: /courses/web-frontend-framework/reactivity-template/index.md
---
# 第2章：让数据驱动页面

第1章已经把 Vue 工程运行起来了。本章继续使用同一个工程，把只能显示固定文字的页面，逐步改造成可以输入、勾选、筛选、统计和保存的“Vue 学习计划表”。

::: tip 本章成果
本章将完成“Vue 学习计划表”，并在章末独立完成“实训器材借用清单”。两个案例分别用于验证基础响应式能力和整章综合应用能力。
:::

::: tip 🎯 学习梯度指引（分层通关）

* **核心必学（保底通关 · 备忘录/计算器）**：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 节的 `nextTick` DOM 异步时机。供学有余力或想深入探索框架机制的同学自主选学，完全不挂钩任何考核分数。
* **练习分层**：每节练习设【必做任务】与【选做挑战🌟】。必做任务面向全员通关；选做挑战完全由学生个人根据兴趣自主拓展，不包含也不挂钩任何分数。
  :::

## 上课前准备

本章示例工程位于：

```text
resources/ch02/classroom-demo/
```

第一次使用时，在终端执行：

```bash
cd resources/ch02/classroom-demo
npm install
npm run dev
```

终端出现本地地址后，按住 Command 或 Ctrl 点击地址。在后面的课堂演示中，把代码粘贴到 `src/App.vue`，保存后浏览器会自动更新。

::: warning 先确认再继续
如果浏览器没有打开页面，先检查终端是否仍在运行、当前目录是否正确，以及文件是否已经保存。不要在服务没有启动时反复修改代码。
:::

## 2.1 单文件组件、`ref`、插值与基础点击

### 学习目标

掌握 Vue 单文件组件、`ref`、插值语法和最基本的点击事件，能够点击按钮让一段文字跟着数据变化。

### 语法/概念

一个 `.vue` 文件通常有三块：

| 区域 | 简单说明 |
| --- | --- |
| `<script setup>` | 放数据和做事的函数，相当于页面的“大脑” |
| `<template>` | 放页面上要出现的结构，相当于页面的“骨架” |
| `<style scoped>` | 放当前组件的颜色、间距和外观 |

`ref` 可以理解为“给数据装一个提醒器”。数据改变后，Vue 会通知使用它的页面位置重新显示。

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

```vue
<button @click="changeMessage">修改文字</button>
```

它的意思只是“点击按钮时执行 `changeMessage`”。事件对象和修饰符会在 2.5 节详细学习。

### 课堂演示

#### 步骤 1：打开组件文件

打开 `resources/ch02/classroom-demo/src/App.vue`，删除原内容。

保存后浏览器可能暂时显示空白，这是正常现象，因为组件还没有新内容。

#### 步骤 2：粘贴最小示例

::: code-tree title="课程标题状态卡" entry="src/App.vue" height="520px"

```vue title="src/App.vue" :active
<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` 的使用边界上：

::: code-tabs
@tab 常见错误代码

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

@tab 规范修正代码

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

:::

### 当堂练习

**【必做任务（基础通关）】**

1. 在 `<script setup>` 中使用 `ref` 增加一个 `studentName` 状态，初始值写你的真实姓名：
   ```js
   const studentName = ref('你的姓名')
   ```
2. 在 `<template>` 的标题下方用插值展示：“学习者：`{{ studentName }}`”；
3. 保存后，在浏览器中确认文字正确显示，验证基础响应式插值。

::: details 展开查看练习骨架代码提示

```vue
<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。

::: tip 🌟 进阶选学：模板文本指令与内容安全
在现代 Vue 3 开发中，99% 的文本展示直接使用普通插值 `{{ 变量 }}` 即可，它会自动对 HTML 标签进行字符转义，既简单又安全。`v-text` 和 `v-html` 了解其区别即可，初学阶段重点掌握普通插值，避免直接将不可信内容交给 `v-html`。
:::

### 语法/概念

三种写法看起来都能放内容，但处理方式不同：

| 写法 | 页面怎样处理内容 | 适合什么内容 |
| --- | --- | --- |
| `{{ message }}` | 当作普通文字 | 绝大多数文字 |
| `v-text="message"` | 替换整个元素的文字 | 需要明确绑定整个文本时 |
| `v-html="content"` | 当作 HTML 标签解析 | 只允许可信且经过清洗的内容 |

```vue
<!-- 1. 普通插值：最常用的安全方式，HTML 标签会被转义为纯文本显示 -->
<p>{{ message }}</p>

<!-- 2. v-text 指令：将元素的整个文本内容替换为对应字符串（功能与插值类似） -->
<p v-text="message"></p>

<!-- 3. v-html 指令：将字符串解析为真实 HTML 标签渲染（仅限 100% 可信内容，谨防 XSS 攻击） -->
<div v-html="trustedContent"></div>
```

::: danger 不要把用户输入交给 `v-html`
用户输入、未知接口返回和未经审核的 AI 内容，都不能直接使用 `v-html`。普通插值会把危险标签当文字显示，通常更安全。
:::

### 课堂演示

#### 步骤 1：替换 `src/App.vue`

::: code-tree title="文字显示安全对比" entry="src/App.vue" height="470px"

```vue title="src/App.vue" :active
<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>` 并保存。前两块会显示普通标签文字，不会凭空创建一个大标题。

### 当堂练习

**【必做任务（基础通关）】**

1. 增加常量 `studentNotice`，值为 `'<button>领取奖励</button>'`；
2. 在模板中直接使用双花括号插值 `{{ studentNotice }}` 展示；
3. 保存后在浏览器中确认页面安全地显示为一段纯文本字符，而不会误渲染出真正的可点击按钮。

**【选做挑战（🌟 自主拓展）】**
尝试在下方增加一行使用 `<p v-html="studentNotice"></p>` 渲染，观察浏览器真实渲染出可点击按钮的效果，并思考为什么用户输入的内容绝不能直接传给 `v-html`。

> 必做任务预计 10 分钟完成。

## 2.3 `reactive`、`v-model`、`toRef` 与 `toRefs`

### 学习目标

掌握 `reactive`、`v-model`、`toRef` 和 `toRefs`，能够让一组表单字段与预览内容保持同步。

::: tip 🌟 进阶选学：响应式对象的底层机制（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 = '...'`。

```js
import { reactive } from 'vue'

// 1. reactive 适合集中管理属于同一个业务的对象字段（例如表单）
const form = reactive({
  subject: '',
  place: '实训室',
})

// 2. 直接修改属性即可触发响应式更新，无需写 .value
form.subject = 'Vue 基础'
```

> 💡 **老师建议**：初学阶段建议**优先使用 `ref`**（`ref({ ... })` 同样支持对象表单，且不会踩解构陷阱）。遇到既有的 `reactive` 对象时，记住它只管整个对象，属性直接读写。

#### 2. `v-model`：输入框和数据的“双向拉绳”

`v-model` 是初学阶段最常用的指令。它在表单控件和数据之间拴了一根“双向拉绳”：

* 代码里的数据改了，输入框里的文字**自动更新**；
* 用户在输入框敲字，代码里的数据也**即时同步**。

```vue
<!-- .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` 集合。

```js
import { reactive, toRef, toRefs } from 'vue'

const form = reactive({ subject: '', place: '实训室' })

// 正确解构方式（保持与原响应式对象的数据双向连接）：
const subject = toRef(form, 'subject') // 单个属性转换为 ref 保持连接
const { place } = toRefs(form)         // 批量解构出所有属性并保持响应式连接
```

#### 4. 典型易错代码诊断：直接重新赋值 reactive 对象导致的代理断裂

::: code-tabs
@tab 常见错误代码

```vue
<script setup>
import { reactive } from 'vue'

let userProfile = reactive({
  name: '李同学',
  grade: '2024级'
})

function resetForm() {
  // 错误：直接将普通新对象覆盖赋值给 reactive 变量
  // 这会切断原有的 Proxy 代理指针，导致后续修改失去响应性
  userProfile = {
    name: '重置姓名',
    grade: '2024级'
  }
}
</script>
```

@tab 规范修正代码

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

::: code-tree title="学习计划实时预览" entry="src/App.vue" height="580px"

```vue title="src/App.vue" :active
<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` 与原对象字段保持着连接，而不是复制了一份新数据。

### 当堂练习

**【必做任务（基础通关）】**

1. 给 `form` 增加 `level` 字段，初始值为 `'入门'`；
2. 在左侧表单中增加一个难度下拉框，选项为“入门、熟练、挑战”，使用 `v-model="form.level"` 双向绑定；
3. 在右侧实时预览卡片中增加一行：“难度：`{{ form.level }}`”，确认切换下拉框时右侧实时变化。

::: details 展开查看练习骨架代码提示

```vue
<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`，简写是冒号 `:`。

```vue
<!-- 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` 更适合宽度、位置等连续变化的数字。

#### 典型易错代码诊断：动态属性绑定遗漏冒号前缀

::: code-tabs
@tab 常见错误代码

```vue
<template>
  <!-- 错误：遗漏冒号简写，disabled 会被当作原生 HTML 纯字符串 "!canSubmit" 处理 -->
  <!-- 在 HTML 原生规范中，布尔属性只要存在即代表生效，导致按钮被永久禁用 -->
  <button disabled="!canSubmit">提交</button>
</template>
```

@tab 规范修正代码

```vue
<template>
  <!-- 正确：加上冒号 : 作为 v-bind 简写，引号内部作为 JavaScript 表达式计算 -->
  <button :disabled="!canSubmit">提交</button>
</template>
```

:::

::: tip 状态不能只靠颜色
完成状态除了改变颜色，还要显示“已完成”文字或删除线。这样投影颜色不清楚时，仍然能看懂结果。
:::

### 课堂演示

#### 步骤 1：替换 `src/App.vue`

::: code-tree title="学习进度状态卡" entry="src/App.vue" height="560px"

```vue title="src/App.vue" :active
<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` 根据具体百分比改变进度条宽度。

### 当堂练习

**【必做任务（基础通关）】**

1. 体验动态状态与按钮禁用绑定：根据滑块数字判断，当进度小于 60 时显示“继续努力”，大于或等于 60 时显示“进度良好”；
2. 勾选“已完成”时，通过 `:disabled="!done"` 联动解除底部按钮的禁用状态。

::: details 展开查看练习骨架代码提示

```vue
<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` 状态控制，独立完成一个包含交互动画与等级晋升的成长看板：

::: code-tree title="学员等级与成长经验卡" entry="src/App.vue" height="580px"

```vue title="src/App.vue" :active
<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` 用来说明“用户做了什么以后，调用哪个函数”，简写是 `@`：

```vue
<!-- 1. 基础事件绑定：点击按钮触发 addPlan 函数 -->
<button @click="addPlan">添加</button>

<!-- 2. 事件修饰符 .prevent：阻止原生表单提交引起的页面整页刷新 -->
<form @submit.prevent="addPlan"></form>

<!-- 3. 按键修饰符 .esc：仅当用户按下 Esc 键时触发清空操作 -->
<input @keyup.esc="clearForm" />
```

浏览器触发事件时会带来一个事件对象。需要知道事件类型或当前元素时，可以接收 `event`；同时传业务数据和事件对象时使用 `$event`：

```vue
<!-- 显式传递当前业务 ID 与原生浏览器事件对象 $event -->
<button @click="removePlan(plan.id, $event)">删除</button>
```

常用修饰符：

| 修饰符 | 简单说明 |
| --- | --- |
| `.prevent` | 阻止表单提交后刷新页面等默认动作 |
| `.stop` | 不让事件继续向外层冒泡 |
| `.once` | 只响应一次 |
| `.enter` | 只处理回车键 |
| `.esc` | 只处理 Esc 键 |

### 课堂演示

#### 步骤 1：替换 `src/App.vue`

::: code-tree title="新增学习计划事件" entry="src/App.vue" height="540px"

```vue title="src/App.vue" :active
<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` 已经阻止了表单的默认刷新。

### 当堂练习

**【必做任务（基础通关）】**

1. **反向对比体验**：尝试将表单上的 `.prevent` 临时删除（改为 `<form @submit="addPlan">`），点击提交，观察浏览器地址栏变化以及整页白屏刷新的现象；体验完成后重新加回 `.prevent`，亲身理解修饰符的作用；
2. **快捷键盘操作**：在文本输入框上增加键盘事件修饰符 `@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` |

```vue
<!-- 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`

::: code-tree title="计划空状态与操作说明" entry="src/App.vue" height="500px"

```vue title="src/App.vue" :active
<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` 为假时说明节点仍在，只是多了隐藏样式。

### 当堂练习

**【必做任务（基础通关）】**

1. 增加 `progress` 进度状态（默认 `50`）与数字输入框；
2. 使用 `v-if`、`v-else-if` 和 `v-else` 实现进度状态分级：
   * 小于 60 时显示“待推进”；
   * 60～84 时显示“推进良好”；
   * 85 及以上时显示“接近完成”。
3. 在页面中修改数字，确认文字分级即时切换。

**【选做挑战（🌟 自主拓展）】**
为当前页面的帮助提示增加一个折叠开关，使用 `v-show` 控制提示内容的显隐，并在浏览器 F12 Elements 中观察它只是被添加了 `display: none` 样式，节点并未被从 DOM 树移除。

> 必做任务预计 10 分钟完成。

## 2.7 `v-for` 与稳定 `key`

### 学习目标

掌握 `v-for` 和稳定 `key`，能够根据数组生成可新增、可删除的学习计划列表。

### 语法/概念

#### 1. 列表渲染：根据数组“复印”页面结构

`v-for` 可以理解为一台**自动化复印机**：数组里有几个元素，它就照着模板结构复印出几个 HTML 节点。

```vue
<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 个计划，每个计划前面都有一个输入框或复选框：

1. 如果你写了 `:key="index"`（0, 1, 2），当你删除了第 1 项（原 0 号）；
2. 原本的第 2 项（原 1 号）就会往前顶上来，变成新的 0 号；
3. Vue 按照排队号去复用 DOM 时，就会把原本已经被勾选的状态，**错误地保留在新顶上来的 0 号元素上**！造成严重的\*\*“勾选状态错位、输入框内容串门”\*\* Bug。

#### 4. 对比实验：不同 key 策略引发的输入框状态错位现象

通过下方代码可直观复现就地复用缺陷：

```vue
<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 同级混用的优先级陷阱

::: code-tabs
@tab 常见错误代码

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

@tab 规范修正代码

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

::: code-tree title="可增删学习计划列表" entry="src/App.vue" height="620px"

```vue title="src/App.vue" :active
<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` 查找，不依赖当前位于第几行。

### 当堂练习

**【必做任务（基础通关）】**

1. 给数组中的每条计划增加 `place` 字段（如 `'实训室'`、`'图书馆'`）；
2. 在 `v-for` 循环模板中展示为“计划名称 · 学习地点”；
3. 新增表单增加地点下拉框，练习新增一条计划，并点击删除按钮删除对应计划，确认列表渲染正常。

::: details 展开查看练习骨架代码提示

```vue
<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）

```js
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. 对比实验：验证计算属性缓存性能与普通方法执行频次

通过下方代码可直观通过浏览器控制台对比二者的调用频次：

```vue
<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 与试图手动赋值

::: code-tabs
@tab 错误代码一：遗漏 return

```js
// 错误：计算属性内部没有使用 return 语句返回值
// 导致在模板中渲染时得到的是 undefined
const completedCount = computed(() => {
  const total = plans.value.filter(item => item.done).length
})
```

@tab 错误代码二：试图手动赋值

```js
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
}
```

@tab 规范修正代码

```js
// 正确：必须具有 return，且仅通过修改它所依赖的原始数据（plans）来间接驱动变化
const completedCount = computed(() => {
  return plans.value.filter(item => item.done).length
})
```

:::

#### 计算属性的三大铁律：

1. ✅ **必须有 `return`**：计算属性的职责就是算出并返回一个值；
2. ❌ **切勿手动赋值**：它是只读的推导结果，不能写 `completedCount.value = 10`；
3. ❌ **切勿执行副作用**：它只负责纯算账，绝不能在里面发送网络请求、修改其他变量或保存本地缓存。

| 业务需求 | 是否使用 `computed` | 原因说明 |
| --- | :---: | --- |
| 统计已完成任务数量 | **是** | 由 `plans` 纯推导得出的数字 |
| 根据关键字/状态筛选列表 | **是** | 由列表和筛选条件派生出的新数组 |
| 点击按钮把新任务存入数组 | **否** | 这是用户操作，应该写普通事件函数 |
| 数据变动时把数据备份到浏览器 | **否** | 这是有外部影响的动作，应该使用 `watch` |

### 课堂演示

#### 步骤 1：替换 `src/App.vue`

::: code-tree title="自动统计与筛选" entry="src/App.vue" height="650px"

```vue title="src/App.vue" :active
<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`。

### 当堂练习

**【必做任务（基础通关）】**

1. 使用 `computed` 声明 `todoCount`，自动计算未完成的计划数量：
   ```js
   const todoCount = computed(() => plans.value.filter(item => !item.done).length)
   ```
2. 在界面统计区域展示“剩余未完成：`{{ todoCount }}` 项”；
3. 勾选任意一项计划，确认未完成数量立刻自动减少，无需手动维护第二份计数变量。

::: details 展开查看练习骨架代码提示

```vue
<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`，能够在状态变化后把非敏感课堂数据保存到浏览器。

::: tip 🌟 进阶选学：数据侦听与本地持久化（watch & localStorage）
在数据驱动开发中，界面的计算展示优先使用 `computed`。而 `watch` 专注于“数据变动后执行特定动作”（例如将数据备份存进浏览器的 `localStorage` 中实现刷新不丢）。本节的数据侦听与本地持久化属于进阶拓展内容，初学者建议先吃透计算属性，学有余力时再深入探索。
:::

### 语法/概念

#### 1. 生活中的比喻：站岗值守的“保安与行动员”

如果说 `computed` 是只动脑算账的“军师”，那 `watch` 就是**拿着对讲机的保安（行动员）**：

* 平时一言不发，两眼死死盯住指定的目标数据；
* 一旦目标数据发生风吹草动（比如草稿内容变了），他立刻拿起对讲机去干具体的脏活累活（例如把数据存进本地存储、发送网络请求或打印日志）。

#### 2. 标准语法三要素（主谓宾结构）

```js
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`**，它专门用来执行产生外部影响的“副作用”动作。

#### 3. 深度侦听配置 deep 的必要性与对象内部变更监控

* 监听普通字符串或数字（如 `draft`）时，直接侦听即可；
* 但如果监听的是一个包含很多任务对象的**数组或复杂对象**（如 `plans`），当用户只是勾选了内部某一项（`plan.done = true`）时，数组本身的引用地址并没有变，普通侦听会漏掉这个动作；
* 加上 `{ deep: true }`，相当于给保安配了透视眼镜，连数组内部每一层对象属性的细微改动都能精准捕捉：

```js
// 深度侦听：配置 { deep: true }，即使只是修改了数组内部某一项的属性，也能被捕捉
watch(plans, (newPlans) => {
  savePlans(newPlans)
}, { deep: true })
```

#### 4. `localStorage`：浏览器里的小抽屉

`localStorage` 是浏览器提供给网站的本地存储空间：

* **存入**：`localStorage.setItem(键名, 字符串内容)`；
* **读取**：`localStorage.getItem(键名)`；
* 刷新页面或重新打开浏览器后，存入的数据仍然保留。它只适合保存学习草稿或界面偏好，**绝不能保存密码、令牌、身份证号等敏感信息**。

#### 5. 典型易错代码诊断：侦听器内部自我修改导致的死循环

::: code-tabs
@tab 常见错误代码

```js
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
})
```

@tab 规范修正方案

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

::: code-tree title="自动保存学习草稿" entry="src/App.vue" height="520px"

```vue title="src/App.vue" :active
<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` 和对应内容。

::: warning 解析数组时要防止白屏
数组通常使用 `JSON.stringify` 保存、`JSON.parse` 恢复。外部数据可能损坏，因此恢复时应使用 `try...catch`。完整学习计划表会演示这一步。
:::

### 当堂练习

**【必做任务（基础通关）】**

1. 在组件中定义响应式变量 `draft` 与 `message`；
2. 使用 `watch` 监听 `draft` 的变化：每当内容变动时，更新提示文字为“草稿内容已侦听到变动”；
3. 在页面中输入字符，确认提示文字实时响应更新。

::: details 展开查看练习骨架代码提示

```vue
<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 结果。

::: tip 🌟 进阶选学：DOM 异步更新与时机控制（nextTick）
在数据驱动模式下，绝大多数业务直接操作响应式数据即可（例如要展示计划数量直接使用 `plans.length`，界面会自动更新），不需要手动去获取真实 DOM 元素。`nextTick` 仅用于极少数“修改数据后必须立刻读取真实 DOM 最新高度、尺寸或行数”的高阶场景，作为拓展选学即可。
:::

### 语法/概念

#### 1. 核心机制：Vue 的“异步批处理更新”

当你在代码里写下：

```js
count.value = 100
```

很多同学以为：页面上的数字在这一毫秒内立刻就画好了。\
**实际上并不是！**\
Vue 为了保证性能，采用了**异步批处理**策略：它就像一位聪明的快递员，绝不会因为你每买一件小物品就立刻单独跑一趟送货，而是把同一瞬间的所有包裹（所有的响应式数据变动）**先统一攒起来**，然后在下一轮时机（微任务队列）**一次性合并渲染更新页面**。这样可以彻底避免浏览器频繁重排重绘导致的卡顿。

#### 2. 最经典的实战痛点：数据变动后即时读取 DOM 失败的根因分析

最典型的场景就是\*\*“点击按钮显示输入框，并希望输入框立刻自动获得焦点（`.focus()`）”\*\*：

```js
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 更新彻底渲染到页面中”**。

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

::: code-tree title="新增后读取列表行数" entry="src/App.vue" height="530px"

```vue title="src/App.vue" :active
<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”这一种特殊情况。

### 当堂练习

**【必做任务（基础通关）】**

1. 体验数据驱动的天然响应性：直接在模板中使用 `{{ plans.length }}` 展示计划总条数；
2. 连续点击“新增计划”按钮，观察界面上的数量无需手动查找 DOM 元素即刻同步更新最新数字。

**【选做挑战（🌟 自主拓展）】**
尝试结合 `await nextTick()`，在新增后获取列表中最后一个 `<li>` 的文本内容并提示在页面上，体验 DOM 异步微任务更新完成的时机。

> 必做任务预计 10 分钟完成。

## 2.11 串联案例：完成“Vue 学习计划表”

### 学习目标

掌握本章知识的组合方式，能够完成一张可新增、勾选、筛选、统计并自动保存的学习计划表。

### 语法/概念

完整案例只有一份业务真源：

* `plans` 保存学习计划；
* `form` 保存正在填写的表单；
* `filter` 保存当前筛选条件；
* `computed` 负责统计和筛选，不复制第二份结果；
* `watch` 负责保存，不负责计算；
* 指令把这些状态连接到页面。

### 课堂演示

#### 案例阶梯式实现路径

本案例综合运用了响应式数据、表单双向绑定、列表渲染、计算属性和数据侦听，建议在编码实训时遵循“由简入深、分步验证”的递进路线：

::: steps

1. **阶段一：数据源与基础表单新增**
   * 建立响应式表单对象 `form` 与计划数组 `plans`；
   * 使用 `v-model` 绑定输入框，编写 `addPlan` 函数；
   * 使用 `v-for` 渲染列表，绑定稳定 `:key="plan.id"`，验证能否正常新增与删除。

2. **阶段二：计算属性融入与条件筛选**
   * 引入 `computed` 声明 `completedCount` 与 `progress` 百分比；
   * 声明 `visiblePlans` 计算属性，根据当前 `filter`（'all' / 'done' / 'todo'）动态派生出可见列表；
   * 将模板中 `v-for` 的遍历源从 `plans` 切换为 `visiblePlans`，验证筛选按钮与数据统计联动。

3. **阶段三：数据持久化与体验优化**
   * 使用 `watch` 深度侦听 `plans`，配置 `{ deep: true }`，在数据变动时同步写入 `localStorage`；
   * 编写初始恢复逻辑并加入 `try...catch` 防御异常数据；
   * 在新增计划后调用 `await nextTick()`，实现更新后读取最新行数，完成全功能闭环。
     :::

#### 步骤 1：恢复完整课堂工程

如果前面一直在替换 `src/App.vue`，把下面的完整代码粘贴进去；也可以直接使用仓库中的课堂工程。

::: code-tree title="Vue 学习计划表完整案例" entry="src/App.vue" height="760px"

```vue title="src/App.vue" :active
<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：按顺序验收课堂案例

1. 输入科目和内容，选择地点，点击“添加学习计划”；页面应新增一行并清空表单。
2. 勾选刚添加的计划；状态文字、卡片样式、完成数和百分比应一起变化。
3. 切换“未完成”和“已完成”；列表应只显示对应计划。
4. 尝试删除未完成计划；按钮不可用。完成计划后应可以删除。
5. 刷新浏览器；已经保存的计划应恢复。
6. 在终端执行 `npm run build`；应看到构建成功，不应出现红色错误。

### 当堂练习

**【必做任务（基础通关）】**

1. 在完整案例上增加“学习难度”字段，选项为“入门、熟练、挑战”；
2. 新增时保存选中的难度，并在列表中展示“难度：xxx”，测试成功新增一条记录。

**【选做挑战（🌟 自主拓展）】**
增加一个筛选按钮“只看挑战任务”，点击后利用计算属性过滤出难度为“挑战”的计划展示；测试刷新浏览器后，新加难度的计划依然能从 `localStorage` 中完整恢复。

> 必做任务预计 15 分钟完成。

## 2.12 课后任务：实训器材借用清单

### 任务目标

使用 `resources/ch02/after-class-starter/` 骨架工程，完成一张可以登记借用、勾选归还并实时统计的“实训器材借用清单”。本任务包含“基础通关要求（全员必做）”与“进阶拓展任务（自主选做）”。

### 基础通关要求（全员必做）

1. 包含借用人、器材分类和器材名称输入表单，点击“添加借用记录”按钮能将新数据添加进列表；
2. 基础输入校验：若借用人或器材名称为空，不能新增并提示“内容不能为空”；
3. 使用 `v-for` 与稳定 `:key="item.id"` 循环渲染借用记录列表；
4. 每条记录包含一个勾选框，勾选后状态变为“已归还”，并展示完成划线样式；
5. 使用 `computed` 计算属性自动统计当前借用记录总数与已归还数量；
6. 项目能通过 `npm run dev` 正常启动，在浏览器中交互流畅、无红色报错。

### 进阶拓展任务（自主选做）

以下任务完全由个人根据学习兴趣自主选择探索，不与任何考核分数绑定：

1. **拓展一【分类与状态筛选】**：实现“全部 / 借用中 / 已归还”下拉切换，利用 `computed` 过滤出对应状态的记录；
2. **拓展二【未归还防误删保护】**：增加安全约束，只有已勾选“已归还”的器材才允许点击删除，未归还点击提示“不可删除”；
3. **拓展三【本地数据自动保存】**：尝试使用 `watch` 将器材数组序列化存入 `localStorage`，实现刷新浏览器后借用记录自动恢复。

### 必须使用的知识

| 知识 | 在任务中的用途 |
| --- | --- |
| `ref` | 借用记录列表、筛选状态与提示文字 |
| `v-model` | 表单输入框、下拉框与复选框的双向绑定 |
| `v-bind` / class 绑定 | 按钮禁用状态控制与已归还划线样式 |
| `v-if` / `v-show` | 空状态提示与说明显示 |
| `v-for` 与稳定 `key` | 循环生成借用记录列表 |
| `computed` | 自动计算总借用数、已归还数与筛选列表 |
| `watch`（选做） | 尝试将借用记录自动同步到本地存储 |
| 事件处理 | 表单提交与记录删除 |

### 完成效果

页面包含借用登记表单、归还统计卡片与借用记录列表。新增、勾选归还后，相关文字、统计数字和样式同步变化；修改代码支持热更新。

### 验收步骤

1. 在项目根目录执行 `npm install` 与 `npm run dev`，成功打开页面；
2. 输入借用人“张明”，选择“摄影设备”，填写“移动三脚架”，点击添加，确认列表中新增一条记录；
3. 提交一次空借用人，确认不会新增并弹出提示；
4. 勾选新记录为“已归还”，确认归还统计数量自动加 1，卡片显示删除线样式；
5. （选做）测试切换“借用中”和“已归还”筛选器；
6. （选做）测试刷新浏览器后数据是否恢复。

### 提交内容

* **基础提交（全员必做）**：
  * 完整项目源码（不包含 `node_modules/` 和 `dist/`）；
  * 一张新增与勾选归还成功的正常运行效果图。
* **拓展提交（自选提交）**：
  * 若尝试了筛选或本地持久化拓展，可附带对应功能效果截图。

## 2.13 应用判断

{{reflection-checkpoint:vue-reactivity-template-checkpoint}}

选择学习计划表中的一个状态，用自己的话说明：它从哪里来、由哪个动作修改、页面哪一处可以证明修改成功。

## 2.14 本章小测

{{assessment:vue-reactivity-template-check}}

## 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 的情况；
* 下一章会把学习计划表拆成多个组件，让不同组件分工协作。
