从零理解 Vue3 响应式:一个 Todo 应用讲透 ref、computed 与 v-for 的正确姿势

45 阅读5分钟

在前端开发的世界里,我们常常会面临一个根本性的问题:如何高效、优雅地操作页面内容?
过去,我们习惯于直接操作 DOM(Document Object Model)——通过 getElementByIdquerySelector 等方式找到元素,再手动修改其内容、样式或行为。这种方式虽然直观,但随着应用复杂度的提升,代码会变得冗长、难以维护,且极易出错。

而 Vue.js 的出现,彻底改变了我们的思维方式:不再关注“怎么改页面”,而是聚焦于“数据如何变化” 。本文将带你深入理解这一范式转变,并通过一个完整的 Todo 任务清单应用,手把手讲解 Vue3 Composition API 的核心概念与最佳实践。


🧠 第一章:从传统 DOM 编程说起 —— 为什么我们需要框架?

在没有框架的时代,我们要实现一个简单的 Todo 列表,可能需要这样写:

// 1. 获取输入框和列表容器
const input = document.getElementById('todo-input');
const list = document.getElementById('todo-list');

// 2. 绑定回车事件
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') {
    const text = input.value.trim();
    if (!text) return;

    // 3. 手动创建 DOM 元素
    const li = document.createElement('li');
    li.innerHTML = `<span>${text}</span><button>删除</button>`;
    
    // 4. 手动绑定删除事件
    li.querySelector('button').addEventListener('click', () => {
      li.remove();
    });

    // 5. 插入到页面
    list.appendChild(li);
    input.value = '';
  }
});

这段代码看似简单,但隐藏着几个严重问题:

  • 逻辑与视图耦合:业务逻辑(添加任务)和 DOM 操作混在一起。
  • 状态管理困难:如果要统计“未完成任务数量”,你得遍历所有 <li> 元素,检查是否被勾选。
  • 可维护性差:一旦需求变更(比如支持编辑、拖拽排序),代码会迅速膨胀。
  • 性能隐患:频繁操作 DOM 会导致重排重绘,影响性能。

💡 关键认知:DOM 是浏览器渲染的结果,不是应用状态本身。我们应该把精力放在管理数据状态上,让框架自动同步到视图。


❓ 答疑解惑:为什么说“操作 DOM 是低效的”?

现代 Web 应用的核心是状态驱动视图。每次用户交互(点击、输入)都会改变应用的状态(如任务列表、筛选条件),而视图应自动反映这些变化。

手动操作 DOM 相当于“硬编码”了状态到视图的映射关系。一旦状态结构变化(比如任务增加优先级字段),所有相关 DOM 操作都要重写。

而 Vue 通过响应式系统,自动追踪数据依赖,在数据变化时精准更新受影响的 DOM 节点,开发者只需声明“数据是什么”,无需关心“如何更新”。


🚀 第二章:Vue 的核心思想 —— 数据驱动视图

“Vue 做法不再思考页面的元素怎么操作,而是要思考数据是怎么变化的。”

这句话道出了 Vue 的灵魂。让我们看看你的 Todo 应用是如何体现这一点的。

✨ 响应式数据:refreactive

const title = ref("Todos任务清单");
const todos = ref([
  { id: 1, title: "吃饭", done: false },
  // ...
]);

这里 ref 是 Vue3 Composition API 提供的响应式引用。它将普通 JavaScript 值包装成一个响应式对象,任何对其 .value 的读写都会被 Vue 追踪。

🔍 干货知识

  • ref 适用于基本类型(string, number, boolean)和对象/数组
  • 在模板中使用 ref 时,Vue 会自动解包 .value,所以你写 {{ title }} 而不是 {{ title.value }}
  • 如果你有一个复杂对象,也可以用 reactive(),它直接返回一个响应式代理对象,无需 .value
// 使用 reactive
const state = reactive({
  title: "Todos",
  todos: []
});
// 模板中:{{ state.title }}

⚠️ 注意refreactive 不能混用。建议统一使用 ref,因其更通用(可包裹任何类型),且在组合函数中更易传递。


❓ 答疑解惑:为什么 ref 需要 .value,但在模板里不用?

这是 Vue 的智能解包机制。在模板编译阶段,Vue 会检测变量是否为 ref,如果是,则自动访问其 .value。这避免了模板中到处写 .value 的冗余。

