命令式和声明式
从范式上来说 视图层框架分未命令式和声明式
命令式
jQ 就是典型的命令式 命令式更加关注过程
- 获取 id 为 app 的 div 标签
- 设置文本内容为 hello
- 绑定点击事件
- 点击时弹出 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 的性能呢?鱼和熊掌兼得?
运行时和编译时
当设计一个框架的时候,我们有三种选择:
- 纯运行时的
- 运行时 + 编译时
- 纯编译时
- 我们先聊聊纯运行时的框架。假设我们设计了一个框架,它提供一个 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 函数,然后回答:“抱歉,暂不支持。”实际上,我们刚刚编写的框架就是一个纯运行时的框架。
- 你开始思考,能不能引入编译的手段,把 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 程序将用户提供的内容编译好,等到运行时就无须编译了,这对性能是非常友好的。
- 聪明的你一定意识到了另外一个问题:既然编译器可以把 HTML 字符串编译成数据对象,那么能不能直接编译成命令式代码呢?
这样我们只需要一个 Compiler 函数就可以了,连 Render 都不需要了。其实这就变成了一个纯编译时的框架,因为我不支持任何运行时内容,用户的代码通过编译器编译后才能运行。
我们发现,一个框架**既可以是纯运行时的,也可以是纯编译时的,还可以是既支持运行时又支持编译时的。**那么,它们都有哪些优缺点呢?是不是既支持运行时又支持编译时的框架最好呢?为了搞清楚这个问题,我们逐个分析
- 纯运行时的框架。由于它没有编译的过程,因此我们没办法分析用户提供的内容,但是如果加入编译步骤,可能就大不一样了,我们可以分析用户提供的内容,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。
- 纯编译时的框架,那么它也可以分析用户提供的内容。由于不需要任何运行时,而是直接编译成可执行的 JavaScript 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。
Svelte 就是纯编译时的框架,但是它的真实性能可能达不到理论高度。解析 svelte 与 vue/react
Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。等到后面讲解 Vue.js 3 编译优化相关内容时,你会看到 Vue.js 3 在保留运行时的情况下,其性能甚至不输纯编译时的框架。
总结
命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果。命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;而声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化。
虚拟 DOM 的性能,并给出了一个公式:声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗。虚拟 DOM 的意义就在于使找出差异的性能消耗最小化。
了运行时和编译时的相关知识,了解纯运行时、纯编译时以及两者都支持的框架各有什么特点,并总结出 Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。