Skip to content

L03 · 响应式数据:让 Todo 动起来 ​

🎯 本节目标:掌握 ref() 和 reactive(),在组件中管理动态数据
📦 本节产出:可动态添加 Todo 的应用 + 理解响应式心智模型
🔗 前置钩子:L02 的 TodoItem 组件(接收 Props 渲染)
🔗 后续钩子:L04 将用 v-for 渲染 Todo 数组

1. 什么是"响应式" ​

问题: 用原生 JS 操作 DOM,数据和视图是断开的。

javascript
// 原生 JS:手动同步数据和视图
let count = 0
document.getElementById('counter').textContent = count // 初始渲染

function increment() {
  count++
  document.getElementById('counter').textContent = count // 💀 必须手动更新 DOM
}

Vue 的响应式: 数据变了 → 视图自动更新,你不需要碰 DOM。


2. ref():让原始值变成响应式 ​

2.1 基本用法 ​

vue
<script setup lang="ts">
import { ref } from 'vue'

// ref 把原始值包装成响应式引用
const count = ref(0)        // 类型自动推断为 Ref<number>
const message = ref('Hello') // Ref<string>
const isDone = ref(false)    // Ref<boolean>

// 在 JS 中读写需要 .value
console.log(count.value)     // 0
count.value++                // 触发视图更新
console.log(count.value)     // 1
</script>

<template>
  <!-- 在模板中自动解包,不需要 .value -->
  <p>{{ count }}</p>
  <p>{{ message }}</p>
</template>

模板会解包顶层 ref;数组或 Map 中的 ref 元素等场景仍需 .value,详见 官方解包规则。

2.2 .value 的存在理由 ​

为什么不能像普通变量那样 count = 1 就触发更新?

JavaScript 没有提供拦截普通局部变量赋值的运行时接口。Vue 的 ref 把值放进对象,通过 .value 的 getter / setter 追踪读取和通知更新;reactive 则用 Proxy 拦截对象操作。ref 保存对象时,默认也会把内部对象转换成响应式代理。参见 Vue 响应式原理。

深入原理请参考深度专题 D05。


3. reactive():让对象变成响应式 ​

vue
<script setup lang="ts">
import { reactive } from 'vue'

// reactive 直接让对象变成响应式
const todo = reactive({
  text: '学习 Vue 3',
  done: false,
  priority: 'medium' as 'low' | 'medium' | 'high'
})

// 不需要 .value,直接读写属性
console.log(todo.text)       // '学习 Vue 3'
todo.done = true             // 触发视图更新
todo.text = '已学完 Vue 3'   // 触发视图更新
</script>

<template>
  <p>{{ todo.text }}</p>
  <p>{{ todo.done ? '✅ 完成' : '⬜ 进行中' }}</p>
</template>

3.1 reactive 的限制 ​

下面的“丢失”是指现有依赖与变量之间的连接断开,新创建的代理本身仍是响应式对象。解构到嵌套对象时也可能保留该对象的代理,不能把所有解构都视为失效。

typescript
import { reactive } from 'vue'

// ✅ 对象
const state = reactive({ count: 0 })

// ❌ 原始值 —— reactive 不接受
// const count = reactive(0)  // TS Error + 运行时警告

// ❌ 解构原始值属性会断开响应式连接
const { count } = reactive({ count: 0 })
// count 现在是普通数字 0,与 reactive 对象断开连接

// ❌ 整体替换会丢失响应式
let state2 = reactive({ count: 0 })
state2 = reactive({ count: 1 })  // 变量赋值不会通知依赖旧代理的 effect

4. ref vs reactive:如何选择 ​

特性ref()reactive()
接受类型任意类型(原始值 + 对象)仅对象
读写方式JS 中 .value,模板中自动解包直接读写属性
可以整体替换✅ state.value = newObj 会通知依赖变量重新赋值不会通知旧代理的依赖
解构时的注意点取出 .value 的原始值只是快照;对象中的 ref 可直接解构保留引用原始值属性需用 toRefs() 保持关联
TypeScript自动推断 Ref<T>自动推断

