Skip to content

命令式与声明式:两种范式的权衡

在 jQuery 时代,我们手动操作 DOM——获取元素、修改属性、绑定事件。这种方式直接、可控,但随着应用复杂度增加,代码变得难以维护。现代框架几乎都转向了声明式范式。

这里有一个关键问题:声明式代码的性能永远不可能超过命令式代码,那为什么还要用声明式? 理解这个问题的答案,是理解框架设计权衡的基础。

命令式:告诉计算机"如何做"

命令式编程的特点是显式描述每一步操作。你需要告诉计算机:先做这个,再做那个,最后做另一件事。

以一个简单的计数器为例:

javascript
// 命令式实现
// 命令式实现:每一步都由开发者显式控制
let count = 0

// 第 1 步:获取 DOM 元素的引用
const btn = document.querySelector('#btn')
const display = document.querySelector('#display')

// 第 2 步:初始化视图
display.innerText = count

// 第 3 步:绑定事件,处理用户交互
btn.addEventListener('click', () => {
  // 第 4 步:更新状态
  count++
  // 第 5 步:手动同步状态到视图
  // 这是命令式的核心特征:你必须告诉 DOM "怎么更新"
  display.innerText = count
})

// 问题:如果 count 在其他地方也会变化呢?
// 你需要在每个修改 count 的地方都记得更新 DOM

这段代码做了什么?

  1. 获取按钮和显示区域的 DOM 元素
  2. 初始化显示内容
  3. 绑定点击事件
  4. 在事件处理中更新状态
  5. 手动将新状态同步到视图

最后一步是关键:每次状态变化后,开发者需要手动更新相关的 DOM。

命令式的优势

性能上限最高:你直接操作 DOM,没有任何中间层。如果你足够细心,可以只更新真正变化的部分,性能达到理论最优。

完全可控:每一步操作都在你的掌控中,没有"黑盒"行为。

命令式的问题

想象一下,应用变复杂了,状态不再是一个简单的 count,而是:

javascript
const state = {
  user: { name: 'Alice', age: 25 },
  todos: [
    { id: 1, text: '学习 Vue', done: false },
    { id: 2, text: '写代码', done: true }
  ],
  filter: 'all'
}

现在,user.name 会影响页面顶部的用户名显示,todos 会影响列表渲染,filter 会影响哪些 todo 被显示。当任意一个状态变化时,你需要:

  1. 判断变化会影响哪些 DOM 元素
  2. 找到这些元素
  3. 更新它们的内容

随着应用规模增长,状态与视图的对应关系变得错综复杂。一个修改可能影响多处 DOM,一处 DOM 可能依赖多个状态。手动维护这种映射关系,极易出错。

声明式:告诉计算机"做什么"

声明式编程的特点是描述目标状态,而不关心如何达到这个状态。

同样的计数器,用 Vue 实现:

template
<template>
  <button @click="count++">{{ count }}</button>
</template>

<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>

这段代码描述的是:UI 应该是什么样子

  • 有一个按钮
  • 按钮显示 count 的值
  • 点击按钮时 count 加 1

至于如何把 count 的变化反映到 DOM 上,开发者不需要关心,框架会处理。

声明式的认知模型

声明式 UI 的核心思想可以用一个公式表达:

UI = f(state)

// 解读这个公式:
// - state 是应用的状态(数据)
// - f 是一个纯函数(Vue 的渲染函数)
// - UI 是视图的描述(虚拟 DOM)

// 这意味着:
// 1. 相同的 state 总是产生相同的 UI
// 2. state 变化 → 自动重新计算 f(state) → 得到新 UI
// 3. 框架负责比较新旧 UI,最小化 DOM 操作

视图是状态的函数。给定相同的状态,总是产生相同的视图。状态变化时,框架自动重新计算视图并更新 DOM。

这个模型极大地简化了心智负担:

  • 你只需要管理状态
  • 状态变化后,视图自动更新
  • 不需要思考"哪些 DOM 需要更新"

声明式的代价

但是,声明式有一个不可回避的事实:它的性能上限低于命令式

为什么?因为框架需要额外的工作来确定"什么变了"。

假设 count 从 0 变成了 1,命令式代码直接执行:

javascript
display.innerText = 1

而声明式代码需要:

  1. 根据新状态生成新的虚拟 DOM
  2. 对比新旧虚拟 DOM,找出差异
  3. 根据差异更新真实 DOM

