Skip to content

Latest commit

 

History

History
706 lines (502 loc) · 57.5 KB

03-vuejs设计与实现总结.md

File metadata and controls

706 lines (502 loc) · 57.5 KB

对《Vue.js 设计与实现》这本书的各部分“总结”章节的整理,在看完全书后对 vue 整体的架构设计有个大概了解。

一:框架设计概览

1 权衡的艺术

命令式和声明式这两种范式的差异,其中命令式更加关注过程,而声明式更加关注结果。

  • 命令式在理论上可以做到极致优化,但是用户要承受巨大的心智负担;
  • 声明式能够有效减轻用户的心智负担,但是性能上有一定的牺牲,框架设计者要想办法尽量使性能损耗最小化。

虚拟 DOM 的性能,并给出了一个公式: 声明式的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

  • 虚拟 DOM 的意义就在于使 找出差异的性能消耗最小化
    • 用原生 js 操作DOM的方法(如 document.createElement)、虚拟DOMinnerHTML三者操作页面的性能,不可以简单地下定论:
      • 这与页面大小变更部分的大小都有关系,除此之外,与创建页面还是更新页面也有关系。
      • 选择哪种更新策略,需要结合心智负担可维护性等因素综合考虑。
  • 一番权衡之后,发现虚拟 DOM 是个还不错的选择。

运行时和编译时的相关知识,了解纯运行时、纯编译时以及两者都支持的框架各有什么特点(Vue.js 3 是一个编译时+运行时的框架):

  • 纯运行时: 用户为 Render 函数提供一个树型结构的数据对象,然后 Render 函数会根据该对象递归地将数据渲染成 DOM 元素。
  • 编译时+运行时: 用户使用 Compiler 的程序把 HTML 字符串编译成树型结构的数据对象,再使用 Render 函数渲染成 DOM 元素。
  • 纯编译时: Compiler 把 HTML 字符串直接编译成命令式代码。

2 框架设计的核心要素

提升用户的开发体验。框架设计中关于开发体验的内容,开发体验是衡量一个框架的重要指标之一。

  • 提供友好的警告信息至关重要,这有助于开发者快速定位问题:
    • 因为大多数情况下“框架”要比开发者更清楚问题出在哪里,因此在框架层面抛出有意义的警告信息是非常必要的。

控制框架代码的体积。但提供的警告信息越详细,就意味着框架体积越大。

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

Tree-Shaking 是一种排除 dead code 的机制,框架中会内建多种能力,例如 Vue.js 内建的组件等。

  • 对于用户可能用不到的能力,可以利用 Tree-Shaking 机制使最终打包的代码体积最小化。
  • Tree-Shaking 本身基于 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' 语句。

特性开关,框架会提供多种能力或功能。 有时出于灵活性和兼容性的考虑,对于同样的任务,框架提供了两种解决方案,例如:

  • vue 中的选项对象式API和组合式API都能用来完成页面的开发,两者虽然不互斥,但从框架设计的角度看完全是基于兼容性考虑的。
  • 有时用户明确知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被 Tree-Shaking 机制排除。

框架的错误处理做得好坏直接决定了用户应用程序的健壮性,同时还决定了用户开发应用时处理错误的心智负担(app.config.errorHandler)。

  • 框架需要为用户提供统一的错误处理接口,这样用户可以通过注册自定义的错误处理函数来处理全部的框架异常。

一个常见的认知误区,即“使用 TS 编写框架和框架对 TS 类型支持友好是两件完全不同的事”。

  • 有时候为了让框架提供更加友好的类型支持,甚至要花费比实现框架功能本身更多的时间和精力。

3 vue3 的设计思路

声明式地描述 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 是各个模块组成的有机整体

  • 组件的实现依赖于 渲染器 ,模板的编译依赖于 编译器 ,并且编译后生成的代码是根据渲染器和虚拟 DOM 的设计决定的。

二:响应系统

4 响应系统的作用与实现

