Skip to content

响应式基础

Vue 的响应式系统是其核心特性之一。当你修改应用状态时,视图会自动更新。这种响应式的数据绑定让开发变得更加直观和高效。

声明响应式状态

选项式 API

在选项式 API 中,我们用 data 选项来声明组件的响应式状态:

vue
<template>
  <div>
    <button @click="increment">{{ count }}</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

data 选项的值应该是一个函数,返回一个对象。Vue 会在创建新组件实例的时候调用此函数,并将函数返回的对象用响应式系统进行包装。此对象的所有顶层属性都会被代理到组件实例上。

组合式 API

在组合式 API 中,我们可以使用 ref() 函数来声明响应式状态:

vue
<template>
  <div>
    <button @click="increment">{{ count }}</button>
  </div>
</template>

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

const count = ref(0)

function increment() {
  count.value++
}
</script>

ref() 接收参数,并将其包裹在一个带有 .value 属性的 ref 对象中返回。

ref()

ref() 让我们能创造一种对任意值的 "引用",并能够在不丢失响应性的前提下传递这些引用。

基本用法

javascript
import { ref } from 'vue'

const count = ref(0)
const message = ref('Hello')
const isVisible = ref(true)

console.log(count.value) // 0
count.value = 1
console.log(count.value) // 1

在模板中访问

当 ref 在模板中作为顶层属性被访问时,它们会被自动"解包",所以不需要使用 .value

vue
<template>
  <div>
    <p>{{ count }}</p> <!-- 无需 .value -->
    <button @click="count++">增加</button>
  </div>
</template>

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

const count = ref(0)
</script>

ref 解包的注意事项

只有当 ref 是模板渲染上下文的顶层属性时才适用自动"解包"。例如,foo 是顶层属性,但 object.foo 不是。

javascript
const object = { foo: ref(1) }

下面的表达式将不会按预期工作:

vue
<template>
  {{ object.foo + 1 }} <!-- 这不会按预期工作 -->
</template>

渲染的结果会是 [object Object]1,因为 object.foo 是一个 ref 对象。我们可以通过将 foo 改为顶层属性来解决这个问题:

javascript
const { foo } = object
vue
<template>
  {{ foo + 1 }} <!-- 现在可以正常工作了 -->
</template>

reactive()

还有另一种声明响应式状态的方式,即使用 reactive() API。与将内部值包装在特殊对象中的 ref 不同,reactive() 将使对象本身具有响应性:

javascript
import { reactive } from 'vue'

const state = reactive({ count: 0 })

在模板中使用:

vue
<template>
  <div>
    <button @click="state.count++">
      {{ state.count }}
    </button>
  </div>
</template>

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

const state = reactive({ count: 0 })
</script>

reactive() 的局限性

reactive() API 有一些局限性:

  1. 有限的值类型:它只能用于对象类型(对象、数组和如 MapSet 这样的集合类型)。它不能持有如 stringnumberboolean 这样的原始类型。

  2. 不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地"替换"响应式对象,因为这样的话与第一个引用的响应性连接将丢失:

javascript
let state = reactive({ count: 0 })

// 上面的 ({ count: 0 }) 引用将不再被追踪
// (响应性连接已丢失!)
state = reactive({ count: 1 })
  1. 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:
javascript
const state = reactive({ count: 0 })

// 当解构时,count 已经与 state.count 断开连接
let { count } = state
// 不会影响原始的 state
count++

// 该函数接收到的是一个普通的数字
// 并且无法追踪 state.count 的变化
callSomeFunction(state.count)

ref() vs reactive()

何时使用 ref()

  • 声明原始类型的响应式数据
  • 需要重新分配整个值
  • 在组合函数中返回响应式数据
javascript
// 原始类型
const count = ref(0)
const message = ref('hello')

// 重新分配
const todos = ref([])
todos.value = newTodos

// 组合函数
function useCounter() {
  const count = ref(0)
  return { count }
}

何时使用 reactive()

  • 声明对象类型的响应式数据
  • 不需要重新分配整个对象
  • 本地状态管理
javascript
// 对象状态
const state = reactive({
  user: {
    name: 'John',
    age: 30
  },
  settings: {
    theme: 'dark'
  }
})

