Skip to content

Vue3深入本质

Vue3源码解读

Vue3 整体可以分为几大核心模块:

  • 编译器
  • 渲染器
  • 响应式系统

介绍一下 Vue3 内部的运行机制是怎样的?

Vue3 是一个声明式的框架。声明式的好处在于,它直接描述结果,用户不需要关注过程。

Vue 采用模板的方式来描述 UI,但它同样支持JSX或直接创建虚拟DOM。虚拟 DOM 要比模板更加灵活,但模板要比虚拟 DOM 更加直观

当用户使用模板来描述 UI 的时候,内部的编译器会将其编译为渲染函数,渲染函数执行后能够确定响应式数据和渲染函数之间的依赖关系,之后响应式数据一变化,渲染函数就会重新执行。

渲染函数执行的结果是得到虚拟DOM,之后就需要渲染器来将虚拟DOM对象渲染为真实DOM元素。它的工作原理是,递归地遍历虚拟DOM对象,并调用原生DOM API来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出新旧虚拟DOM的变更点,并且只会更新需要更新的内容。

编译器、渲染器、响应式系统都是 Vue 内部的核心模块,它们共同构成一个有机的整体,不同模块之间互相配合,进一步提升框架性能。

编译器

主要负责将开发者所书写的模板转换为渲染函数

面试题:说一下 Vue 中 Compiler 的实现原理是什么?

解析器: 负责将模板字符串解析为所对应的模板 AST

  • 内部使用有限状态机(Finite State Machine)的思想依次解析字符串,生成token列表
  • 使用token构造模板AST:通过一个栈维护token元素间的父子关系,依次扫描token列表,得到模板的抽象语法树

转换器: 将模板AST转换为JS AST

  • 模板AST的遍历与转换
  • 生成 JavaScript AST

生成器: 将JS AST生成最终的渲染函数JS代码

面试题:说一下 Vue3 在进行模板编译时做了哪些优化?

  1. 针对渲染函数执行效率的优化

静态提升: 解决的是静态内容不要重复生成新的虚拟 DOM 节点的问题

  • 在模板编译阶段识别并提升不变的静态节点到渲染函数外部,从而减少每次渲染时的计算量。被提升的节点无需重复创建。

  • 预字符串化: 解决的是大量的静态内容,干脆虚拟 DOM 节点都不要了,直接生成字符串,虚拟 DOM 节点少了,diff 的时间花费也就更少。

  • 当大量的连续的静态节点被编译为字符串节点后,整体的虚拟 DOM 节点数量就少了,自然而然 diff 的速度就更快了。与之相比静态提升仍然会创建虚拟节点

  • 在 SSR 的时候,无需重复计算和转换,减少了服务器端的计算量和处理时间。

思考🤔:大量连续的静态内容时,会启用预字符串化处理,大量连续的边界在哪里?

答案:在 Vue3 编译器内部有一个阀值,目前是 10 个节点左右会启动预字符串化。

缓存内联事件处理函数:模板在进行编译的时候,会针对内联的事件处理函数做缓存。在 Vue2 中,每次渲染都会为这个内联事件创建一个新的函数,这会产生不必要的内存开销和性能损耗。

思考🤔:为什么仅针对内联事件处理函数进行缓存?

答案:非内联事件处理函数不需要缓存,因为非内联事件处理函数在组件实例化的时候就存在了,不会在每次渲染时重新创建。缓存机制主要是为了解决内联事件处理函数在每次渲染的时候重复创建的问题。

  1. 针对渲染函数执行生成的虚拟DOM树的优化

block tree:解决的是跳过静态节点比较的问题

  • Vue2 在对比新旧树的时候,并不知道哪些节点是静态的,哪些是动态的,因此只能全量比较,这就浪费了大部分时间在比对静态节点上
  • 静态提升解决的是不再重复生成静态节点所对应的虚拟DOM节点。现在要解决的问题是虚拟DOM树中静态节点比较能否跳过的问题。

补丁标记 PatchFlags:能够做到即便动态节点进行比较,也只比较有变化的部分的效果

  • vue2全面对比, 会逐个去检查节点的每个属性(class、data-id、title)以及子节点的内容
  • PatchFlag 通过为每个节点生成标记,标记可能发生变化的属性,显著优化了对比过程

