第 115 章:响应式基础
学习目标
- 理解 Vue 3 响应式系统的核心原理
- 掌握 ref、reactive、computed 的用法
- 学会 watch 和 watchEffect 的使用
- 区分 ref 与 reactive 的适用场景
一、什么是响应式
响应式:当数据变化时,依赖该数据的视图自动更新。
1.1 一个最简单的例子
vue
<script setup>
import { ref } from 'vue';
const count = ref(0);
const increment = () => count.value++;
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>点击按钮 → count.value++ → Vue 检测到变化 → 模板自动更新。
1.2 响应式 vs 普通变量
typescript
// ❌ 普通变量:改变后视图不会更新
let count = 0;
const increment = () => {
count++;
// 视图不会更新,因为 Vue 没有追踪 count
};
// ✅ 响应式:改变后视图自动更新
import { ref } from 'vue';
const count = ref(0);
const increment = () => {
count.value++;
// 视图自动更新
};二、ref:定义响应式数据
2.1 基本用法
typescript
import { ref } from 'vue';
const count = ref(0); // number
const name = ref('Tom'); // string
const list = ref([1, 2, 3]); // 数组
const user = ref({ id: 1 }); // 对象
const nothing = ref(null); // null2.2 访问与修改
typescript
const count = ref(0);
console.log(count.value); // 0
count.value = 10; // 修改
count.value++; // 自增模板自动解包
在 <template> 中使用 ref 时,Vue 会自动 .value,不需要写 .value。
vue
<script setup>
const count = ref(0);
</script>
<template>
<!-- 不用 .value -->
<p>{{ count }}</p>
<button @click="count++">+</button>
</template>2.3 ref 包装对象
typescript
const user = ref({ name: 'Tom', age: 18 });
// 修改整个对象
user.value = { name: 'Jerry', age: 20 };
// 修改属性(自动响应式)
user.value.name = 'Jerry';
user.value.age = 21;
// 在模板中,顶层属性自动解包
// user.value 是对象,需要通过 user.name 访问三、reactive:定义响应式对象
3.1 基本用法
typescript
import { reactive } from 'vue';
const state = reactive({
count: 0,
name: 'Tom',
list: [1, 2, 3],
user: { id: 1 }
});
// 直接访问/修改属性(不需要 .value)
state.count++;
state.user.name = 'Jerry';
state.list.push(4);3.2 reactive 的局限性
typescript
const state = reactive({ count: 0 });
// ❌ 整体替换不响应
state = reactive({ count: 1 }); // 报错!const 不能重新赋值
// ✅ 用 ref 替代
const count = ref(0);
count.value = 1; // ✅
// 或
let state = reactive({ count: 0 });
state = reactive({ count: 1 }); // ✅ 但失去响应式
// ✅ 用 Object.assign
Object.assign(state, { count: 1, name: 'New' });3.3 ref vs reactive
| 维度 | ref | reactive |
|---|---|---|
| 适用类型 | 任意 | 仅对象 |
| 访问 | .value | 直接访问 |
| 整体替换 | ✅ 支持 | ❌ 不支持 |
| 解构 | 丢失响应式 | 丢失响应式 |
| TS 推断 | 精确 | 自动推断 |
3.4 何时选哪个
typescript
// ✅ ref:基本类型 / 需要整体替换 / 单值
const count = ref(0);
const name = ref('Tom');
// ✅ reactive:对象/数组,且不需要整体替换
const state = reactive({
user: null,
list: []
});
// ✅ 实际项目推荐:全部用 ref + 单个 store
const user = ref<User | null>(null);
const list = ref<User[]>([]);四、computed:计算属性
4.1 基本用法
typescript
import { ref, computed } from 'vue';
const firstName = ref('Tom');
const lastName = ref('Jerry');
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`;
});
// fullName 是一个 ref
console.log(fullName.value); // 'Tom Jerry'4.2 特点:有缓存
typescript
const count = ref(0);
const doubled = computed(() => {
console.log('计算 doubled');
return count.value * 2;
});
console.log(doubled.value); // 计算 doubled, 0
console.log(doubled.value); // 不再计算,直接返回缓存
count.value = 1;
console.log(doubled.value); // 计算 doubled, 2(因为 count 变了)4.3 可写的计算属性
typescript
const firstName = ref('Tom');
const lastName = ref('Jerry');
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`;
},
set(newValue: string) {
const [first, last] = newValue.split(' ');
firstName.value = first;
lastName.value = last;
}
});
fullName.value = 'Alice Cooper';
console.log(firstName.value); // 'Alice'
console.log(lastName.value); // 'Cooper'五、watch:侦听器
5.1 基本用法
typescript
import { ref, watch } from 'vue';
const count = ref(0);
// 监听 ref
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} -> ${newVal}`);
});
// 修改 count 触发回调
count.value = 1; // 输出: count: 0 -> 15.2 监听 reactive 对象的属性
typescript
const user = reactive({ name: 'Tom', age: 18 });
// ❌ 直接监听 reactive 对象不生效
// watch(user, ...) // ❌
// ✅ 监听 reactive 的某个属性(getter 形式)
watch(
() => user.name,
(newVal, oldVal) => {
console.log(`name: ${oldVal} -> ${newVal}`);
}
);5.3 监听多个数据源
typescript
const firstName = ref('Tom');
const lastName = ref('Jerry');
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`${oldFirst} ${oldLast} -> ${newFirst} ${newLast}`);
}
);5.4 选项
typescript
const count = ref(0);
// 立即执行
watch(count, (val) => {
console.log(val);
}, { immediate: true });
// 深监听(reactive 对象)
const state = reactive({ nested: { count: 0 } });
watch(state, (val) => {
console.log(val);
}, { deep: true });5.5 watchEffect:自动收集依赖
typescript
import { watchEffect } from 'vue';
const count = ref(0);
// 自动追踪函数内用到的响应式数据
watchEffect(() => {
console.log('count is:', count.value);
// 任何 count 变化都会触发
});
count.value = 1; // 输出: count is: 1watch vs watchEffect:
| 维度 | watch | watchEffect |
|---|---|---|
| 监听源 | 显式指定 | 自动收集 |
| 初始执行 | 默认不执行 | 立即执行 |
| 访问新旧值 | ✅ 支持 | ❌ 不支持 |
| 适用 | 已知监听源 | 自动追踪 |
六、解构响应式对象
6.1 直接解构会丢失响应式
typescript
const state = reactive({ count: 0, name: 'Tom' });
// ❌ 丢失响应式
const { count, name } = state;
console.log(count); // 普通 number,变化不触发更新6.2 用 toRefs 保留响应式
typescript
import { toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Tom' });
// ✅ 解构后仍是 ref
const { count, name } = toRefs(state);
console.log(count.value); // ref 对象,变化触发更新6.3 用 toRef 单独转换
typescript
import { toRef } from 'vue';
const state = reactive({ count: 0 });
// 单个属性转 ref
const countRef = toRef(state, 'count');七、响应式工具函数
7.1 isRef / isReactive / isReadonly
typescript
import { ref, reactive, readonly, isRef, isReactive, isReadonly } from 'vue';
const r = ref(0);
const obj = reactive({});
const ro = readonly({});
isRef(r); // true
isReactive(obj); // true
isReadonly(ro); // true7.2 unref
typescript
import { unref } from 'vue';
const r = ref(0);
const value = unref(r); // 等价于 r.value,如果是普通值则原样返回7.3 shallowRef / shallowReactive
typescript
import { shallowRef, shallowReactive } from 'vue';
// 浅响应式:只对顶层属性响应
const state = shallowReactive({
count: 0,
nested: { value: 0 }
});
state.count = 1; // ✅ 响应
state.nested.value = 1; // ❌ 不响应7.4 readonly
typescript
import { readonly } from 'vue';
const original = reactive({ count: 0 });
const copy = readonly(original);
// 修改会报警告
copy.count = 1; // ⚠️ Set operation on key "count" failed八、实战案例
8.1 待办列表
vue
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Todo {
id: number;
text: string;
done: boolean;
}
const todos = ref<Todo[]>([]);
const newTodo = ref('');
// 添加
const add = () => {
if (!newTodo.value.trim()) return;
todos.value.push({
id: Date.now(),
text: newTodo.value,
done: false
});
newTodo.value = '';
};
// 删除
const remove = (id: number) => {
todos.value = todos.value.filter((t) => t.id !== id);
};
// 切换状态
const toggle = (id: number) => {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
};
// 统计
const remaining = computed(
() => todos.value.filter((t) => !t.done).length
);
</script>
<template>
<div class="todo">
<input v-model="newTodo" @keyup.enter="add" placeholder="添加待办" />
<button @click="add">添加</button>
<ul>
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" :checked="todo.done" @change="toggle(todo.id)" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
<button @click="remove(todo.id)">删除</button>
</li>
</ul>
<p>剩余: {{ remaining }} 项</p>
</div>
</template>8.2 搜索过滤
vue
<script setup lang="ts">
import { ref, computed } from 'vue';
const search = ref('');
const items = ref([
'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'
]);
const filteredItems = computed(() => {
if (!search.value) return items.value;
return items.value.filter((item) =>
item.toLowerCase().includes(search.value.toLowerCase())
);
});
</script>
<template>
<input v-model="search" placeholder="搜索..." />
<ul>
<li v-for="item in filteredItems" :key="item">{{ item }}</li>
</ul>
</template>8.3 异步数据加载
vue
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
const userId = ref(1);
const user = ref<{ name: string } | null>(null);
const loading = ref(false);
watchEffect(async () => {
loading.value = true;
user.value = await fetch(`/api/users/${userId.value}`).then((r) => r.json());
loading.value = false;
});
</script>
<template>
<input v-model.number="userId" type="number" />
<p v-if="loading">加载中...</p>
<p v-else-if="user">{{ user.name }}</p>
</template>九、常见错误
9.1 解构丢失响应式
typescript
const state = reactive({ count: 0 });
// ❌
const { count } = state;
// ✅
const { count } = toRefs(state);
// 或者
const countRef = toRef(state, 'count');9.2 ref 在模板外忘 .value
typescript
const count = ref(0);
// ❌ 在 script 中直接用
setTimeout(() => {
console.log(count); // ref 对象
}, 1000);
// ✅
setTimeout(() => {
console.log(count.value);
}, 1000);9.3 整体替换 reactive 对象
typescript
const state = reactive({ count: 0 });
// ❌ 整体赋值不响应
state = reactive({ count: 1 });
// ✅ 修改属性
state.count = 1;
// ✅ 用 ref 替代
const state = ref({ count: 0 });
state.value = { count: 1 };十、本章小结
| API | 作用 |
|---|---|
ref | 任意类型的响应式数据 |
reactive | 对象/数组的响应式代理 |
computed | 带缓存的派生值 |
watch | 显式监听数据变化 |
watchEffect | 自动收集依赖 |
toRefs / toRef | 解构保留响应式 |
readonly | 只读代理 |
shallowRef / shallowReactive | 浅响应式 |
动手练习
- 计算属性:写一个购物车,显示商品总数和总价
- watch:监听表单输入,实现"输入即搜索"(debounce)
- watchEffect:实现一个自动同步 localStorage 的响应式数据
- 解构:用 toRefs 解构 reactive 对象,验证模板中仍响应
推荐阅读
- 📖 Vue 3 响应式基础 — 官方文档
- 📖 Vue 3 响应式进阶 — 深入响应式
- 🌐 Vue 3 Reactivity Transform — 实验性语法
下一章:第 116 章:组件基础 →