副作用函数和响应式数据的概念,以及它们之间的关系。

  • effect 函数的执行会直接或间接影响其他函数的执行,这时 effect 函数产生了副作用(例如:添加 DOM 事件监听器或者请求数据)。
  • 一个响应式数据最基本的实现依赖于对读取设置操作的拦截,从而在副作用函数与响应式数据之间建立联系(响应系统的根本实现原理)。
    • 当“读取”操作发生时,将当前执行的副作用函数存储到“桶”(一个 Set 实例)中;
    • 当“设置”操作发生时,再将副作用函数从“桶”里取出并执行。

一个相对完善的响应系统。使用 WeakMap 配合 Map 构建了新的“桶”结构,从而能够在响应式数据与副作用函数之间建立更加精确的联系。

  • WeakMap 与 Map 这两个数据结构之间的区别:
    • WeakMap是弱引用的,不影响垃圾回收器的工作。当用户代码对一个对象没有引用关系时,WeakMap不会阻止垃圾回收器回收该对象。

分支切换导致的冗余副作用的问题,这个问题会导致副作用函数进行不必要的更新。为了解决这个问题:

  • 需要在每次副作用函数重新执行之前,清除上一次建立的响应联系,而当副作用函数重新执行后,会再次建立新的响应联系
    • 新的响应联系中不存在冗余副作用问题,从而解决了问题。
  • 但在此过程中,还遇到了遍历 Set 数据结构导致无限循环的新问题,该问题产生的原因可以从 ECMA 规范中得知:
    • 即“在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”
      const set = new Set([1,2]);
      set.forEach(item => {set.delete(1);set.add(1);console.log('遍历中');}) // 无限循环
    • 解决方案是建立一个新的 Set 数据结构用来遍历。
      const set = new Set([1,2]);
      const newSet = new Set(set); // newSet 和set结构一样,地址不一样: Set(2) { 1, 2 }
      newSet.forEach((item) => {set.delete(1);set.add(1);console.log("遍历中");});

嵌套的副作用函数的问题

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

响应系统的可调度性

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

计算属性,即 computed。计算属性实际上是一个懒执行的副作用函数,通过 lazy 选项使得副作用函数可以懒执行。

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

watch 的实现原理。它本质上利用了副作用函数重新执行时的可调度性。

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

过期的副作用函数,它会导致竞态问题

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

5 非原始值的响应式方案

Proxy 与 Reflect

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

JavaScript 中对象的概念,以及 Proxy 的工作原理

  • 在 ECMAScript 规范中,JavaScript 中有两种对象,其中一种叫作常规对象(ordinary object),另一种叫作异质对象(exotic object)。
    • 满足以下三点要求的对象就是常规对象
      • 对于[[Get]][[Set]]等 11 个必要的内部方法,必须使用规范 10.1.x 节给出的定义实现;
      • 对于内部方法 [[Call]],必须使用规范 10.2.1 节给出的定义实现(对象是函数);
      • 对于内部方法 [[Construct]],必须使用规范 10.2.2 节给出的定义实现(对象是构造函数)。
    • 而所有不符合这三点要求的对象都是异质对象。 Proxy 是一个异质对象。
  • 一个对象是函数(必须部署内部方法[[Call]],普通对象则没有)还是其他对象,是由部署在该对象上的内部方法和内部槽决定的。

关于对象 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 这两个函数。它们本质上是对响应式数据做了一层包装,或者叫作“访问代理”。
  • toRef 函数接收两个参数(一个响应式数据,对象的一个键),返回一个类似于 ref 结构的 wrapper 对象。toRefs 函数为批量地完成转换。

自动脱 ref 的能力(在模板可以直接访问一个 ref 的值而无须通过 .value 属性来访问的原因)。

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

三:渲染器(renderer)

7 渲染器的设计

渲染器与响应系统的关系

  • 利用响应系统的能力,可以做到,当响应式数据变化时自动完成页面更新(或重新渲染),这与渲染器的具体实现无关。
  • 一个极简的渲染器,它只能利用 innerHTML 属性将给定的 HTML 字符串内容设置到容器中。

