《Vue.js 设计与实现》—— 01 权衡的艺术

news2024/11/17 22:32:28

书籍链接:https://weread.qq.com/web/bookDetail/c5c32170813ab7177g0181ae

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

当我们设计一个框架时,框架本身的各个模块之间并不是相互独立的,而是相互关联、相互制约的。

作为框架设计者,一定要对框架的定位和方向拥有全局的把控,这样才能做好后续的模块设计和拆分。

作为学习者,在学习框架的时候,也应该从全局的角度对框架的设计拥有清晰的认知,否则很容易被细节困住,看不清全貌。

框架的范式有命令式和声明式,框架的设计有纯运行时、纯编译时和运行时 + 编译时,它们各有优缺,如何选择呢?这里面都体现了“权衡”的艺术。

1. 命令式和声明式

从范式上来看,视图层框架通常分为命令式和声明式,它们各有优缺点。

命令式框架的一大特点就是关注过程。曾一度盛行的 jQuery 就是典型的命令式框架。例如,我们把下面这段话翻译成对应的代码:

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

jQuery 对应的代码为:

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

原生 JavaScript 对应的代码为:

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

显然,原生 JavaScript 和 jQuery 都是命令式编程,自然语言描述能够与代码产生一一对应的关系,代码本身描述的是“做事的过程”,符合我们的逻辑直觉。

与命令式框架更加关注过程不同,声明式框架更加关注结果。结合 Vue.js,来看一下如何实现上面自然语言描述的功能:

<div @click="() => alert('ok')">hello world</div>

可以看到,我们提供的是一个“结果”,并不关心如何实现它,实现该“结果”的过程则是由 Vue.js 帮我们完成的。换句话说,Vue.js 帮我们封装了过程。

因此,能够猜到 Vue.js 的内部实现一定是命令式的,而暴露给用户的却更加声明式。

2. 性能与可维护性的权衡

命令式和声明式各有优缺点,在框架设计方面,则体现在性能与可维护性之间的权衡。这里先抛出一个结论:声明式代码的性能不优于命令式代码的性能

以上面的例子来说,假设现在要将 div 标签的文本内容修改为 hello vue3,如何用命令式代码实现呢?很简单,直接调用相关命令操作即可:

div.textContent = 'hello vue3' // 直接修改

有没有比上面这句代码性能更好的方案呢?答案是“没有”。可以看到,理论上命令式代码可以做到极致的性能优化,因为我们明确知道哪些发生了变更,只做必要的修改就行了。但是声明式代码不一定能做到这一点,因为它描述的是结果:

<!-- 之前: -->
<div @click="() => alert('ok')">hello world</div>
<!-- 之后: -->
<div @click="() => alert('ok')">hello vue3</div>

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

div.textContent = 'hello vue3' // 直接修改

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

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

可以看到,声明式会比命令式多出找出差异的性能消耗,最理想的情况是找出差异的消耗为 0 时,声明式代码与命令式代码的性能相同,但无法做到超越,毕竟框架本身就是封装了命令式代码才实现了面向用户的声明式。

但是,相比命令式,声明式代码的可维护性更强。从上例可以感知,采用命令式开发时,我们需要维护实现目标的整个过程,包括手动完成 DOM 元素的创建、更新、删除等工作。而声明式仅需要我们关注结果,过程不需要关心,心智负担较小,代码更可观。

在采用声明式提升可维护性的同时,性能就会有一定的损失,而框架设计者要做的就是:在保持可维护性的同时让性能损失最小化

3. 虚拟 DOM 的性能

前文说到,声明式代码的更新性能消耗 = 找出差异的性能消耗+ 直接修改的性能消耗,因此,如果我们能够最小化找出差异的性能消耗,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟 DOM,就是为了实现这一最小化而出现的。

也就是说,采用虚拟 DOM 的更新技术的性能理论上不可能比原生 JavaScript 操作 DOM 更高。为什么要强调「理论上」这三个字,因为在大部分情况下,我们很难写出绝对优化的命令式代码,尤其在项目工程规模非常大的情况下。

虚拟 DOM 要解决的问题就是在书写可维护性更强的声明式代码时,尽可能的让应用程序的性能更好,甚至逼近命令式代码的性能。

