Skip to content

第 24 本《Vue.js 设计与实现》

简介

本书基于 Vue.js 3,从规范出发,以源码为基础,并结合大量直观的配图,循序渐进地讲解Vue.js 中各个功能模块的实现,细致剖析框架设计原理。全书共 18 章,分为六篇,主要内容包括:框架设计概览、响应系统、渲染器、组件化、编译器和服务端渲染等。通过阅读本书,对 Vue.js 2/3 具有上手经验的开发人员能够进一步理解 Vue.js 框架的实现细节,没有Vue.js 使用经验但对框架设计感兴趣的前端开发人员,能够快速掌握 Vue.js 的设计原理

阅读笔记

《Vue.js设计与实现》 霍春阳著 83个笔记

◆ 点评

2023/11/12 认为好看 真的很不错的一本技术书📗,作者引用到很多框架源码跟实践理论一起讲解,基本每行代码都有注释,深入浅出,娓娓道来,基本都能看懂,学习起来特别印象深刻。看完了脑子里回想起来还能理解整个框架的设计与原理,特别是一些代码,因为我也琢磨了比较久去研究为啥要这么做🤔 工作中用了几年了这个框架,但是对框架底层的设计与原理了解甚少,平时接触一些技术博客和官方文档而已,刚好有一本书可以讲解学习。从第一篇框架设计到第四篇组件化的讲解,不仅从高纬度了解一个架构层面的设计与演进,还可以从底层学习了解响应式系统的方案实现过程、渲染器设计中 Diff 算法优化过程、组件的实现原理等很细节的技术知识,让我对这个框架有了更深的了解。代码也涉及到了一些算法的讲解学习,比如最长子序列、递归下降算法等,第五篇编译器和服务端渲染接触和使用比较少,看起来有点费劲,可能对这方面的知识掌握的不多吧,后续还得加强学习才是 总的来说,对 Vue3 框架有了更深的学习和对一个框架的设计与实现有了了解,收获满满,做了很多笔记学习,不过也有一些理解不到位的,因为自身能力有限问题,对一些代码原理设计衔接理解不是很透彻。但也激发了我想看框架源码的欲望,可以对着这本书一起再学习一遍,也期待这本书可以再看一遍,说不定会有不一样的学习收获呢

◆ 前言

2023/7/29 发表想法 学习一门新的框架、技术、编程语言等,首先了解其设计思想,不仅可以帮助我们体会到作者创作的设计灵感过程,也能激发自己去想想为啥作者要这样设计,有什么好处呢等问题。可以更深刻的了解这个东西的全貌及学习底层的原理,对解决问题能力有非常好的提升和有机会让自己去组织设计一个新东西的时候,也许会有很多借鉴的思想吧

理解 Vue.js 3.0 的核心设计思想非常重要。它不仅能够让我们更加从容地面对复杂问题,还能够指导我们在其他领域进行架构设计。

◆ 第一篇 框架设计概览

2023/7/29 发表想法 不识庐山真面目,只缘身在此山中。对框架有一个全局的视角很重要,由上至下,由浅入深

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

涉及 DOM 的运算要远比JavaScript 层面的计算性能差

innerHTML 创建页面的性能:HTML 字符串拼接的计算量 + innerHTML 的DOM 计算量

使用 innerHTML 更新页面的过程是重新构建 HTML 字符串,再重新设置 DOM 元素的 innerHTML 属性

Vue.js 3 是一个编译时 + 运行时的框架,它在保持灵活性的基础上,还能够通过编译手段分析用户提供的内容,从而进一步提升更新性能。

在开发环境中为用户提供友好的警告信息的同时,不会增加生产环境代码的体积

实现 Tree-Shaking,必须满足一个条件,即模块必须是 ESM(ES Module),因为 Tree-Shaking 依赖 ESM 的静态结构

Tree-Shaking 中的第二个关键点——副作用。如果一个函数调用会产生副作用,那么就不能将其移除。什么是副作用?简单地说,副作用就是,当调用函数的时候会对外部产生影响,例如修改了全局变量

ESM 格式的资源有两种:用于浏览器的 esm-browser.js 和用于打包工具的 esm-bundler.js。它们的区别在于对预定义常量 DEV 的处理,前者直接将 DEV 常量替换为字面量 true 或 false,后者则将 DEV 常量替换为 process.env.NODE_ENV !=='production' 语句