但在 JavaScript 逻辑中(如 setup() 函数内),你必须显式使用 .value,因为此时运行的是纯 JS,Vue 无法干预。

// 正确
console.log(title.value); // "Todos任务清单"

// 错误(不会触发响应式更新)
title = "New Title"; // 这只是替换 ref 对象,不是修改其值!

🔁 第三章:列表渲染 —— v-for 的正确姿势

v-for 指令用于循环输出数组中的元素,语法:v-for="(item, index) in todos" :key="index"

这个理解基本正确,但有一个重要陷阱不要用 index 作为 key

✅ 正确的 key 使用方式

<li v-for="todo in todos" :key="todo.id">

这才是最佳实践!为什么?

🔍 干货知识key 的作用是帮助 Vue 识别每个节点的唯一身份,从而在列表更新时高效复用和移动 DOM 元素。

  • 如果用 index 作 key,当你在列表中间插入或删除元素时,后续所有元素的 index 都会变化,导致 Vue 认为“所有节点都变了”,从而销毁并重建整个列表 —— 性能灾难!
  • 而用唯一 ID(如 todo.id),Vue 能精准知道哪些节点新增、哪些移动、哪些删除,只做最小化 DOM 操作。

⚠️ 注意key 必须是稳定、唯一、不可变的。通常来自后端数据库 ID,或前端生成的 UUID。


❓ 答疑解惑:如果我的数据没有 ID 怎么办?

可以临时用 Symbol() 或时间戳生成唯一标识,但强烈建议从源头解决——任务数据理应有唯一标识。如果没有,说明数据模型设计有问题。

// 临时方案(不推荐长期使用)
todos.value.push({
  id: Symbol(), // 或 Date.now() + Math.random()
  title: ...,
  done: false
});

更好的做法是在添加任务时生成可靠 ID:

let nextId = ref(1);
const addTodo = () => {
  todos.value.push({
    id: nextId.value++,
    title: title.value,
    done: false
  });
};

🔄 第四章:双向绑定与事件处理 —— v-model@

你的输入框这样写:

<input type="text" v-model="title" @keydown.enter="addTodo" />

这展示了 Vue 的两大利器:

1. v-model:双向数据绑定

v-model="title" 等价于:

<input 
  :value="title" 
  @input="title = $event.target.value"
/>

它自动将输入框的 valuetitle 同步:用户输入 → 更新 titletitle 变化 → 更新输入框显示。

🔍 干货知识v-model 不仅用于 input,还可用于自定义组件,实现父子组件的数据同步。

2. 事件修饰符:.enter

@keydown.enter="addTodo" 表示“监听 keydown 事件,且只有按 Enter 键时才触发”。

Vue 提供了丰富的事件修饰符

  • .stop:阻止事件冒泡
  • .prevent:阻止默认行为
  • .capture:使用捕获模式
  • .once:只触发一次
  • .self:只有 event.target 是元素自身时才触发

⚠️ 注意@v-on: 的缩写,:v-bind: 的缩写。这是 Vue 的语法糖,让模板更简洁。


❓ 答疑解惑:v-model 能用于非 input 元素吗?

可以!但需手动实现。例如,对一个自定义组件:

<template>
  <MyInput v-model="searchText" />
</template>

<!-- MyInput.vue -->
<template>
  <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>
<script setup>
defineProps(['modelValue']);
defineEmits(['update:modelValue']);
</script>

这就是 v-model 的底层原理:prop + emit


🧮 第五章:计算属性 —— computed 的强大之处

computed 实现了两个关键功能:

// 未完成任务数
const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length;
});

// 全选/取消全选
const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    todos.value.forEach(todo => todo.done = value);
  }
});

✨ 为什么用 computed 而不用普通函数?

  • 缓存性computed基于响应式依赖缓存的。只要 todos 没变,多次访问 active 不会重复执行过滤操作。
  • 响应式:当 todos 变化时,active 自动重新计算,并触发视图更新。

相比之下,方法(methods)每次渲染都会调用,无缓存。

🔍 干货知识computed 的 getter/setter 模式非常适合“派生状态”的双向绑定。allDone 就是一个典型例子:它由子项状态计算得出(get),但用户也能通过勾选它来反向控制所有子项(set)。


⚠️ 注意事项

  • computed 的 getter 中,只能读取响应式数据,不能修改(否则会破坏单向数据流)。
  • setter 中可以修改数据,但要小心无限循环(例如 setter 又触发了 getter 的依赖变化)。