与渲染器相关的基本名词和概念

  • *渲染器(renderer)*的作用是把虚拟 DOM 渲染为特定平台上的真实元素。

  • 虚拟 DOM 通常用英文 virtual DOM 来表达,有时会简写成 vdom 或 vnode。

  • 渲染器会执行挂载和打补丁操作:

    • 对于新的元素,渲染器会将它挂载到容器(container)内;渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载(mount)
    • 对于新旧 vnode 都存在的情况,渲染器则会执行打补丁操作,即对比新旧 vnode,只更新变化的内容。
      • 渲染器会使用newVNode与上一次渲染的oldVNode进行比较,试图找到并更新变更点。这个过程叫作 打补丁(patch) 或更新。
      • patch 函数不仅可以用来完成打补丁(例如更新虚拟节点所对应的真实DOM的文本内容,从 hi 改为 hello),也可以用来执行挂载。

一个运行时渲染器将会遍历整个虚拟DOM树,并据此构建真实的DOM树。这个过程被称为挂载

假如有两份虚拟DOM树,渲染器将会有比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的DOM。这个过程被称为打补丁

自定义渲染器的实现

  • 在浏览器平台上,渲染器可以利用 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 的三种方式及其性能情况:
    • 在浏览器中为一个元素设置 class 有三种方式,即使用 setAttributeel.classNameel.classList
    • 其中,el.className 的性能最优,所以选择在 patchProps 函数中使用 el.className 来完成 class 属性的设置。
  • 除了 class 属性之外,Vue.js 也对 style 属性做了增强,所以 style 属性也需要做类似的处理。

卸载(unmount)操作

  • 一开始,直接使用 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函数的调用。
  • 如何处理事件与更新时机的问题: 利用事件处理函数被绑定到 DOM 元素的时间与事件触发时间之间的差异。

    • 需要屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行
  • 子节点的更新: 对虚拟节点中的 children 属性进行了规范化,规定vnode.children属性只能有如下三种类型:

    • 字符串类型:代表元素具有文本子节点。数组类型:代表元素具有一组子节点。null:代表元素没有子节点。

    • 在更新时,新旧 vnode 的子节点都有可能是以上三种情况之一,所以在执行更新时一共要考虑九种可能,即下面所展示的那样。

      • 但落实到代码中,并不需要罗列所有情况:

           新子节点     旧子节点      新子节点      旧子节点      新子节点     旧子节点
          -----------------------------------------------------------------------
                      没有子节点                 没有子节点                没有子节点
          没有子节点    文本子节点    文本子节点     文本子节点    一组子节点    文本子节点
                      一组子节点                 一组子节点                一组子节点
    • 另外,当新旧 vnode 都具有一组子节点时,采用了比较笨的方式来完成更新,即卸载所有旧子节点,再挂载所有新子节点

    • 更好的做法是通过 Diff 算法比较新旧两组子节点,试图最大程度复用 DOM 元素。

如何使用虚拟节点来描述文本节点和注释节点

  • 利用了 symbol 类型值的唯一性,为文本节点和注释节点分别创建唯一标识,并将其作为 vnode.type 属性的值。

Fragment 及其用途

  • Vue.js 3 支持多根节点模板。Vue.js 3 使用 Fragment(一个新的 vnode 类型)来描述多根节点模板。
  • 当渲染器渲染 Fragment 类型的虚拟节点时,由于Fragment 本身并不会渲染任何内容,所以渲染器只会渲染 Fragment 的子节点。
  • 当卸载 Fragment 类型的虚拟节点时,同样只需要遍历它的 children 数组,并将其中的节点逐个卸载即可。

9 简单 Diff 算法

Diff 算法的作用: Diff 算法用来计算两组子节点的差异,并试图最大程度地复用 DOM 元素。

  • 之前的方法卸载所有旧子节点,再挂载所有新子节点无法对 DOM 元素进行复用,需要大量的 DOM 操作才能完成更新,非常消耗性能。
  • 改进后的方案是,遍历新旧两组子节点中数量较少的那一组,并逐个调用 patch 函数进行打补丁(节点有差异就更新差异,没有就不变化),然后比较新旧两组子节点的数量,如果新的一组子节点数量更多,说明有新子节点需要挂载;否则说明在旧的一组子节点中,有节点需要卸载。