值得一提的是,前文中所说的原生 JavaScript 实际上指的是像 document.createElement 之类的 DOM 操作方法,并不包含 innerHTML,因为它比较特殊,需要单独讨论。

在使用 jQuery 或者直接使用 JavaScript 编写页面的时候,使用 innerHTML 来操作页面非常常见。现在,我们也许要思考一下:

  • 使用 innerHTML 操作页面和虚拟 DOM 相比性能如何?
  • innerHTMLdocument.createElement 等 DOM 操作方法有何差异?

先看第一个问题,为了比较 innerHTML 和虚拟 DOM 的性能,需要了解它们创建、更新页面的过程。

对于 innerHTML 来说,为了创建页面,需要构造一段 HTML 字符串:

const html = `<div><span>...</span></div>`

接着将该字符串赋值给 DOM 元素的 innerHTML 属性:

div.innerHTML = html

为了渲染出页面,首先要把字符串解析成 DOM 树,这是一个 DOM 层面的计算。我们知道,涉及 DOM 的运算要远比 JavaScript 层面的计算性能差,这有一个跑分结果可供参考,如下:

跑分结果显示,纯 JavaScript 层面的操作要比 DOM 操作快得多,它们不在一个数量级上。

我们可以用一个公式来表达通过 innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的 DOM 计算量

接下来,讨论虚拟 DOM 在创建页面时的性能。虚拟 DOM 创建页面的过程分为两步:

  • 创建 JavaScript 对象,这个对象可以理解为真实 DOM 的描述
  • 递归地遍历虚拟 DOM 树并创建真实 DOM

我们同样可以用一个公式来表达:创建 JavaScript 对象的计算量 + 创建真实 DOM 的计算量

可以看到,无论是纯 JavaScript 层面的计算,还是 DOM 层面的计算,其实两者差距不大。如果在同一个数量级,则认为没有差异。在创建页面的时候,都需要新建所有 DOM 元素。

给出第一个结论:在同一个数量级下,创建页面时,虚拟 DOM 和 innerHTML 的性能几乎没有区别

下面看看它们在更新页面时的性能。

使用 innerHTML 更新页面的过程是重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML 属性,也就是说,哪怕只更改了一个文字,也要重新设置 innerHTML 属性,这等价于销毁所有旧的 DOM 元素,再全量创建新的 DOM 元素。

虚拟 DOM 更新页面的,需要重新创建 JavaScript 对象(虚拟 DOM 树),然后比较新旧虚拟 DOM,找到变化的元素并更新它。

可以发现,在更新页面时,虚拟 DOM 在 JavaScript 层面的运算要比创建页面时多出一个 Diff 的性能消耗,但它也是 JavaScript 层面的运算,所以不会产生数量级的差异。再观察 DOM 层面的运算,可以发现虚拟 DOM 在更新页面时只会更新必要的元素,但 innerHTML 需要全量更新。这时虚拟 DOM 的优势就体现出来了。

另外,当更新页面时,影响虚拟 DOM 的性能因素与影响 innerHTML 的性能因素不同。对于虚拟 DOM 来说,无论页面多大,都只会更新变化的内容,而对于 innerHTML 来说,页面越大,就意味着更新时的性能消耗越大。如果加上性能因素,那么最终它们在更新页面时的性能如下图:

给出第二个结论:在同一个数量级下,更新页面时,虚拟 DOM 比 innerHTML 的性能要高

基于此,可以粗略地总结一下 innerHTML、虚拟 DOM 以及原生 JavaScript(指 createElement 等方法)在更新页面时的性能:

事实上,innerHTML 已经有一点声明式编程的味道了,但它更像是一种没有经过优化的声明式代码,没有做好性能和可维护性两者之间的权衡。

4. 运行时和编译时

当设计一个框架的时候,有三种选择:纯运行时的、运行时 + 编译时的或纯编译时的。这需要你根据目标框架的特征,以及对框架的期望,做出合适的决策。

先看一下纯运行时的框架。假设我们设计了一个框架,它提供一个 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 标签的方式描述树型结构的数据对象呢?”你会发现,它并不支持。实际上,刚刚编写的框架就是一个纯运行时的框架。

