当前位置: 首页 > article >正文

vue.js设计与实现(霍春阳著) 章节总结

vue.js设计与实现

第 1 章 权衡的艺术第一篇

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

接着,我们讨论了虚拟 DOM 的性能,并给出了一个公式:

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

声明式更新虽然增加了查找差异的额外开销,但通常通过虚拟DOM的高效实现,将DOM操作降到最低,从而实现性能优化。对于开发者来说,关键是优化每次更新的复杂度,避免大量无意义的虚拟DOM生成与差异计算,以保持声明式更新的性能优势。

虚拟 DOM 的意义就在于使找出差异的性能消耗小化。我们发现,用原生 JavaScript 操作 DOM 的方法(如 document.createElement)、虚拟 DOM 和 innerHTML 三者操作页面的性能,不可以简单地下定论,这与页面大小、变更部分的大小都有关系,除此之外,与创建页面还是更新页面也有关系,选择哪种更新策略,需要我们结合心智负担、可维护性等因素综合考虑。一 番权衡之后,我们发现虚拟 DOM 是个还不错的选择。 之后,我们介绍了运行时和编译时的相关知识,了解纯运行时、 纯编译时以及两者都支持的框架各有什么特点,并总结出 Vue.js 3 是一 个 编译时 + 运行时 的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。

第 2 章 框架设计的核心要素

本章首先讲解了框架设计中关于开发体验的内容,开发体验 是衡量一个框架的重要指标之一。提供友好的 警告信息 至关重要,这有助 于开发者快速定位问题,因为大多数情况下“框架”要比开发者更清楚 问题出在哪里,因此在框架层面抛出有意义的警告信息是非常必要 的。

但提供的警告信息越详细,就意味着框架体积越大。因此,为了 框架体积不受警告信息的影响,我们需要利用 Tree-Shaking 机制,配 合构建工具 预定义常量 的能力,例如预定义 DEV 常量,从而实现 **仅在开发环境中打印警告信息,**而生产环境中则不包含这些用于提升 开发体验的代码,从而实现线上代码体积的可控性。

Tree-Shaking 是一种排除 dead code 的机制,框架中会内建多种能力,例如 Vue.js 内建的组件等。对于用户可能用不到的能力,我们可以利用 Tree-Shaking 机制使终打包的代码体积小化。另外,TreeShaking 本身基于 ESM,并且 JavaScript 是一门动态语言,通过纯静态分析的手段进行 Tree-Shaking 难度较大,因此大部分工具能够识别 /#PURE/ 注释,在编写框架代码时,我们可以利用 /#PURE/ 来辅助构建工具进行 Tree-Shaking。

接着我们讨论了框架的输出产物,不同类型的产物是为了满足不同的需求。为了让用户能够通过 script 标签直接引用并使用,我们需要输出 IIFE 格式的资源,即立即调用的函数表达式。为了让用户能够通过 script type=“module” 引用并使用,我们需要输出 ESM 格式的资源。这里需要注意的是,ESM 格式的资源有两种:用于浏览器的 esm-browser.js 和用于打包工具的 esm-bundler.js。它们的区别 在于对预定义常量 DEV 的处理,前者直接将 DEV 常量替换为字面量 true 或 false,后者则将 DEV 常量替换为 process.env.NODE_ENV !== ‘production’ 语句。

开发大型应用时,使用 esm-bundler.js,配合打包工具实现更好的性能和更小的包体积。
简单项目或实验中,可以使用 esm-browser.js,直接在浏览器环境中运行模块代码。

框架会提供多种能力或功能。有时出于灵活性和兼容性的考虑, 对于同样的任务,框架提供了两种解决方案,例如 Vue.js 中的选项对象式 API组合式 API 都能用来完成页面的开发,两者虽然不互斥, 但从框架设计的角度看,这完全是基于兼容性考虑的。有时用户明确 知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过 特性开关 关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。

框架的错误处理做得好坏直接决定了用户应用程序的健壮性,同时还决定了用户开发应用时处理错误的心智负担。框架需要为用户提供统一的错误处理接口,这样用户可以通过 注册自定义的错误处理函数 来处理全部的框架异常。

之后,我们点出了一个常见的认知误区,即“使用 TS 编写框架和 框架对 TS 类型支持友好 是两件完全不同的事”。有时候为了让框架提供更加友好的类型支持,甚至要花费比实现框架功能本身更多的时间和精力。

第 3 章 Vue.js 3 的设计思路

在本章中,我们首先介绍了声明式地描述 UI 的概念。我们知道, Vue.js 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟 DOM 来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟 DOM 更加直观。

然后我们讲解了基本的渲染器的实现。渲染器的作用是,把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染 器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会 更新需要更新的内容。后面我们会专门讲解渲染器的相关知识。

接着,我们讨论了组件的本质。组件其实就是一组虚拟 DOM 元素 的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但 这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。渲染器在渲染组件时,会先获取组件要渲染的内容,即执行组件的渲染函 数并得到其返回值,我们称之为 subtree,后再递归地调用渲染器 将 subtree 渲染出来即可。

Vue.js 的模板会被一个叫作编译器的程序编译为渲染函数,后面我 们会着重讲解编译器相关知识。最后,编译器、渲染器都是 Vue.js 的 核心组成部分,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。

第 4 章 响应系统的作用与实现

在本章中,我们首先介绍了副作用函数和响应式数据的概念,以 及它们之间的关系。一个响应式数据基本的实现依赖于对**“读取”**和 **“设置”**操作的拦截,从而在副作用函数与响应式数据之间建立联系。 当“读取”操作发生时,我们将当前执行的副作用函数存储到“桶”中;当 “设置”操作发生时,再将副作用函数从“桶”里取出并执行。这就是响应 系统的根本实现原理。

