# 权衡的艺术

“框架设计里到处都体现了权衡的艺术。”

# 命令式和声明式

# 命令式(更加关注过程)

个人理解为类似面向过程的思路,代码本身描述的是“做事的过程”。

// - 获取 id 为 app 的 div 标签
// - 它的文本内容为 hello world
// - 为其绑定点击事件
// - 当点击时弹出提示:ok

$('#app') // 获取 div
    .text('hello world') // 设置文本内容
    .on('click', () => {
        alert('ok')
    }) // 绑定点击事件

// 或者
const div = document.querySelector('#app') // 获取 div
div.innerText = 'hello world' // 设置文本内容
div.addEventListener('click', () => {
    alert('ok')
}) // 绑定点击事件

# 声明式(更加关注结果)

个人理解为类似面向对象的思路,更加关注结果。

<!-- - 和上述逻辑类似 -->
<div @click="() => alert('ok')">hello world</div>

上述代码Vue.js 帮我们封装了过程(命令式),用户只需要关注结果(声明式)。

# 性能与可维护性的权衡

声明式代码的性能 不优于命令式代码的性能。

// 命令式代码 直接更新文本内容 性能最优
div.textContent = 'hello vue3' // 直接修改 

对于框架来说,为了实现最优的更新性能,它需要找到前后的差。异并只更新变化的地方,但是最终完成这次更新的代码仍然是命令式的。

如果我们把直接修改的性能消耗定义为 A,把找出差异的性能消耗定义为 B,那么有:

  • 命令式代码的更新性能消耗 = A

  • 声明式代码的更新性能消耗 = B + A

声明式代码会比命令式代码多出找出差异的性能消 耗,因此最理想的情况是,当找出差异的性能消耗为 0 时,声明式代 码与命令式代码的性能相同,但是无法做到超越

框架本身就是 封装了命令式代码才实现了面向用户的声明式。

既然在性能层面命令式代码是更好的选择,那么为什么 Vue.js 要选择声明式的设计方案呢?

  • 声明式代码的可维护性更强

  • 命令式需要手动完成 DOM 元素的创建、更新、删除等工作。声明式代码不需要我们关心,Vue.js 已经封装好了。

框架设计者要做的就是:在保持可维护性的同时让性能损失最 小化

# 虚拟 DOM 的性能

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

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

  • 采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。

  • 在大部分情况下,我们很难写出绝对优化的命令式代码,尤其是当应用程序的规模很大的时候。

# innerHTML 操作页面和虚拟 DOM 的性能对比。

// innerHTML 创建页面
const html = '<div id="app">hello world</div>'
document.body.innerHTML = html
  • innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM计算量。

  • 虚拟 DOM 创建页面的性能:创建 JavaScript 对象的 计算量 + 创建真实 DOM 的计算量。

由此得出在同一个数量级,则认为没有差异。在创建页面的时候,都需要新建所有 DOM 元素。虚拟DOM 相比 innerHTML 没有优势可言。

虚拟 DOM 和 innerHTML 在更新页面时的性能

innerHTML 虚拟 DOM
纯JS操作DOM 渲染HTML字符串 创建新的虚拟DOM对象+diff算法计算差异
DOM运算 1. 销毁所有旧的DOM
2. 新建所有新的DOM
必要时仅更新变化的DOM
性能因素 与模版大小有关 与数据变换量有关

所以,在更新页面的时候,虚拟 DOM 相比 innerHTML 有着更好的性能表现。多一个diff算法的计算量,只更新变化的DOM。

innerHTML、虚拟 DOM 以及原生 JavaScript(指 createElement 等方法)在更新页面时的性能

性能差 性能中等 性能高
心智负担中等
性能差
心智负担小
可维护性强
性能中等
心智负担大
可维护性差
性能高

# 运行时和编译时

框架设计一般分为 纯运行时的、运行时 + 编译时的、纯编译时的

Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。

# 纯运行时的框架

提供一个 Render 函数,用户可以为该函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素

 const obj = {
     tag: 'div',
     children: [{
         tag: 'span',
         children: 'hello world'
     }]
 }

每个对象都有两个属性:tag 代表标签名称,children 既可以是一个数组(代表子节点),也可以直接是一段文本(代表文本子节点)。接着,我们来实现 Render 函数:

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)

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

用户在使用它渲染内容时,直接为 Render 函数提供了一个树型结构的数据对象。手写树型结构的数据对象太麻烦了,而且不直观

# 运行时 + 编译时的框架

将HTML模板编译成 Render 函数需要的树型结构的数据对象。

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

变成了一个运 行时 + 编译时的框架。它既支持运行时,用户可以直接提供数据对象从而无须编译;又支持编译时,用户可以提供 HTML 字符串,我们将其编译为数据对象后再交给运行时处理。

# 纯编译时的框架

把HTML 字符串编译成数据对象,直接编译成命令式代码。

const template = `
    <div>
        <span>hello world</span>
    </div>

`
const div = docuemnt.createElement('div')
const span = docuemnt.createElement('span')
span.textContent = 'hello world'
div.appendChild(span)
document.body.appendChild(div)

纯编译时的框架,因为我们不支持任何运行时内容,用户的代码通过编译器编译后才能运行