Vue.js 3 是一个声明式的 UI 框架,意思是说用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的

使用 JavaScript 对象来描述 UI 的方式,其实就是所谓的虚拟 DOM

Vue.js 3 除了支持使用模板描述 UI 外,还支持使用虚拟 DOM 描述 UI。其实我们在 Vue.js 组件中手写的渲染函数就是使用虚拟 DOM 来描述 UI 的

其实 h 函数的返回值就是一个对象,其作用是让我们编写虚拟 DOM 变得更加轻松

h 函数就是一个辅助创建虚拟 DOM 的工具函数,仅此而已

渲染器的作用就是把虚拟 DOM 渲染为真实 DOM

渲染器的精髓都在更新节点的阶段

组件就是一组 DOM 元素的封装

编译器的作用其实就是将模板编译为渲染函数

对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程

组件的实现依赖于渲染器,模板的编译依赖于编译器

◆ 第二篇 响应系统

WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息

在 Vue.js 中,watch 函数的回调函数接收第三个参数 onInvalidate,它是一个函数,类似于事件监听器,我们可以使用 onInvalidate 函数注册一个回调,这个回调函数会在当前副作用函数过期时执行

2023/8/19 发表想法 这一章中有非常多的干货,我看完都还没好好消化,主要把响应式数据底层原理讲解很透彻,自己也跟着作者举例写的代码,照猫画虎跟着做了一遍,但对整体的设计及思路需要慢慢琢磨一下。其中,副作用函数的运用及响应式数据的应用,包括 computed/watch 等的设计实现,让我印象深刻。作者也提到开发遇到的几个问题,比如 Set 数据遍历问题,watch 竞态问题等,让我对响应式数据的设计有更好的理解,特别是作者画的响应式数据结构关系图:WeakMap 配合 Map,Map 与 Set 之间的依赖关系,特别喜欢,让我对响应式数据结构有更好的理解和学习

在本章中,我们首先介绍了副作用函数和响应式数据的概念,以及它们之间的关系。

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

我们还遇到了遍历 Set 数据结构导致无限循环的新问题,该问题产生的原因可以从ECMA 规范中得知,即“在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但这个值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么这个值会重新被访问。”解决方案是建立一个新的 Set 数据结构用来遍历。

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

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

watch 的实现原理。它本质上利用了副作用函数重新执行时的可调度性。一个 watch 本身会创建一个 effect,当这个 effect 依赖的响应式数据发生变化时,会执行该 effect 的调度器函数,即 scheduler

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

什么是 Proxy 呢?简单地说,使用 Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作

实际上,根据ECMAScript 规范,在 JavaScript 中有两种对象,其中一种叫作常规对象(ordinary object),另一种叫作异质对象(exotic object)

在ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽

如何区分一个对象是普通对象还是函数呢?一个对象在什么情况下才能作为函数调用呢?答案是,通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会

,Map 和 Set 这两个数据类型的操作方法相似。它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加元素,而 Map 类型使用set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值

可迭代协议指的是一个对象实现了 Symbol.iterator 方法,而迭代器协议指的是一个对象实现了 next 方法。但一个对象可以同时实现可迭代协议和迭代器协议

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

◆ 第三篇 渲染器

渲染器的作用是把虚拟DOM 渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟 DOM 渲染为真实 DOM 元素

虚拟 DOM 和真实 DOM 的结构一样,都是由一个个节点组成的树型结构。所以,我们经常能听到“虚拟节点”这样的词,即 virtual node,有时会简写成 vnode。虚拟 DOM 是树型结构,这棵树中的任何一个 vnode 节点都可以是一棵子树,因此 vnode 和vdom 有时可以替换使用

渲染器把虚拟 DOM 节点渲染为真实 DOM 节点的过程叫作挂载,通常用英文mount 来表达

没有选择使用 setAttribute 函数,而是直接将属性设置在DOM 对象上,即 el[key] = vnode.props[key]。实际上,无论是使用 setAttribute 函数,还是直接操作 DOM 对象,都存在缺陷

DOM Properties 与 HTML Attributes 的名字不总是一模一样的

在浏览器中为一个元素设置 class 有三种方式,即使用 setAttribute、el.className 或el.classList

el.className 的性能最优

屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行

Vue.js 3 是如何用vnode 来描述多根节点模板的呢?答案是,使用 Fragment