虚拟节点中 key 属性的作用,它就像虚拟节点的“身份证号”。

  • 只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么就认为它们是相同的,即可以进行 DOM 的复用
  • 在更新时渲染器通过 key 属性找到可复用的节点,然后尽可能地通过DOM移动操作来完成更新,避免过多地对DOM元素进行销毁和重建。

简单 Diff 算法是如何寻找需要移动的节点的。简单 Diff 算法的核心逻辑:

  • 新一组子节点中的节点去旧一组子节点中寻找可复用的节点。如果找到则记录该节点的位置索引并保持最大值。这个位置索引称为最大索引
  • 在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动
    • 取新一组 children 遍历时,新节点在旧 children 中寻找具有相同 key 值节点的过程中,遇到的最大索引值(初始为 0)。
    • 如果在后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动。

10 双端 Diff 算法

双端 Diff 算法的原理及其优势:

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

11 快速 Diff 算法

快速 Diff 算法在实测中性能最优:

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

无论是简单 Diff 算法,还是双端 Diff 算法,抑或快速 Diff 算法,它们都遵循同样的处理规则:

  • 1 判断是否有节点需要移动,以及应该如何移动;2 找出那些需要被添加或移除的节点。

最原始: 当新旧 vnode 都具有一组子节点时,采用了比较笨的方式来完成更新,即"卸载所有旧子节点,再挂载所有新子节点"。 简单 Diff 算法: 利用虚拟节点的 key 属性,尽可能地复用 DOM 元素,并通过移动 DOM 的方式来完成更新,从而减少不断地创建和销毁 DOM 元素带来的性能开销。 双端 Diff 算法: 同时对新旧两组子节点的两个端点进行比较,比较四次(新首旧首,新尾旧尾,新首旧尾,新尾旧首),如果首尾没有可复用的,尝试看看非头部、非尾部的节点能否复用。复用了双端节点,减少了 DOM 的移动操作。 快速 Diff 算法: 包含预处理步骤,相同的前置元素和后置元素不需要核心 Diff 算法。剩余部分子节点构建 source 数组 和最长连续子序列 seq(不需要移动),判断 source 对应索引值是否为-1(挂载新节点),或者 source 的索引对应的 seq 位置的值是否相等(不相等则需要移动节点)

四:组件化

12 组件的实现原理

如何使用虚拟节点来描述组件。(一个组件必须包含一个渲染函数,即 render 函数,并且渲染函数的返回值应该是虚拟 DOM)

  • 使用虚拟节点的 vnode.type 属性来存储组件对象(例如值是 div、Text),渲染器根据虚拟节点的该属性的类型来判断它是否是组件
    • 不同类型的节点,需要采用不同的处理方法来完成挂载和更新。虚拟节点的 vnode.type 属性值为对象,该虚拟节点为组件的描述
    • 如果是组件,则渲染器会使用 mountComponent 和 patchComponent 来完成组件的挂载和更新。

组件的自更新

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

组件实例:本质上是一个对象,包含了组件运行过程中的状态。

  • 对象包含的组件运行状态例如组件是否挂载、组件自身的响应式数据、组件所渲染的内容(即 subtree)等:
    • state:组件自身的状态数据,即 data。
    • isMounted:一个布尔值,用来表示组件是否被挂载。
    • subTree:存储组件的渲染函数返回的虚拟 DOM,即组件的子树(subTree)
    • 实际上,可以在需要的时候,任意地在组件实例 instance 上添加需要的属性。但应该尽可能保持组件实例轻量,以减少内存占用。
  • 有了组件实例后,在渲染副作用函数内,就可以根据组件实例上的状态标识,来决定应该进行全新的挂载,还是应该打补丁。
    • 例如组件实例的 instance.isMounted 属性可以用来区分组件的挂载和更新。同理可以在合适的时机调用组件对应的其他生命周期钩子

