Skip to content
On this page

命令式和声明式

从范式上来说 视图层框架分未命令式和声明式

命令式

jQ 就是典型的命令式 命令式更加关注过程

  1. 获取 id 为 app 的 div 标签
  2. 设置文本内容为 hello
  3. 绑定点击事件
  4. 点击时弹出 ok
js
$("#app")
  .text("hello")
  .on("click", () => {
    alert("ok");
  });

声明式

vue 就是声明式框架 更加关注结果

html
<div @click="()=>{alert('ok')}">hello</div>

我们只需要关注结果,不需要关注过程。

换句话说,是 vue 帮我们封装了过程,但是 vue 内部实现肯定是命令式的,而暴露给用户的却更加声明式。

性能与可维护性的权衡

先抛出一个结论:声明式代码的性能不优于命令式代码的性能,因为 vue 本来就是在命令式的基础上封装出了声明式。

声明式代码的更新消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

js
假设现在我们要将 div 标签的文本内容修改为 hello Vue3,那么如何用命令式代码实现呢?很简单,因为我们明确知道要修改的是什么,所以直接调用相关命令操作即可
div.textContent = 'hello Vue3' // 直接修改

但是对于框架来说,为了实现最优的更新性能,它需要找到前后的差异并只更新变化的地方,因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代码与命令式代码的性能相同,但是无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。

声明式更利于维护,在性能和可维护之间,框架设计者要做的是:保持可维护性的同时让性能损失最小化。

虚拟 DOM

前面说到,声明式代码的更新消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。

因此我们能最小化找到差异的性能消耗,就能让声明式代码的性能无限接近命令式代码的性能。

所谓的虚拟 DOM 就是为了最小化找出差异这一步的性能消耗而出现的

纯 JavaScript 计算(模板) < 虚拟 DOM < 原生 JavaScript

纯 JavaScript 计算(模板)  <  虚拟 DOM  <   原生 JavaScript

  心智负担中等               心智负担小         心智负担大

    性能差                  可维护性强         可维护性差

                            性能不错           性能高

原生 DOM 操作心智负担最大,因为要手动创建、删除、修改大量的 DOM 元素。但是性能是最好的,不过为了性能需要承受更多的心智负担。并且可维护性也极差。

innerHTML 来说性能极差,尤其是少量更新,心智负担中等。但是事件的绑定和字符串拼接导致了心智负担也不小。

虚拟 DOM 是声明式,心智负担最小,可维护性强,虽然比不上极致优化的原生,但是也相当不错了。

思考:有没有办法做到,既能声明式的描述 UI,又具备原生 JavaScript 的性能呢?鱼和熊掌兼得?

运行时和编译时

当设计一个框架的时候,我们有三种选择:

  1. 纯运行时的
  2. 运行时 + 编译时
  3. 纯编译时

  1. 我们先聊聊纯运行时的框架。假设我们设计了一个框架,它提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。我们规定树型结构的数据对象如下:
js
const obj = {
  tag: "div",
  children: [{ tag: "span", children: "hello world" }],
};

function Render(obj, root) {
  const el = document.createElement(obj.tag);
  if (typeof obj.children === "string") {
    const text = document.createTextNode(obj.children);
    el.appendChild(text);
  } else if (obj.children) {
    // 数组,递归调用 Render,使用 el 作为 root 参数
    obj.children.forEach((child) => Render(child, el));
  }
  // 将元素添加到 root
  root.appendChild(el);
}

有了这个函数,用户就可以这样来使用它:

js
const obj = {
  tag: "div",
  children: [{ tag: "span", children: "hello world" }],
};
// 渲染到 body 下
Render(obj, document.body);

现在我们回过头来思考一下用户是如何使用 Render 函数的。可以发现,用户在使用它渲染内容时,直接为 Render 函数提供了一个树型结构的数据对象。这里面不涉及任何额外的步骤,用户也不需要学习额外的知识。但是有一天,你的用户抱怨说:**“手写树型结构的数据对象太麻烦了,而且不直观,能不能支持用类似于 HTML 标签的方式描述树型结构的数据对象呢?”**你看了看现在的 Render 函数,然后回答:“抱歉,暂不支持。”实际上,我们刚刚编写的框架就是一个纯运行时的框架。

  1. 你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就可以继续使用 Render 函数了吗?

为此,你编写了一个叫作 Compiler 的程序,它的作用就是**把 HTML 字符串编译成树型结构的数据对象,**于是交付给用户去用了。那么用户该怎么用呢?其实这也是我们要思考的问题,最简单的方式就是让用户分别调用 Compiler 函数和 Render 函数:

js
const html = `
<div>
<span>hello world</span>
</div>
`;
// 调用 Compiler 编译得到树型结构的数据对象
const obj = Compiler(html);
// 再调用 Render 进行渲染
Render(obj, document.body);

这时我们的框架就变成了一个运行时 + 编译时的框架。它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。准确地说,上面的代码其实是运行时编译,意思是代码运行的时候才开始编译,而这会产生一定的性能开销,因此我们也可以在构建的时候就执行 Compiler 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。

  1. 聪明的你一定意识到了另外一个问题:既然编译器可以把 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?

这样我们只需要一个 Compiler 函数就可以了,连 Render 都不需要了。其实这就变成了一个纯编译时的框架,因为我不支持任何运行时内容,用户的代码通过编译器编译后才能运行。


我们发现,一个框架**既可以是纯运行时的,也可以是纯编译时的,还可以是既支持运行时又支持编译时的。**那么,它们都有哪些优缺点呢?是不是既支持运行时又支持编译时的框架最好呢?为了搞清楚这个问题,我们逐个分析

  1. 纯运行时的框架。由于它没有编译的过程,因此我们没办法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。
  2. 纯编译时的框架,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。

Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。解析 svelte 与 vue/react

Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。等到后面讲解 Vue.js 3 编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。

总结

  1. 命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果。命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化。

  2. 虚拟 DOM 的性能,并给出了一个公式:声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。

  3. 了运行时和编译时的相关知识,了解纯运行时、纯编译时以及两者都支持的框架各有什么特点,并总结出 Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。