Appearance
命令式与声明式:两种范式的权衡
在 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这段代码做了什么?
- 获取按钮和显示区域的 DOM 元素
- 初始化显示内容
- 绑定点击事件
- 在事件处理中更新状态
- 手动将新状态同步到视图
最后一步是关键:每次状态变化后,开发者需要手动更新相关的 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 被显示。当任意一个状态变化时,你需要:
- 判断变化会影响哪些 DOM 元素
- 找到这些元素
- 更新它们的内容
随着应用规模增长,状态与视图的对应关系变得错综复杂。一个修改可能影响多处 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而声明式代码需要:
- 根据新状态生成新的虚拟 DOM
- 对比新旧虚拟 DOM,找出差异
- 根据差异更新真实 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-if 和 v-for 会改变结构,但 的结构是稳定的。编译器可以把结构稳定的区域标记为 Block,跳过子树遍历。{{ text }}
通过这些优化,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 是如何在这两个阶段分配工作的。
练习与思考
分别用原生 JavaScript 和 Vue 实现一个简单的 TodoList(添加、删除、标记完成)。对比两个版本的代码量、复杂度和可维护性。
如果要实现一个拖拽排序列表,你会选择命令式还是声明式?为什么?
在 Vue 组件中,使用
ref获取 DOM 元素并直接操作,算命令式还是声明式?这种混合使用有什么注意事项?