接着,我们实现了一个相对完善的响应系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间 建立更加精确的联系。同时,我们也介绍了 WeakMap 与 Map 这两个数据结构之间的区别。WeakMap 是弱引用的,它不影响垃圾回收器的 工作。当用户代码对一个对象没有引用关系时,WeakMap 不会阻止垃 圾回收器回收该对象。

我们还讨论了分支切换导致的冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题,我们需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系,新的响应联系中不存 在冗余副作用问题,从而解决了问题。但在此过程中,我们还遇到了 遍历 Set 数据结构导致无限循环的新问题,该问题产生的原因可以从 ECMA 规范中得知,即“在调用 forEach 遍历 Set 集合时,如果一个 值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”解决方案是建立一个新的 Set 数据结构用来遍历。

然后,我们讨论了关于嵌套的副作用函数的问题。在实际场景 中,嵌套的副作用函数发生在组件嵌套的场景中,即父子组件关系。 这时为了避免在响应式数据与副作用函数之间建立的响应联系发生错乱,我们需要使用副作用函数栈来存储不同的副作用函数。当一个副作用函数执行完毕后,将其从栈中弹出。当读取响应式数据的时候, 被读取的响应式数据只会与当前栈顶的副作用函数建立响应联系,从而解决问题。而后,我们遇到了副作用函数无限递归地调用自身,导致栈溢出的问题。该问题的根本原因在于,对响应式数据的读取和设置操作发生在同一个副作用函数内。解决办法很简单,如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

随后,我们讨论了响应系统的可调度性。所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。为了实现调度能力,我们为 effect 函数 增加了第二个选项参数,可以通过 scheduler 选项指定调用器,这 样用户可以通过调度器自行完成任务的调度。我们还讲解了如何通过调度器实现任务去重,即通过一个微任务队列对任务进行缓存,从而实现去重。

而后,我们讲解了计算属性,即 computed。计算属性实际上是 一个懒执行的副作用函数,我们通过 lazy 选项使得副作用函数可以 懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化 时,会通过 scheduler 将 dirty 标记设置为 true,代表“脏”。这样,下次读取计算属性的值时,我们会重新计算真正的值。

之后,我们讨论了 watch 的实现原理。它本质上利用了副作用函 数重新执行时的可调度性。一个 watch 本身创建一个 effect,当 这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler。这里的 scheduler 可以理解为“回调”, 所以我们只需要在 scheduler 中执行用户通过 watch 函数注册的回 调函数即可。此外,我们还讲解了立即执行回调的 watch,通过添加 新的 immediate 选项来实现,还讨论了如何控制回调函数的执行时机,通过 flush 选项来指定回调函数具体的执行时机,本质上是利用 了调用器和异步的微任务队列。

最后,我们讨论了过期的副作用函数,它会导致竞态问题。为了解决这个问题,Vue.js 为 watch 的回调函数设计了第三个参数,即 onInvalidate。它是一个函数,用来注册过期回调。每当 watch 的 回调函数执行之前,会优先执行用户通过 onInvalidate 注册的过期回调。这样,用户就有机会在过期回调中将上一次的副作用标记为“过期”,从而解决竞态问题。

举个例子

假设我们有一个输入框,用户输入内容后,系统会自动发送请求获取搜索结果。如果用户输入得很快,输入内容还没处理完,新的输入就已经触发了下一个请求。这时,后一个请求可能先完成,而返回的结果就可能不匹配当前输入内容,导致页面显示出错。

如何避免
Vue 的 onInvalidate 就是为了解决这种问题的。每次发起新的请求之前,可以把上一次的请求标记为“过期”,确保每次展示的都是最新的结果。这样可以避免前后操作的混乱,确保显示结果总是与用户的最新输入一致。

第 5 章 非原始值的响应式方案

在本章中,我们首先介绍了 Proxy 与 Reflect。Vue.js 3 的响应式数据是基于 Proxy 实现的,Proxy 可以为其他对象创建一个代理对象。所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。在实现代理的过程中,我们遇到了访问器属性的 this 指向问题,这需要使用 Reflect.* 方法并指定正确的 receiver 来解决。

然后我们详细讨论了 JavaScript 中对象的概念,以及 Proxy 的工作原理。在 ECMAScript 规范中,JavaScript 中有两种对象,其中一种叫作常规对象,另一种叫作异质对象。满足以下三点要求的对象就是 常规对象:

标准行为:常规对象会按照 JavaScript 规范的标准实现行为。
无特殊操作:所有属性的访问、设置、删除、枚举等操作都符合标准行为。
普通对象字面量:例如,通过 {}、new Object() 等方式创建的对象都是常规对象。

而所有不符合这三点要求的对象都是异质对象。一个对象是 函数 还是 其他对象,是由部署在该对象上的 内部方法 和 内部槽 决定的。

接着,我们讨论了关于对象 Object 的代理。代理对象的本质, 就是查阅规范并找到可拦截的基本操作的方法。有一些操作并不是基本操作,而是复合操作,这需要我们查阅规范了解它们都依赖哪些基本操作,从而通过基本操作的拦截方法间接地处理复合操作。我们还 详细分析了添加、修改、删除属性对 for…in 操作的影响,其中添 加和删除属性都会影响 for…in 循环的执行次数,所以当这些操作 发生时,需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。 而修改属性值则不影响 for…in 循环的执行次数,因此无须处理。 我们还讨论了如何合理地触发副作用函数重新执行,包括对 NaN 的 理,以及访问原型链上的属性 导致 的副作用函数重新执行两次的问 题。对于 NaN,我们主要注意的是 NaN === NaN 永远等于 false。 对于原型链属性问题,需要我们 查阅规范 定位问题的原因。由此可见,想要基于 Proxy 实现一个相对完善的响应系统,免不了去了解 ECMAScript 规范。 而后,我们讨论了深响应与浅响应,以及深只读与浅只读。这里的深和浅指的是对象的层级,浅响应(或只读)代表仅代理一个对象 的第一层属性,即只有对象的第一层属性值是响应(或只读)的。深 响应(或只读)则恰恰相反,为了实现深响应(或只读),我们需要 在返回属性值之前,对值做一层包装,将其包装为响应式(或只读) 数据后再返回。