虚拟DOM

DOM工作原理

  • 浏览器引擎是如何处理DOM操作的?

WebIDL(web interface definition language)。定义浏览器和js之间如何通信,通过webIDL浏览器开发者可以描述哪些方法可以被js访问,以及这些方法如何被映射到js中。

真实DOM: 浏览器底层调用C++应用API的操作

虚拟DOM本质

  • 是一种编程概念,在这个概念里,UI是以一种理想化的、“虚拟”的形式保存在内存中
  • 虚拟DOM的本质就是普通的JS对象
  • 在Vue中可以通过h函数创建虚拟DOM节点

为什么需要使用虚拟DOM

  1. 虚拟DOM主要是防止在重新渲染时性能恶化,对底层真实DOM提供了一层抽象的表达,支持异步操作,避免多次重复渲染
  2. 跨平台性:虚拟DOM增加一层抽象层,相当于和底层操作解耦。这个其实是设计原则里的依赖倒置原则:高层模块不应该依赖底层模块的额实现,两者都应该依赖于抽象。
  3. 框架更加灵活,无需手动操作dom

平时所说的虚拟DOM快的前提,要看和谁比较:

  • 肯定是比原生js的DOM操作慢,因为使用虚拟dom涉及两个层面的计算
    • 创建js对象
    • 根据js对象创建DOM节点
  • 相比innerHTML比较
    • 在初始化渲染时两者间差距并不大,虚拟DOM多了一层计算会略慢
    • 主要是更新时,虚拟DOM性能更高, 因为普通的模板语法只能做到全量更新
  • 异步更新,可以合并多次操作,避免无效渲染

模板的本质

渲染函数(h)

模板编译:将模板中的字符串编译成渲染函数

  • 解析器:将模板字符串解析成对应的模板AST
  • 转换器:将模板AST转换为JS AST
  • 生成器:将JS AST生成最终的渲染函数

编译的时机

  • 运行时编译,当通过CDN引入Vue,原生写法创建时
  • 预编译

组件树和虚拟DOM树

组件树:由组件所组成的树结构

虚拟DOM树:指某一个组件内部的虚拟DOM树,并非整个应用的虚拟DOM结构

指令的本质

最终编译出来的渲染函数,根本不存在什么指令,不同的指令会被编译为不同处理。

  • v-if编译后后就是三目运算符的不同分支,每一次 $setup 上对应的值变化,都会触发渲染函数重新运行,进入到不同的分支
  • v-for编译后用到了一个renderList内部方法,使用 $setup 对应的数据,renderList内部方法会对数据进行遍历,使用renderItem渲染每一项数据
  • v-bind编译后将$setup对应的属性值赋给元素的属性,每一次 $setup 对应的值变化,都会触发重新渲染
  • v-on编译为元素上对应的事件函数如onClick等

插槽的本质

  • 默认插槽:拥有默认的一些内容
  • 具名插槽:给你的插槽取一个名字,从而在不同位置设置多个插槽
  • 作用域插槽:数据来自于子组件,通过插槽的形式传递给父组件使用

使用时的表现:子组件通过slot设置插槽,父组件向子组件传递template模板内容

传递内容的本质:父组件向子组件传递的是一个对象,每一个KV对应一个插槽{ default: function { ... } },值是一个(渲染)函数,能够得到对应的虚拟DOM

子组件设置插槽的本质:调用对应的渲染函数,得到对应的虚拟Dom

v-model的本质

v-model有两个使用场景:

  • 表单元素和响应式数据的双向绑定
  • 父子组件传递数据

语法糖,v-model会被展开为一个名为onUpdate:modelValue的自定义事件

setup语法标签

<script setup>做了什么

是一个对于vue3初始时的 export default { ...配置 } 的配置式选项写法中setup的语法糖

区别:

  • 配置式setup内书写的内容在编译后会原封不动的放到编译后的setup()函数中
    • setup返回的内容会默认全部暴漏出去,除非手动配置expose或在setup中调用expose()
  • 标签式也会编译成一个setup()函数,
    • 其内会自动调用expose(),默认不会暴露任何东西
    • 宏函数:在开始编译之前,预处理时会对宏代码进行文本替换,编译后不存在

组件生命周期

