从原生 JS 到 Vue3 Composition API:手把手教你用现代 Vue 写一个优雅的 Todos 任务清单
大家好,今天用一个最经典的 Todos 应用,来带大家彻底搞清楚:
「为什么我们不再手动操作 DOM?Vue 到底替我们做了什么?」
很多初学者看完 Vue 文档后,会觉得「好像很简单啊」,但真正自己写的时候,又会不自觉地回到原来的命令式写法:
js
document.getElementById('app').innerHTML = xxx
这篇文章将通过一个逐步演进的过程,让你从「机械式 DOM 操作」进化到「数据驱动」的现代 Vue3 开发思维,彻底领悟响应式编程的魅力。
一、原生 JS 写 Todos:痛并痛苦着
先来看看传统写法(很多人还在这么写):
html
<h2 id="app"></h2>
<input type="text" id="todo-input">
<script>
const app = document.getElementById('app');
const todoInput = document.getElementById('todo-input');
todoInput.addEventListener('change', function(event) {
const todo = event.target.value.trim();
if (!todo) return;
app.innerHTML = todo; // 只能显示最后一个!
})
</script>
这代码能跑,但问题一大堆:
- 只能显示一条任务(innerHTML 被覆盖)
- 要实现多条任务、删除、完成状态......需要写几百行 DOM 操作
- 一旦需求变动,改起来就是灾难
这就是典型的命令式编程:我们的大脑一直在想「我要先找到哪个元素,然后怎么改它」。
而 Vue 的核心思想是:别管 DOM,你只管数据就行。
二、Vue3 + Composition API 完整实现