之后,我们讨论了关于数组的代理。数组是一个异质对象,因为 数组对象部署的内部方法 [[DefineOwnProperty]] 不同于常规对象。通过索引为数组设置新的元素,可能会隐式地改变数组 length 属性的值。对应地,修改数组 length 属性的值,也可能会间接影响 数组中的已有元素。所以在触发响应的时候需要额外注意。我们还讨论了如何拦截 for…in 和 for…of 对数组的遍历操作。使用 for…in 循环遍历数组与遍历普通对象区别不大,唯一需要注意的 是,当追踪 for…in 操作时,应该使用数组的 length 作为追踪的 key。for…of 基于迭代协议工作,数组内建了 Symbol.iterator 方法。根据规范的 23.1.5.1 节可知,数组迭代器 执行时,会读取数组的 length 属性或数组的索引。因此,我们不需 要做其他额外的处理,就能够实现对 for…of 迭代的响应式支持。

我们还讨论了数组的查找方法。如 includes、indexOf 以及 lastIndexOf 等。对于数组元素的查找,需要注意的一点是,用户既可能使用代理对象进行查找,也可能使用原始对象进行查找。为了支持这两种形式,我们需要重写数组的查找方法。原理很简单,当用户 使用这些方法查找元素时,我们可以先去代理对象中查找,如果找不到,再去原始数组中查找。 我们还介绍了会隐式修改数组长度的原型方法,即 push、pop、 shift、unshift 以及 splice 等方法。调用这些方法会间接地读取 和 设置数组的 length 属性,因此,在不同的副作用函数内对同一个 数组执行上述方法,会导致多个副作用函数之间循环调用,终导致调用栈溢出。为了解决这个问题,我们使用一个标记变量 shouldTrack 来代表是否允许进行追踪,然后重写了上述这些方法, 目的是,当这些方法间接读取 length 属性值时,我们会先将 shouldTrack 的值设置为 false,即禁止追踪。这样就可以断开 length 属性与副作用函数之间的响应联系,从而避免循环调用导致的调用栈溢出。

最后,我们讨论了关于集合类型数据的响应式方案。集合类型指 Set、Map、WeakSet 以及 WeakMap。我们讨论了使用 Proxy 为集合类型 创建代理对象 的一些注意事项。集合类型不同于普通对象,它 有特定的数据操作方法。当使用 Proxy 代理 集合类型 的数据时要格外注意,例如,集合类型的 size 属性是一个访问器属性,当通过代理 对象访问 size 属性时,由于代理对象本身并没有部署 [[SetData]] 这样的内部槽,所以会发生错误。另外,通过代理对象执行集合类型 的操作方法时,要注意这些方法执行时的 this 指向,我们需要在 get 拦截函数内通过 .bind 函数为这些方法绑定正确的 this 值。我们还讨论了 集合类型 响应式数据 的实现。我们需要通过“重写”集合方法的方式来实现自定义的能力,当 Set 集合的 add 方法执行时,需要 调用 trigger 函数触发响应。我们也讨论了关于“数据污染”的问题。 数据污染指的是不小心将响应式数据添加到原始数据中,它导致用户 可以通过原始数据执行响应式相关操作,这不是我们所期望的。为了 避免这类问题发生,我们通过响应式数据对象的 raw 属性来访问对应 的原始数据对象,后续操作使用原始数据对象就可以了。我们还讨论 了关于集合类型的遍历,即 forEach 方法。集合的 forEach 方法与 对象的 for…in 遍历类似,大的不同体现在,当使用 for…in

遍历对象时,我们只关心对象的键是否变化,而不关心值;但使用 forEach 遍历集合时,我们既关心键的变化,也关心值的变化。

第 6 章 原始值的响应式方案

在本章中,我们首先介绍了 ref 的概念。ref 本质上是一个“包裹 对象”。因为 JavaScript 的 Proxy 无法提供对原始值的代理,所以我们 需要使用一层对象作为包裹,间接实现原始值的响应式方案。由于“包 裹对象”本质上与普通对象没有任何区别,因此为了区分 ref 与普通响 应式对象,我们还为“包裹对象”定义了一个值为 true 的属性,即 __v_isRef,用它作为 ref 的标识。

ref 除了能够用于原始值的响应式方案之外,还能用来解决响应 丢失问题。为了解决该问题,我们实现了 toRef 以及 toRefs 这两个 函数。它们本质上是对响应式数据做了一层包装,或者叫作“访问代理”。

最后,我们讲解了自动脱 ref 的能力。为了减轻用户的心智负担,我们自动对暴露到模板中的响应式数据进行脱 ref 处理。这样, 用户在模板中使用响应式数据时,就无须关心一个值是不是 ref 了。

第 7 章 渲染器的设计

在本章中,我们首先介绍了渲染器与响应系统的关系。利用 响应系统 的能力,我们可以做到,当响应式数据变化时自动完成页面更新 (或重新渲染)。同时我们注意到,这与渲染器的具体实现无关。我 们实现了一个极简的渲染器,它只能利用 innerHTML 属性将给定的 HTML 字符串内容设置到容器中。 接着,我们讨论了与渲染器相关的基本名词和概念。渲染器的作用是把虚拟 DOM 渲染为特定平台上的真实元素,我们用英文 renderer 来表达渲染器。虚拟 DOM 通常用英文 virtual DOM 来表达,有时会简 写成 vdom 或 vnode。渲染器会执行挂载和打补丁操作,对于新的元素,渲染器会将它挂载到容器内;对于新旧 vnode 都存在的情况,渲 染器则会执行打补丁操作,即对比新旧 vnode,只更新变化的内容。

