Vue.js从零到精通系列(二):响应式核心——ref、reactive、computed与watch

摘要: 本篇文章深入Vue 3的响应式系统,我们将从Proxy响应式原理讲起,逐一攻克refreactive两个核心响应式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可以无缝监听对象的动态属性增删、数组索引变化,且性能更佳。对开发者来说,不再有那些"小心,此数据不是响应式的"坑。

你不必深入源码,只需记住:当你用refreactive包装了数据,Vue就用Proxy为你建立了一个"数据追踪网络",任何变化都会精确通知视图。

1.2 依赖收集与派发更新(通俗版)

想象一个快递系统:包裹(数据)被放入智能保管箱(Proxy),收件人(组件的render函数)的地址被自动登记(依赖收集)。当包裹内容发生改变,系统立刻通知收件人更新(派发更新)。整个流程自动、精准,无需你手动发短信。


二、响应式基础API:ref 和 reactive

Vue 3提供了两种核心方式创建响应式数据:refreactive。它们像两种不同的包装纸,分别适用于不同场景。

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 } = booktitle就不再是响应式的。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(只读),在模板中使用时自动解包。只要firstNamelastName不变,多次访问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接受两个参数:要监听的数据源和回调函数。数据源可以是refreactive对象,或者一个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 深度侦听与立即执行

默认情况下,watchreactive对象内部属性变化时能侦听到(深层侦听),但如果是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为classstyle属性提供了特殊的增强,让你可以用对象或数组动态切换样式。

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变为falseactive类被移除。

你还可以绑定一个响应式对象直接:

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

当你把classstyle属性放在自定义组件上时,这些属性会自动合并到组件的根元素上。如果你的组件有多个根节点(使用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 }"


七、总结

我们从响应式原理出发,通过refreactive构建数据驱动的基础,用computed优化派生逻辑,用watchwatchEffect处理副作用,最后用Class/Style绑定让界面随着数据变化而生动。你已经掌握了Vue响应式系统的核心API,足以编写大部分常见的数据交互逻辑。

  • 响应式由ES6 Proxy实现,自动追踪依赖并更新视图。

  • ref用于基本类型或需要替换的对象,访问需.valuereactive用于聚合对象,解构会丢失响应。

  • computed具有缓存,适合派生状态;可写计算属性常用于v-model。

  • watch可获新旧值,支持懒执行;watchEffect自动收集依赖,立即执行。

  • :class:style支持对象和数组语法,让CSS动态响应。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
放下华子我只抽RuiKe52 小时前
FastAPI 全栈后端(二):路由与数据模型
前端·人工智能·react.js·前端框架·html·fastapi
lichenyang4532 小时前
ArkTS 严格类型系统:我答错 2 道题后才真正搞懂的几条规则
前端
小小小小宇2 小时前
定高、不定高、瀑布流虚拟列表
前端
天启HTTP2 小时前
开启全局代理后网络变慢,问题出在哪
开发语言·前端·网络·tcp/ip·php
卡布鲁3 小时前
Webpack 核心原理与自定义 Loader/Plugin 实战
前端·javascript
小林ixn3 小时前
从拼多多手机号验证到模板引擎:深入正则表达式与 JS 字符串处理
开发语言·javascript·正则表达式
智码看视界3 小时前
Web Storage 的无障碍实践与工程化应用
前端·javascript·web
孟陬3 小时前
国外技术周刊 #140:在 Jeff Bezos 的私密 Campfire 峰会上,我学到了关于亿万富翁的事
前端·后端
槑有老呆3 小时前
Bun:一个让 Node 开发者原地起飞的 JS/TS 运行时
前端