在前端开发的世界里,我们常常会面临一个根本性的问题:如何高效、优雅地操作页面内容?
过去,我们习惯于直接操作 DOM(Document Object Model)——通过 getElementById、querySelector 等方式找到元素,再手动修改其内容、样式或行为。这种方式虽然直观,但随着应用复杂度的提升,代码会变得冗长、难以维护,且极易出错。
而 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 应用是如何体现这一点的。
✨ 响应式数据:ref 与 reactive
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 }}
⚠️ 注意:
ref和reactive不能混用。建议统一使用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"
/>
它自动将输入框的 value 与 title 同步:用户输入 → 更新 title;title 变化 → 更新输入框显示。
🔍 干货知识:
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 的依赖变化)。
❓ 答疑解惑:computed 和 watch 有什么区别?
-
computed:用于声明式派生状态。你描述“这个值等于什么”,Vue 自动管理依赖和更新。const fullName = computed(() => firstName.value + ' ' + lastName.value); -
watch:用于响应式副作用。当某个数据变化时,执行特定逻辑(如发请求、打日志)。watch(todos, (newVal) => { localStorage.setItem('todos', JSON.stringify(newVal)); });
原则:能用 computed 就不用 watch。watch 是命令式,computed 是声明式,后者更符合 Vue 哲学。
🎨 第六章:动态样式与条件渲染
<span :class="{ done: todo.done }">{{ todo.title }}</span>
这使用了 Vue 的动态 class 绑定。
✨ 动态 Class 的几种写法
-
对象语法(最常用):
<div :class="{ active: isActive, error: hasError }"></div> -
数组语法:
<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 不是“操作页面的工具”,而是“管理状态的框架”。你越早接受“数据驱动视图”的思想,就越能写出简洁、健壮、可维护的代码。
📚 延伸思考
- 如何持久化 Todo 数据?
使用watch监听todos,存入localStorage。 - 如何支持任务编辑?
为每个todo添加editing状态,双击进入编辑模式。 - 如何单元测试这个组件?
使用@vue/test-utils模拟用户输入、断言 DOM 状态。 - 性能优化点?
大列表可用v-memo(Vue3.2+)或虚拟滚动。
这是篇巩固Vue3 的核心概念的文章,是否点燃了你对声明式编程之美的热爱?Happy Coding! 💻✨