最后,我们讨论了自定义渲染器的实现。在浏览器平台上,渲染器 可以利用 DOM API 完成 DOM 元素的创建、修改和删除。为了让渲染器不直接依赖浏览器平台特有的 API,我们将这些用来创建、修改和 删除元素的操作抽象成可配置的对象。用户可以在调用 createRenderer 函数创建渲染器的时候指定自定义的配置对象,从 而实现自定义的行为。我们还实现了一个用来打印渲染器操作流程的 自定义渲染器,它不仅可以在浏览器中运行,还可以在 Node.js 中运行。

第 8 章 挂载与更新

在本章中,我们首先讨论了如何挂载子节点,以及节点的属性。 对于子节点,只需要递归地调用 patch 函数完成挂载即可。而节点的 属性比想象中的复杂,它涉及两个重要的概念:HTML Attributes 和 DOM Properties。为元素设置属性时,我们不能总是使用 setAttribute 函数,也不能总是通过元素的 DOM Properties 来设 置。至于如何正确地为元素设置属性,取决于被设置属性的特点。例如,表单元素的 el.form 属性是只读的,因此只能使用 setAttribute 函数来设置。

接着,我们讨论了特殊属性的处理。以 class 为例,Vue.js 对 class 属性做了增强,它允许我们为 class 指定不同类型的值。但在 把这些值设置给 DOM 元素之前,要对值进行正常化。我们还讨论了为 元素设置 class 的三种方式及其性能情况。其中,el.className 的性能最优,所以我们选择在 patchProps 函数中使用 el.className 来完成 class 属性的设置。除了 class 属性之外, Vue.js 也对 style 属性做了增强,所以 style 属性也需要做类似的处 理。

然后,我们讨论了卸载操作。一开始,我们直接使用 innerHTML 来清空容器元素,但是这样存在诸多问题。

  • 容器的内容可能是由某个或多个组件渲染的,当卸载操作发生时,应该正确地调用这些组件的 beforeUnmount、unmounted 等生命周期函数。
  • 即使内容不是由组件渲染的,有的元素存在自定义指令,我们应该在卸载操作发生时正确地执行对应的指令钩子函数。
  • 使用 innerHTML 清空容器元素内容的另一个缺陷是,它不会移除绑定在 DOM 元素上的事件处理函数

因此,我们不能直接使用 innerHTML 来完成卸载任务。为了解决这些问题,我们封装了 unmount 函数。该函数是以一个 vnode 的 维度来完成卸载的,它会根据 vnode.el 属性 取得该虚拟节点对应的 真实 DOM,然后调用原生 DOM API 完成 DOM 元素的卸载。这样做 还有两点 额外的好处。

  • 在 unmount 函数内,我们有机会调用绑定在 DOM 元素上的 指令钩子函数,例如 beforeUnmount、unmounted 等。
  • 当 unmount 函数执行时,我们有机会检测 虚拟节点 vnode 的类型。如果该虚拟节点描述的是组件,则我们也有机会调用组件相关的生命周期函数。

而后,我们讨论了 vnode 类型的区分。渲染器在执行更新时,需要优先检查新旧 vnode 所描述的内容是否相同。只有当它们所描述的内容相同时,才有打补丁的必要。另外,即使它们描述的内容相同, 我们也需要进一步检查它们的类型,即检查 vnode.type 属性值的类型,据此判断它描述的具体内容是什么。如果类型是字符串,则它描述的是普通标签元素,这时我们会调用 mountElement 和 patchElement 来完成挂载和打补丁;如果类型是对象,则它描述的是组件,这时需要调用 mountComponent 和 patchComponent 来完成挂载和打补丁。

我们还讲解了事件的处理。首先介绍了如何在虚拟节点中描述事件,我们把 vnode.props 对象中以字符串 on 开头的属性当作事件对待。接着,我们讲解了如何绑定和更新事件。在更新事件的时候,为了提升性能,我们伪造了 invoker 函数,并把真正的事件处理函数存储在 invoker.value 属性中,当事件需要更新时,只更新 invoker.value 的值即可,这样可以避免一次 removeEventListener 函数的调用。

示例:
假设我们有一个组件的事件绑定如下:

<button @click="handleClick">Click Me</button>

首次渲染时,Vue 会创建一个 invoker 函数:

invoker = function invoker(event) {
  invoker.value(event); // 调用存储在 invoker.value 中的事件处理函数
};
invoker.value = this.handleClick; // 存储实际的事件处理函数

更新时,如果 handleClick 函数发生变化,Vue 只需要更新 invoker.value:

invoker.value = newHandleClick; // 更新事件处理函数

Vue 会避免重新绑定事件监听器,而是只更新 invoker.value。

我们还讲解了如何处理事件与更新时机的问题。解决方案是,利用事件处理函数 被绑定到 DOM 元素的时间与事件触发时间之间的差异。我们需要屏蔽所有 绑定时间 晚于 事件触发时间 的事件处理函数 的执行

之后,我们讨论了子节点的更新。我们对虚拟节点中的 children 属性进行了规范化,规定 vnode.children 属性只能有 如下三种类型。
  • 字符串类型:代表元素具有文本子节点。
  • 数组类型:代表元素具有一组子节点。
  • null:代表元素没有子节点。

在更新时,新旧 vnode 的子节点都有可能是以上三种情况之一, 所以在执行更新时一共要考虑九种可能,即图 8-5 所展示的那样。但落实到代码中,我们并不需要罗列所有情况。另外,当新旧 vnode 都具有一组子节点时,我们采用了比较笨的方式来完成更新,即卸载所有旧子节点,再挂载所有新子节点。更好的做法是,通过 Diff 算法比较新旧两组子节点,试图大程度复用 DOM 元素。我们会在后续章节中 详细讲解 Diff 算法的工作原理。