// 表单数据
const form = reactive({
  name: '',
  email: '',
  message: ''
})

响应式代理 vs 原始对象

值得注意的是,reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的:

javascript
const raw = {}
const proxy = reactive(raw)

// 代理对象和原始对象不是全等的
console.log(proxy === raw) // false

只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 Vue 的响应式系统的最佳实践是仅使用你声明对象的代理版本

为保证访问代理的一致性,对同一个原始对象调用 reactive() 会总是返回同样的代理对象,而对一个已存在的代理对象调用 reactive() 会返回其本身:

javascript
// 在同一个对象上调用 reactive() 会返回相同的代理
console.log(reactive(raw) === proxy) // true

// 在一个代理上调用 reactive() 会返回它自己
console.log(reactive(proxy) === proxy) // true

实际应用示例

计数器组件

vue
<template>
  <div class="counter">
    <h2>计数器: {{ count }}</h2>
    <div class="buttons">
      <button @click="decrement" :disabled="count <= 0">-</button>
      <button @click="increment">+</button>
      <button @click="reset">重置</button>
    </div>
    <p>计数器状态: {{ count > 0 ? '正数' : count < 0 ? '负数' : '零' }}</p>
  </div>
</template>

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

const count = ref(0)

function increment() {
  count.value++
}

function decrement() {
  count.value--
}

function reset() {
  count.value = 0
}
</script>

<style scoped>
.counter {
  text-align: center;
  padding: 20px;
}

.buttons {
  margin: 20px 0;
}

button {
  margin: 0 5px;
  padding: 8px 16px;
  font-size: 16px;
  cursor: pointer;
}

button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

用户信息表单

vue
<template>
  <div class="user-form">
    <h2>用户信息</h2>
    <form @submit.prevent="submitForm">
      <div class="form-group">
        <label for="name">姓名:</label>
        <input 
          id="name"
          v-model="form.name" 
          type="text" 
          required
        >
      </div>
      
      <div class="form-group">
        <label for="email">邮箱:</label>
        <input 
          id="email"
          v-model="form.email" 
          type="email" 
          required
        >
      </div>
      
      <div class="form-group">
        <label for="age">年龄:</label>
        <input 
          id="age"
          v-model.number="form.age" 
          type="number" 
          min="1" 
          max="120"
        >
      </div>
      
      <div class="form-group">
        <label>
          <input 
            v-model="form.subscribe" 
            type="checkbox"
          >
          订阅邮件通知
        </label>
      </div>
      
      <button type="submit" :disabled="!isFormValid">提交</button>
      <button type="button" @click="resetForm">重置</button>
    </form>
    
    <div v-if="submitted" class="result">
      <h3>提交的信息:</h3>
      <pre>{{ JSON.stringify(form, null, 2) }}</pre>
    </div>
  </div>
</template>

<script setup>
import { reactive, ref, computed } from 'vue'

const form = reactive({
  name: '',
  email: '',
  age: null,
  subscribe: false
})

const submitted = ref(false)

const isFormValid = computed(() => {
  return form.name.trim() && form.email.trim()
})

function submitForm() {
  console.log('提交表单:', form)
  submitted.value = true
}

function resetForm() {
  form.name = ''
  form.email = ''
  form.age = null
  form.subscribe = false
  submitted.value = false
}
</script>

<style scoped>
.user-form {
  max-width: 400px;
  margin: 0 auto;
  padding: 20px;
}