vue
<!-- App.vue -->
<script setup>
import { ref, computed } from 'vue'
// 1. 响应式数据(重点!)
const title = ref('') // 输入框内容
const todos = ref([
{ id: 1, title: '吃饭', done: false },
{ id: 2, title: '睡觉', done: true }
])
// 2. 计算属性:统计未完成任务数量(带缓存!)
const active = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
// 3. 添加任务
const addTodo = () => {
if (!title.value.trim()) return
todos.value.push({
id: Date.now(), // 推荐用时间戳,比 Math.random() 更可靠
title: title.value.trim(),
done: false
})
title.value = '' // 清空输入框
}
// 4. 高级技巧:全选/全不选(computed 的 getter + setter)
const allDone = computed({
get() {
if (todos.value.length === 0) return false
return todos.value.every(todo => todo.done)
},
set(value) {
todos.value.forEach(todo => {
todo.done = value
})
}
})
</script>
<template>
<div class="todos">
<h2>我的任务清单</h2>
<input
type="text"
v-model="title"
@keydown.enter="addTodo"
placeholder="今天要做什么?按回车添加"
class="input"
/>
<!-- 任务列表 -->
<ul v-if="todos.length" class="todo-list">
<li v-for="todo in todos" :key="todo.id" class="todo-item">
<input type="checkbox" v-model="todo.done">
<span :class="{ done: todo.done }">{{ todo.title }}</span>
</li>
</ul>
<div v-else class="empty">
🎉 暂无任务,休息一下吧~
</div>
<!-- 统计 + 全选 -->
<div class="footer">
<label>
<input type="checkbox" v-model="allDone">
全选
</label>
<span>未完成:{{ active }} / 总数:{{ todos.length }}</span>
</div>
</div>
</template>
<style scoped>
.done{
color: gray;
text-decoration: line-through;
}
</style>
三、核心知识点深度拆解(建议反复看)
1. ref() 是如何做到响应式的?
js
const title = ref('')
这句话背后发生了什么?
- Vue 在内部为 title 创建了一个响应式对象
- 真正的数据存在 title.value 中
- 当你读取 title.value 时,Vue 会记录「当前组件依赖了这个数据」
- 当你修改 title.value 时,Vue 知道「哪些组件需要重新渲染」,自动更新 DOM
这就叫「依赖收集 + 自动更新」,你完全不用管 DOM!
2. 为什么 computed 比普通函数香?
js
// 普通函数写法(每次都会计算!)
const activeCount = () => todos.value.filter(...).length
// computed 写法(只有依赖变化才重新计算)
const active = computed(() => todos.value.filter(...).length)
性能差异巨大!当你有 1000 条任务时,普通函数会在每次渲染都执行 1000 次过滤,而 computed 可能只执行一次。
3. computed 的 getter + setter 神技(90%的人不知道)
js
const allDone = computed({
get() {
// 如果todos为空,返回false
if (todos.value.length === 0) return false;
// 如果所有todo都完成,返回true
return todos.value.every(todo => todo.done);
},
set(value) {
// 设置所有todo的done状态
todos.value.forEach(todo => {
todo.done = value;
});
}
})
这才是真正的「双向计算属性」!点击全选框时,v-model 会自动调用 setter,把所有任务的 done 状态同步修改。
4. v-for 一定要写 :key!不然会出大问题
html
<li v-for="todo in todos" :key="todo.id">
不写 key 的后果:
- Vue 无法准确判断哪条数据变了,会导致整张列表重绘
- 输入框焦点丢失、动画错乱、状态错位
推荐 key 使用:
js
id: Date.now() + Math.random() // 更稳妥
// 或使用 uuid 库
5. v-model 本质是 :value + @input 的语法糖
Vue 的双向绑定(v-model) = 数据 → 视图 的绑定 + 视图 → 数据的绑定
它让「数据」和「表单元素的值」始终保持同步,你改数据,界面自动更新;你改输入框,数据也自动更新。
html
<input v-model="title">
<!-- 等价于 -->
<input :value="title" @input="title = $event.target.value">
拆解一下:
| 方向 | 对应指令 | 作用 |
|---|---|---|
| 数据 → 视图 | :value="msg" | 把 msg 的值渲染到 input 上 |
| 视图 → 数据 | @input="msg = $event.target.value" | 用户输入时,把值重新赋值给 msg |
而 @keydown.enter 是 Vue 提供的键位修饰符,超级好用:
html
@keydown.enter="addTodo"
@keydown.ctrl.enter="addTodo"
@click.prevent="submit" <!-- 阻止默认行为 -->
四、常见坑位避雷指南(血泪经验)
| 场景 | 错误写法 | 正确写法 | 说明 |
|---|---|---|---|
| 添加任务后输入框不清空 | 没重置 title.value | title.value = '' | v-model 是双向绑定,必须手动清空 |
| 全选状态不同步 | 用普通变量控制 | 用 computed({get,set}) | 普通变量无法响应所有任务的变化 |
| key 使用 index | :key="index" | :key="todo.id" | index 会导致状态错乱 |
| id 使用 Math.random() | id: Math.random() | id: Date.now() | 可能重复,尤其快速添加时 |
| computed 忘记 .value | return todos.filter(...) | return todos.value.filter(...) | script setup 中 ref 要加 .value |
五、细节知识点