本质:在合适时机调用的用户所设置的回调函数

  1. 初始化选项式API: 涉及组件实例对象的创建,创建前后对应着一组生命周期钩子函数,创建前:setup beforeCreate,组件实例创建后 created
  2. 模板编译:编译后执行beforeMount
  3. 初始化渲染,创建和插入真实DOM节点,之后执行mounted
  4. 组件更新,更新前执行beforeUpdate,更新后执行updated
  5. 销毁组件,销毁前执行beforeDestroy,销毁后执行destroyed
  6. 失活/激活组件,keep-alive的生命周期,失活前执行onDeactived,激活后执行onActived
  7. 子组件错误:onErrorCaptured 当捕获一个来自子孙组件的错误时被调用。此钩子会收到三个参数:错误对象、发生错误的组件实例以及一个包含错误来源信息的字符串。此钩子可以返回 false 以阻止该错误继续向上传播

了解:Vue3和Vue2的生命周期方法可以共存,Vue3的会更早执行

组件实例本质上是一个对象,该对象维护者组件运行过程中的所有信息

监听子组件的生命周期

vue
<Child @vnode-mounted="doSomething"></Child>

KeepAlive

对组件进行缓存,避免组件被重复创建销毁

vue
<template>
  <keep-alive>
      <Tab v-if="currentTab === 1">...</Tab>
      <Tab v-if="currentTab === 2">...</Tab>
      <Tab v-if="currentTab === 3">...</Tab>	
  </keep-alive>
</template>

keepalive的本质:需要渲染器层面的支持,当组件需要卸载的时候,不能真的卸载,而是搬(move)到一个隐藏的容器(createElement)里,实现“假卸载”

  • keep-alive 会给内部组件添加一些特殊的标识,这些标识就是给渲染器的用,回头渲染器在挂载和卸载组件的时候,会根据这些标识执行特定的操作
  • include 和 exclude 核心原理就是对内部组件进行一个匹配操作,匹配上了再进入后面的缓存逻辑
  • max:添加之前看一下缓存里面有没有缓存过该组件
    • 缓存过:更新到队列最后
    • 没有缓存过:加入到缓存里面,但是要看一下有没有超过最大值,超过了就需要进行修剪。

key

虚拟dom节点的唯一标识

  • 提升渲染性能,高效的更新:vue能通过key快速定位需要更新的元素
    • 在没有key时,vue会尽量复用已有的元素,而不管他们实际内容是否变化,这可能导致不必要的更新或错误更新
    • 通过key,vue可以准确知道哪些元素发生了变化,从而高效更新
  • 确保元素的唯一性:key属性要求是唯一的,防止混淆

nextTick实现原理

面试题:Vue 的 nextTick 是如何实现的?

nextTick 的本质将回调函数包装为一个微任务放入到微任务队列,这样浏览器在完成渲染任务后会优先执行微任务。

nextTick 在 Vue2 和 Vue3 里的实现有一些不同:

  • Vue2 为了兼容旧浏览器,会根据不同的环境选择不同包装策略:

    • 优先使用 Promise,因为它是现代浏览器中最有效的微任务实现。
    • 如果不支持 Promise,则使用 MutationObserver,这是另一种微任务机制。
    • 在 IE 环境下,使用 setImmediate,这是一种表现接近微任务的宏任务。
    • 最后是 setTimeout(fn, 0) 作为兜底方案,这是一个宏任务,但会在下一个事件循环中尽快执行。
  • Vue3 则是只考虑现代浏览器环境,直接使用 Promise 来实现微任务的包装,这样做的好处在于代码更加简洁,性能更高,因为不需要处理多种环境的兼容性问题。

整体来讲,Vue3 的 nextTick 实现更加简洁和高效,是基于现代浏览器环境的优化版本,而 Vue2 则为了兼容性考虑,实现层面存在更多的兼容性代码。

渲染器

diff算法

  1. diff的概念

diff 算法是用于比较两棵虚拟 DOM 树的算法,目的是找到它们之间的差异,并根据这些差异高效地更新真实 DOM,从而保证页面在数据变化时只进行最小程度的 DOM 操作。

思考🤔:为什么需要进行diff,不是已经有响应式了么?

