拒绝做 DOM 的“搬运工”:从 Vanilla JS 到 Vue 3 响应式思维的进化
在前端开发的漫长演进中,我们经常听到“数据驱动”这个词。但对于很多习惯了 jQuery 或者原生 JavaScript(Vanilla JS)的开发者来说,从“操作 DOM”到“操作数据”的思维转变,往往比学习新语法更难。
今天,我们将通过重构一个经典的 Todos 任务清单应用,来深度剖析 Vue 3 Composition API 是如何解放我们的双手,让我们专注于业务逻辑而非繁琐的页面渲染。
1. 痛点回顾:原生 JS 的“命令式”困境
在没有框架的时代,写一个简单的输入框回显功能,我们通常需要经历这几个步骤:寻找元素 -> 监听事件 -> 获取值 -> 修改 DOM。
让我们看看这个基于原生 JS 的实现片段:
// 先找到DOM元素, 命令式的, 机械的
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;
// 手动操作 DOM 更新
app.innerHTML = todo;
})
这种代码被称为命令式编程(Imperative Programming) 。正如在代码注释中所写,这是一种“机械”的过程。我们需要关注每一个步骤的实现细节。而且,频繁地操作 DOM 性能是低下的,因为这涉及到了 JS 引擎(V8)与渲染引擎之间的跨界通信。
随着应用变得复杂,大量的 getElementById 和 innerHTML 会让代码变成难以维护的“意大利面条”。
2. Vue 3 的破局:响应式数据与声明式渲染
Vue 的核心在于声明式编程(Declarative Programming) 。你只需要告诉 Vue “想要什么结果”,中间的 DOM 更新过程由 Vue 替你完成。
在 Vue 3 中,我们利用 setup 函数和 Composition API(组合式 API)来组织逻辑。
2.1 核心概念:ref 与数据驱动
在 App.vue 中,我们不再去查询 DOM 元素,而是定义了响应式数据:
import { ref, computed } from 'vue'
// 响应式数据
const title = ref("");
const todos = ref([
{ id: 1, title: '睡觉', done: true },
{ id: 2, title: '吃饭', done: false }
]);
这里体现了 Vue 开发的核心思路: “不再需要思考页面的元素怎么操作,而是要思考数据是怎么变化的” 。
2.2 指令:连接数据与视图的桥梁
有了数据,我们通过 Vue 的指令将数据绑定到模板上:
-
双向绑定 (
v-model) :<input type="text" v-model="title">。当用户输入时,title变量自动更新;反之亦然。这比手动写addEventListener优雅得多。 -
列表渲染 (
v-for) :<li v-for="todo in todos" :key="todo.id">。Vue 会根据todos数组的变化,智能地添加、删除或更新<li>元素。注意这里:key的使用,它是 Vue 识别节点的唯一标识,对性能至关重要。 -
样式绑定 (
:class) :<span :class="{done: todo.done}">。我们不再需要手动classList.add('done'),只需改变数据todo.done,样式就会自动生效。
2.3 智能的条件渲染:v-if 与 v-else 的排他性逻辑
在实际应用中,用户体验细节至关重要。例如,当任务列表被清空时,我们不应该留给用户一片空白,而应该展示“暂无任务”的提示。在原生 JS 中,这通常需要我们在每次添加或删除操作后,手动检查数组长度并切换 DOM 的 display 属性。
而在 Vue 中,我们可以通过 v-if 和 v-else 指令,像写 if-else 代码块一样在模板中轻松处理这种逻辑分支:
<ul v-if="todos.length">
<li v-for="todo in todos" :key="todo.id">
...
</li>
</ul>
<div v-else>
<span>暂无任务</span>
</div>
代码深度解析:
-
真实 DOM 的销毁与重建:
v-if是真正的条件渲染。当todos.length为 0 时,Vue 不仅仅是隐藏了<ul>(像 CSS 的display: none那样),而是直接从 DOM 中移除了整个列表元素。这意味着此时 DOM 中只有<div>暂无任务</div>,减少了页面的 DOM 节点数量。 -
响应式切换:一旦我们向
todos数组push了一条新数据,todos.length变为 1。Vue 的响应式系统会立即感知,销毁v-else元素,并重新创建并插入<ul>列表。 -
逻辑互斥:
v-else必须紧跟在v-if元素之后,它们构成了一个封闭的逻辑组,保证了同一时间页面上只会存在其中一种状态。
通过这两个指令,我们不仅实现了界面的动态交互,更重要的是,我们将“列表为空时显示什么”的业务逻辑直接通过模板表达了出来,不仅代码量减少了,意图也更加清晰。
3. 深度解析:Computed 计算属性 vs. 模板逻辑
在开发中,我们经常需要根据现有的数据计算出新的状态,比如统计“剩余未完成任务数”。
3.1 为什么要用 Computed?
初学者可能会直接在模板里写逻辑:
{{ todos.filter(todo => !todo.done).length }}
虽然这也能工作,但 Vue 官方更推荐使用 Computed(计算属性) :
// 创建一个响应式的计算属性
const active = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
computed 的四大优势:
-
性能优化(带缓存) :这是最大的区别。模板内的表达式在每次组件重渲染时都会重新执行。而
computed只有在它依赖的数据(这里是todos)发生变化时才会重新计算。如果todos没变,多次访问active会直接返回缓存值。 - 可读性:将复杂的逻辑从 HTML 模板中剥离到 JS 中,让模板保持干净、语义化。
-
可复用性:
active可以在模板中多处使用,也可以在 JS 逻辑中被引用。 - 调试与测试:单独测试一个 JS 函数远比测试模板中的一段逻辑要容易。
3.2 进阶技巧:Computed 的 Get 与 Set
计算属性通常是只读的,但 Vue 也允许我们定义 set 方法,这在处理“全选/全不选”功能时非常强大。
看看这段精妙的代码:
const allDone = computed({
// 读取值:判断是否所有任务都已完成
get() {
return todos.value.every(todo => todo.done)
},
// 设置值:当点击全选框时,将所有任务状态同步修改
set(value) {
todos.value.forEach(todo => todo.done = value)
}
})
在模板中,我们只需绑定 <input type="checkbox" v-model="allDone">。
- 当用户点击复选框,Vue 调用
set(value),我们遍历数组更新所有todo.done。 - 当所有子任务被手动勾选,
get()返回true,全选框自动被勾选。
这种双向的逻辑联动,如果用原生 JS 实现,需要编写大量的事件监听和状态判断代码,而在 Vue 中,它被封装成了一个优雅的属性。
4. 总结:Vue 开发方式的哲学
从 demo.html 到 App.vue,我们经历的不仅仅是语法的改变,更是思维模式的重构:
- Focus on Business:我们不再是浏览器的“建筑工人”(搬运 DOM),而是“设计师”(定义数据状态)。
-
Composition API:
setup、ref、computed让我们能够更灵活地组合逻辑,比 Vue 2 的 Options API 更利于代码复用和类型推断。 -
Best Practices:永远不要在模板中写复杂的逻辑,善用
computed缓存机制。
Vue 3 通过响应式系统,替我们处理了脏活累活(DOM 更新),让我们能将精力集中在真正有价值的业务逻辑上。对于想要构建复杂交互系统(如粒子特效、数据可视化)的开发者来说,掌握这种“数据驱动”的思维是迈向高阶开发的第一步。
5.附录:完整App.vue代码
<template>
<div>
<h2>{{ title }}</h2>
<input type="text" v-model="title" @keydown.enter="addTodo">
<ul v-if="todos.length">
<li v-for="todo in todos" :key="todo.id">
<input type="checkbox" v-model="todo.done">
<span :class="{done: todo.done}">{{ todo.title }}</span>
</li>
</ul>
<div v-else>
<span>暂无任务</span>
</div>
<div>
全选<input type="checkbox" v-model="allDone">
{{ active }}
/
{{ todos.length }}
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 响应式数据
const title = ref("");
const todos = ref([
{
id: 1,
title: '睡觉',
done: true
},
{
id: 2,
title: '吃饭',
done: false
}
]);
const active = computed(() => {
return todos.value.filter(todo => !todo.done).length
})
const addTodo = () => {
if(!title.value) return;
todos.value.push({
id: todos.value.length + 1,
title: title.value,
done: false
});
title.value = '';
}
const allDone = computed({
get() {
return todos.value.every(todo => todo.done)
},
set(value) {
todos.value.forEach(todo => todo.done = value)
}
})
</script>
<style>
.done {
color: gray;
text-decoration: line-through;
}
</style>