组件的 props 与组件的被动更新

  • props 本质上是父组件的数据,当 props 发生变化时,会触发父组件重新渲染。
    • 响应式数据变化后,父组件的渲染函数会重新执行副作用函数进行自更新;
    • 自更新过程中发现父组件的 subTree 包含组件类型的虚拟节点,所以会调用 patchComponent 函数完成子组件的更新。
  • 副作用自更新所引起的子组件更新叫作子组件的被动更新。发生被动更新时,需要:
    • 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的;如果需要更新,则更新子组件的 props、slots 等内容。
  • 渲染上下文对象(renderContext),它实际上是组件实例的代理对象。它的意义在于:
    • 拦截数据状态的读取和设置操作,每当在渲染函数或生命周期钩子中通过 this 来读取数据时,都会优先从组件的自身状态中读取
      • 如果组件本身并没有对应的数据,则再从 props 数据中读取。
    • 在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。

setup 函数 是为了组合式 API 而生的,所以要避免将其与 Vue.js 2 中的“传统”组件选项混合使用。

  • setup 函数的返回值两种类型:如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象中包含的数据暴露给模板使用。
  • setup 函数接收两个参数:第一个参数是 props 数据对象,第二个参数是 setupContext 对象(保存着与组件接口相关的数据和方法):
    • slots(非响应式对象)、emit 触发事件(函数)、attrs(没有显式地声明为 props 的属性,非响应式对象)、expose 暴露公共属性(函数)

emit 函数包含在 setupContext 对象中,可以通过 emit 函数发射组件的自定义事件。

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

组件的插槽,指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入.

  • 它借鉴了 Web Component 中<slot> 标签的概念。插槽内容会被编译为插槽函数,插槽函数的返回值就是向槽位填充的内容。
  • <slot>标签则会被编译为插槽函数的调用,通过执行对应的插槽函数,得到外部向槽位填充的内容虚拟DOM,最后将该内容渲染到槽位中。

onMounted 等用于注册生命周期钩子函数的方法的实现:

  • 通过 onMounted 注册的生命周期函数会被注册到当前组件实例的 instance.mounted 数组中
    • 每当初始化组件并执行组件的 setup 函数之前,先将 currentInstance 设置为当前组件实例,再执行组件的 setup 函数,这样就可以通过 currentInstance 来获取当前正在被初始化的组件实例,从而将那些通过 onMounted 函数注册的钩子函数与组件实例进行关联。
  • 为了维护当前正在初始化的组件实例,定义了全局变量 currentInstance,以及用来设置该变量的 setCurrentInstance 函数。

13 异步组件与函数式组件

异步组件要解决的问题。异步组件在页面性能、拆包以及服务端下发组件等场景中尤为重要。

  • 从根本上来说,异步组件的实现可以完全在用户层面实现,而无须框架支持。但一个完善的异步组件仍需要考虑诸多问题,例如:
    • 允许用户指定加载出错时要渲染的组件;
    • 允许用户指定 Loading 组件,以及展示该组件的延迟时间;
    • 允许用户设置加载组件的超时时长;
    • 组件加载失败时,为用户提供重试的能力。
  • 因此,框架有必要内建异步组件的实现。Vue.js 3 提供了 defineAsyncComponent 函数,用来定义异步组件。

异步组件的加载超时问题,以及当加载错误发生时,如何指定 Error 组件

  • 通过为 defineAsyncComponent 函数指定选项参数,允许用户通过 timeout 选项设置超时时长。
  • 当加载超时后,会触发加载错误,这时会渲染用户通过 errorComponent 选项指定的 Error 组件。

在加载异步组件的过程中,受网络状况的影响较大。当网络状况较差时,加载过程可能很漫长。

  • 为了提供更好的用户体验,需要在加载时展示 loading 组件。vue 设计了 loadingComponent 选项以允许用户配置自定义的 loading 组件
  • 为了避免 Loading 组件导致的闪烁问题,还需要设计一个接口,让用户能指定延迟展示 Loading 组件的时间,即 delay 选项。