我们还讨论了如何使用虚拟节点来描述文本节点和注释节点。我 们利用了 symbol 类型值的唯一性,为文本节点和注释节点分别创建唯一标识,并将其作为 vnode.type 属性的值。

最后,我们讨论了 Fragment 及其用途。渲染器渲染 Fragment 的方式 类似于 渲染普通标签,不同的是,Fragment 本身并不会渲染任何 DOM 元素。所以,只需要渲染一个 Fragment 的所有子节点即可。

第 9 章 简单 Diff 算法

在本章中,我们首先讨论了 Diff 算法的作用。Diff 算法用来计算 两组子节点的 差异,并试图大程度地复用 DOM 元素。在上一章中, 我们采用了一种简单的方式来更新子节点,即卸载所有旧子节点,再挂载所有新子节点。然而这种更新方式无法对 DOM 元素进行复用,需要大量的 DOM 操作才能完成更新,非常消耗性能。于是,我们对它进行了改进。改进后的方案是,遍历新旧两组子节点中数量较少的那一 组,并逐个调用 patch 函数进行打补丁,然后比较 新旧两组子节点的 数量,如果新的一组子节点数量更多,说明有新子节点需要挂载;否则说明在旧的一组子节点中,有节点需要卸载。

然后,我们讨论了虚拟节点中 key 属性的作用,它就像虚拟节点 的“身份证号”。在更新时,渲染器通过 key 属性找到可复用的节点, 然后尽可能地通过 DOM 移动操作来完成更新,避免 过多地对 DOM 元 素进行销毁和重建。

接着,我们讨论了简单 Diff 算法是如何寻找需要移动的节点的。 简单 Diff 算法的核心逻辑是,拿新的一组子节点中的节点 去 旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索 引。我们把这个位置索引称为大索引。在整个更新过程中,如果一 个节点的索引值小于大索引,则说明该节点对应的真实 DOM 元素需 要移动。

最后,我们通过几个例子讲解了渲染器是如何移动、添加、删除 虚拟节点所对应的 DOM 元素的。

第 10 章 双端 Diff 算法

本章我们介绍了双端 Diff 算法的原理及其优势。顾名思义,双端 Diff 算法指的是,在新旧两组子节点的四个端点之间分别进行比较, 并试图找到可复用的节点。相比简单 Diff 算法,双端 Diff 算法的优势 在于,对于同样的更新场景,执行的 DOM 移动操作次数更少。

第 11 章 快速 Diff 算法

快速 Diff 算法在实测中性能优。它借鉴了文本 Diff 中的预处理 思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。 当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新 节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引 关系,构造出一个长递增子序列。长递增子序列所指向的节点即 为不需要移动的节点。

第 12 章 组件的实现原理

在本章中,我们首先讨论了如何使用 虚拟节点 来描述组件。使用 虚拟节点的 vnode.type 属性来存储组件对象,渲染器根据虚拟节点 的该属性的类型 来判断它是否是组件。如果是组件,则渲染器会使用 mountComponent 和 patchComponent 来完成组件的挂载和更新。

接着,我们讨论了组件的自更新。我们知道,在组件挂载阶段, 会为组件创建一个用于渲染其内容的副作用函数。该副作用函数 会与组件自身的响应式数据 建立响应联系。当组件自身的响应式数据 发生变化时,会触发渲染副作用函数重新执行,即重新渲染。但由于默认情况下重新渲染 是同步执行的,这导致无法对任务去重,因此我们在 创建渲染副作用函数 时,指定了自定义的调用器。该调度器的作用 是,当组件自身的响应式数据 发生变化时,将渲染副作用函数 缓冲到 微任务队列中。有了缓冲队列,我们即可实现对渲染任务的去重,从而避免无用的重新渲染 所导致的额外性能开销。

然后,我们介绍了组件实例。它本质上是一个对象,包含了组件运行过程中的状态,例如组件是否挂载、组件自身的响应式数据,以及组件所渲染的内容(即 subtree)等。 组件实例使得 Vue 渲染器可以根据组件的当前状态决定是进行全新的挂载还是对现有的组件进行更新。

  • 全新挂载:如果组件还没有挂载,渲染器会调用 mountComponent 来挂载组件。
  • 更新组件:如果组件已经挂载,并且响应式数据发生变化,渲染器会使用 patchComponent 来对组件进行更新。

而后,我们讨论了组件的 props 与组件的 被动更新。副作用自更新所引起的子组件更新 叫作子组件的被动更新。我们还介绍了渲染上下文(renderContext),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据 都是通过该代理对象实现的。

之后,我们讨论了 setup 函数。该函数是为了组合式 API 而生 的,所以我们要避免将其与 Vue.js 2 中的“传统”组件选项混合使用。 setup 函数的返回值可以是两种类型,如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象暴露到渲染上下文中。

emit 函数包含在 setupContext 对象中,可以通过 emit 函数 发射组件的自定义事件。通过 v-on 指令为组件绑定的事件在经过编译后,会以 onXxx 的形式存储到 props 对象中。当 emit 函数执行时,会在 props 对象中 寻找对应的事件处理函数 并执行它。

随后,我们讨论了组件的插槽。插槽是 Vue 中一种强大的功能,允许父组件向子组件插入内容。它基于 Web Components 中的 标签的概念。

  • 插槽函数:插槽内容会被编译成插槽函数。父组件向插槽传递内容时,实际上是向插槽函数传递虚拟 DOM
    结构。子组件通过调用插槽函数,将内容渲染到指定的插槽位置。
  • 标签:在子组件模板中, 标签会被编译为调用插槽函数的代码,插槽函数的返回值即为插槽填充的内容。

