精读《Vue.js设计与实现》


前言

《Vue.js 设计与实现》:它不同于市场上纯粹的 “源码分析” 类的书籍。而是 从高层的设计角度,探讨框架需要关注的问题(-尤雨溪序),以 提出问题 - 分析思路 - 解决问题 的方式,来讲解 vue 3 的核心设计。其内部,没有读一行 vue3 的源码,但却可以让我们对整个 vue 3 的核心,拥有一个非常清楚的认知。

第一篇:框架设计概览

第一章:权衡的艺术

命令式和声明式

首先第一个方面就是:命令式和声明式 的概念。

所谓 命令式 指的就是:关注过程 的范式。

image-20230612192529928

声明式 指的就是: 关注结果 的范式。

image-20230612192601767

那么这里大家来想一下,vue 是声明式的?还是命令式的?

对于 vue 而言,它的内部实现一定是 命令式 的,而我们在使用 vue 的时候,则是通过 声明式 来使用的。

也就是说: vue 封装了命令式的过程,对外暴露出了声明式的结果

性能与可维护性的权衡

在明确好了命令式和声明式的概念之后。接下来咱们来看下从 性能 层面,vue 所体现出来的一种权衡的方式。

针对于性能的分析,主要从两个方面去说。

首先第一个方面:大家觉得 是命令式的性能更强,还是声明式的性能更强呢?

答案是:命令式的性能 > 声明式的性能

其实原因非常简单,对于 命令式 的代码而言,它直接通过 原生的 JavaScript 进行实现,这是最简单的代码,没有比这个更简单的了,我们把它的性能比作 1

而声明式,无论内部做了什么,它想要实现同样的功能,内部必然要实现同样的命令式代码。所以它的性能消耗一定是 1 + N 的。

那么既然如此,vue 为什么还要对外暴露出声明式的接口呢?

这其实是因为:声明式的可维护性,要远远大于命令式的可维护性

当性能与可维护性产生冲突时,那么舍鱼而取熊掌者也。(注意:在 vue 的性能优化之下,它并不会比纯命令式的性能差太多)

而这样的一种权衡,在 template 模板中,更是体现的淋漓尽致。

在前端领域,想要使用 JavaScript 修改 html 的方式,主要有三种:原生 JavaScript、innerHTML、虚拟 DOM

很多小伙伴都会认为 虚拟 DOM 的性能是最高的,其实不是。

image-20230612192855594

从这个对比我们可以发现,虚拟 DOM 的性能,并不是最高的。

但是它的 心智负担(书写难度)最小, 从而带来了 可维护性最高。所以哪怕它的性能并不是最高的。vue 依然选择了 虚拟 DOM 来进行了渲染层的构建。

这个也是一种性能与可维护性的权衡。

运行时和编译时

第一章的最后一部分,主要讲解的就是 运行时和编译时

这两个名词,各位小伙伴在日常开发中,应该是经常听到的。

它们两个都是框架设计的一种方式,可单独出现,也可组合使用。

那么下面咱们就分别来介绍一下它们。

首先是 运行时:runtime

它指的是:利用 render 函数,直接把 虚拟 DOM 转化为 真实 DOM 元素 的一种方式。

在整个过程中,不包含编译的过程,所以无法分析用户提供的内容。

其次是 编译时:compiler

它指的是:直接把 template 模板中的内容,转化为 真实 DOM 元素

因为存在编译的过程,所以可以分析用户提供的内容。

同时,没有运行时理论上性能会更好。

目前该方式,有具体的实现库,那就是现在也非常火的 Svelte

但是这里要注意: 它的真实性能,没有办法达到理论数据。

最后是 运行时 + 编译时

它的过程被分为两步:

  1. 先把 template 模板转化为 render 函数。也就是 编译时
  2. 再利用 render 函数,把 虚拟 DOM 转化为 真实 DOM。也就是 运行时

两者的结合,可以:

在 编译时,分析用户提供的内容
在 运行时,提供足够的灵活性

这也是 vue 的主要实现方式。

第二章:框架设计的核心要素

  1. 通过 环境变量 和 TreeShanking 控制打包之后的体积
  2. 构建不同的打包产物,以应用不同的场景
  3. 提供了 callWithErrorHandling 接口函数,来对错误进行统一处理
  4. 源码通过 TypeScript 开发,以保证可维护性。
  5. 内部添加了大量的类型判断和其他工作,以保证开发者使用时的良好体验。

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

