孟凡的博客blog.mengfanyaoapp.com
课程资料库›Vue 3 + Element Plus 前端开发›第3次 · 响应式状态:ref 与 reactive
模块总览

第3次 · 响应式状态:ref 与 reactive

2026-09-09 · Vue 3 + Element Plus 前端开发 · 课程笔记
课堂练习资料 03-响应式状态ref与reactive.zip
下载 ZIP 压缩包

第3次 · 响应式状态:ref 与 reactive

第1/7页 · 先知道今天要做什么

上一课你已经发现一个现象:只改数据,页面自己就变了。今天要把这件事的原理讲透,并且把两个核心函数 ref 和 reactive 用熟。

今天的场景

课堂点名册有三件事要同时做:人数要能被加减、名单要能增删、当前选中的那个学员信息要能被编辑。如果用一个普通 JS 变量存人数,你点了按钮页面上数字却不动——因为浏览器根本不知道变量改了。

今天要解决的就是这个问题:让数据“被盯着”。

下课时拿出这三样结果

做到什么 在哪里看 完成的证据
用 ref 驱动一个数字 页面上方计数器 点“+1”数字增长,点“-1”减少,页面无需刷新。
用 ref 驱动一个数组 名单表格 点“添加学员”表格立刻多一行,点“移除”少一行。
用 reactive 驱动一个对象 右侧编辑卡片 改输入框,卡片上对应的文字实时跟着变。

你只需要带着这些基础来

上一课的工程还能跑;知道 ref 是“Vue 提供的响应式包装”,知道模板里写变量名、JS 里写 .value。今天要把这个规则讲清楚。

本课时 45 分钟怎么上

时间 老师带着做 学生留下什么
0—8 分钟 演示“用普通变量改数字,页面不动”的反面例子,说明为什么要响应式。 明白问题的根源是“没人通知浏览器”。
8—22 分钟 讲 ref:为什么 JS 里要 .value、模板里不用;演示 ref 包数组、包对象。 能说出两种写法各自的场合。
22—32 分钟 讲 reactive:只能包对象/数组、不能包基本类型、不能整体替换引用。演示解构丢失响应性的坑。 笔记里写下 ref 与 reactive 的选择规则。
32—42 分钟 粘贴本课 App.vue,完成计数器、名单增删、对象编辑三件事。 三项交互全部跑通。
42—45 分钟 抽查:问“为什么这里写 .value 那里不写”。 全员提交。

从哪里开始

继续用上一课的工程,或复制一份改名 lesson-vue-03。不需要新增依赖。

今天先掌握到这里

今天只讲“怎么让数据活起来”,不讲“数据变了之后怎么算出新值”(computed,第 4 课)、也不讲“怎么监听变化做副作用”(watch,第 4 课)。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第2/7页 · 把新词说清楚

新词与本课含义

名字 用熟悉的话理解 本课里长什么样
响应式(Reactivity) 数据被“登记”过,一旦变化,用它的地方自动重新计算。相当于给数据装了个报警器。 改 count.value 页面数字就变。
ref() 把任意类型的值包成一个带 .value 的响应式对象。是使用最多的一个。 const count = ref(0)
.value ref 对象的属性名,真正的值放在里面。JS 里必须写,模板里不用写。 count.value++
reactive() 把一个对象或数组变成响应式。返回的是原对象的代理,直接点属性,没有 .value。 const profile = reactive({ name: '小林' })
代理(Proxy) 一个替你挡在前面的中间人,你读写属性都得经过它,它顺手记录下来并通知页面。 reactive 内部就是靠它实现的。
依赖收集 Vue 在渲染时记录“这个页面用到了 count”,之后 count 一变就只更新用到它的地方。 不用你写,自动发生。

ref 与 reactive 的选择规则

你的数据是 推荐用 写法 读取时
数字、字符串、布尔 ref(0) const count = ref(0) JS 里 count.value
数组 ref([]) const list = ref([...]) JS 里 list.value.push(...)
一个对象(表单、资料) ref({...}) 或 reactive({...}) 两种都行 ref 要 .value,reactive 直接点
需要整体替换的对象/数组 ref list.value = [] 只有 ref 能整体换掉
关联紧密的一组字段 reactive reactive({ a, b, c }) 直接 obj.a

一句话规则:不确定就用 ref。 它通用、可整体替换、也容易看出来是个响应式对象(因为有 .value)。reactive 适合一个不想到处写 .value 的、结构固定的对象。