响应式虽然能够侦测到响应式数据的变化,但是只能定位到组件,代表着某一个组件要重新渲染。组件的重新渲染就是重新执行对应的渲染函数,此时就会生成新的虚拟 DOM 树。但是此时我们并不知道新树和旧树具体哪一个节点有区别,这个时候就需要diff算法来找到两棵树的区别。

  1. diff算法的特点
  • 分层对比:它会逐层对比每个节点和它的子节点,避免全树对比,从而提高效率。
  • 相同层级节点对比:在进行 diff 对比的时候,Vue会假设对比的节点是同层级的,也就是说,不会做跨层的比较。
  1. diff算法详细流程
    1. 从根节点开始比较,看是否相同。所谓相同,是指两个虚拟节点的标签类型、key 值均相同,但 input 元素还要看 type 属性
      • 相同:
        • 相同就说明能够复用,此时就会将旧虚拟DOM节点对应的真实DOM赋值给新虚拟DOM节点
        • 对比新节点和旧节点的属性,如果属性有变化更新到真实DOM. 这说明了即便是对 DOM 进行复用,也不是完全不处理,还是会有一些针对属性变化的处理
        • 进入【对比子节点】
      • 不同,该节点以及往下的子节点没有意义了,全部卸载。直接根据新虚拟DOM节点递归创建真实DOM,同时挂载到新虚拟DOM节点。销毁旧虚拟DOM对应的真实DOM,背后调用的是 vnode.elm.remove() 方法
    2. 对比子节点:仍然是同层做对比,深度优先,同层比较时采用的是双端对

Vue2的双端diff算法

Vue3的快速diff

讲一讲 Vue3 的 diff 算法做了哪些改变?

Vue2 采用的是双端 diff 算法,而 Vue3 采用的是快速 diff. 这两种 diff 算法前面的步骤都是相同的,先是新旧列表的头节点进行比较,当发现无法复用则进行新旧节点列表的尾节点比较。

一头一尾比较完后,如果旧节点列表有剩余,就将对应的旧 DOM 节点全部删除掉,如果新节点列表有剩余:将新节点列表中剩余的节点创建对应的 DOM,放置于新头节点对应的 DOM 节点后面。

之后两种 diff 算法呈现出不同的操作,双端会进行旧头新尾比较、无法复用则进行旧尾新头比较、再无法复用这是暴力比对,这样的处理会存在多余的移动操作,即便一些新节点的前后顺序和旧节点是一致的,但是还是会产生移动操作。

而 Vue3 快速 diff 则采用了另外一种做法,找到新节点在旧节点中对应的索引列表,然后求出最长递增子序列,凡是位于最长递增子序列里面的索引所对应的元素,是不需要移动位置的,这就做到了只移动需要移动的 DOM 节点,最小化了 DOM 的操作次数,没有任何无意义的移动。可以这么说,Vue3 的 diff 再一次将性能优化到了极致,整套操作下来,没有一次 DOM 操作是多余的,仅仅执行了最必要的 DOM 操作。

面试题

说一说渲染器的核心功能是什么?

渲染器最最核心的功能是处理从虚拟 DOM 到真实 DOM 的渲染过程,这个过程包含几个阶段:

  1. 挂载:初次渲染时,渲染器会将虚拟 DOM 转化为真实 DOM 并插入页面。它会根据虚拟节点树递归创建 DOM 元素并设置相关属性。
  2. 更新:当组件的状态或属性变化时,渲染器会计算新旧虚拟 DOM 的差异,并通过 Patch 过程最小化更新真实 DOM。
  3. 卸载:当组件被销毁时,渲染器需要将其从 DOM 中移除,并进行必要的清理工作。

另外,渲染器和响应式系统是紧密结合在一次的,当组件首次渲染的时候,组件里面的响应式数据会和渲染函数建立依赖关系,当响应式数据发生变化后,渲染函数会重新执行,生成新的虚拟 DOM 树,渲染器随即进入更新阶段,根据新旧两颗虚拟 DOM 树对比来最小化更新真实 DOM,这涉及到了 Vue 中的 diff 算法。diff 算法这一块儿,Vue2 采用的是双端 diff,Vue3 则是做了进一步的优化,采用的是快速 diff 算法。

说一下 Vue 内部是如何绑定和更新事件的?

开发者在模板中书写事件绑定:

html
<p @click='clickHandler'>text</p>

