摘要: 本篇文章深入Vue 3的响应式系统,我们将从Proxy响应式原理讲起,逐一攻克ref、reactive两个核心响应式API,并学习如何使用TypeScript为它们标注类型。接着,你会掌握计算属性computed的缓存魔法与侦听器watch/watchEffect的副作用处理能力。最后,通过Class与Style的动态绑定让页面样式也能"响应"起来。
一、从一个谜题开始:Vue如何知道数据变了?
回顾上一篇,我们在<template>中写下{``{ greeting }},修改data里的greeting值,视图立刻更新。这背后的秘密就是响应式系统。
1.1 Vue 2的"属性劫持"与Vue 3的Proxy
-
Vue 2时代 :使用
Object.defineProperty将数据对象的每个属性转为getter/setter。当读取属性时收集依赖(哪些视图依赖此属性),当设置属性时触发更新。这种方式有两个缺陷:无法检测对象属性的添加/删除,也不能直接响应数组索引和length变化(Vue 2使用了特殊的方法包裹数组)。 -
Vue 3破局 :采用ES6的
Proxy对象。Proxy可以直接代理整个对象,拦截包括属性读取、赋值、删除、in操作等在内的13种操作。这意味着Vue 3可以无缝监听对象的动态属性增删、数组索引变化,且性能更佳。对开发者来说,不再有那些"小心,此数据不是响应式的"坑。
你不必深入源码,只需记住:当你用ref或reactive包装了数据,Vue就用Proxy为你建立了一个"数据追踪网络",任何变化都会精确通知视图。
1.2 依赖收集与派发更新(通俗版)
想象一个快递系统:包裹(数据)被放入智能保管箱(Proxy),收件人(组件的render函数)的地址被自动登记(依赖收集)。当包裹内容发生改变,系统立刻通知收件人更新(派发更新)。整个流程自动、精准,无需你手动发短信。
二、响应式基础API:ref 和 reactive
Vue 3提供了两种核心方式创建响应式数据:ref和reactive。它们像两种不同的包装纸,分别适用于不同场景。
2.1 ref:将基本类型变成"响应式引用"
ref可以包装任何值,但通常用来给基本类型(string, number, boolean)添加响应式。在<script setup>中使用非常简洁:
javascript
<script setup lang="ts">
import { ref } from 'vue'
// 定义一个类型为 number 的响应式引用
const count = ref<number>(0)
const message = ref<string>('Hello Vue 3')
</script>
<template>
<p>计数:{{ count }}</p>
<p>消息:{{ message }}</p>
</template>

在模板中,Vue会自动"解包".value,所以我们直接写count而不是count.value。但在JavaScript/TypeScript逻辑中,你必须通过.value访问和修改其值:
javascript
function increment() {
count.value++
}
为什么需要.value? ref是一个带有.value属性的对象,Vue通过追踪这个对象的.value属性来知晓变化。这种设计是为了在传递基本类型时不丢失响应性。如果你有React背景,可以类比useState返回的[value, setValue],但Vue的ref更直接,修改值就自动触发更新,无需调用setter函数。
TypeScript类型注解 ref<number>(0)显式指定了泛型类型。如果不传,TypeScript会根据初始值自动推断,例如ref(0)会推断为ref<number>,但显式指定可以让类型更明确。对于复杂对象,也可以使用ref<User>({...})。
2.2 reactive:直接让对象本身变为响应式
reactive返回一个对象的响应式代理,适用于复合数据类型(对象、数组)。它不需要.value,直接像原生对象一样使用:
javascript
<script setup lang="ts">
import { reactive } from 'vue'
interface Book {
title: string
author: string
price: number
}
const book = reactive<Book>({
title: 'Vue.js从入门到精通',
author: '你',
price: 99
})
function changeTitle() {
book.title = '新版标题' // 直接修改,视图自动更新
}
</script>
<template>
<h2>{{ book.title }}</h2>
<p>作者:{{ book.author }} | 价格:¥{{ book.price }}</p>
<button @click="changeTitle">修改标题</button>
</template>