❓ 答疑解惑:computedwatch 有什么区别?

  • computed:用于声明式派生状态。你描述“这个值等于什么”,Vue 自动管理依赖和更新。

    const fullName = computed(() => firstName.value + ' ' + lastName.value);
    
  • watch:用于响应式副作用。当某个数据变化时,执行特定逻辑(如发请求、打日志)。

    watch(todos, (newVal) => {
      localStorage.setItem('todos', JSON.stringify(newVal));
    });
    

原则:能用 computed 就不用 watchwatch 是命令式,computed 是声明式,后者更符合 Vue 哲学。


🎨 第六章:动态样式与条件渲染

<span :class="{ done: todo.done }">{{ todo.title }}</span>

这使用了 Vue 的动态 class 绑定

✨ 动态 Class 的几种写法

  1. 对象语法(最常用):

    <div :class="{ active: isActive, error: hasError }"></div>
    
  2. 数组语法

    <div :class="[isActive ? 'active' : '', errorClass]"></div>
    

配合 CSS:

.done {
  color: gray;
  text-decoration: line-through;
}

即可实现“任务完成时变灰并加删除线”。

✨ 条件渲染:v-if vs v-show

<ul v-if="todos.length">...</ul>
<div v-else>暂无计划</div>
  • v-if真正地销毁/创建 DOM 元素。适合条件很少改变的场景。
  • v-show始终渲染 DOM,仅切换 display: none。适合频繁切换的场景。

⚠️ 注意v-if 有更高的切换开销,v-show 有更高的初始渲染开销。根据使用频率选择。


❓ 答疑解惑:为什么不用 v-show 显示“暂无计划”?

因为“暂无计划”和“任务列表”是互斥的两种 UI 状态,且切换不频繁(只有初始化和清空时)。用 v-if 更语义化,也节省内存(空列表时不渲染 ul)。


🧩 第七章:Composition API 的优势 —— setup() 与逻辑组织

<script setup>,这是 Vue3 的编译时语法糖,等价于:

export default {
  setup() {
    // 你的代码
    return { title, todos, addTodo, active, allDone };
  }
}

✨ 为什么 Composition API 比 Options API 更好?

  • 逻辑关注点分离:Options API(data, methods, computed 分离)在大型组件中会导致代码碎片化。而 Composition API 允许你将相关逻辑(如 Todo 管理)封装在同一个代码块中。
  • 更好的 TypeScript 支持
  • 更灵活的逻辑复用(通过自定义 Hook)。

例如,你可以将 Todo 逻辑抽离成一个 useTodos.js

// useTodos.js
export function useTodos() {
  const todos = ref([]);
  const active = computed(...);
  const addTodo = () => { ... };
  return { todos, active, addTodo };
}

// App.vue
const { todos, active, addTodo } = useTodos();

❓ 答疑解惑:<script setup> 和普通 setup() 有什么区别?

  • <script setup>编译时优化,无需显式 return,变量自动暴露给模板。
  • 它更简洁,且在 IDE 中有更好的类型推导。
  • 适用于大多数场景。只有需要兼容旧版或动态组件时,才用普通 setup()

🏁 总结:从命令式到声明式的思维跃迁

通过这个 Todo 应用,我们完成了从前端开发范式的根本转变:

传统 DOM 编程Vue 响应式编程
找元素 → 改内容定义数据 → 声明模板
手动绑定事件声明式事件处理
遍历 DOM 统计状态计算属性自动派生
代码与视图强耦合逻辑与模板清晰分离

🌈 记住:Vue 不是“操作页面的工具”,而是“管理状态的框架”。你越早接受“数据驱动视图”的思想,就越能写出简洁、健壮、可维护的代码。


📚 延伸思考

  1. 如何持久化 Todo 数据?
    使用 watch 监听 todos,存入 localStorage
  2. 如何支持任务编辑?
    为每个 todo 添加 editing 状态,双击进入编辑模式。
  3. 如何单元测试这个组件?
    使用 @vue/test-utils 模拟用户输入、断言 DOM 状态。
  4. 性能优化点?
    大列表可用 v-memo(Vue3.2+)或虚拟滚动。

这是篇巩固Vue3 的核心概念的文章,是否点燃了你对声明式编程之美的热爱?Happy Coding! 💻✨