模板被编译器编译后会生成渲染函数,渲染函数的执行得到的是虚拟 DOM.

事件在虚拟 DOM 中其实就是以 Props 的形式存在的。在渲染器内部,会有一个专门针对 Props 进行处理的方法,当遇到以 on 开头的 Prop 时候,会认为这是一个事件,从而进行事件的绑定操作。

为了避免事件更新时频繁的卸载旧事件,绑定新事件所带来的性能消耗,Vue 内部将事件作为一个对象的属性,更新事件的时候只需要更新对象的属性值即可(做了个中间层)。该对象的结构大致为:

js
{
     onClick: [
         ()=>{},
         ()=>{},
     ],
     onContextmenu: ()=>{}
     // ...
}

这种结构能做到:

  • 一个元素绑定多种事件
  • 支持同种事件类型绑定多个事件处理函数

响应式系统

面试题:vue3的响应式与之前有什么变化

  • 数据拦截的变化
    • Vue2: 使用 Object.defineProperty 进行拦截,Vue3: 使用 Proxy + Object.defineProperty 进行拦截
    • 相同点:都可以实现数据拦截,深度拦截
    • 差异:
      • 拦截的广度:
        • Object.defineProperty 是针对对象特定属性的读写操作进行拦截,这意味着之后新增加/删除的属性是侦测不到的
        • Proxy 则是针对一整个对象的多种操作,包括属性的读取、赋值、属性的删除、属性描述符的获取和设置、原型的查看、函数调用等行为能够进行拦截。
  • 创建响应式数据的方式的变化
    • Vue2: 通过 data 来创建响应式数据
    • Vue3: 通过 ref、reactvie 等方法来创建响应式数据
  • 依赖收集方式的变化
    • Vue2:Watcher + Dep
      • 每个响应式属性都有一个 Dep 实例,用于做依赖收集,内部包含了一个数组,存储依赖这个属性的所有 watcher
      • 当属性值发生变化,dep 就会通知所有的 watcher 去做更新操作
    • Vue3:WeakMap + Map + Set
      • Vue3 的依赖收集粒度更细
      • WeakMap 键对应的是响应式对象,值是一个 Map,这个 Map 的键是该对象的属性,值是一个 Set,Set 里面存储了所有依赖于这个属性的 effect 函数

数据拦截的本质

  • js中的数据拦截
    • Vue1\2: Object.defineProperty
    • Vue3: Proxy和Obeject.defineProperty

共同点:

  • 都可以实现数据拦截
  • 都可以实现深度拦截,Object.defineProperty需要手写递归

不同点:

  • 拦截的广度
    • Object.defineProperty针对特定属性进行读写拦截,后续新增属性无法拦截
    • Proxy针对一整个对象的多种操作,包括属性的读取、复制、属性的删除等拦截,凡是被Reflect暴露的底层操作应该是都可以拦截
  • 性能上的区别
    • 大多数情况下Proxy更加高效

响应式数据的本质

响应式数据就是被拦截的对象

ref: Object.defineProperty(一个RefImpl的类,利用类的get和set对变量value进行响应式拦截) 和 Proxy

reactive: Proxy

学会判断某个操作是否会产生拦截, 因为只有拦截才会由依赖收集和派发更新

js
const state = ref(1)

state;  // 不会产生拦截
console.log(state); // 不会拦截
console.log(state.value) // 会拦截
console.log(state.a) // 不会拦截
state.a = 2 // 不会拦截
delete state.a // 不会拦截
state = 3; // 不会拦截
js
const state = ref({ a: 1})

state;  // 不会产生拦截
console.log(state); // 不会拦截
console.log(state.value) // 会拦截
console.log(state.a) // 不会拦截
console.log(state.value.a) // 会拦截  出发两层拦截  value  a
state.a = 2 // 不会拦截
delete state.value.a // 会拦截  value的get  a的delete
state = 3; // 不会拦截
js
const state = reactive({ a: 1})

state;  // 不会产生拦截
console.log(state); // 不会拦截
console.log(state.a) // 会拦截
state.a = 2 // 会拦截
state.a = {
  b: {
    c: 3
  }
}  // 会拦截 a的set
console.log(state.a.b.c) // 会拦截 3次
delete state.a // 会拦截  a的delete
js
const arr = reactive([1,2,3])
arr; // 不会
arr.length // 会
arr[0] // 会, 0 的get操作
arr[0] = 4 // 会, 0 的set操作
arr[0].push(4); // 会, push的get length的get 3的set length的set