最后,我们讨论了 onMounted 等用于注册生命周期钩子函数的 方法的实现。通过 onMounted 注册的生命周期函数 会被注册到当前组件实例的 instance.mounted 数组中。为了维护当前正在初始化 的组件实例,我们定义了全局变量 currentInstance,以及用来设置该变量的 setCurrentInstance 函数。

第 13 章 异步组件与函数式组件

在本章中,我们首先讨论了异步组件要解决的问题。异步组件在 页面性能、拆包以及服务端下发组件等场景中尤为重要。从根本上来 说,异步组件的实现可以完全在用户层面实现,而无须框架支持。但 一个完善的异步组件仍需要考虑诸多问题,例如:

  • 允许用户指定加载出错时要渲染的组件;
  • 允许用户指定 Loading 组件,以及展示该组件的延迟时间;
  • 允许用户设置加载组件的超时时长
  • 组件加载失败时,为用户提供重试的能力

因此,框架有必要内建异步组件的实现。

Vue.js 3 提供了 defineAsyncComponent 函数,用来定义异步组件。

接着,我们讲解了异步组件的加载超时问题,以及当加载错误发生时,如何指定 Error 组件。通过为 defineAsyncComponent 函数 指定选项参数,允许用户通过 timeout 选项设置超时时长。当加载超时后,会触发加载错误,这时会渲染用户通过 errorComponent 选项指定的 Error 组件。

在加载异步组件的过程中,受网络状况的影响较大。当网络状况较差时,加载过程可能很漫长。为了提供更好的用户体验,我们需要在加载时展示 Loading 组件。所以,我们设计loadingComponent 选项,以允许用户配置自定义的 Loading 组件。但展示 Loading 组件的时机是一个需要仔细考虑的问题。为了避免 Loading 组件导致的闪烁问题,我们还需要设计一个接口,让用户能指定延迟展示 Loading 组件的 时间,即 delay 选项。

在加载组件的过程中,发生错误的情况 非常常见。所以,我们设计了组件加载发生错误后的 重试机制。在讲解异步组件的 重试加载机制 时,我们类比了接口请求发生错误时的重试机制,两者的思路类似。

最后,我们讨论了函数式组件。它本质上是一个函数,其内部实现逻辑可以复用 有状态组件的实现逻辑。为了给函数式组件定义 props,我们允许开发者在 函数式组件 的主函数上 添加静态的 props 属性。出于更加严谨的考虑,函数式组件没有自身状态,也没有生命周期的概念。所以,在初始化函数式组件时,需要选择性地 复用有状态组件的初始化逻辑。

第 14 章 内建组件和模块

在本章中,我们介绍了 Vue.js 内建的三个组件,即 KeepAlive 组 件、Teleport 组件和 Transition 组件。它们的共同特点是,与渲染器的 结合非常紧密,因此需要框架提供底层的实现与支持。

KeepAlive 组件的作用类似于 HTTP 中的持久链接。它可以避免组 件实例不断地被销毁和重建。KeepAlive 的基本实现并不复杂。当被 KeepAlive 的组件“卸载”时,渲染器并不会真的将其卸载掉,而是会将 该组件搬运到一个隐藏容器中,从而使得组件可以维持当前状态。当 被 KeepAlive 的组件“挂载”时,渲染器也不会真的挂载它,而是将它从 隐藏容器搬运到原容器。

我们还讨论了 KeepAlive 的其他能力,如匹配策略和缓存策略。 include 和 exclude 这两个选项用来指定哪些组件需要被 KeepAlive,哪些组件不需要被 KeepAlive。默认情况下,include 和 exclude 会匹配组件的 name 选项。但是在具体实现中,我们可以扩 展匹配能力。对于缓存策略,Vue.js 默认采用“新一次访问”。为了让 用户能自行实现缓存策略,我们还介绍了正在讨论中的提案。

接着,我们讨论了 Teleport 组件所要解决的问题和它的实现原理。 Teleport 组件可以跨越 DOM 层级完成渲染,这在很多场景下非常有 用。在实现 Teleport 时,我们将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:

  • 可以避免渲染器逻辑代码“膨胀”;
  • 可以利用 Tree-Shaking 机制在终的 bundle 中删除 Teleport 相关 的代码,使得终构建包的体积变小。

Teleport 组件是一个特殊的组件。与普通组件相比,它的组件选项 非常特殊,例如 __isTeleport 选型和 process 选项等。这是因为Teleport 本质上是渲染器逻辑的合理抽象,它完全可以作为渲染器的一 部分而存在。

最后,我们讨论了 Transition 组件的原理与实现。我们从原生 DOM 过渡开始,讲解了如何使用 JavaScript 为 DOM 元素添加进场动效和离场动效。在此过程中,我们将实现动效的过程分为多个阶段, 即 beforeEnter、enter、leave 等。Transition 组件的实现原理与 为原生 DOM 添加过渡效果的原理类似,我们将过渡相关的钩子函数定 义到虚拟节点的 vnode.transition 对象中。渲染器在执行挂载和 卸载操作时,会优先检查该虚拟节点是否需要进行过渡,如果需要, 则会在合适的时机执行 vnode.transition 对象中定义的过渡相关 钩子函数。

第 15 章 编译器核心技术概览

在本章中,我们首先讨论了 Vue.js 模板编译器的工作流程。Vue.js 的模板编译器用于把模板编译为渲染函数。它的工作流程大致分为三个步骤。

(1) 分析模板,将其解析为模板 AST。

(2) 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。

(3) 根据 JavaScript AST 生成渲染函数代码。

接着,我们讨论了 parser 的实现原理,以及如何用有限状态自 动机构造一个词法分析器。词法分析的过程就是状态机在不同状态之 间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表。我们将使用该 Token 列表来构造用于描述模板的 AST。具 体做法是,扫描 Token 列表并维护一个开始标签栈。每当扫描到一个 开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的 节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树 型 AST。