本教程的选择:优先用 ref()

理由:

  1. ref 能处理所有类型,心智模型统一
  2. ref 可以整体替换(todos.value = newArray),reactive 不行
  3. Vue 官方文档也推荐 ref 作为默认选择

5. 实战:给 Todo App 加上动态数据 ​

5.1 定义 Todo 类型 ​

先创建类型定义文件 src/types/todo.ts:

typescript
// src/types/todo.ts
export interface Todo {
  id: number
  text: string
  done: boolean
  priority: 'low' | 'medium' | 'high'
  createdAt: string
}

5.2 在 App.vue 中使用 ref ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TodoItem from './components/TodoItem.vue'
import type { Todo } from './types/todo'

// 用 ref 管理 Todo 列表
const todos = ref<Todo[]>([
  {
    id: 1,
    text: '搭建项目脚手架',
    done: true,
    priority: 'low',
    createdAt: '2024-01-01',
  },
  {
    id: 2,
    text: '理解组件和 Props',
    done: true,
    priority: 'medium',
    createdAt: '2024-01-02',
  },
  {
    id: 3,
    text: '学习响应式系统',
    done: false,
    priority: 'high',
    createdAt: '2024-01-03',
  },
])

// 单次运行期间递增,避免同一毫秒添加时重复
let nextTodoId = 4

// 新 Todo 的输入文本
const newTodoText = ref('')

// 添加 Todo
function addTodo() {
  const text = newTodoText.value.trim()
  if (!text) return

  todos.value.push({
    id: nextTodoId++,
    text,
    done: false,
    priority: 'medium',
    createdAt: new Date().toISOString().slice(0, 10), // UTC 日期
  })

  newTodoText.value = '' // 清空输入框
}
</script>

<template>
  <div class="app">
    <header class="app-header">
      <h1>📝 Vue Todo</h1>
      <p class="subtitle">{{ todos.length }} 个任务</p>
    </header>

    <div class="add-todo">
      <input
        :value="newTodoText"
        @input="newTodoText = ($event.target as HTMLInputElement).value"
        @keydown.enter="!$event.isComposing && addTodo()"
        placeholder="添加新任务..."
        class="todo-input"
      />
      <button @click="addTodo" class="add-btn">添加</button>
    </div>

    <main class="app-main">
      <!-- 暂时硬编码索引,L04 会用 v-for -->
      <TodoItem
        v-if="todos[0]"
        :text="todos[0].text"
        :done="todos[0].done"
        :priority="todos[0].priority"
        :created-at="todos[0].createdAt"
      />
      <TodoItem
        v-if="todos[1]"
        :text="todos[1].text"
        :done="todos[1].done"
        :priority="todos[1].priority"
        :created-at="todos[1].createdAt"
      />
      <TodoItem
        v-if="todos[2]"
        :text="todos[2].text"
        :done="todos[2].done"
        :priority="todos[2].priority"
        :created-at="todos[2].createdAt"
      />
      <p class="hint">⚠️ 目前只能显示前 3 条,L04 用 v-for 解决</p>
    </main>
  </div>
</template>

<style scoped>
.app {
  max-width: 640px;
  margin: 0 auto;
  padding: 2rem;
}

.app-header {
  text-align: center;
  margin-bottom: 2rem;
}

.app-header h1 {
  font-size: 2rem;
  color: #42b883;
  margin-bottom: 0.25rem;
}

.subtitle {
  color: #888;
  font-size: 0.9rem;
}

.add-todo {
  display: flex;
  gap: 8px;
  margin-bottom: 1.5rem;
}

.todo-input {
  flex: 1;
  padding: 10px 16px;
  border: 2px solid #e8e8e8;
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
  transition: border-color 0.2s;
}

.todo-input:focus {
  border-color: #42b883;
}