在加载组件的过程中,发生错误的情况非常常见。所以,vue 设计了组件加载发生错误后的重试机制(当加载出错时有能力重新发起加载组件的请求)。

  • 异步组件的重试加载机制与接口请求发生错误时的重试机制,两者的思路类似。

函数式组件。它本质上是一个函数,其内部实现逻辑可以复用有状态组件的实现逻辑。

  • 为了给函数式组件定义 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 对象中定义的过渡相关钩子函数。

五:编译器(compiler)

15 编译器核心技术概览

Vue.js 模板编译器的工作流程。Vue.js 的模板编译器用于把模板编译为渲染函数。它的工作流程大致分为三个步骤:

  • (1) 分析模板,将其解析为模板 AST。(解析器 parser 的功能)
  • (2) 将模板 AST 转换为用于描述渲染函数的 JavaScript AST。(转换器 transformer 的功能)
  • (3) 根据 JavaScript AST 生成渲染函数代码。(生成器 generator 的功能)

解析器(parser) 的实现原理,以及如何用有限状态自动机构造一个词法分析器。

  • 词法分析的过程就是状态机在不同状态之间迁移的过程。在此过程中,状态机会产生一个个 Token,形成一个 Token 列表。
    • 将使用该 Token 列表来构造用于描述模板的 AST。具体做法是:
      • 扫描 Token 列表并维护一个开始标签栈。每当扫描到一个开始标签节点,就将其压入栈顶。栈顶的节点始终作为下一个扫描的节点的父节点。这样,当所有 Token 扫描完毕后,即可构建出一棵树型 AST。

AST 的转换与插件化架构

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

如何将模板 AST 转换为用于描述渲染函数的 JavaScript AST

  • 模板 AST 用来描述模板,类似地,JavaScript AST 用于描述 JavaScript 代码。
  • 需要将模板编译为渲染函数。而渲染函数是由 JavaScript 代码来描述的。
    • 因此只有把模板 AST 转换为 JavaScript AST 后,才能据此生成最终的渲染函数代码。

渲染函数代码的生成工作

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

16 解析器(parser)

解析器的文本模式及其对解析器的影响

  • 文本模式指的是解析器在工作时所进入的一些特殊状态,如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 树的比对算法失效。
    • 而解决方式很简单,只需要让带有 v-if、v-for 等指令的节点也作为 Block 角色即可。

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

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

六:服务端渲染

18 同构渲染

Vue.js 的三种渲染方式:CSR、SSR 和同构渲染

  • 客户端渲染(client-side rendering,CSR): Vue.js 可以用于构建客户端应用程序,组件的代码在浏览器中运行,并输出 DOM 元素。
  • 服务端渲染(server-side rendering,SSR): Vue.js 还可以在 Node.js 环境中运行,它可以将同样的组件渲染为字符串并发送给浏览器。
  • Vue.js 作为现代前端框架,不仅能够独立地进行 CSR 或 SSR,还能够将两者结合,形成所谓的同构渲染(isomorphic rendering)。

服务端渲染(在服务端完成模板和数据的融合)的工作流程:

  1. 用户通过浏览器请求站点。
  2. 服务器请求 API 获取数据。
  3. 接口返回数据给服务器。
  4. 服务器根据模板和获取的数据拼接出最终的 HTML 字符串。
  5. 服务器将 HTML 字符串发送给浏览器,浏览器解析 HTML 内容并渲染。

客户端渲染(在浏览器中完成模板与数据的融合,并渲染出最终的 HTML 页面)的工作流程:

  • 客户端向服务器或 CDN 发送请求,获取静态的 HTML 页面。
    • 注意,此时获取的 HTML 页面通常是空页面。在 HTML 页面中,会包含 <style><link><script> 等标签
  • 虽然 HTML 页面是空的,但浏览器仍然会解析 HTML 内容。
    • 因为存在<style><link><script> 等标签,所以会加载这些引用资源。
  • 服务器或 CDN 会将相应的资源返回给浏览器,浏览器对 CSS 和 JavaScript 代码进行解释和执行。
    • 因为页面的渲染任务是由 JavaScript 来完成的,所以当 JavaScript 被解释和执行后,才会渲染出页面内容,即“白屏”结束。
    • 但初始渲染出来的内容通常是一个“骨架”,因为还没有请求 API 获取数据。
  • 客户端再通过 AJAX 技术请求 API 获取数据,一旦接口返回数据,客户端就会完成动态内容的渲染,并呈现完整的页面。