然后,我们讨论了 AST 的转换与插件化架构。AST 是树型数据结 构,为了访问 AST 中的节点,我们采用深度优先的方式对 AST 进行遍 历。在遍历过程中,我们可以对 AST 节点进行各种操作,从而实现对 AST 的转换。为了解耦节点的访问和操作,我们设计了插件化架构, 将节点的操作封装到独立的转换函数中。这些转换函数可以通过 context.nodeTransforms 来注册。这里的 context 称为转换上 下文。上下文对象中通常会维护程序的当前状态,例如当前访问的节 点、当前访问的节点的父节点、当前访问的节点的位置索引等信息。 有了上下文对象及其包含的重要信息后,我们即可轻松地实现节点的 替换、删除等能力。但有时,当前访问节点的转换工作依赖于其子节 点的转换结果,所以为了优先完成子节点的转换,我们将整个转换过 程分为“进入阶段”与“退出阶段”。每个转换函数都分两个阶段执行,这 样就可以实现更加细粒度的转换控制。

之后,我们讨论了如何将模板 AST 转换为用于描述渲染函数的 JavaScript AST。模板 AST 用来描述模板,类似地,JavaScript AST 用 于描述 JavaScript 代码。只有把模板 AST 转换为 JavaScript AST 后,我 们才能据此生成终的渲染函数代码。

最后,我们讨论了渲染函数代码的生成工作。代码生成是模板编 译器的后一步工作,生成的代码将作为组件的渲染函数。代码生成 的过程就是字符串拼接的过程。我们需要为不同的 AST 节点编写对应 的代码生成函数。为了让生成的代码具有更强的可读性,我们还讨论 了如何对生成的代码进行缩进和换行。我们将用于缩进和换行的代码 封装为工具函数,并且定义到代码生成过程中的上下文对象中。

第 16 章 解析器

在本章中,我们首先讨论了解析器的文本模式及其对解析器的影响。文本模式指的是解析器在工作时所进入的一些特殊状态,如 RCDATA 模式、CDATA 模式、RAWTEXT 模式,以及初始的 DATA 模式 等。在不同模式下,解析器对文本的解析行为会有所不同。

接着,我们讨论了如何使用 递归下降算法 构造模板 AST。在 parseChildren 函数运行的过程中,为了处理标签节点,会调用 parseElement 解析函数,这会间接地调用 parseChildren 函数, 并产生一个新的状态机。随着标签嵌套层次的增加,新的状态机也会 随着 parseChildren 函数被递归地调用而不断创建,这就是“递归下 降”中“递归”二字的含义。而上级 parseChildren 函数的调用用于构 造上级模板 AST 节点,被递归调用的下级 parseChildren 函数则用 于构造下级模板 AST 节点。终会构造出一棵树型结构的模板 AST, 这就是“递归下降”中“下降”二字的含义。

在解析模板构建 AST 的过程中,parseChildren 函数是核心。 每次调用 parseChildren 函数,就意味着新状态机的开启。状态机 的结束时机有两个。

  • 第一个停止时机是当模板内容被解析完毕时。
  • 第二个停止时机则是遇到结束标签时,这时解析器会取得父级节 点栈栈顶的节点作为父节点,检查该结束标签是否与父节点的标 签同名,如果相同,则状态机停止运行。

我们还讨论了文本节点的解析。解析文本节点本身并不复杂,它的复杂点在于,我们需要对解析后的文本内容进行 HTML 实体的解码 工作。WHATWG 规范中也定义了解码 HTML 实体过程中的状态迁移 流程。HTML 实体类型有两种,分别是命名字符引用和数字字符引 用。命名字符引用的解码方案可以总结为两种。

  • 当存在分号时:执行完整匹配。
  • 当省略分号时:执行短匹配。

对于数字字符引用,则需要按照 WHATWG 规范中定义的规则逐步实现。

第 17 章 编译优化

本章中,我们主要讨论了 Vue.js 3 在编译优化方面所做的努力。编译优化指的是通过编译的手段提取关键信息,并以此指导生成优代码的过程。具体来说,Vue.js 3 的编译器会 充分分析模板,提取关键信 息并将其附着到对应的虚拟节点上。在运行时阶段,渲染器通过这些 关键信息执行“快捷路径”,从而提升性能。

编译优化的核心在于,区分动态节点与静态节点。Vue.js 3 会为动 态节点打上补丁标志,即 patchFlag。同时,Vue.js 3 还提出了 Block 的概念,一个 Block 本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个 dynamicChildren 数组。该数组用来收集所 有动态子代节点,这利用了 createVNode 函数和 createBlock 函 数的层层嵌套调用的特点,即以“由内向外”的方式执行。再配合一个 用来临时存储动态节点的节点栈,即可完成动态子代节点的收集。

由于 Block 会收集所有动态子代节点,所以对动态节点的比对操 作是忽略 DOM 层级结构的。这会带来额外的问题,即 v-if、v-for 等结构化指令会影响 DOM 层级结构,使之不稳定。这会间接导致基于 Block 树的比对算法失效。而解决方式很简单,只需要让带有 vif、v-for 等指令的节点也作为 Block 角色即可。

除了 Block 树以及补丁标志之外,Vue.js 3 在编译优化方面还做 了其他努力,具体如下。

  • 静态提升:能够减少更新时创建虚拟 DOM 带来的性能开销和内存 占用。
  • 预字符串化:在静态提升的基础上,对静态节点进行字符串化。 这样做能够减少创建虚拟节点产生的性能开销以及内存占用。
  • 缓存内联事件处理函数:避免造成不必要的组件更新。
  • v-once 指令:缓存全部或部分虚拟节点,能够避免组件更新时重 新创建虚拟 DOM 带来的性能开销,也可以避免无用的 Diff 操 作。