.add-btn {
  padding: 10px 20px;
  background: #42b883;
  color: #fff;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  cursor: pointer;
  transition: background 0.2s;
}

.add-btn:hover {
  background: #38a575;
}

.hint {
  text-align: center;
  color: #999;
  font-size: 0.85rem;
  margin-top: 1rem;
}
</style>

6. 响应式更新的流程 ​

当你在输入框输入文字并点击"添加"时,发生了什么:

要点:

  1. 你只修改了数据(todos.value.push(...) 和 newTodoText.value = "")
  2. 没有写任何 DOM 操作代码
  3. Vue 自动检测到数据变化,自动更新视图

7. DevTools 中观察响应式 ​

安装 Vue DevTools 浏览器插件,打开 F12 → Vue 标签页。

你可以:

  • 查看每个组件的 Props 和 State
  • 实时编辑 ref 的值,观察视图变化
  • 追踪组件的渲染触发原因

8. 常见陷阱 ​

8.1 忘记 .value ​

typescript
const count = ref(0)

// ❌ 错误:直接操作 ref 对象,而不是 .value
count++         // ref 对象不是数字,不能 ++
if (count) {}   // ref 对象始终 truthy,即使 value 是 0

// ✅ 正确
count.value++
if (count.value) {}

8.2 reactive 解构丢失响应式 ​

typescript
const state = reactive({ count: 0, name: 'Vue' })

// ❌ 解构后变成普通变量,失去响应式
let { count: plainCount } = state
plainCount++ // 只修改普通数字,不会触发 state 的更新

// ✅ 用 toRefs 解构
import { toRefs } from 'vue'
const { count, name } = toRefs(state)
count.value++ // 保持响应式

8.3 整体替换 reactive 对象 ​

typescript
let state = reactive<{ items: number[] }>({ items: [] })

// ❌ 整体替换,旧引用丢失
state = reactive({ items: [1, 2, 3] })

// ✅ 修改属性
state.items = [1, 2, 3]

// ✅ 或者用 ref 就没有这个问题
const state2 = ref<{ items: number[] }>({ items: [] })
state2.value = { items: [1, 2, 3] } // OK

9. 本节总结 ​

知识图谱 ​

检查清单 ​

  • [ ] 能解释"响应式"是什么意思
  • [ ] 能用 ref() 创建响应式原始值
  • [ ] 能用 reactive() 创建响应式对象
  • [ ] 知道 .value 存在的原因
  • [ ] 知道模板中 ref 自动解包(不需要 .value)
  • [ ] 能说出 ref 和 reactive 的区别与选择策略
  • [ ] 知道三个常见陷阱(忘记 .value、解构丢失、整体替换)
  • [ ] 能在 Vue DevTools 中查看组件状态

课后练习 ​

练习 1:跟做(15 min) 完整实现本节的 Todo 添加功能,确认输入框输入 → 点击添加 → 任务总数增加 → 输入框清空。本节只显示前 3 条,新条目会在 L04 的列表改造后显示。

练习 2:举一反三(15 min) 为每个 Todo 添加"删除"按钮:在 App.vue 中实现 deleteTodo(id: number) 函数,用 todos.value = todos.value.filter(...) 删除指定项。思考:为什么 filter 返回新数组也会触发视图更新?

挑战题(20 min) 不使用 ref,尝试用普通的 let count = 0 实现一个计数器,点击按钮 count++ 后观察页面是否更新。然后改为 ref(0),对比行为差异。用自己的话总结"响应式"的含义。

Git 提交 ​

bash
git add .
git commit -m "L03: ref/reactive 响应式数据 + 动态添加 Todo"

🔗 钩子连接 ​

→ 下一节:L04 · 列表渲染:v-for + key ​

L03 遗留的问题:硬编码 todos[0]、todos[1]、todos[2],新添加的 Todo 显示不出来。

L04 将用 v-for 指令循环渲染整个数组,彻底解决这个问题。同时讲透 key 在 Virtual DOM diff 算法中的作用。