在这一章中,作者站在一个高层的角度,以 UI 形式、渲染器、组件、编辑器 为逻辑主线进行的讲解。

下面咱们就来捋一捋这条线。

VueUI 形式主要分为两种:

  • 声明式的模板描述
    ![image-20230208170232727](…/…/…/书籍/一小时读完系列/book_read_quickly-master/Vue.js 设计与实现/Vue.js 设计与实现.assets/image-20230208170232727.png)
  • 命令式的 render 函数
    image-20230208170236795

而针对于 声明式的模板描述 而言,本质上就是咱们常用的 tempalte 模板。它会被 编辑器 编译,得到 渲染函数 render

渲染器与渲染函数,并 不是 一个东西。

渲染器是 函数 createRenderer 的返回值,是一个对象。被叫做 rendererrenderer 对象中有一个方法 render,这个 render ,就是我们常说的渲染函数

渲染函数接收两个参数 VNodecontainer

其中 VNode 表示 虚拟 DOM,本质上是一个 JS 对象。container 是一个容器,表示被挂载的位置。而 render 函数的作用,就是: vnode 挂载到 container

同时,因为 Vue 以组件代表最小颗粒度,所以 vue 内部的渲染,本质上是:大量的组件渲染

而组件本质上是一组 DOM 的集合,所以渲染一个一个的组件,本质上就是在渲染一组这一组的 DOM。也就是说,Vue 本质上是: 以组件作为介质,来完成针对于一组、一组的 DOM 渲染。

第二篇:响应式系统

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

在这一章中,作者从 响应式数据的概念开始,讲解了响应式系统的实现。 然后针对于 计算属性与 watch 的实现原理,进行了分析。 在分析的过程中,也对其所设计到的 调度系统(scheduler)惰性执行(lazy) 的原理进行了明确。 最后讲解了在 竞态问题下,关于过期的副作用的处理逻辑。

响应式数据

那么首先咱们先来看基本概念 副作用函数 与 响应式数据

所谓 副作用函数 指的是 会产生副作用的函数,这样的函数非常的多。比如

image-20230612193528677

在这段代码中, effect 的触发会导致全局变化 val 发生变化,那么 effect 就可以被叫做副作用函数。而如果 val 这个数据的变化,导致了视图的变化,那么 val 就被叫做 响应式数据

那么如果想要实现响应式数据的话,那么它的核心逻辑,必然要依赖两个行为:

  • 第一个是 getter 行为,也就是 数据读取
  • 第二个是 setter 行为,也就是 数据修改

vue 2 中,这样的两个行为通过 Object.defineProperty 进行的实现。

vue 3 中,这样的两个行为通过 Proxy 进行的实现。

那么具体的实现逻辑是什么呢?咱们来看下面的图示:

首先是 getter 形式:

image-20230208191120105

在该函数中,存在一个 effect 方法,方法内部触发了 getter 行为。一旦 getter 行为被触发,则把对应的 effect 方法保存到一个 “桶(数据对象)” 中

当触发 setter 行为时:

image-20230208191257788

则会从 “桶” 中取出 effect 方法,并执行。

那么此时因为 obj.text 的值发生了变化,所以 effect 被执行时 document.body.innerText 会被赋上新的值。从而导致视图发生变化。

调度系统(scheduler)