computed 是如何做到「又快又省」的?
一句话结论:
computed 只有在它的「依赖」真正发生变化时,才会重新计算一次,其他所有时间直接返回缓存结果。
这才是它比普通方法快 10~100 倍的根本原因!
一、最直观的对比实验
vue
<script setup>
import { ref, computed } from 'vue'
const a = ref(1)
const b = ref(10)
// 场景1:普通方法(每次渲染都重新算)
const sum1 = () => {
console.log('普通方法被调用了')
return a.value + b.value
}
// 场景2:computed(只有依赖变了才算)
const sum2 = computed(() => {
console.log('computed 被调用了')
return a.value + b.value
})
</script>
<template>
<p>普通方法:{{ sum1() }}</p>
<p>computed:{{ sum2 }}</p>
<button @click="a++">a + 1</button>
<button @click="b++">b + 1</button>
</template>
你会看到:
| 操作 | 普通方法打印几次 | computed 打印几次 |
|---|---|---|
| 页面首次渲染 | 1 次 | 1 次 |
| 点击 a++ | 再次打印 | 再次打印 |
| 点击 b++ | 再次打印 | 再次打印 |
| 页面任意地方触发渲染(比如父组件更新) | 又打印! | 不打印!(直接用缓存) |
这就是「缓存」带来的性能飞跃!
Vue 内部到底是怎么实现这个缓存的?(底层逻辑)
Vue 用了一个经典的「脏检查 + 依赖收集」机制(Vue3 用 Proxy 更优雅,但原理一致):
| 步骤 | 发生了什么 |
|---|---|
| 1. 创建 computed | Vue 创建一个「计算属性对象」,里面有个 value(缓存值)和 dirty(是否脏)标志」 |
| 2. 第一次读取 computed | 执行计算函数 → 同时收集所有用到的响应式数据(a、b、todos.length 等)作为依赖 |
| 3. 把依赖和这个 computed 关联起来 | a.effect.deps.push(computed) |
| 4. 依赖变化时 | Vue 把这个 computed 的 dirty 标志设为 true(表示缓存失效了) |
| 5. 下一次读取时 | 发现 dirty = true → 重新执行计算函数 → 更新缓存 → dirty = false |
| 6. 之后再读取 | dirty = false → 直接返回缓存值,不执行函数 |
图解:
ini
首次读取 computed
↓
执行计算函数 → 依赖收集(记录依赖了 a 和 b)
↓
把结果缓存起来,dirty = false
a.value = 999(依赖变化)
↓
Vue 自动把所有依赖了 a 的 computed 的 dirty 设为 true
下次读取 computed
↓
发现 dirty = true → 重新计算 → 更新缓存 → dirty = false
哪些情况会打破缓存?(常见坑)
| 情况 | 是否重新计算 | 说明 |
|---|---|---|
| 依赖的 ref/reactive 变了 | 是 | 正常触发 |
| 依赖的普通变量(let num = 1) | 否 | 不是响应式的!永远只算一次(大坑!) |
| 依赖了 props | 是 | props 也是响应式的 |
| 依赖了 store.state(Pinia/Vuex) | 是 | store 是响应式的 |
| 依赖了 route.params | 是 | $route 是响应式的(Vue Router 注入) |
| 依赖了 window.innerWidth | 否 | 不是响应式!要配合 watchEffectScope 手动处理 |
实战避雷清单
| 错误写法 | 正确写法 | 后果 |
|---|---|---|
computed(() => Date.now()) |
改成普通方法或用 ref(new Date()) + watch |
每一次读取都重新计算,缓存失效 |
computed(() => Math.random()) |
同上 | 永远不缓存,性能灾难 |
computed(() => props.list.length) |
完全正确 | 推荐写法 |
computed(() => JSON.parse(JSON.stringify(todos.value))) |
不要这么做,深拷贝太重 | 浪费性能 |
六、一句话记住
computed 的高性能秘诀只有 8 个字:
「依赖不变,绝不重新计算」
现在你再也不用担心「用 computed 会不会影响性能」了,反而应该大胆用!
因为它比你手写任何缓存逻辑都要聪明、都要快!
六、总结:从「操作 DOM」到「操作数据」的思维跃迁
| 传统 JS 思维 | Vue 响应式思维 |
|---|---|
| 先找元素 → 再改 innerHTML | 只改数据 → Vue 自动更新 DOM |
| 手动 addEventListener | 用 v-model / @event 声明式绑定 |
| 手动计算未完成数量 | 用 computed 自动计算 + 缓存 |
| 全选要遍历 DOM | 用 computed setter 一行搞定 |
当你真正理解了「数据驱动视图」后,你会发现:
写 Vue 代码不再是「怎么操作页面」,而是「数据怎么变化。
这才是现代前端开发的正确姿势!