步骤 1 和 2 是额外的开销。用公式表达:

声明式性能消耗 = 找出差异的消耗 + 直接修改的消耗
命令式性能消耗 = 直接修改的消耗

声明式永远比命令式多一个"找出差异"的步骤。

为什么 Vue 选择声明式

既然声明式性能不如命令式,Vue 为什么选择声明式?

答案是:在大多数场景下,开发效率和可维护性比极致性能更重要

可维护性的价值

思考一下,一个应用的生命周期中,什么成本最高?

不是运行时的那几毫秒性能差异,而是:

  • 开发新功能的时间
  • 修复 bug 的时间
  • 理解和修改老代码的时间
  • 团队协作的沟通成本

声明式代码在这些方面都有明显优势:

代码即文档

{{ user.name }}
一眼就能看出这里显示用户名。而命令式代码需要追踪 innerText 赋值才能确定 DOM 显示什么。

状态与视图自动同步:不会出现"忘了更新某处 DOM"的 bug。

关注点分离:逻辑代码只处理状态,不需要关心 DOM 操作。

Vue 3 的优化策略

Vue 并没有接受"声明式必然慢"的结论,而是通过编译时优化来缩小差距。

回想一下"找出差异"这个步骤。传统的 Virtual DOM 方案(如 React)在运行时做 Diff,需要遍历整个虚拟 DOM 树。但 Vue 的模板不是普通的 JavaScript,它是一种结构化的 DSL,编译器可以在编译时分析出大量信息:

哪些节点是静态的:静态节点不需要参与 Diff,可以直接跳过。

动态节点的哪些部分是动态的:一个节点可能只有 class 是动态的,其他属性都是静态的。编译器可以生成 PatchFlags 告诉运行时只比较 class

哪些结构是稳定的v-ifv-for 会改变结构,但

{{ text }}
的结构是稳定的。编译器可以把结构稳定的区域标记为 Block,跳过子树遍历。

通过这些优化,Vue 3 的 Diff 开销被大幅降低,声明式的性能损耗变得微乎其微。

不同框架的选择

不同框架在"声明式与命令式"这个光谱上处于不同位置:

React:纯运行时方案。JSX 是 JavaScript,编译器只做语法转换,优化完全在运行时进行。

Svelte:纯编译时方案。编译器直接生成命令式的 DOM 操作代码,运行时几乎为零。

Vue 3:编译时 + 运行时混合方案。编译器生成优化提示,运行时利用这些提示跳过不必要的工作。

每种方案都有权衡:

  • 纯运行时灵活性最高,但优化空间有限
  • 纯编译时性能最好,但对动态性支持有限
  • 混合方案试图兼顾两者,复杂度也相应增加

什么时候需要命令式

声明式是默认选择,但某些场景确实需要命令式的精确控制:

性能敏感的热点代码:游戏渲染循环、大数据可视化、复杂动画。这些场景下,每一毫秒都很重要。

与第三方库集成:很多库期望直接操作 DOM,比如某些图表库、富文本编辑器。

极端边界情况:框架的抽象偶尔会碰到边界,需要直接操作来绕过。

Vue 为这些场景提供了"逃生舱":

javascript
// 模板引用,获取原始 DOM
const divRef = ref<HTMLDivElement>()

// 生命周期钩子中直接操作
onMounted(() => {
  divRef.value?.focus()
})

声明式是默认模式,命令式是必要时的补充。

本章小结

命令式和声明式的核心区别:

  • 命令式:告诉计算机"如何做",性能上限高,但维护成本高
  • 声明式:告诉计算机"做什么",维护成本低,但有额外的运行时开销

Vue 选择声明式,理由是:

  • 大多数应用不需要极致性能
  • 开发效率和可维护性更重要
  • 通过编译优化可以大幅降低性能损耗

下一章,我们将深入探讨 Vue 3 的"编译时 vs 运行时"设计,看看 Vue 是如何在这两个阶段分配工作的。


练习与思考

  1. 分别用原生 JavaScript 和 Vue 实现一个简单的 TodoList(添加、删除、标记完成)。对比两个版本的代码量、复杂度和可维护性。

  2. 如果要实现一个拖拽排序列表,你会选择命令式还是声明式?为什么?

  3. 在 Vue 组件中,使用 ref 获取 DOM 元素并直接操作,算命令式还是声明式?这种混合使用有什么注意事项?

命令式与声明式:两种范式的权衡 has loaded