三个必须记住的规则

  1. ref 在 JS 里必须写 .value,模板里不用写。 模板里写 {{ count }},Vue 编译时自动帮你取 .value;在 <script setup> 的函数里就必须自己写 count.value。

  2. reactive 只能包对象或数组,不能包数字/字符串。 reactive(0) 不会报错但也不是响应式,改它页面不动。基本类型一律用 ref。

  3. reactive 解构会丢失响应性。 const { name } = profile 拿到的 name 是一个普通的字符串快照,之后它变了页面不会跟。要么不解构,要么用 toRefs(profile)。

本课只用四个组件

组件 什么时候用 达到的效果
<el-button> 触发增减、增删 普通/主要/危险三种按钮。
<el-input> 编辑对象的字段 输入框,v-model 双向绑定(第 6 课细讲)。
<el-tag> 显示状态 彩色徽章。
<el-card> 分区块 白色卡片,把三块内容分开。

只看这五行:谁在“盯着”数据

<script setup>
import { ref } from 'vue'

const count = ref(0)          // 包一个数字

const increase = () => {
  count.value++               // JS 里:必须 .value
}
</script>

<template>
  <p>当前人数:{{ count }}</p>  <!-- 模板里:不写 .value,Vue 自动帮你取 -->
  <el-button @click="increase">+1</el-button>
</template>

先口头预测

如果写成下面这样,点按钮页面数字会不会变?

const increase = () => {
  count++          // 漏了 .value
}

不会变。count 本身是一个带 .value 的包装对象,count++ 实际是在对“包装对象”做数字运算,得到 NaN,而真正被盯着的是 count.value,它根本没动。这就是新手最常见的坑之一。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第3/7页 · 先看一个小例子

完整代码:App.vue(点名册三件事)

<script setup>
import { ref, reactive } from 'vue'
import {
  ElCard, ElButton, ElInput, ElTable, ElTableColumn, ElTag, ElTag as _, ElDivider
} from 'element-plus'
import 'element-plus/dist/index.css'

/* ---------- 1. ref 包一个数字:签到人数 ---------- */
const count = ref(0)
const increase = () => { count.value++ }
const decrease = () => { if (count.value > 0) count.value-- }

/* ---------- 2. ref 包一个数组:学员名单 ---------- */
const students = ref([
  { id: 1, name: '小林', major: '软件工程' },
  { id: 2, name: '张伟', major: '大数据技术' }
])
const newName = ref('')
const addStudent = () => {
  const name = newName.value.trim()
  if (!name) return
  const nextId = students.value.length ? Math.max(...students.value.map(s => s.id)) + 1 : 1
  students.value.push({ id: nextId, name, major: '待分配' })   // 注意 .value
  newName.value = ''
}
const removeStudent = (id) => {
  students.value = students.value.filter(s => s.id !== id)      // 整体替换也走 .value
}

/* ---------- 3. reactive 包一个对象:正在编辑的资料 ---------- */
const profile = reactive({
  name: '小林',
  major: '软件工程',
  phone: '13800000000'
})
const resetProfile = () => {
  profile.name = '小林'
  profile.major = '软件工程'
  profile.phone = '13800000000'
}
</script>

<template>
  <div class="page">
    <h1 class="title">课堂点名册:三种数据,三种改法</h1>

    <!-- 第一块:ref 数字 -->
    <el-card class="block">
      <template #header>① ref 包数字 —— 签到人数</template>
      <div class="row">
        <span class="big">{{ count }}</span>
        <el-button type="primary" @click="increase">+1 签到</el-button>
        <el-button @click="decrease" :disabled="count === 0">-1 撤销</el-button>
        <el-tag v-if="count >= 3" type="success">已达到开课人数</el-tag>
      </div>
    </el-card>

    <!-- 第二块:ref 数组 -->
    <el-card class="block">
      <template #header>② ref 包数组 —— 学员名单</template>
      <div class="row">
        <el-input v-model="newName" placeholder="输入姓名后点添加" style="width: 220px" />
        <el-button type="primary" @click="addStudent">添加到名单</el-button>
        <el-tag type="info">共 {{ students.length }} 人</el-tag>
      </div>
      <el-table :data="students" border stripe style="margin-top: 12px">
        <el-table-column prop="id" label="序号" width="70" align="center" />
        <el-table-column prop="name" label="姓名" width="140" />
        <el-table-column prop="major" label="专业" />
        <el-table-column label="操作" width="100" align="center">
          <template #default="scope">
            <el-button type="danger" link size="small" @click="removeStudent(scope.row.id)">
              移除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>

    <!-- 第三块:reactive 对象 -->
    <el-card class="block">
      <template #header>③ reactive 包对象 —— 正在编辑的资料</template>
      <div class="grid">
        <div class="form">
          <el-input v-model="profile.name" placeholder="姓名" />
          <el-input v-model="profile.major" placeholder="专业" />
          <el-input v-model="profile.phone" placeholder="手机号" />
          <el-button @click="resetProfile">重置</el-button>
        </div>
        <el-descriptions-like>
          <div class="preview">
            <div class="pv-name">{{ profile.name }}</div>
            <div class="pv-line">专业:{{ profile.major }}</div>
            <div class="pv-line">电话:{{ profile.phone }}</div>
          </div>
        </el-descriptions-like>
      </div>
    </el-card>
  </div>