响应式的本质

  • 依赖收集:收集一些函数,当数据变化时需要重新执行这些函数
  • 派发更新:就是通知收集的函数重新执行

数据

ref\reactivee\props\computed 这几种得到的就是响应式数据

依赖

在函数运行期间,出现了读取响应式数据被拦截的情况,就会产生依赖关系

  • 注意:如果存在异步,则异步之后不看
  • 函数:函数必须是被监控的函数: effect、watchEffect、watch、组件渲染函数

响应式和组件渲染

当render函数内部用到了响应式数据时,会产生关联,当响应式数据变化,关联的render函数会重新运行(源码中是updateComponent)

vite-plugin-inspect可以编译过程中代码变化

为什么vue能实现组件更新

因为响应式数据是和组件的渲染函数关联在一起的,建立了依赖关系

为什么vue能实现数据共享

在vue中可以轻松实现数据共享,只需要将响应式数据单独提取出来,在多个组件中使用即可

那pinia的作用呢? Pinia是经过完善的测试的,更多附加功能,例如:

  • 开发者工具支持
  • 热替换
  • 插件机制
  • 自动补全
  • SSR
  • 持久化

实现响应式系统

源码

面试题

谈谈 computed 的机制,缓存了什么?为什么 computed 不支持异步?

computed 属性的初衷是用于计算并缓存一个基于响应式依赖的同步计算结果,当其依赖的响应式数据发生变化时,Vue 会自动重新计算 computed 的值,并将其缓存,以提高性能。

  1. 缓存的是上一次 getter 计算出来的值。
  2. 不支持异步的原因:
    • 缓存机制与同步计算:computed 属性的一个核心特性是缓存。当依赖的响应式数据没有变化时,computed 的计算结果会被缓存并直接返回,而不会重新执行计算。这种缓存机制是基于同步计算的,假如允许异步计算,那么在异步操作完成之前,computed 属性无法提供有效的返回值,这与它的同步缓存理念相违背。
    • 数据一致性:computed 属性通常用于模板中的绑定,它的计算结果需要在渲染期间是稳定且可用的。如果 computed 支持异步操作,渲染过程中的数据可能不一致,会导致模板渲染时无法确定使用什么数据,从而可能造成视图的闪烁或数据错误。
    • 调试与依赖追踪困难:如果 computed 属性是异步的,那么在调试和依赖追踪时就会变得非常复杂。异步操作的完成时间不确定,会使得依赖追踪的过程变得不直观,也难以预期。

如果需要进行异步操作,通常推荐使用 watch 来实现。

watch 和 computed 的区别是什么?说一说各自的使用场景?

  • computed
    • 作用:用于创建计算属性,依赖于 Vue 的响应式系统来做数据追踪。当依赖的数据发生变化时,会自动重新计算。
    • 无副作用:计算属性内部的计算应当是没有副作用的,也就是说仅仅基于数据做二次计算。
    • 缓存:计算属性具备缓存机制,如果响应式数据没变,每次获取计算属性时,内部直接返回的是上一次计算值。
    • 用处:通常用于模板当中,以便在模板中显示二次计算后的结构。
    • 同步:计算属性的一个核心特性是缓存,而这种缓存机制是基于同步计算的,假如允许异步计算,那么在异步操作完成之前,计算属性无法提供有效的返回值,这与它的缓存设计理念相违背。
  • watch
    • 作用:用于监听数据的变化,可以监听一个或者多个数据,当数据发生改变时,执行一些用户指定的操作。
    • 副作用:监听器中的回调函数可以执行副作用操作,例如发送网络请求、手动操作 DOM 等。
    • 无缓存:监听器中的回调函数执行结果不会被缓存,也没办法缓存,因为不知道用户究竟要执行什么操作,有可能是包含副作用的操作,有可能是不包含副作用的操作。
    • 用处:常用于响应式数据发生变化后,重新发送网络请求,或者修改 DOM 元素等场景。
    • 支持异步:在监听到响应式数据发生变化后,可以进行同步或者异步的操作。