那么说完了基本的响应性之后,接下来咱们来看 调度系统(scheduler

所谓调度系统,指的就是 响应性的可调度性

而所谓的可调度,指的就是 当数据更新的动作,触发副作用函数重新执行时,有能力决定:副作用函数(effect)执行的时机、次数以及方式

比如,在这段打印中,决定打印的顺序

image-20230612193750814

而想要实现一个调度系统,则需要依赖 异步:Promise队列:jobQueue 来进行实现。咱们需要 基于 Set 构建出一个基本的队列数组 jobQueue,利用 Promise 的异步特性,来控制执行的顺序

计算属性(computed)

当我们可以控制了执行顺序之后,那么就可以利用这个特性来完成 计算属性(computed) 的实现了。

计算属性本质上是: 一个属性值,当依赖的响应式数据发生变化时,重新计算

那么它的实现就需要彻底依赖于 调度系统(scheduler) 来进行实现。

惰性执行(lazy)

说完计算属性,那么下面我们来看下 watch 监听器。

watch 监听器本质上是 观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数

这也就意味着,watch 很多时候并不需要立刻执行。

那么此时,就需要使用到 惰性执行(lazy 来进行控制。

惰性执行的实现要比调度系统简单。它本质上 是一个 boolean 型的值,可以被添加到 effect 函数中,用来控制副作用的执行

if (!lazy) {
  // 执行副作用函数
}

过期的副作用

watch 监听器的实现非常广泛,有时候我们甚至可以在 watch 中完成一些异步操作。

但是大量的异步操作,极有可能会导致 竞态问题

所谓的竞态问题,指的是 在描述一个系统或者进程的输出,依赖于不受控制的事件出现顺序或者出现时机。比如咱们来看这段代码

image-20230612193927515

这段代码完成的是一个异步操作。

如果 obj 连续被修改了两次,那么就会发起两个请求。我们最终的期望应该是 data 被赋值为 请求B 的结果。

但是,因为异步的返回结果我们无法预计。所以,如果 请求 B 先返回,那么最终 data 的值就会变为 请求 A 的返回值。

这个咱们的期望是不一样的。

那么这样的问题,就是 竞态问题

而如果想要解决这问题,那么就需要使用到 watch 回调函数的第三个参数 onInvalidate,它本身也是一个回调函数。并且 该回调函数(onInvalidate)会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求

onInvalidate 的实现原理也非常简单,只需要 在副作用函数(effct)重新执行前,先触发 onInvalidate 即可。

第五章:非原始值(对象)的响应性方案

书中的第五章整体而言非常简单,主要就介绍了两个接口,ProxyReflect

这两个接口通常会一起进行使用,其中:

  • Proxy 可以 代理一个对象(被代理对象)的 getter 和 setter 行为,得到一个 proxy 实例(代理对象)
  • Reflect 可以 在 Proxy 中使用 this 时,保证 this 指向 proxy,从而正确执行次数的副作用

第六章:原始值(非对象)的响应性方案

如果大家熟悉 proxy 的话,那么可以知道,针对于 proxy 而言,它只能代理复杂数据类型。这就意味着,简单数据类型无法具备响应性。

但是,在 vue 中,我们可以通过 ref 构建简单数据类型的响应。

那么 ref 是如何进行实现的呢?

这里大家要注意:针对于最新的 vue 3.2 而言,书中在 《6.1 引入 ref 的概念》中所讲解的 ref 实现原理存在 “落后性”。 vue 3.2 已经修改了 ref 的实现,这得益于 @basvanmeurs 的贡献

在最新的 vue 3.2 代码中,vue 通过 **getset 函数标记符,让函数以属性调用的形式被触发。**这两个修饰符,可以让我们 像调用属性一样,调用方法。 所以当我们平时 访问 ref.value 属性时,本质上是 value() 函数的执行

第三篇:渲染器

第七章:渲染器的设计

在之前咱们说过 渲染器与渲染函数不是一个东西

  • 渲染器createRenderer 的返回值,是一个对象。
  • 渲染函数 是渲染器对象中的 render 方法

vue 3.2.37 的源码内部,createRenderer 函数的具体实现是通过 baseCreateRenderer 进行的。它的代码量非常庞大,涉及到了 2000 多行的代码。

总体可以被分为两部分:

  1. 在浏览器端渲染时,利用 DOM API 完成 DOM 操作:比如,如果渲染 DOM 那么就使用 createElement,如果要删除 DOM 那么就使用 removeChild
  2. 渲染器不能与宿主环境(浏览器)产生强耦合:因为 vue 不光有浏览器渲染,还包括了 服务端 渲染,所以如果在渲染器中绑定了宿主环境,那么就不好实现服务端渲染了。

所谓 vnode 本身是 一个普通的 JavaScript 对象,代表了渲染的内容。对象中通过 type 表示渲染的 DOM。比如 type === div:则表示 div 标签、type === Framgnet 则表示渲染片段(vue 3 新增)、type === Text 则表示渲染文本节点。

第八章:挂载与更新

对于渲染器而言,它做的最核心的事情就是 对节点进行挂载、更新的操作。作者在第八章中,详细的介绍了对应的逻辑。

整个第八章分为两部分来讲解了这个事情:

  1. DOM 节点操作
  2. 属性节点操作

DOM 节点操作

首先先来看 DOM 节点操作。DOM 节点的操作可以分为三部分:

  • 挂载:所谓挂载表示节点的初次渲染。比如,可以直接通过 createElement 方法新建一个 DOM 节点,再利用 parentEl.insertBefore 方法插入节点。
  • 更新:当响应性数据发生变化时,可能会涉及到 DOM 的更新。此时的更新本质上是属于 属性的更新。咱们等到属性节点操作那里再去说。
  • 卸载:所谓卸载表示旧节点不在被需要了。那么此时就需要删除旧节点,比如可以通过 parentEl.removeChild 进行。

以上三种类型,是 vue 在进行 DOM 操作时的常见逻辑。基本上覆盖了 DOM 操作 90% 以上 的常见场景

属性节点操作

看完了 DOM 操作之后,接下来咱们来看属性节点操作。

针对于属性而言,大体可以分为两类:

  1. 属性:比如 classidvaluesrc
  2. 事件:比如 clickinput

那么咱们就先来看 非事件的属性部分

想要了解 vue 中对于属性的处理,那么首先咱们需要先搞明白一个很重要的问题。那就是 浏览器中的属性分类

在浏览器中 DOM 属性其实被分为了两类:

  • 第一类叫做 HTML Attributes:直接定义在 HTML 标签 上的属性,都属于这一类。
  • 第二类叫做 DOM Properties:它是拿到 DOM 对象后定义的属性。咱们接下来主要要说的就是它。

HTML Attributes 的定义相对而言比较简单和直观,但是问题在于 它只能在 html 中进行操作

而如果想要在 JS 中操作 DOM 属性,就必须要通过 DOM Properties 来进行实现。但是因为 JS 本身特性的问题,会导致某些 DOM Properties 的设置存在特殊性。比如 class、type、value 这三个。

所以为了保证 DOM Properties 的成功设置,那么我们就必须要知道 **不同属性的 DOM Properties 定义方式 **。

下面咱们来看一下。

DOM Properties 的设置一共被分为两种:

  1. el.setAttribute('属性名', '属性值')
  2. . 属性赋值el.属性名 = 属性值 或者 el[属性名] = 属性值 都属于 .属性赋值

我们来看这段代码:

image-20230209093545078

在这段代码中,我们为 textarea 利用 DOM Properties 的方式设置了三个不同的属性:

  • 首先是 classclass 在属性操作中是一个非常特殊的存在。它有两个名字 classclassName。如果我们直接通过 el.setAttribute 的话,那么必须要用 class 才可以成功,而如果是通过 . 属性 的形式,那么必须要使用 className 才可以成功。
  • 第二个是 typetype 仅支持 el.setAttribute 的方式,不支持 .属性的方式
  • 第三个是 valuevalue 不支持直接使用 el.setAttribute 设置,但是支持 .属性 的设置方式

除了这三个属性之外,其实还有一些其他的属性也需要进行特殊处理,咱们这里就不再一一赘述了。

事件

接下来,咱们来看 vue 对事件的处理操作。

事件的处理和属性、DOM 一样,也是分为 添加、删除、更新 三类。

  • 添加:添加比较简单,主要利用 el.addEventListener 进行实现即可。
  • 删除:主要利用 el.removeEventListener 进行处理。
  • 更新:但是对于更新来说,就比较有意思了。下面咱们主要来看的就是这个更新操作。

通常情况下,我们所认知的事件更新应该是 删除旧事件、添加新事件 的过程。但是如果利用 el.addEventListenerel.removeEventListener 来完成这件事情,是一件非常消耗性能的事。

那么怎么能够节省性能,同时完成事件的更新呢?

这时,vue 对事件的更新提出了一个叫做 vei 的概念,这个概念的意思是: addEventListener 回调函数,设置了一个 value 的属性方法,在回调函数中触发这个方法。通过更新该属性方法的形式,达到更新事件的目的。

这个代码比较多,大家如果想要查看具体代码的话,可以 在 github 搜索 vue-next-mini,进入到 packages/runtime-dom/src/modules/events.ts 路径下查看。

第九、十、十一章:Diff 算法

整个渲染器最后的三个章节全部都用来讲解了 diff 算法。

针对于 diff 而言,它的本质其实就是一个对比的方法,其描述的核心就是: “旧 DOM 组”更新为“新 DOM 组”时,如何更新才能效率更高。

目前针对于 vue 3.2.37 的版本来说,整个的 diff 算法被分为 5 步(这 5 步不跟大家读了,因为咱们没头没尾的读一遍,其实对大家也没有什么帮助):

  1. sync from start:自前向后的对比
  2. sync from end:自后向前的对比
  3. common sequence + mount:新节点多于旧节点,需要挂载
  4. common sequence + unmount:旧节点多于新节点,需要卸载
  5. unknown sequence:乱序

而,针对于书中的这三章来说,本质上是按照 简单 diff 算法、双端 diff 算法、快速 diff 算法 的顺序把整个 diff 的前世今生基本上都说了一遍。里面涉及到了非常多的代码。

第四篇:组件化

第十二章:组件的实现原理

想要了解 vue 中组件的实现,那么首先我们需要知道什么是组件。

组件本质上就是一个 JavaScript 对象,比如,以下对象就是一个基本的组件

image-20230209105953064

而对于组件而言,同样需要使用 vnode 来进行表示,当 vnodetype 属性是一个 自定义对象 时,那么这个 vnode 就表示组件的 vnode

image-20230209110548502

而组件的渲染,本质上是 组件包含的 DOM 的渲染。 对于组件而言,必然会包含一个 render 渲染函数。如果没有 render 函数,那么 vue 会把 template 模板编译为 render 函数。而组件渲染的内容,其实就是 render 函数返回的 vnode。具体的渲染逻辑,全部都通过渲染器执行。

image-20230613164740214

vue 3 之后提出了 composition APIcomposition API 包含一个入口函数,也就是 setup 函数。 setup 函数包含两种类型的返回值:

  1. 返回一个函数:当 setup 返回一个函数时,那么该函数会被作为 render 函数直接渲染。
  2. 返回一个对象:当 setup 返回一个对象时,那么 vue 会直接把该对象的属性,作为 render 渲染时的依赖数据

同时,对于组件来说还有一个 插槽 的概念。插槽的实现并不神奇。插槽本质上 是一段 innerHTML 的内容,在 vnode 中以 children 属性进行呈现。当插槽被渲染时,只需要渲染 children 即可。

对于组件来说,除了咱们常用的 对象组件 之外,vue 还提供了额外的两种组件,也就是 异步组件与函数式组件

第十三章:异步组件与函数式组件

所谓异步组件,指的是: 异步加载的组件

比如服务端返回一个组件对象,那么我们也可以拿到该对象,直接进行渲染。

异步组件在 优化页面性能、拆包、服务端下发组件 时,会比较有用。

而对于 函数式组件 来说,相对就比较冷僻了。函数式组件指的是 没有状态的组件。本质上是一个函数,可以通过静态属性的形式添加 props 属性 。在实际开发中,并不常见。

第十四章:内建组件和模块

这一章中,主要描述了 vue 的三个内置组件。

keepAlive

首先第一个是 KeepAlive

这是我们在日常开发中,非常常用的内置组件。它可以 缓存一个组件,避免该组件不断地销毁和创建

看起来比较神奇,但是它的实现原理其实并不复杂,主要围绕着 组件卸载组件挂载 两个方面:

  • 组件卸载:当一个组件被卸载时,它并不被真正销毁,而是把组件保存在一个容器中
  • 组件挂载:因为组件被保存了。所以当这个组件需要被挂载时,就不需要在重新创建,而是直接从容器中获取即可。

Teleport

Teleportvue 3 新增的组件,作用是 Teleport 插槽的内容渲染到其他的位置。比如我们可以把 dialog 渲染到 body 根标签之下。

它的实现原理,主要也是分为两部分:

  1. 把 Teleport 组件的渲染逻辑,从渲染器中抽离
  2. 在指定的位置进行独立渲染

Transition

Transition 是咱们常用的动画组件,作用是 实现动画逻辑

其核心原理同样被总结为两点:

  1. DOM 元素被挂载时,将动效附加到该 DOM 元素上
  2. DOM 元素被卸载时,等在 DOM 元素动效执行完成后,执行卸载 DOM 操作

第五篇:编译器

第十五章:编译器核心技术概述

在编译器核心技术概述,主要包含两个核心内容:

  1. 模板 DSL 的编译器
  2. Vue 编译流程三大步

模板 DSL 的编译器

在任何一个编程语言中,都存在编译器的概念。 vue 的编译器是在 一种领域下,特定语言的编译器 ,那么这种编译器被叫做 DSL 编译器。

而编译器的本质是 通过一段程序,可以把 A 语言翻译成 B 语言。在 vue 中的体现就是 tempalte 模板,编译成 render 渲染函数

一个完整的编译器,一个分为 两个阶段、六个流程

  • 编译前端:
    • 词法分析
    • 语法分析
    • 语义分析
  • 编译后端:
    • 中间代码生成
    • 优化
    • 目标代码生成

image-20230209113241592

而对于 vue 的编译器而言,因为它是一个特定领域下的编译器,所以流程会进行一些优化,一共分为三大步

image-20230209113421705

  1. parse:通过 parse 函数,把模板编译成 AST 对象
  2. transform:通过 transform 函数,把 AST 转化为 JavaScript AST
  3. generate:通过 generate 函数,把 JavaScript AST 转化为 渲染函数(render

第十六章:解析器(parse)

这一章,主要详细讲解了 parse 解析逻辑。是在三大步中的 parse 逻辑的基础上,进行了一个加强。

所以这里咱们也按下不表

第十七章:编译优化

最后就是编译优化。

编译优化也是一个非常大的概念,其核心就是 通过编译的手段提取关键信息,并以此知道生成最优代码的过程

它的核心优化逻辑,主要是 把节点分为两类

  • 第一类是 动态节点:也就是会 受数据变化影响 的节点
  • 第二类是 静态节点:也就是 不受数据变化影响 的节点

优化主要的点,就是 动态节点

优化的方式主要是通过 Block 树 进行优化。

Block 树 本质上就是一个 虚拟节点数对象,内部包含一个 dynamicChildren 属性,用来 收集所有的动态子节点,以达到提取关键点进行优化的目的。

除此之外,还有一些小的优化手段,比如:

  • 静态提升
  • 预字符串化
  • 缓存内联事件处理函数
  • v-once 指令

第六篇:服务端渲染

最后一篇只有一个章节,就是 同构渲染

想要了解同构渲染,那么需要先搞明白 CSR、SSR 的概念。

  • CSR:所谓 CSR 指的是 客户端渲染
    • 浏览器向服务器发起请求
    • 服务器查询数据库,返回数据
    • 浏览器得到数据,进行页面构建
  • SSR:表示 服务端渲染
    • 览器向服务器发起请求
    • 服务器查询数据库,根据数据,生成 HTML ,并进行返回
    • 浏览器直接渲染 HTML

两种方式各有利弊,所以同构渲染,指的就是 CSRSSR 进行合并。既可以单独 CSR ,也可以单独 SSR,同时还可以 结合两者,在首次渲染时,通过 SSR,在非首次渲染时,通过 CSR

以下是三者的对比图

image-20230209121227934

而针对 vue 的服务端渲染来说,它是 将虚拟 DOM 渲染为 HTML 字符串,本质上是 解析的 vnode 对象,然后进行的 html 的字符串拼接

最后又讲解了客户端激活的原理,大致分为两步:

  1. 为页面中的 DOM 元素与虚拟节点对象之间建立联系
  2. 为页面中的 DOM 元素添加事件绑定

这两步主要是通过 renderer.hydrate() 方法进行实现了。

视频出处:【一小时读完《Vue.js 设计与实现》】 https://www.bilibili.com/video/BV1K24y1q7eJ/?share_source=copy_web&vd_source=a9f0fd4630ebe41da19ca2c83eb295e6

视频文档出处:https://juejin.cn/post/7197980894363156540

作者:LGD_Sunday


文章作者: QT-7274
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 QT-7274 !
评论
  目录