为了满足用户的需求,你开始思考,能不能引入编译的手段,把 HTML 标签编译成树型结构的数据对象,这样不就可以继续使用 Render 函数了吗?思路如下:

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

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 代码,因此性能可能会更好,但是这种做法有损灵活性,即用户提供的内容必须编译后才能用。

Vue 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化,其在保留运行时的情况下,性能甚至不输纯编译时的框架。

更多文章可关注:GopherBlog、GopherBlog副站

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/503231.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Windows10安装免安装版redis

下载 官方下载地址&#xff1a;github.com/MicrosoftAr…选择版本 解压安装 配置环境变量&注册成服务 配置环境变量 以管理员启动命令行&#xff0c;在redis安装根目录&#xff0c;把redis注册服务 redis-server --service-install redis.windows-service.conf --lo…

Communications chemisty|德睿智药工作-用于分子性质预测的药物约束异构图Transformer模型

德睿智药的分子性质预测任务 题目&#xff1a; Pharmacophoric-constrained heterogeneous graph transformer model for molecular property prediction 文献来源&#xff1a;COMMUNICATIONS CHEMISTRY | (2023) 6:60 | 代码&#xff1a;https://github.com/stardj/PharmHG…

springboot+dubbo+zookeeper 项目实战

现在有一段代码再前台&#xff0c;后台系统中都存在&#xff0c;都需要这段代码&#xff0c;存在这种情况&#xff0c;我们可以选择将这段代码提取出来作为一个服务&#xff0c;让前台和后台系统作为消费者远程调用这段代码&#xff0c;提高了代码的复用性。 springboot集成dub…

Unity Audio -- (2)创建动态音效

评估场景需求 本节的目标是添加脚步声到角色身上&#xff0c;当角色走路时&#xff0c;触发动画事件并播放声音。 脚步声是我们在真实世界中常常被我们所忽视的声音&#xff0c;但脚步声能够传达出许多环境信息。你现在可以花一小段时间绕着你周围的环境走一走并仔细听听脚步声…

CLIP : Learning Transferable Visual Models From Natural Language Supervision

CLIP : Learning Transferable Visual Models From Natural Language Supervision IntroductionApproach Introduction 在raw的数据上自监督的训练模型&#xff0c;已经在NLP领域取得了革命性进展&#xff0c;这种模型需要收到硬件、数据的限制&#xff0c;但是能得到很好的迁…

算法 DAY55 动态规划11 392.判断子序列 115.不同的子序列

392.判断子序列 本题可以直接用双指针解法。但是本题是编辑距离的入门题目&#xff0c;故采用动态规划解法为后序“编辑距离”类题目打基础。 本题与最大子序列非常相似&#xff0c;但不同的是s必须连续&#xff0c;t可以不连续。 五部曲 1、dp[i][j] 表示以下标i-1为结尾的字…

Seata介绍

介绍&#xff1a; Seata的设计目标是对这个业务无侵入&#xff0c;因此从业务无侵入的2PC方案开始的&#xff0c;在传统的2PC的基础上演进的。它把一个分布式事务拆分理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致性&#xff0c;要…

25.自定义注解

自定义注解 一、什么是注解 Annontation是Java1.5开始引入的新特征&#xff0c;中文名称叫注解。 它提供了一种安全的类似注释的机制&#xff0c;用来将信息或元数据&#xff08;metadata&#xff09;与程序元素&#xff08;类、方法、成员变量等&#xff09;进行关联。为程序…

大数据技术之SparkSQL——数据的读取和保存

一、通用的加载和保存方式 SparkSQL提供了通用的保存数据和数据加载的方式。根据不同的参数读取&#xff0c;并保存不同格式的数据。SparkSQL默认读取和保存的文件格式为Parquet。 1.1 加载数据 spark.read.load 是加载数据的通用方式。 如果读取不同格式的数据&#xff0c;可…

如何编译DPDK静态库

阅读前面文章https://blog.csdn.net/qq_36314864/article/details/130243348,知道了哪些dpdk文件可以在windows下生成。 打开vs,新建一个生成静态库工程,在生成的lib文件中找到D:\dpdk-21.07\build\lib D:\dpdk-21.07\build\drivers找到对应的文件,并按照路径,新建筛选项…

