普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月12日首页

拒绝做 DOM 的“搬运工”:从 Vanilla JS 到 Vue 3 响应式思维的进化

作者 San30
2025年12月12日 13:20

在前端开发的漫长演进中,我们经常听到“数据驱动”这个词。但对于很多习惯了 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)与渲染引擎之间的跨界通信。

随着应用变得复杂,大量的 getElementByIdinnerHTML 会让代码变成难以维护的“意大利面条”。

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-ifv-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>

代码深度解析:

  1. 真实 DOM 的销毁与重建v-if 是真正的条件渲染。当 todos.length 为 0 时,Vue 不仅仅是隐藏了 <ul>(像 CSS 的 display: none 那样),而是直接从 DOM 中移除了整个列表元素。这意味着此时 DOM 中只有 <div>暂无任务</div>,减少了页面的 DOM 节点数量。
  2. 响应式切换:一旦我们向 todos 数组 push 了一条新数据,todos.length 变为 1。Vue 的响应式系统会立即感知,销毁 v-else 元素,并重新创建并插入 <ul> 列表。
  3. 逻辑互斥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 的四大优势:

  1. 性能优化(带缓存) :这是最大的区别。模板内的表达式在每次组件重渲染时都会重新执行。而 computed 只有在它依赖的数据(这里是 todos)发生变化时才会重新计算。如果 todos 没变,多次访问 active 会直接返回缓存值。
  2. 可读性:将复杂的逻辑从 HTML 模板中剥离到 JS 中,让模板保持干净、语义化。
  3. 可复用性active 可以在模板中多处使用,也可以在 JS 逻辑中被引用。
  4. 调试与测试:单独测试一个 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.htmlApp.vue,我们经历的不仅仅是语法的改变,更是思维模式的重构:

  • Focus on Business:我们不再是浏览器的“建筑工人”(搬运 DOM),而是“设计师”(定义数据状态)。
  • Composition APIsetuprefcomputed 让我们能够更灵活地组合逻辑,比 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>
❌
❌