渲染Fragment 与渲染普通元素的区别在于,Fragment 本身并不渲染任何内容,所以只需要处理它的子节点即可

当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法

key 属性就像虚拟节点的“身份证”号,只要两个虚拟节点的 type 属性值和 key 属性值都相同,那么我们就认为它们是相同的,即可以进行 DOM 的复用

在旧 children 中寻找具有相同 key 值节点的过程中,遇到的最大索引值。如果在后续寻找的过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动

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

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

2023/10/31 发表想法 双端 Diff 算法确实对性能优化提升了很多。作者也讲解很通俗易懂,用到了很多图形说明算法执行的流程。加深了我对这一块的了解,也激发了我想看源码的动机。之前也有刷过类似的算法题目,可能对原理不熟悉,看这一部分的时候有点似曾相识的感觉。所以说学号算法还是对编程思维有很大好处的,学好之后,说不定就有场景可以用上了呢

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

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

什么是一个序列的递增子序列。简单来说,给定一个数值序列,找到它的一个子序列,并且该子序列中的值是递增的,子序列中的元素在原序列中不一定连续。一个序列可能有很多个递增子序列,其中最长的那一个就称为最长递增子序列。举个例子,假设给定数值序列 [ 0, 8, 4, 12 ],那么它的最长递增子序列就是 [0, 8,12]。当然,对于同一个数值序列来说,它的最长递增子序列可能有多个,例如 [0,4, 12] 也是本例的答案之一

◆ 第四篇 组件化

setup 函数主要用于配合组合式 API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力

setup 函数的返回值可以是两种类型,如果返回函数,则将该函数作为组件的渲染函数;如果返回数据对象,则将该对象暴露到渲染上下文中

在异步组件中,“异步”二字指的是,以异步的方式加载并渲染一个组件

函数式组件允许使用一个普通函数定义组件,并使用该函数的返回值作为组件要渲染的内容。函数式组件的特点是:无状态、编写简单且直观

在 Vue.js 3 中使用函数式组件,主要是因为它的简单性,而不是因为它的性能好。

一个函数式组件本质上就是一个普通函数,该函数的返回值是虚拟 DOM

因为对于函数式组件来说,它无须初始化 data 以及生命周期钩子。从这一点可以看出,函数式组件的初始化性能消耗小于有状态组件

KeepAlive 组件的实现需要渲染器层面的支持。这是因为被 KeepAlive 的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。正确的做法是,将被 KeepAlive 的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。这个过程对应到组件的生命周期,其实就是 activated 和 deactivated

KeepAlive 组件、Teleport 组件和 Transition 组件。它们的共同特点是,与渲染器的结合非常紧密,因此需要框架提供底层的实现与支持

◆ 第五篇 编译器

编译器将源代码翻译为目标代码的过程叫作编译(compile)

完整的编译过程通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等步骤

Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。接着,将模板AST 转换(transform)成 JavaScript AST。最后,根据 JavaScript AST 生成JavaScript 代码,即渲染函数代码

你可能或多或少听说过关于 Context(上下文)的内容,我们可以把 Context 看作程序在某个范围内的“全局变量”

在编写 Vue.js 应用时,我们也可以通过 provide/inject 等能力,向一整棵组件树提供数据。这些数据可以称为上下文

解析器本质上是一个状态机

在Vue.js 模板中,文本节点所包含的 HTML 实体不会被浏览器解析

文本插值是 Vue.js 模板中用来渲染动态数据的常用方法

默认情况下,插值以字符串 结尾。我们通常将这两个特殊的字符串称为定界符。定界符中间的内容可以是任意合法的 JavaScript 表达式

编译优化指的是编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,并以此指导生成最优代码的过程

Vue.js 3 的编译器会将编译时得到的关键信息“附着”在它生成的虚拟 DOM 上,这些信息会通过虚拟 DOM 传递给渲染器。最终,渲染器会根据这些关键信息执行“快捷路径”,从而提升运行时的性能

静态提升。它能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用

Vue.js 3 还提出了 Block 的概念,一个 Block 本质上也是一个虚拟节点,但与普通虚拟节点相比,会多出一个 dynamicChildren 数组

静态提升:能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用

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

◆ 第六篇 服务端渲染

客户端渲染(client-side rendering,CSR),以及服务端渲染(server-side rendering,SSR)

所谓快照,指的是在当前数据状态下页面应该呈现的内容