同构渲染(“同构”指的是同样的代码既能在服务端运行,也能在客户端运行)的工作机制:

  • 同构渲染中的首次渲染与 SSR 的工作流程是一致的。
    • 当首次访问或者刷新页面时,整个页面的内容是在服务端完成渲染的,浏览器最终得到的是渲染好的 HTML 页面。
    • 该页面是纯静态的,这意味着用户还不能与页面进行任何交互,因为整个应用程序的脚本还没有加载和执行。
    • 该静态的 HTML 页面中也会包含 <link><script> 等标签。
    • 同构渲染所产生的 HTML 页面与 SSR 所产生的 HTML 页面有一点最大的不同,即前者会包含当前页面所需要的初始化数据
  • 浏览器接收到初次渲染的静态 HTML 页面后会解析并渲染该页面。
    • 在解析过程中,浏览器发现HTML代码中存在<link><script>标签,于是会从CDN或服务器获取相应的资源,与 CSR 一致。
    • 当 JavaScript 资源加载完毕后,会进行激活操作(在 Vue.js 中常说的 “hydration”)。激活包含两部分工作内容:
      • Vue.js 在当前页面已经渲染的 DOM 元素以及 Vue.js 组件所渲染的虚拟 DOM 之间建立联系。
      • Vue.js 从 HTML 页面中提取由服务端序列化后发送过来的数据,用以初始化整个 Vue.js 应用程序。
  • 激活完成后,整个应用程序已经完全被 Vue.js 接管为 CSR 应用程序了。后续操作都会按照 CSR 应用程序的流程来执行。
    • 当然,如果刷新页面,仍然会进行服务端渲染,然后再进行激活,如此往复。

CSR、SSR 和同构渲染的各自的优缺点。具体可以总结为下表(为应用程序选择渲染架构时,需要结合软件的需求及场景,选择合适的渲染方案):

关注点 SSR CSR 同构渲染
SEO 友好 不友好 友好
白屏问题
占用服务端资源
用户体验

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

  • 自闭合标签的处理。对于自闭合标签,无须为其渲染闭合标签部分,也无须处理其子节点。
  • 属性名称的合法性,以及属性值的转义。
  • 文本子节点的转义。HTML 转义指的是将特殊字符转换为对应的 HTML 实体,这对于防御 XSS 攻击至关重要。
    • 具体的转义规则如下:
      • 对于普通内容,应该对文本中的以下字符进行转义:
        • 将字符 & 转义为实体 &amp;
        • 将字符 < 转义为实体 &lt;
        • 将字符 > 转义为实体 &gt;
      • 对于属性值,除了上述三个字符应该转义之外,还应该转义下面两个字符:
        • 将字符 " 转义为实体 &quot;
        • 将字符 ' 转义为实体 &#39;

如何将组件渲染为 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 来实现读取;而在服务端,则需要根据请求头来实现读取。
    • 所以,很多功能模块需要为客户端和服务端分别实现。
  • 避免交叉请求引起的状态污染。状态污染既可以是应用级的,也可以是模块级的。
    • 对于应用,应该为每一个请求创建一个独立的应用实例。这是为了避免不同请求共用同一个应用实例所导致的状态污染。
    • 对于模块,应该避免使用模块级的全局变量。因为在不做特殊处理的情况下,多个请求会共用模块级的全局变量,造成请求间的交叉污染。
  • 仅在客户端渲染组件中的部分内容。这需要自行封装<ClientOnly> 组件,被该组件包裹的内容仅在客户端才会被渲染。