.form-group {
  margin-bottom: 15px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

input[type="text"],
input[type="email"],
input[type="number"] {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}

input[type="checkbox"] {
  margin-right: 8px;
}

button {
  margin-right: 10px;
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
}

button[type="button"] {
  background-color: #6c757d;
}

.result {
  margin-top: 20px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 4px;
}

pre {
  background-color: #e9ecef;
  padding: 10px;
  border-radius: 4px;
  overflow-x: auto;
}
</style>

待办事项列表

vue
<template>
  <div class="todo-app">
    <h2>待办事项</h2>
    
    <div class="add-todo">
      <input 
        v-model="newTodo"
        @keyup.enter="addTodo"
        placeholder="添加新的待办事项..."
        class="todo-input"
      >
      <button @click="addTodo" :disabled="!newTodo.trim()">添加</button>
    </div>
    
    <div class="filters">
      <button 
        v-for="filter in filters" 
        :key="filter.key"
        @click="currentFilter = filter.key"
        :class="{ active: currentFilter === filter.key }"
      >
        {{ filter.label }}
      </button>
    </div>
    
    <ul class="todo-list">
      <li 
        v-for="todo in filteredTodos" 
        :key="todo.id"
        :class="{ completed: todo.completed }"
      >
        <input 
          v-model="todo.completed"
          type="checkbox"
          class="todo-checkbox"
        >
        <span class="todo-text">{{ todo.text }}</span>
        <button @click="removeTodo(todo.id)" class="remove-btn">删除</button>
      </li>
    </ul>
    
    <div v-if="todos.length === 0" class="empty-state">
      暂无待办事项
    </div>
    
    <div v-if="todos.length > 0" class="stats">
      <span>总计: {{ todos.length }}</span>
      <span>已完成: {{ completedCount }}</span>
      <span>未完成: {{ remainingCount }}</span>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'

const newTodo = ref('')
const currentFilter = ref('all')
const todos = ref([])
const nextId = ref(1)

const filters = reactive([
  { key: 'all', label: '全部' },
  { key: 'active', label: '未完成' },
  { key: 'completed', label: '已完成' }
])

const filteredTodos = computed(() => {
  switch (currentFilter.value) {
    case 'active':
      return todos.value.filter(todo => !todo.completed)
    case 'completed':
      return todos.value.filter(todo => todo.completed)
    default:
      return todos.value
  }
})

const completedCount = computed(() => {
  return todos.value.filter(todo => todo.completed).length
})

const remainingCount = computed(() => {
  return todos.value.length - completedCount.value
})

function addTodo() {
  if (newTodo.value.trim()) {
    todos.value.push({
      id: nextId.value++,
      text: newTodo.value.trim(),
      completed: false
    })
    newTodo.value = ''
  }
}

function removeTodo(id) {
  const index = todos.value.findIndex(todo => todo.id === id)
  if (index > -1) {
    todos.value.splice(index, 1)
  }
}
</script>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.add-todo {
  display: flex;
  margin-bottom: 20px;
}

.todo-input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 4px 0 0 4px;
  font-size: 16px;
}

.add-todo button {
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 0 4px 4px 0;
  cursor: pointer;
}

.filters {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

.filters button {
  padding: 8px 16px;
  border: 1px solid #ddd;
  background-color: white;
  border-radius: 4px;
  cursor: pointer;
}

.filters button.active {
  background-color: #007bff;
  color: white;
}

.todo-list {
  list-style: none;
  padding: 0;
}

.todo-list li {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.todo-list li.completed {
  opacity: 0.6;
}

.todo-checkbox {
  margin-right: 10px;
}

.todo-text {
  flex: 1;
  font-size: 16px;
}

.todo-list li.completed .todo-text {
  text-decoration: line-through;
}

.remove-btn {
  padding: 4px 8px;
  background-color: #dc3545;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 12px;
}

.empty-state {
  text-align: center;
  color: #666;
  font-style: italic;
  padding: 40px;
}

.stats {
  display: flex;
  justify-content: space-between;
  margin-top: 20px;
  padding: 10px;
  background-color: #f8f9fa;
  border-radius: 4px;
  font-size: 14px;
}
</style>

最佳实践

  1. 优先使用 ref():对于大多数情况,推荐使用 ref(),因为它更灵活且没有 reactive() 的限制。

  2. 保持响应式引用:避免解构响应式对象,如果需要解构,使用 toRefs()toRef()

  3. 合理组织状态:将相关的状态组织在一起,使用 reactive() 管理复杂的对象状态。

  4. 避免深层嵌套:过深的响应式对象嵌套可能影响性能,考虑扁平化数据结构。

  5. 使用 readonly:对于不应该被修改的数据,使用 readonly() 创建只读代理。

下一步

现在你已经了解了 Vue 的响应式基础,让我们继续学习:

响应式系统是 Vue 的核心,掌握它将让你能够构建高效、动态的应用程序!

基于 Vue.js 官方文档构建的学习宝典