第 18 章 同构渲染

在本章中,我们首先讨论了 CSR、SSR 和同构渲染的工作机制, 以及它们各自的优缺点。

当我们为应用程序选择渲染架构时,需要结合软件的需求及场景,选择合适的渲染方案。

接着,我们讨论了 Vue.js 是如何把虚拟节点渲染为字符串的。以 普通标签节点 为例,在将其渲染为字符串时,要考虑以下内容。

  • 自闭合标签的处理。对于自闭合标签,无须为其渲染闭合标签部分,也无须处理其子节点。

  • 属性名称的合法性,以及属性值的转义。

  • 文本子节点的转义。

    具体的转义规则如下。

对于普通内容,应该对文本中的以下字符进行转义。

  • 将字符 & 转义为实体 &
  • 将字符 < 转义为实体 <
  • 将字符 > 转义为实体 >

对于属性值,除了上述三个字符应该转义之外,还应该转义下面 两个字符。

  • 将字符 " 转义为实体 "
  • 将字符 ’ 转义为实体 ’

然后,我们讨论了如何将组件渲染为 HTML 字符串。在服务端渲 染组件与渲染普通标签并没有本质区别。我们只需要通过执行组件的 render 函数,得到该组件所渲染的 subTree 并将其渲染为 HTML 字 符串即可。另外,在渲染组件时,需要考虑以下几点。

  • 服务端渲染不存在数据变更后的重新渲染,所以无须调用 reactive 函数对 data 等数据进行包装,也无须使用 shallowReactive 函数对 props 数据进行包装。正因如此,我 们也无须调用 beforeUpdate 和 updated 钩子。
  • 服务端渲染时,由于不需要渲染真实 DOM 元素,所以无须调用组件的 beforeMount 和 mounted 钩子。

之后,我们讨论了客户端激活的原理。在同构渲染过程中,组件 的代码会分别在 服务端和浏览器中执行一次。在服务端,组件会被渲染为静态的 HTML 字符串,并发送给浏览器。浏览器则会渲染由服务 端返回的静态的 HTML 内容,并下载打包在静态资源中的组件代码。 当下载完毕后,浏览器会解释并执行该组件代码。当组件代码在客户端执行时,由于页面中已经存在对应的 DOM 元素,所以渲染器并不会 执行创建 DOM 元素的逻辑,而是会执行激活操作。激活操作可以总结 为两个步骤。

  • 在虚拟节点与真实 DOM 元素之间建立联系,即 vnode.el = el。这样才能保证后续更新程序正确运行。
  • 为 DOM 元素添加事件绑定。

最后,我们讨论了如何编写同构的组件代码。由于组件代码既运 行于服务端,也运行于客户端,所以当我们编写组件代码时要额外注意。具体可以总结为以下几点。

  • 注意组件的生命周期。beforeUpdate、updated、 beforeMount、mounted、beforeUnmount、unmounted 等 生命周期钩子函数不会在服务端执行。
  • 使用跨平台的 API。由于组件的代码既要在浏览器中运行,也要在 服务器中运行,所以编写组件代码时,要额外注意代码的跨平台 性。通常我们在选择第三方库的时候,会选择支持跨平台的库, 例如使用 Axios 作为网络请求库。
  • 特定端的实现。无论在客户端还是在服务端,都应该保证功能的 一致性。例如,组件需要读取 cookie 信息。在客户端,我们可以 通过 document.cookie 来实现读取;而在服务端,则需要根据 请求头来实现读取。所以,很多功能模块需要我们为客户端和服务端分别实现。
  • 避免交叉请求引起的状态污染。状态污染既可以是应用级的,也 可以是模块级的。对于应用,我们应该为每一个请求创建一个独立的应用实例。对于模块,我们应该避免使用模块级的全局变量。这是因为在不做特殊处理的情况下,多个请求会共用模块级 的全局变量,造成请求间的交叉污染。
  • 仅在客户端渲染组件中的部分内容。这需要我们自行封装 组件,被该组件包裹的内容仅在客户端才会被渲染。

http://www.kler.cn/a/397573.html

相关文章:

  • stm32——通用定时器时钟知识点
  • YOLOv5、YOLOv6、YOLOv7、YOLOv8、YOLOv9、YOLOv10、YOLOv11 推理的 C++ 和 Python 实现
  • 基于PHP技术的校园站的设计与实现
  • Python安装(ubuntu)
  • 前端怎么获取视口大小
  • 数据分析-48-时间序列变点检测之在线实时数据的CPD
  • golang对日期格式化
  • Tailwind CSS 和 UnoCSS简单比较
  • 数据库管理-第262期 崖山:知其不可而为之(20241116)
  • 【笔记】Vue3回忆录
  • 【C语言指南】C语言内存管理 深度解析
  • aitrader双界面引擎(dash和streamlit),引入zvt作为数据获取及存储支持
  • 以太坊基础知识结构详解
  • 将大型语言模型(如GPT-4)微调用于文本续写任务
  • STM32设计井下瓦斯检测联网WIFI加Zigbee多路节点协调器传输
  • 【jvm】如何破坏双亲委派机制
  • LeetCode - #134 加油站
  • vocode Vue3项目 红色波浪线解决方案集锦
  • 丹摩征文活动|丹摩智算平台使用指南
  • 1436:数列分段II -整型二分
  • 两行命令搭建深度学习环境(Docker/torch2.5.1+cu118/命令行美化+插件),含完整的 Docker 安装步骤
  • 护眼模式浓度调整到最低
  • 【软件测试】一个简单的自动化Java程序编写
  • ELMo模型介绍:深度理解语言模型的嵌入艺术
  • Java基础——网络编程
  • 魔方和群论