reactive的局限
-
reactive仅对对象类型有效(对象、数组、Map、Set等),对基本类型无效。如果你尝试reactive(0),开发环境会报警告。 -
不能随意替换整个对象:如果你把
book重新赋值为一个新对象,会破坏响应性连接。因此通常用ref来包裹可能整个替换的数据。 -
解构会丢失响应性:若使用ES6解构提取值,如
let { title } = book,title就不再是响应式的。Vue提供了toRefs来解决这个问题。
2.3 ref与reactive如何选择?
一个很实用的原则:
-
单个基本值或需要灵活替换的对象 → 用
ref。 -
一个聚合了很多状态的对象,且不需要整个替换 → 用
reactive。 -
实际项目中,
ref使用更为广泛,因为它可以包裹任何类型,且配合<script setup>的编译优化,模板中无需.value的特性让代码更整洁。尤雨溪也推荐在大多数场景优先考虑ref。
2.4 响应式数据在模板中的深层访问
无论是ref包裹对象还是reactive对象,模板中都可以逐层访问:
javascript
<script setup lang="ts">
import { reactive } from 'vue'
const user = reactive({
name: 'Alice',
address: {
city: 'Beijing',
zip: '100000'
}
})
</script>
<template>
<p>城市:{{ user.address.city }}</p>
</template>
user.address.city是深层响应式的:当你修改user.address.city = 'Shanghai',视图会同步更新。这个深层侦测由Proxy递归实现。

三、计算属性 computed:会"缓存"的动态值
当模板里需要根据已有数据衍生出一些新数据时,可以使用计算属性computed。它像一个高效率的数据处理工厂,会缓存计算结果,只在依赖变化时才重新计算。
3.1 基本用法
javascript
<script setup lang="ts">
import { ref, computed } from 'vue'
const firstName = ref<string>('张')
const lastName = ref<string>('三')
// 计算属性返回一个只读的ref
const fullName = computed<string>(() => {
return firstName.value + ' ' + lastName.value
})
</script>
<template>
<p>姓名:{{ fullName }}</p>
</template>
fullName现在就是一个ref(只读),在模板中使用时自动解包。只要firstName或lastName不变,多次访问fullName都会立刻返回缓存结果,不会重复执行计算函数。