【Vue学习笔记7】Vue3中如何开发组件

重点学习&#xff1a;vue3.0之组件通信机制defineProps&#xff08;组件接收外部传来的参数&#xff09;、defineEmits&#xff08;向组件外部传递参数&#xff09;。 1. 评级组件第一版 简单的评级需求&#xff0c;只需要一行代码就可以实现&#xff1a; "★★★★★☆…

SLAM面试笔记(5) — ROS面试

目录 1 ROS概述 2 ROS通信机制 问题&#xff1a;服务通信概念 问题&#xff1a;服务通信理论模型 3 常见面试题 问题&#xff1a;roslaunch和rosrun区别&#xff1f; 问题&#xff1a;什么是ROS&#xff1f; 问题&#xff1a;ROS中的节点是什么&#xff1f; 问题&…

挠性航天器姿态机动动力学模型及PD鲁棒控制

挠性航天器姿态机动动力学模型及PD鲁棒控制 1挠性航天器姿态机动动力学模型2挠性航天器姿态机动PD鲁棒控制2.1 动力学模型及PD控制律2.2仿真模型2.3 控制程序2.4 被控对象程序2.5 绘图程序2.6 结果 1挠性航天器姿态机动动力学模型 2挠性航天器姿态机动PD鲁棒控制 2.1 动力学模…

【NLP开发】Python实现聊天机器人(ChatterBot,集成web服务)

&#x1f37a;NLP开发系列相关文章编写如下&#x1f37a;&#xff1a; &#x1f388;【NLP开发】Python实现词云图&#x1f388;&#x1f388;【NLP开发】Python实现图片文字识别&#x1f388;&#x1f388;【NLP开发】Python实现中文、英文分词&#x1f388;&#x1f388;【N…

澳大利亚兔灾和——栈?

一.背景 1859年&#xff0c;当一位叫托马斯奥斯汀的农民收到英国老家送来的24只野兔并将它们放归农场的时候&#xff0c;他绝对意想不到&#xff0c;这些看似人畜无害的小兔子&#xff0c;竟为古老的澳洲大陆带来一场巨大的生态破坏。到20世纪初&#xff0c;澳大利亚的兔子数量…

操作系统内存管理(上)——内存管理基础

一、内存的基本知识 1.什么是内存&#xff1f;有什么作用&#xff1f; 内存可存放数据。程序执行前先放到内存才能被CPU处理——缓和CPU和硬盘之间的速度矛盾。 给内存的存储单元编址。如果计算机按字节编址&#xff0c;则每个存储单元大小为1字节。即1B8b&#xff08;8个二进…

智能医院导航导诊系统,门诊地图导航怎么做?

现在很多医院都是综合化大型医院&#xff0c;有很多的科室&#xff0c;院区面积也逐渐扩大&#xff0c;一方面给病患提供了更为全面的医疗资源&#xff0c;另一方面&#xff0c;医院复杂的环境也给病患寻医问诊带来了一定的困扰。电子地图作为大家最喜闻乐见的高效应用形式&…

Python的socket模块及示例

13.2 socket模块 socket由一些对象组成&#xff0c;这些对象提供网络应用程序的跨平台标准。 13.2.1 认识socket模块 socket又称“套接字”&#xff0c;应用程序通常通过“套接字”向网络发出请求或应答网络请求&#xff0c;使主机间或一台计算机上的进程间可以通信。sock…

Android 路由框架ARouter源码解析

作者&#xff1a;小马快跑 我们知道在使用ARouter时&#xff0c;需要在build.config里配置&#xff1a; annotationProcessor com.alibaba:arouter-compiler:1.2.2并且知道annotationProcessor用来声明注解解析器&#xff0c;arouter-compiler用来解析ARouter中的各个注解并自…

代码管理记录(一): 码云Gitee代码提交和维护

文章目录 Gitee介绍登录地址代码提交 Gitee介绍 Gitee 是一个类似于GitHub的代码托管平台&#xff0c;是中国的开源社区和开发者社区。它为开发者提供了基于Git的代码托管、协作、部署、代码质量检测、漏洞扫描、容器镜像等服务&#xff0c;同时也提供了一系列的个人资料和社交…