前言
《Vue.js 设计与实现》:它不同于市场上纯粹的 “源码分析” 类的书籍。而是 从高层的设计角度,探讨框架需要关注的问题(-尤雨溪序),以 提出问题 - 分析思路 - 解决问题 的方式,来讲解 vue 3
的核心设计。其内部,没有读一行 vue3
的源码,但却可以让我们对整个 vue 3
的核心,拥有一个非常清楚的认知。
第一篇:框架设计概览
第一章:权衡的艺术
命令式和声明式
首先第一个方面就是:命令式和声明式 的概念。
所谓 命令式 指的就是:关注过程 的范式。
而 声明式 指的就是: 关注结果 的范式。
那么这里大家来想一下,vue
是声明式的?还是命令式的?
对于 vue
而言,它的内部实现一定是 命令式 的,而我们在使用 vue
的时候,则是通过 声明式 来使用的。
也就是说: vue 封装了命令式的过程,对外暴露出了声明式的结果
性能与可维护性的权衡
在明确好了命令式和声明式的概念之后。接下来咱们来看下从 性能 层面,vue
所体现出来的一种权衡的方式。
针对于性能的分析,主要从两个方面去说。
首先第一个方面:大家觉得 是命令式的性能更强,还是声明式的性能更强呢?
答案是:命令式的性能 > 声明式的性能。
其实原因非常简单,对于 命令式 的代码而言,它直接通过 原生的 JavaScript
进行实现,这是最简单的代码,没有比这个更简单的了,我们把它的性能比作 1
。
而声明式,无论内部做了什么,它想要实现同样的功能,内部必然要实现同样的命令式代码。所以它的性能消耗一定是 1 + N
的。
那么既然如此,vue
为什么还要对外暴露出声明式的接口呢?
这其实是因为:声明式的可维护性,要远远大于命令式的可维护性。
当性能与可维护性产生冲突时,那么舍鱼而取熊掌者也。(注意:在 vue
的性能优化之下,它并不会比纯命令式的性能差太多)
而这样的一种权衡,在 template
模板中,更是体现的淋漓尽致。
在前端领域,想要使用 JavaScript
修改 html
的方式,主要有三种:原生 JavaScript、innerHTML、虚拟 DOM
很多小伙伴都会认为 虚拟 DOM
的性能是最高的,其实不是。
从这个对比我们可以发现,虚拟 DOM
的性能,并不是最高的。
但是它的 心智负担(书写难度)最小, 从而带来了 可维护性最高。所以哪怕它的性能并不是最高的。vue
依然选择了 虚拟 DOM
来进行了渲染层的构建。
这个也是一种性能与可维护性的权衡。
运行时和编译时
第一章的最后一部分,主要讲解的就是 运行时和编译时。
这两个名词,各位小伙伴在日常开发中,应该是经常听到的。
它们两个都是框架设计的一种方式,可单独出现,也可组合使用。
那么下面咱们就分别来介绍一下它们。
首先是 运行时:runtime
。
它指的是:利用 render 函数,直接把 虚拟
DOM
转化为 真实DOM
元素 的一种方式。在整个过程中,不包含编译的过程,所以无法分析用户提供的内容。
其次是 编译时:compiler:
它指的是:直接把
template
模板中的内容,转化为 真实DOM
元素。因为存在编译的过程,所以可以分析用户提供的内容。
同时,没有运行时理论上性能会更好。
目前该方式,有具体的实现库,那就是现在也非常火的
Svelte
但是这里要注意: 它的真实性能,没有办法达到理论数据。
最后是 运行时 + 编译时:
它的过程被分为两步:
- 先把
template
模板转化为render
函数。也就是 编译时- 再利用
render
函数,把 虚拟DOM
转化为 真实DOM
。也就是 运行时两者的结合,可以:
在 编译时,分析用户提供的内容
在 运行时,提供足够的灵活性这也是
vue
的主要实现方式。
第二章:框架设计的核心要素
- 通过 环境变量 和
TreeShanking
控制打包之后的体积 - 构建不同的打包产物,以应用不同的场景
- 提供了
callWithErrorHandling
接口函数,来对错误进行统一处理 - 源码通过
TypeScript
开发,以保证可维护性。 - 内部添加了大量的类型判断和其他工作,以保证开发者使用时的良好体验。
第三章:Vue.js 3 的设计思路
在这一章中,作者站在一个高层的角度,以 UI
形式、渲染器、组件、编辑器 为逻辑主线进行的讲解。
下面咱们就来捋一捋这条线。
在 Vue
中 UI
形式主要分为两种:
- 声明式的模板描述
![image-20230208170232727](…/…/…/书籍/一小时读完系列/book_read_quickly-master/Vue.js 设计与实现/Vue.js 设计与实现.assets/image-20230208170232727.png) - 命令式的 render 函数
而针对于 声明式的模板描述 而言,本质上就是咱们常用的 tempalte
模板。它会被 编辑器 编译,得到 渲染函数 render
。
渲染器与渲染函数,并 不是 一个东西。
渲染器是 函数 createRenderer
的返回值,是一个对象。被叫做 renderer
。 renderer
对象中有一个方法 render
,这个 render
,就是我们常说的渲染函数。
渲染函数接收两个参数 VNode
和 container
。
其中 VNode
表示 虚拟 DOM,本质上是一个 JS
对象。container
是一个容器,表示被挂载的位置。而 render
函数的作用,就是: 把 vnode
挂载到 container
上。
同时,因为 Vue
以组件代表最小颗粒度,所以 vue
内部的渲染,本质上是:大量的组件渲染。
而组件本质上是一组 DOM
的集合,所以渲染一个一个的组件,本质上就是在渲染一组这一组的 DOM
。也就是说,Vue
本质上是: 以组件作为介质,来完成针对于一组、一组的 DOM
渲染。
第二篇:响应式系统
第四章:响应系统的作用与实现
在这一章中,作者从 响应式数据的概念开始,讲解了响应式系统的实现。 然后针对于 计算属性与 watch
的实现原理,进行了分析。 在分析的过程中,也对其所设计到的 调度系统(scheduler)
和 惰性执行(lazy)
的原理进行了明确。 最后讲解了在 竞态问题下,关于过期的副作用的处理逻辑。
响应式数据
那么首先咱们先来看基本概念 副作用函数 与 响应式数据。
所谓 副作用函数 指的是 会产生副作用的函数,这样的函数非常的多。比如
在这段代码中, effect
的触发会导致全局变化 val
发生变化,那么 effect
就可以被叫做副作用函数。而如果 val
这个数据的变化,导致了视图的变化,那么 val
就被叫做 响应式数据。
那么如果想要实现响应式数据的话,那么它的核心逻辑,必然要依赖两个行为:
- 第一个是
getter
行为,也就是 数据读取 - 第二个是
setter
行为,也就是 数据修改
在 vue 2
中,这样的两个行为通过 Object.defineProperty
进行的实现。
在 vue 3
中,这样的两个行为通过 Proxy
进行的实现。
那么具体的实现逻辑是什么呢?咱们来看下面的图示:
首先是 getter
形式:
在该函数中,存在一个 effect
方法,方法内部触发了 getter
行为。一旦 getter
行为被触发,则把对应的 effect
方法保存到一个 “桶(数据对象)” 中
当触发 setter
行为时:
则会从 “桶” 中取出 effect
方法,并执行。
那么此时因为 obj.text
的值发生了变化,所以 effect
被执行时 document.body.innerText
会被赋上新的值。从而导致视图发生变化。
调度系统(scheduler)
那么说完了基本的响应性之后,接下来咱们来看 调度系统(scheduler
)
所谓调度系统,指的就是 响应性的可调度性。
而所谓的可调度,指的就是 当数据更新的动作,触发副作用函数重新执行时,有能力决定:副作用函数(effect)执行的时机、次数以及方式
比如,在这段打印中,决定打印的顺序
而想要实现一个调度系统,则需要依赖 异步:Promise
和 队列:jobQueue
来进行实现。咱们需要 基于 Set
构建出一个基本的队列数组 jobQueue
,利用 Promise
的异步特性,来控制执行的顺序
计算属性(computed)
当我们可以控制了执行顺序之后,那么就可以利用这个特性来完成 计算属性(computed) 的实现了。
计算属性本质上是: 一个属性值,当依赖的响应式数据发生变化时,重新计算
那么它的实现就需要彻底依赖于 调度系统(scheduler) 来进行实现。
惰性执行(lazy)
说完计算属性,那么下面我们来看下 watch
监听器。
watch
监听器本质上是 观测一个响应式数据,当数据发生变化时,通知并执行相应的回调函数
这也就意味着,watch
很多时候并不需要立刻执行。
那么此时,就需要使用到 惰性执行(lazy
) 来进行控制。
惰性执行的实现要比调度系统简单。它本质上 是一个 boolean
型的值,可以被添加到 effect
函数中,用来控制副作用的执行。
if (!lazy) {
// 执行副作用函数
}
过期的副作用
watch
监听器的实现非常广泛,有时候我们甚至可以在 watch
中完成一些异步操作。
但是大量的异步操作,极有可能会导致 竞态问题。
所谓的竞态问题,指的是 在描述一个系统或者进程的输出,依赖于不受控制的事件出现顺序或者出现时机。比如咱们来看这段代码
这段代码完成的是一个异步操作。
如果
obj
连续被修改了两次,那么就会发起两个请求。我们最终的期望应该是data
被赋值为 请求B 的结果。但是,因为异步的返回结果我们无法预计。所以,如果 请求 B 先返回,那么最终
data
的值就会变为 请求 A 的返回值。这个咱们的期望是不一样的。
那么这样的问题,就是 竞态问题
而如果想要解决这问题,那么就需要使用到 watch
回调函数的第三个参数 onInvalidate
,它本身也是一个回调函数。并且 该回调函数(onInvalidate
)会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求
而 onInvalidate
的实现原理也非常简单,只需要 在副作用函数(effct)重新执行前,先触发 onInvalidate
即可。
第五章:非原始值(对象)的响应性方案
书中的第五章整体而言非常简单,主要就介绍了两个接口,Proxy
和 Reflect
。
这两个接口通常会一起进行使用,其中:
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
通过 **get
、set
函数标记符,让函数以属性调用的形式被触发。**这两个修饰符,可以让我们 像调用属性一样,调用方法。 所以当我们平时 访问 ref.value 属性时,本质上是 value() 函数的执行。
第三篇:渲染器
第七章:渲染器的设计
在之前咱们说过 渲染器与渲染函数不是一个东西
- 渲染器 是
createRenderer
的返回值,是一个对象。 - 渲染函数 是渲染器对象中的
render
方法
在 vue 3.2.37
的源码内部,createRenderer
函数的具体实现是通过 baseCreateRenderer
进行的。它的代码量非常庞大,涉及到了 2000
多行的代码。
总体可以被分为两部分:
- 在浏览器端渲染时,利用
DOM API
完成DOM
操作:比如,如果渲染DOM
那么就使用createElement
,如果要删除DOM
那么就使用removeChild
。 - 渲染器不能与宿主环境(浏览器)产生强耦合:因为
vue
不光有浏览器渲染,还包括了服务端
渲染,所以如果在渲染器中绑定了宿主环境,那么就不好实现服务端渲染了。
所谓 vnode
本身是 一个普通的 JavaScript 对象,代表了渲染的内容。对象中通过 type
表示渲染的 DOM
。比如 type === div
:则表示 div
标签、type === Framgnet
则表示渲染片段(vue 3 新增)、type === Text
则表示渲染文本节点。
第八章:挂载与更新
对于渲染器而言,它做的最核心的事情就是 对节点进行挂载、更新的操作。作者在第八章中,详细的介绍了对应的逻辑。
整个第八章分为两部分来讲解了这个事情:
DOM
节点操作- 属性节点操作
DOM
节点操作
首先先来看 DOM
节点操作。DOM
节点的操作可以分为三部分:
- 挂载:所谓挂载表示节点的初次渲染。比如,可以直接通过
createElement
方法新建一个DOM
节点,再利用parentEl.insertBefore
方法插入节点。 - 更新:当响应性数据发生变化时,可能会涉及到
DOM
的更新。此时的更新本质上是属于 属性的更新。咱们等到属性节点操作那里再去说。 - 卸载:所谓卸载表示旧节点不在被需要了。那么此时就需要删除旧节点,比如可以通过
parentEl.removeChild
进行。
以上三种类型,是 vue
在进行 DOM
操作时的常见逻辑。基本上覆盖了 DOM
操作 90% 以上
的常见场景
属性节点操作
看完了 DOM
操作之后,接下来咱们来看属性节点操作。
针对于属性而言,大体可以分为两类:
- 属性:比如
class
、id
、value
、src
… - 事件:比如
click
、input
…
那么咱们就先来看 非事件的属性部分。
想要了解 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
的设置一共被分为两种:
el.setAttribute('属性名', '属性值')
. 属性赋值
:el.属性名 = 属性值
或者el[属性名] = 属性值
都属于.属性赋值
我们来看这段代码:
在这段代码中,我们为
textarea
利用DOM Properties
的方式设置了三个不同的属性:
- 首先是
class
:class
在属性操作中是一个非常特殊的存在。它有两个名字class
和className
。如果我们直接通过el.setAttribute
的话,那么必须要用class
才可以成功,而如果是通过. 属性
的形式,那么必须要使用className
才可以成功。- 第二个是
type
:type
仅支持el.setAttribute
的方式,不支持.属性的方式
- 第三个是
value
:value
不支持直接使用el.setAttribute
设置,但是支持.属性
的设置方式
除了这三个属性之外,其实还有一些其他的属性也需要进行特殊处理,咱们这里就不再一一赘述了。
事件
接下来,咱们来看 vue
对事件的处理操作。
事件的处理和属性、DOM
一样,也是分为 添加、删除、更新 三类。
- 添加:添加比较简单,主要利用
el.addEventListener
进行实现即可。 - 删除:主要利用
el.removeEventListener
进行处理。 - 更新:但是对于更新来说,就比较有意思了。下面咱们主要来看的就是这个更新操作。
通常情况下,我们所认知的事件更新应该是 删除旧事件、添加新事件 的过程。但是如果利用 el.addEventListener
和 el.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 步不跟大家读了,因为咱们没头没尾的读一遍,其实对大家也没有什么帮助):
sync from start
:自前向后的对比sync from end
:自后向前的对比common sequence + mount
:新节点多于旧节点,需要挂载common sequence + unmount
:旧节点多于新节点,需要卸载unknown sequence
:乱序
而,针对于书中的这三章来说,本质上是按照 简单 diff 算法、双端 diff 算法、快速 diff 算法 的顺序把整个 diff
的前世今生基本上都说了一遍。里面涉及到了非常多的代码。
第四篇:组件化
第十二章:组件的实现原理
想要了解 vue
中组件的实现,那么首先我们需要知道什么是组件。
组件本质上就是一个 JavaScript
对象,比如,以下对象就是一个基本的组件
而对于组件而言,同样需要使用 vnode
来进行表示,当 vnode
的 type
属性是一个 自定义对象 时,那么这个 vnode
就表示组件的 vnode
而组件的渲染,本质上是 组件包含的 DOM
的渲染。 对于组件而言,必然会包含一个 render
渲染函数。如果没有 render
函数,那么 vue
会把 template
模板编译为 render
函数。而组件渲染的内容,其实就是 render
函数返回的 vnode
。具体的渲染逻辑,全部都通过渲染器执行。
vue 3
之后提出了 composition API
,composition API
包含一个入口函数,也就是 setup
函数。 setup
函数包含两种类型的返回值:
- 返回一个函数:当
setup
返回一个函数时,那么该函数会被作为render
函数直接渲染。 - 返回一个对象:当
setup
返回一个对象时,那么vue
会直接把该对象的属性,作为render
渲染时的依赖数据
同时,对于组件来说还有一个 插槽 的概念。插槽的实现并不神奇。插槽本质上 是一段 innerHTML
的内容,在 vnode
中以 children
属性进行呈现。当插槽被渲染时,只需要渲染 children
即可。
对于组件来说,除了咱们常用的 对象组件 之外,vue
还提供了额外的两种组件,也就是 异步组件与函数式组件。
第十三章:异步组件与函数式组件
所谓异步组件,指的是: 异步加载的组件 。
比如服务端返回一个组件对象,那么我们也可以拿到该对象,直接进行渲染。
异步组件在 优化页面性能、拆包、服务端下发组件 时,会比较有用。
而对于 函数式组件 来说,相对就比较冷僻了。函数式组件指的是 没有状态的组件。本质上是一个函数,可以通过静态属性的形式添加 props
属性 。在实际开发中,并不常见。
第十四章:内建组件和模块
这一章中,主要描述了 vue
的三个内置组件。
keepAlive
首先第一个是 KeepAlive
。
这是我们在日常开发中,非常常用的内置组件。它可以 缓存一个组件,避免该组件不断地销毁和创建。
看起来比较神奇,但是它的实现原理其实并不复杂,主要围绕着 组件卸载 和 组件挂载 两个方面:
- 组件卸载:当一个组件被卸载时,它并不被真正销毁,而是把组件保存在一个容器中
- 组件挂载:因为组件被保存了。所以当这个组件需要被挂载时,就不需要在重新创建,而是直接从容器中获取即可。
Teleport
Teleport
是 vue 3
新增的组件,作用是 将 Teleport
插槽的内容渲染到其他的位置。比如我们可以把 dialog
渲染到 body
根标签之下。
它的实现原理,主要也是分为两部分:
- 把 Teleport 组件的渲染逻辑,从渲染器中抽离
- 在指定的位置进行独立渲染
Transition
Transition
是咱们常用的动画组件,作用是 实现动画逻辑。
其核心原理同样被总结为两点:
DOM
元素被挂载时,将动效附加到该DOM
元素上DOM
元素被卸载时,等在DOM
元素动效执行完成后,执行卸载DOM
操作
第五篇:编译器
第十五章:编译器核心技术概述
在编译器核心技术概述,主要包含两个核心内容:
- 模板
DSL
的编译器 Vue
编译流程三大步
模板 DSL
的编译器
在任何一个编程语言中,都存在编译器的概念。 vue
的编译器是在 一种领域下,特定语言的编译器 ,那么这种编译器被叫做 DSL
编译器。
而编译器的本质是 通过一段程序,可以把 A 语言翻译成 B 语言。在 vue
中的体现就是 把 tempalte
模板,编译成 render
渲染函数
一个完整的编译器,一个分为 两个阶段、六个流程:
- 编译前端:
- 词法分析
- 语法分析
- 语义分析
- 编译后端:
- 中间代码生成
- 优化
- 目标代码生成
而对于 vue
的编译器而言,因为它是一个特定领域下的编译器,所以流程会进行一些优化,一共分为三大步
parse
:通过parse
函数,把模板编译成AST
对象transform
:通过transform
函数,把AST
转化为JavaScript AST
generate
:通过generate
函数,把JavaScript AST
转化为 渲染函数(render
)
第十六章:解析器(parse)
这一章,主要详细讲解了 parse 解析逻辑。是在三大步中的 parse
逻辑的基础上,进行了一个加强。
所以这里咱们也按下不表
第十七章:编译优化
最后就是编译优化。
编译优化也是一个非常大的概念,其核心就是 通过编译的手段提取关键信息,并以此知道生成最优代码的过程。
它的核心优化逻辑,主要是 把节点分为两类:
- 第一类是 动态节点:也就是会 受数据变化影响 的节点
- 第二类是 静态节点:也就是 不受数据变化影响 的节点
优化主要的点,就是 动态节点。
优化的方式主要是通过 Block 树
进行优化。
Block 树
本质上就是一个 虚拟节点数对象,内部包含一个 dynamicChildren
属性,用来 收集所有的动态子节点,以达到提取关键点进行优化的目的。
除此之外,还有一些小的优化手段,比如:
- 静态提升
- 预字符串化
- 缓存内联事件处理函数
v-once
指令- …
第六篇:服务端渲染
最后一篇只有一个章节,就是 同构渲染。
想要了解同构渲染,那么需要先搞明白 CSR、SSR
的概念。
CSR
:所谓CSR
指的是 客户端渲染。- 浏览器向服务器发起请求
- 服务器查询数据库,返回数据
- 浏览器得到数据,进行页面构建
SSR
:表示 服务端渲染- 览器向服务器发起请求
- 服务器查询数据库,根据数据,生成
HTML
,并进行返回 - 浏览器直接渲染
HTML
两种方式各有利弊,所以同构渲染,指的就是 把 CSR
和 SSR
进行合并。既可以单独 CSR
,也可以单独 SSR
,同时还可以 结合两者,在首次渲染时,通过 SSR
,在非首次渲染时,通过 CSR
。
以下是三者的对比图
而针对 vue
的服务端渲染来说,它是 将虚拟 DOM
渲染为 HTML
字符串,本质上是 解析的 vnode
对象,然后进行的 html
的字符串拼接
最后又讲解了客户端激活的原理,大致分为两步:
- 为页面中的
DOM
元素与虚拟节点对象之间建立联系 - 为页面中的
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