3.2 对比方法:缓存的重要性
你可能会问:为什么不直接在模板写方法?比如:
javascript
<p>{{ getFullName() }}</p>
当然可以,但每次渲染都会调用getFullName方法。如果计算开销很大(例如遍历大数组),且数据未变,就会造成性能浪费。计算属性仅在依赖改变时重新求值,性能更优。
3.3 可写计算属性
默认computed是只读的。但我们可以通过提供getter和setter创建一个可写的计算属性:
javascript
<script setup lang="ts">
import { ref, computed } from 'vue'
const givenName = ref<string>('John')
const familyName = ref<string>('Doe')
const fullName = computed({
get: () => givenName.value + ' ' + familyName.value,
set: (newValue: string) => {
const parts = newValue.split(' ')
givenName.value = parts[0]
familyName.value = parts[1] || ''
}
})
</script>
当你在某个地方修改fullName.value = 'Jane Smith'时,它会自动拆分成名和姓,更新原始ref。这种方式在表单封装、v-model双向绑定时特别有用。
3.4 TypeScript类型提示
computed<string>显式声明返回值类型。如果不写,TypeScript可以从getter函数返回值自动推断,但显式声明可提高可读性。
四、侦听器 watch 与 watchEffect:主动响应副作用
计算属性擅长"纯计算",但当数据变化需要执行异步操作、操作DOM、修改其他非响应式状态等"副作用"时,就需要侦听器。
4.1 watch:精准追踪一个或多个数据源
watch接受两个参数:要监听的数据源和回调函数。数据源可以是ref、reactive对象,或者一个getter函数。回调函数会得到新值和旧值。
监听单个ref
javascript
<script setup lang="ts">
import { ref, watch } from 'vue'
const searchText = ref<string>('')
// 监听ref
watch(searchText, (newVal, oldVal) => {
console.log(`搜索词从 "${oldVal}" 变为 "${newVal}"`)
// 可以在这里发送API请求
})
</script>
监听reactive对象的属性
监听reactive对象的某个属性,需要使用getter函数:
javascript
const state = reactive({ count: 0 })
watch(
() => state.count,
(newVal, oldVal) => {
console.log('count变化了')
}
)
监听多个数据源
用数组包裹:
javascript
const x = ref(0)
const y = ref(0)
watch([x, y], ([newX, newY], [oldX, oldY]) => {
console.log(`x: ${oldX} -> ${newX}, y: ${oldY} -> ${newY}`)
})
4.2 深度侦听与立即执行
默认情况下,watch在reactive对象内部属性变化时能侦听到(深层侦听),但如果是ref包裹对象,需要手动开启deep: true选项。另外可通过immediate: true让回调在侦听开始时就立即执行一次。
javascript
const obj = ref({ a: { b: 1 } })
watch(obj, (newVal) => {
console.log('obj变化了')
}, { deep: true, immediate: true })
4.3 watchEffect:自动追踪依赖,懒人神器
watchEffect会立即运行传入的函数,并自动追踪函数中所有被访问的响应式数据。当这些数据变化时,它会重新执行该函数。相比watch,它更简洁,不需要手动声明依赖,但无法获得旧值。
javascript
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
const userId = ref(1)
const userData = ref(null)
// 立即执行,自动追踪userId
watchEffect(async () => {
// 这里访问了 userId.value,因此它成为依赖
const response = await fetch(`https://api.example.com/user/${userId.value}`)
userData.value = await response.json()
})
</script>
在这个例子中,当userId变化,watchEffect自动重新运行,重新拉取数据。你不需要像watch那样显式指定监听userId。
清理副作用 :在watchEffect的函数中可以接收一个onCleanup参数,用来注册清理函数(如取消未完成的请求),避免竞态问题。
4.4 选择watch还是watchEffect?
-
需要比较新旧值、懒执行、精确控制 → 用
watch。 -
逻辑简单、让框架自动管理依赖 → 用
watchEffect。 -
在组合式函数或初始化时需要立即执行 →
watchEffect更自然。
五、让样式也"响应":Class与Style绑定
数据驱动视图不仅是内容的更新,也包括样式。Vue为class和style属性提供了特殊的增强,让你可以用对象或数组动态切换样式。
5.1 动态绑定HTML Class
对象语法
可以根据布尔值动态决定类是否存在:
javascript
<script setup lang="ts">
import { ref } from 'vue'
const isActive = ref(true)
const hasError = ref(false)
</script>
<template>
<div :class="{ active: isActive, 'text-danger': hasError }">
动态类测试
</div>
</template>
渲染结果:<div class="active">。当isActive变为false,active类被移除。
你还可以绑定一个响应式对象直接:
javascript
<script setup lang="ts">
import { reactive } from 'vue'
const classObject = reactive({
active: true,
'text-danger': false
})
</script>
<template>
<div :class="classObject">对象绑定</div>
</template>
数组语法
需要添加多个类,可以使用数组:
javascript
<template>
<div :class="[activeClass, errorClass]">数组绑定</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const activeClass = ref('active')
const errorClass = ref('text-danger')
</script>
数组里还可以嵌套对象语法灵活组合:
javascript
<div :class="[{ active: isActive }, errorClass]"></div>
5.2 绑定内联Style
内联样式的绑定同样支持对象和数组。
对象语法
CSS属性名可以用驼峰式或短横线分隔(需加引号):
javascript
<template>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }">
动态内联样式
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const activeColor = ref('red')
const fontSize = ref(16)
</script>
更清晰的做法是直接绑定一个样式对象:
javascript
const styleObject = reactive({
color: 'red',
fontSize: '16px'
})
<div :style="styleObject">
数组语法
可以同时应用多个样式对象:
javascript
<div :style="[baseStyles, overridingStyles]"></div>
5.3 组件上的Class和Style
当你把class或style属性放在自定义组件上时,这些属性会自动合并到组件的根元素上。如果你的组件有多个根节点(使用Fragment),可以通过$attrs手动指定哪个节点接收。
六、综合案例:待办事项初版(TodoList)
让我们把上面的知识串联起来,写一个带计数和动态样式的简单待办应用。
javascript
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
interface Todo {
id: number
text: string
done: boolean
}
// 状态
const todos = ref<Todo[]>([
{ id: 1, text: '学习ref和reactive', done: false },
{ id: 2, text: '掌握computed和watch', done: false }
])
const newTodoText = ref('')
let nextId = 3
// 计算属性:未完成数量
const activeTodoCount = computed(() => todos.value.filter(t => !t.done).length)
// 计算属性:所有是否完成
const allDone = computed({
get: () => activeTodoCount.value === 0,
set: (value: boolean) => {
todos.value.forEach(todo => todo.done = value)
}
})
// 侦听器:当所有待办完成时提示
watch(allDone, (newVal) => {
if (newVal) {
console.log('恭喜!所有任务已完成!')
}
})
// 添加任务
function addTodo() {
const text = newTodoText.value.trim()
if (text) {
todos.value.push({ id: nextId++, text, done: false })
newTodoText.value = ''
}
}
// 删除任务
function removeTodo(id: number) {
todos.value = todos.value.filter(todo => todo.id !== id)
}
</script>
<template>
<div class="todo-app">
<div class="todo-card">
<h2>📋 待办事项</h2>
<p class="todo-count">剩余未完成:<span>{{ activeTodoCount }}</span> 项</p>
<!-- 输入区域 -->
<div class="input-wrapper">
<input
v-model="newTodoText"
@keyup.enter="addTodo"
placeholder="请输入新任务..."
class="todo-input"
/>
<button @click="addTodo" class="add-btn">添加</button>
</div>
<!-- 任务列表 -->
<ul class="todo-list">
<li v-for="todo in todos" :key="todo.id" :class="{ done: todo.done }" class="todo-item">
<input type="checkbox" v-model="todo.done" class="todo-checkbox" />
<span class="todo-text">{{ todo.text }}</span>
<button @click="removeTodo(todo.id)" class="delete-btn">删除</button>
</li>
</ul>
<!-- 全选/取消全选 -->
<label class="toggle-all">
<input type="checkbox" v-model="allDone" class="toggle-checkbox" />
全部标为{{ allDone ? '未完成' : '完成' }}
</label>
</div>
</div>
</template>
<style scoped>
/* 整体页面布局 */
.todo-app {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4eaf5 100%);
padding: 20px;
}
/* 卡片容器 */
.todo-card {
width: 100%;
max-width: 480px;
background: #ffffff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
}
/* 标题 */
.todo-card h2 {
text-align: center;
color: #2d3748;
margin: 0 0 12px;
font-size: 24px;
font-weight: 600;
}
/* 未完成数量 */
.todo-count {
text-align: center;
color: #718096;
font-size: 14px;
margin-bottom: 24px;
}
.todo-count span {
color: #4299e1;
font-weight: 600;
}
/* 输入框区域 */
.input-wrapper {
display: flex;
gap: 10px;
margin-bottom: 24px;
}
.todo-input {
flex: 1;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
outline: none;
transition: all 0.2s;
}
.todo-input:focus {
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.15);
}
.add-btn {
padding: 12px 20px;
background: #4299e1;
color: white;
border: none;
border-radius: 10px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #3182ce;
}
/* 任务列表 */
.todo-list {
list-style: none;
padding: 0;
margin: 0 0 20px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* 单个任务项 */
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: #f7fafc;
border-radius: 10px;
transition: background 0.2s;
}
.todo-item:hover {
background: #edf2f7;
}
/* 已完成样式 */
.done .todo-text {
text-decoration: line-through;
color: #a0aec0;
}
/* 复选框 */
.todo-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #48bb78;
}
/* 任务文本 */
.todo-text {
flex: 1;
font-size: 15px;
color: #2d3748;
word-break: break-all;
}
/* 删除按钮 */
.delete-btn {
padding: 6px 10px;
background: #fef2f2;
color: #dc2626;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
background: #fecaca;
}
/* 全选区域 */
.toggle-all {
display: flex;
align-items: center;
gap: 8px;
color: #718096;
font-size: 14px;
cursor: pointer;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.toggle-checkbox {
cursor: pointer;
accent-color: #4299e1;
}
</style>

代码解读:
-
使用
ref<Todo[]>存储列表,因为我们会替换整个数组(删除时用filter),这符合ref的推荐用法。 -
activeTodoCount是计算属性,依赖todos,具有缓存。 -
allDone是可写计算属性,绑定到全选复选框,既能获取全选状态,又能通过赋值设置所有done。 -
使用
v-model双向绑定输入框和复选框,简洁高效。 -
动态绑定类:
:class="{ done: todo.done }"。
七、总结
我们从响应式原理出发,通过ref和reactive构建数据驱动的基础,用computed优化派生逻辑,用watch和watchEffect处理副作用,最后用Class/Style绑定让界面随着数据变化而生动。你已经掌握了Vue响应式系统的核心API,足以编写大部分常见的数据交互逻辑。
-
响应式由ES6 Proxy实现,自动追踪依赖并更新视图。
-
ref用于基本类型或需要替换的对象,访问需.value;reactive用于聚合对象,解构会丢失响应。 -
computed具有缓存,适合派生状态;可写计算属性常用于v-model。 -
watch可获新旧值,支持懒执行;watchEffect自动收集依赖,立即执行。 -
:class和:style支持对象和数组语法,让CSS动态响应。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。