第3次 · 响应式状态:ref 与 reactive
第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 的、结构固定的对象。
三个必须记住的规则
-
ref在 JS 里必须写.value,模板里不用写。 模板里写{{ count }},Vue 编译时自动帮你取.value;在<script setup>的函数里就必须自己写count.value。 -
reactive只能包对象或数组,不能包数字/字符串。reactive(0)不会报错但也不是响应式,改它页面不动。基本类型一律用ref。 -
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">)。
预期看见什么
- 页面三张卡片,分别写着 ① ② ③ 的标题。
- 第一块有一个蓝色大数字
0。点“+1 签到”往上加;加到 3 时右侧出现绿色徽章“已达到开课人数”;点“-1 撤销”往下减,减到 0 时按钮自动变灰不可点。 - 第二块输入姓名点“添加到名单”,表格多一行,右上角人数跟着变;点某行的“移除”,该行消失,人数减一。
- 第三块左边改输入框,右边预览区逐字同步;点“重置”三个字段一起回去。
先猜一猜,读完代码再回答
第二块的 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 _ 删掉。
你的任务
- 换成本班真实名单:把
students初始数组改成 5 位同学的姓名与专业(姓名可以用同音字代替,专业写真实的)。 - 给计数器加一个上限:当
count达到 10 时,“+1 签到”按钮变成禁用状态。 提示::disabled="count >= 10"。 - 增加一个“清空名单”按钮:点击后名单变空,人数归零。
提示:整体替换
students.value = [],不要用length = 0(也能用,但对比一下两种写法)。 - 给资料对象加一个字段:增加
classNo(班号),同时在左侧加一个输入框、右侧预览里加一行显示。体验一下reactive对象“加字段也自动响应”。 - 解释一个现象:把
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) 为什么不行、解构为什么会丢响应性。 |
在本地写练习记录
追加以下内容:
- 抄下三个变量的声明行(
count、students、profile),并在旁边标注“JS 里怎么读”。 - 记录你亲手验证的两个反面现象:
count++的结果、students.push()的报错原文。 - 写下任务 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。
不要用这些办法“修复”
- 页面不更新时不要去点浏览器的刷新按钮来“让它更新”,那只能说明你写错了。
- 不要把
reactive里的数据再复制一份到普通变量里用,两边会不同步。 - 不要为了省事把所有东西都塞进一个巨大的
reactive对象里,调试时很难定位是哪一块出了问题。
需要老师帮忙时交这些
出错的那一段代码原文;Console 报错全文;你期望的数字/行数、实际看到的。说明你用的是什么类型的数据(数字 / 数组 / 对象)。
老师巡视时只问三句话
count在 JS 里怎么读?在模板里怎么读?(能分别指出count.value与count)- 要清空名单,你会写哪一行?(能写出
students.value = []) 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。 |
| 解构为什么丢响应性? | 解构取出的是当前值的一份普通副本,它和原来的代理对象已经没有关系了。 |
一分钟现场演示指南
- 演示反面:先展示普通
let变量改不动页面,说明问题。 - 演示正面:换成
ref,点按钮数字跳。随手把.value删掉让数字变NaN,再改回来。 - 演示数组:添加、移除各一次,指出人数与表格同步变化。
- 演示对象:左边改一个字,右边即时同步,收尾一句:“对象这里没写一次
.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 官方文档