</template>

<style>
html, body, #app {
  margin: 0; padding: 0; max-width: none; width: auto; height: auto;
  display: block; place-items: initial; text-align: left; background: #f5f7fa;
}
</style>

<style scoped>
.page { max-width: 900px; margin: 28px auto; padding: 0 16px; }
.title { font-size: 22px; color: #303133; margin: 0 0 18px; }
.block { margin-bottom: 18px; }
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.big { font-size: 32px; font-weight: 700; color: #409eff; min-width: 60px; }
.grid { display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
.form { display: flex; flex-direction: column; gap: 10px; }
.preview { background: #f5f7fa; border-radius: 6px; padding: 16px 18px; }
.pv-name { font-size: 20px; font-weight: 600; color: #303133; margin-bottom: 8px; }
.pv-line { color: #606266; font-size: 14px; line-height: 1.9; }
</style>

注:上面为展示完整效果,el-descriptions-like 只是占位写法;实际粘贴时请用第 5 页给出的干净版本(去掉这个占位标签,直接用 <div class="preview">)。

预期看见什么

  1. 页面三张卡片,分别写着 ① ② ③ 的标题。
  2. 第一块有一个蓝色大数字 0。点“+1 签到”往上加;加到 3 时右侧出现绿色徽章“已达到开课人数”;点“-1 撤销”往下减,减到 0 时按钮自动变灰不可点。
  3. 第二块输入姓名点“添加到名单”,表格多一行,右上角人数跟着变;点某行的“移除”,该行消失,人数减一。
  4. 第三块左边改输入框,右边预览区逐字同步;点“重置”三个字段一起回去。

先猜一猜,读完代码再回答

第二块的 removeStudent 用了:

students.value = students.value.filter(s => s.id !== id)

如果写成 students = students.filter(...)(去掉 .value),会发生什么?

页面不会更新,而且控制台会报错。students 是一个 const 常量,不能重新赋值;而且就算能赋值,也只是把局部变量指向了新数组,Vue 盯着的还是旧的那个 ref 对象。要换掉整个数组,必须换掉 .value 里装的东西。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第4/7页 · 一步一步做出来

第1步:先亲手验证“普通变量不动”

在 src/App.vue 里先只写一个最小例子:

<script setup>
let plainCount = 0
const increasePlain = () => { plainCount++ }
</script>

<template>
  <p>普通变量:{{ plainCount }}</p>
  <el-button @click="increasePlain">+1</el-button>
</template>

运行后点按钮。预期:页面上的数字一直是 0,纹丝不动。

原因:Vue 只盯着被 ref/reactive 包装过的数据。普通变量改了,没有人通知页面重渲染。

第2步:换成 ref,再看一次

<script setup>
import { ref } from 'vue'
const count = ref(0)
const increase = () => { count.value++ }
</script>

<template>
  <p>响应式:{{ count }}</p>
  <el-button @click="increase">+1</el-button>
</template>

预期:点一下数字就加一。

这一步是本课的分水岭。请务必自己敲一遍,不要直接粘贴。

第3步:把 .value 去掉,看错误

把 count.value++ 改成 count++,保存再点按钮。

预期:数字变成 NaN(或者不动)。看 Console,能理解“对包装对象做运算”这件事本身是错的。

看完改回 count.value++。

第4步:整段替换为本课完整版

把第 3 页的完整代码整段覆盖 src/App.vue,但把 el-descriptions-like 那一对标签删掉,只保留里面的 <div class="preview">...</div>。

保存后确认三块都能交互。

第5步:验证“数组操作也走 .value”

在 addStudent 里把 students.value.push(...) 改成 students.push(...),保存,点“添加到名单”。

预期:Console 报 students.push is not a function。

看完改回来。记住:数组的方法(push/pop/splice/sort)也要从 students.value 上调用。

第6步:亲手踩一次 reactive 的坑

把第三块改成这样试试(不用保存太久):

const { name } = profile          // 解构
const test = () => { name = '张三' }

预期:改 name 后右边预览不跟着变。这就叫“解构丢失响应性”。

看完删掉这段代码。正确做法是:要么直接写 profile.name = '张三',要么用 toRefs(profile) 再解构。

本课文件速查

位置 本次作用
<script setup> 里 count ref 包的基本类型,演示 .value 的必要性。
<script setup> 里 students ref 包的数组,演示 push 与整体替换。
<script setup> 里 profile reactive 包的对象,演示无需 .value 的写法。
<script setup> 里 newName ref 包字符串,配合 v-model 收集输入。

完成修改后检查

操作或输入 预期观察
点“+1 签到” 3 次 数字为 3,绿色徽章出现。
点“-1 撤销” 3 次 数字回到 0,按钮变灰不可点。
添加一个学员 表格多一行,右上人数 +1。
移除第一行 该行消失,人数 -1。
改第三块姓名输入框 右侧预览逐字同步。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第5/7页 · 换成自己的代码

干净版本:第三块请用这个(去掉占位标签)

<el-card class="block">
  <template #header>③ reactive 包对象 —— 正在编辑的资料</template>
  <div class="grid">
    <div class="form">
      <el-input v-model="profile.name" placeholder="姓名" />
      <el-input v-model="profile.major" placeholder="专业" />
      <el-input v-model="profile.phone" placeholder="手机号" />
      <el-button @click="resetProfile">重置</el-button>
    </div>
    <div class="preview">
      <div class="pv-name">{{ profile.name }}</div>
      <div class="pv-line">专业:{{ profile.major }}</div>
      <div class="pv-line">电话:{{ profile.phone }}</div>
    </div>
  </div>
</el-card>

同时把顶部 import 里多余的 ElTag as _ 删掉。

你的任务

  1. 换成本班真实名单:把 students 初始数组改成 5 位同学的姓名与专业(姓名可以用同音字代替,专业写真实的)。
  2. 给计数器加一个上限:当 count 达到 10 时,“+1 签到”按钮变成禁用状态。 提示::disabled="count >= 10"。
  3. 增加一个“清空名单”按钮:点击后名单变空,人数归零。 提示:整体替换 students.value = [],不要用 length = 0(也能用,但对比一下两种写法)。
  4. 给资料对象加一个字段:增加 classNo(班号),同时在左侧加一个输入框、右侧预览里加一行显示。体验一下 reactive 对象“加字段也自动响应”。
  5. 解释一个现象:把 profile 从 reactive 改成 ref,模板里原来的 profile.name 要改成什么?请写在练习记录里(答案是 profile.value.name)。

按这张操作单完成

步骤 改哪里 运行后应该看到什么
1 换名单 students 初始数组 表格显示 5 位同学,人数显示 5。
2 加上限 “+1 签到”按钮的 :disabled 加到 10 后按钮变灰,点不动。
3 清空按钮 新增按钮 + clearAll 函数 点击后表格为空,人数为 0。
4 加字段 profile 加 classNo + 输入框 + 预览行 输入后预览即时更新。
5 ref 改写 把 reactive 换成 ref 并全部加 .value 页面表现完全一样,但代码里到处是 .value。

最低完成标准:先把这三项做实

检查项 通过条件
会说 .value 能指出代码里所有 .value 的位置,并说出模板里为什么不用写。
能整体替换 会写 students.value = students.value.filter(...),也知道直接 students.value = [] 能清空。
知道 reactive 边界 能说出 reactive(0) 为什么不行、解构为什么会丢响应性。

在本地写练习记录

追加以下内容:

  1. 抄下三个变量的声明行(count、students、profile),并在旁边标注“JS 里怎么读”。
  2. 记录你亲手验证的两个反面现象:count++ 的结果、students.push() 的报错原文。
  3. 写下任务 5 的结论:reactive 换成 ref 后,模板访问方式要不要改?为什么?

最后交什么

提交 src/App.vue 与 练习记录.md。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第6/7页 · 出错时从哪里查

把现象和检查动作对上

看到什么现象 / 报错 先检查哪里 修好后怎样确认
点按钮数字不动 是不是漏写了 .value;是不是用了普通 let 变量而不是 ref。 补上 .value 后数字正常变化。
数字变成 NaN 对 ref 对象本身做了加减乘除,例如 count++。 改成 count.value++。
students.push is not a function 在 ref 数组上直接调方法,漏了 .value。 改成 students.value.push(...)。
Assignment to constant variable 对 const 声明的变量整体赋值,例如 students = [...]。 改成赋值给 .value。
reactive(0) 后改了页面不动 reactive 只能包对象/数组。基本类型必须用 ref。 换成 ref(0)。
解构后数据变了页面不跟 直接从 reactive 对象解构。 改成 toRefs(profile) 后再解构,或不解构。
表格删行后页面没变 用了 delete students.value[i] 之类的写法,或重新赋值时改错了变量。 改用 filter 并赋值给 students.value。
改了对象里的字段,界面只变一半 模板里混用了 .value(模板不需要写),或者引用了别的同名变量。 统一模板写法后一致。

排错口诀

不动先查 .value,报错 NaN 也是它; 数组方法从 .value 上叫,整体替换也要赋给它; reactive 只包对象,解构要配 toRefs。

不要用这些办法“修复”

  1. 页面不更新时不要去点浏览器的刷新按钮来“让它更新”,那只能说明你写错了。
  2. 不要把 reactive 里的数据再复制一份到普通变量里用,两边会不同步。
  3. 不要为了省事把所有东西都塞进一个巨大的 reactive 对象里,调试时很难定位是哪一块出了问题。

需要老师帮忙时交这些

出错的那一段代码原文;Console 报错全文;你期望的数字/行数、实际看到的。说明你用的是什么类型的数据(数字 / 数组 / 对象)。

老师巡视时只问三句话

  1. count 在 JS 里怎么读?在模板里怎么读?(能分别指出 count.value 与 count)
  2. 要清空名单,你会写哪一行?(能写出 students.value = [])
  3. profile 为什么不用写 .value?(能说出 reactive 返回的是直接可点属性的代理对象)

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档


第7/7页 · 用自己的话讲一遍

回到第 3 页:你刚才猜对了吗?

students = students.filter(...) 为什么会出错?

参考解释:两层原因。第一,students 是 const,不能整体重新赋值。第二,就算换成 let,把变量指向新数组也不会让 Vue 知道——Vue 盯着的是原来那个 ref 包装对象,必须改它内部的 .value 才算“数据变了”。

回答本课的两个问题

问题 参考解释
ref 和 reactive 到底该选哪个? 默认用 ref,它通用、可整体替换。只有当一整个对象全程不需要替换、又不想到处写 .value 时,才用 reactive。
为什么模板里不用写 .value? 模板在编译阶段就被 Vue 处理过,遇到 ref 绑定的变量会自动加上 .value 再取值,这是语法糖。

五个必须能答上来的问题

考考自己 可以这样通俗回答
响应式到底是什么? 一种机制:数据被登记过,一变就通知用到它的地方重新计算和渲染。
ref 包装后,count 本身是什么? 一个对象,真正的数字在 count.value 里。
数组能整体替换吗? 能,list.value = [...];但不能直接给 list 赋值。
reactive 能包字符串吗? 不能。基本类型一律用 ref。
解构为什么丢响应性? 解构取出的是当前值的一份普通副本,它和原来的代理对象已经没有关系了。

一分钟现场演示指南

  1. 演示反面:先展示普通 let 变量改不动页面,说明问题。
  2. 演示正面:换成 ref,点按钮数字跳。随手把 .value 删掉让数字变 NaN,再改回来。
  3. 演示数组:添加、移除各一次,指出人数与表格同步变化。
  4. 演示对象:左边改一个字,右边即时同步,收尾一句:“对象这里没写一次 .value,因为它用的是 reactive。”

交作业前最后检查

  • src/App.vue 与 练习记录.md 已保存。
  • 确认代码里没有遗留的 el-descriptions-like 这类占位标签。
  • 确认 Console 没有任何红色错误。

课后再做一个小对照

把 profile 从 reactive 改写成 ref({...}),模板里全部改成 profile.value.xxx(或者只改模板、把 profile.name 换成 profile.value.name),跑通后再对比:两种写法页面表现一样,但代码里 .value 出现的次数差别很大。 写下你的偏好和理由。

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(响应式基础章节)· 组件依据 Element Plus 2.8 官方文档