Vue原理

虚拟DOM

面试题:请你阐述一下对vue虚拟dom的理解

  1. 什么是虚拟DOM?

虚拟DOM本质上就是一个普通的JS对象,用于描述视图的界面结构

vue中,每个组件都有一个render函数,每个render函数都会返回一个虚拟DOM树,这也就意味着每个组件都对应一棵虚拟DOM树.

没有render函数,就找template,没有template就找el,把el.outerHTML作为template(就是一个字符串),然后将template编译成render函数.

如果有render函数,就直接使用render函数,每一个render函数都返回一个虚拟DOM(JS对象);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
new Vue({
el: '#app',
data: {
title: 'Hello Vue',
},
render(h) {
// 目标:创建虚拟DOM:这个组件到底要显示啥
// h是一个函数,专门用于创建虚拟DOM
// h('虚拟DOM的名字',配置对象)
const vnode = h("div", {
// 虚拟DOM的配置
attrs: {
id: "app"
}
},
// 子元素
[
h("h1","aslkdfj")
]
);
return vnode;
}
})

image-20250312105836634

  1. 为什么需要虚拟DOM

vue中,渲染视图会调用render函数,这种渲染不仅发生在组件创建时,同时发生在视图依赖的数据更新时.如果在渲染时,直接使用真实DOM,由于真实DOM的创建,更新,插入等操作会带来大量的性能损耗,从而就极大的降低渲染效率.

因此,vue在渲染时,会使用虚拟DOM来代替真实DOM,主要为了解决渲染效率的问题.

  1. 虚拟DOM是如何转换为真实DOM的?

在一个组件实例首次被渲染时,它先生成虚拟DOM树,然后根据虚拟DOM树创建真实DOM,并把真实的DOM挂载到页面中合适的位置,此时,每个虚拟DOM便会对应一个真实的DOM.

如果一个组件受响应式数据变化的影响,需要重新渲染时,它仍然会重新调用render函数,创建出一个新的虚拟DOM树,用新树和旧树对比,通过对比,vue会找到最小更新量,然后更新必要的真实DOM节点.

这样一来,就保证了对真实DOM达到最小的改动.

image-20250312112754492

  1. 模板和虚拟DOM的关系

vue中有一个compile模块,它主要负责将模板转换为render函数,而render函数调用后将得到虚拟DOM.

编译的过程分为两步:

  • 将模板字符串转换为AST
  • 将AST转换为render函数

如果使用传统的引入方式,则编译时间发生在组件第一次加载时,这称之为运行时编译.

如果是在vue-cli的默认配置下,编译发生在打包时,这称之为模板预编译.

编译是一个极其消耗性能的操作,预编译可以有效的提高运行时的性能,而且,由于运行的时候已不需要编译,vue-cli在打包时会排除掉vue中的compile模块,以减少打包体积.

模板的存在,仅仅是为了让开发人员更加方便的书写界面代码

vue最终运行的时候,需要的是render函数,而不是模板,因此,模板中的各种语法,在虚拟DOM中都是不存在的,他们都会变成虚拟DOM的配置

案例: 自动生成目录

数据响应式原理

面试题: 请阐述vue2响应式原理

vue官方阐述: https://cn.vuejs.org/v2/guide/reactivity.html

数据响应式的最终目标,是当对象本身或对象属性发生变化时,将会运行一些函数,最常见的就是render函数.

在具体实现上,vue用到了几个核心部件

  1. Observer
  2. Dep
  3. Watcher
  4. Scheduler

Observer

Observer要实现的目标非常简单,就是把一个普通的对象转换为响应式对象.为了实现这一点,Observer把对象的每个属性通过Object.defineProperty转换为带有gettersetter的属性,这样一来,当访问或设置属性时,vue就有机会做一些别的事情.

image-20250319135250767

Observer是vue内部的构造器,我们可以通过Vue提供的静态方法Vue.observable(object)间接的使用该功能.

在组件生命周期中,这件事发生在beforeCreate之后,created之前.具体实现上,它会递归遍历对象的所有属性,以完成深度的属性转换.

由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态添加或删除的属性,因此vue提供了$set$delete两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性.

对于数组,vue会更改它的隐式原型,之所以这样做,是因为vue需要监听那些可能改变数组内容的方法.

image-20250319140224013

总之,Observer的目标就是让一个对象,它属性的读取,赋值,内部数组的变化都要能够被vue感知到.

Dep

这里有两个问题没解决,就是读取属性时要做什么事,而属性变化时要做什么事,这个问题要依靠Dep来解决.

Dep的含义是Dependency,表示依赖的意思.

Vue会为响应式对象中的每个属性,对象本身,数组本身创建一个Dep实例,每个Dep实例都有能力做以下两件事:

  • 记录依赖: 是谁在用我
  • 派发更新: 我变了,我要通知到那些要用到我的人.

当读取响应式对象的某个属性时,它会进行依赖收集:有人用到了我

当改变某个属性时,它会派发更新:哪些用我的人听好了,我变了.

image-20250319140844219

Watcher

这里又出现一个问题,就是Dep是如何知道是谁在用我?

要解决这个问题,需要依靠另一个东西,就是Watcher

当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道那个函数在用自己

因此,vue通过一种巧妙的办法来解决这个问题.

我们不要直接执行函数,而是把函数交给一个叫做watcher的东西去执行,watcher是一个对象,每个这样的函数执行时都应该创建一个watcher,通过watcher去执行.

watcher会设置一个全局变量,让全局变量记录当前负责执行的watcher等于自己,然后再去执行函数,在函数执行的过程中,如果发生了依赖记录dep.depend(),那么Dep就会把这个全局变量记录下来,表示:有一个watcher用到了我这个属性.

当Dep进行派发更新时,他会通知之前记录的所有watcher,我变了.

image-20250319142811832

每个vue组件实例,都至少对应一个watcher,该watcher中记录了该组件的render函数

watcher首先会把render函数运行一次以收集依赖,于是那些在render中用到的响应式数据就会记录这个watcher

当数据变化时,dep就会通知该watcher,而watcher将重新运行render函数,从而让界面重新渲染同时重新记录当前依赖.

Scheduler

现在还剩下最后一个问题,就是Dep通知了Watcher之后,如果watcher执行重新运行对应的函数,就有可能会导致函数频繁运行,从而导致效率低下

事项,如果一个交给watcher的函数,它用到了属性a,b,c,d,那么a,b,c,d属性都会记录依赖,于是下面的代码将触发4次更新.

1
2
3
4
state.a = "new data";		// 某些watcher想运行了,给调度器
state.b = "new data"; // 某些watcher想运行了,给调度器
state.c = "new data"; // 某些watcher想运行了,给调度器
state.d = "new data"; // 某些watcher想运行了,给调度器

这样显然是不合适的.因此,watcher收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫做调度器的东西

调度器维护一个执行队列,该队列同一个watcher仅会存在一次,队列中的watcher不是立即执行,它会通过一个叫做nextTick的工具方法,把这些需要执行的watchher放入到事件循环的微队列中,nextTick的具体做法是通过Promise完成的.

nextTick通过this.$nextTick暴露给开发者.

也就是说,当响应式数据变化时,render函数的执行是异步的,并且在微队列中.

总体流程

image-20250319143655215

diff算法

面试题: 请阐述vue的diff算法

参考回答:

当组件创建和更新时,vue均会执行内部的update函数,该函数使用render函数生成的虚拟dom树,将新旧两棵树进行对比,找到差异点,最终更新到真实dom.

对比差异的过程叫diff,vue内部通过一个叫patch的函数完成该过程

在对比时,vue采用深度优先,同层比较的方式进行对比.

在判断两个节点是否相同时,vue是通过虚拟节点的key和tag来进行判断的.

具体来说,首先对根节点进行对比,如果相同,则将旧节点关联的真实dom挂到新节点上,然后根据需要更新真实dom,然后再对比其子节点数组;如果不同,则按照新节点的信息递归创建所有真实DOM,同时挂到对应虚拟节点上,然后移除掉旧的DOM.

在对比其子节点数组时,vue对每个子节点数组使用了两个指针,分别指向头尾,然后不断地向中间靠拢来进行对比,这样做的目的是尽量复用真实的DOM,尽量少的销毁和创建真实DOM.如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实的DOM到合适的位置.

这样一直递归遍历下去,直到整棵树完成对比.

  1. diff时机

当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事

  • 运行_render生成一棵新的虚拟dom树(vnode tree)
  • 运行_update,传入虚拟dom树的根节点,对新旧两棵树进行对比,最终完成对真实dom的更新

核心代码如下

1
2
3
4
5
6
7
8
9
// vue构造函数
function Vue() {
// ... 其他代码
var updateComponentf = () => {
this._update(this._render())
}
new Watcher(updateComponent);
// ... 其他代码
}

diff就发生在_update函数的运行过程中.

  1. _update函数在干什么?

_update函数接收到一个vnode参数,这就是新生成的虚拟dom树.

同时,_update函数通过当前组件的_vnode属性,拿到的虚拟dom树

_update函数首先会给组件的_vnode属性重新赋值,让它指向新树.

image-20250319150447820

然后回判断旧树是否存在:

  • 不存在:说明这是第一次加载组件,于是通过内部的patch函数,直接遍历新树,为每个节点生成真实DOM,挂载到每个节点的elm属性上.

    image-20250319151014479

  • 存在,说明之前已经渲染过该组件,于是通过内部的patch函数,对新旧两棵树进行对比,以达到下面两个目标:

    • 完成对所有真实DOM的最小化处理
    • 让新树的节点对象到合适的真实DOM

image-20250319151148476

  1. path函数的对比流程

术语解释:

相同: 是指两个虚拟节点的标签类型,key值均相同,但input元素还要看type属性

新建元素: 是指根据一个虚拟节点提供的信息,创建一个真实dom元素,同时挂载到虚拟节点的elm属性上.

销毁元素:是指vnode.elm.remove()

更新: 是指对两个虚拟节点进行对比更新,它仅发生在两个虚拟节点相同的情况下.

对比子节点: 是指对两个虚拟节点的子节点进行对比,具体过程稍后描述.

详细流程

(1)根节点比较

image-20250319152515738

patch函数首先对根节点进行比较

如果两个节点:

  • 相同: 进入更新流程
    • 将旧节点的真实dom赋值到新节点:newVnode.elm = oldVnode.elm
    • 对比新节点和旧节点的属性,又变化的更新到真实dom中,
    • 当前两个节点处理完毕,开始对比子节点
  • 不相同
    • 新节点递归新建元素
    • 旧节点销毁元素

image-20250319154138725

(2). 对比子节点

对比子节点时,vue的一切出发点,都是为了:

  • 尽量啥也别做
  • 不行的话,尽量改动元素属性
  • 还不行的话,尽量移动元素,而不是删除和创建元素
  • 还不行的话,删除和创建元素.

image-20250319154248261

性能优化

  • 打包体积优化
  • 运行时优化

使用key

对于通过循环生成的列表,应该给每一个列表项一个稳定且唯一的key,这有利于在列表变动时,尽量少的删除,新增,改动元素.

使用冻结的对象

冻结的对象不会被响应化.

使用函数式组件

没有自己的data和生命周期函数.

使用计算属性

如果模板中某个数据会使用多次,并且该数据是通过计算得到的,使用计算属性以缓存他们.

非实时绑定的表单项

当使用v-model绑定一个表单项时,当用户改变表单项的状态时,也会随之改变数据,从而导致vue发生重新渲染(renderer),这会带来一些性能的开销.

特别是当用户改变表单项时,页面会有一些动画正在进行中,由于用于只有一个渲染主线程,所以可能会导致动画出现卡顿.

我们可以通过使用lazy或不使用v-model的方式解决该问题,但要注意,这样可能导致在某一个时间段内数据和表单项的值是不一致的.

保持对象引用稳定

在绝大部分情况下,vue触发render的时机是其依赖的数据发生变化

若数据没有发生变化,哪怕给数据重新赋值了,vue也是不会做出任何处理的

下面是vue判断数据没有变化的源码

1
2
3
4
// value为旧值,newVal为新值
if (newVal === value || (newVal !== newVal && value!==value)) {
return;
}

因此,如果需要,只要能保证组件的依赖数据不发生变化,组件就不会重新渲染.

对于原始数据类型,保持其值不变即可.

对于对象类型,保持其引用不变即可.

从另一方面来说,由于可以通过保持属性引用稳定来避免子组件的重新渲染,那么我们应该细分组件来避免多余的渲染.

使用v-show代替v-if

对于频繁切换显示状态的元素,使用v-show可以保证虚拟dom树的稳定,避免频繁新增和删除元素,特别是对于那些内部包含大量dom元素的节点,这一点极其重要

关键字: 频繁切换显示,内部包含大量dom元素

使用延迟装载(defer)

首页白屏时间主要受到两个因素的影响:

  • 打包体积过大:巨型包需要消耗大量的传输时间,导致js传输完成前页面只有一个div,没有可显示的内容.
  • 需要立即渲染的内容太多:JS传输完成后,浏览器开始执行JS构造页面.但可能一开始渲染的组件太多,导致JS执行时间很长,而且执行完成后浏览器要渲染的元素过多,从而导致白屏.

打包体积过大需要自行优化打包体积.

一个可行的办法就是延迟装载组件,让组件按照指定的先后顺序依次一个一个渲染出来.

延迟装载是一个思路,本质上是利用requestAnimationFrame事件分批渲染内容,它的具体实现多种多样.

使用keep-alive

请阐述keep-alive组件的原理和作用

keep-alive组件是vue的内置组件,用于缓存内部组件实例.这样做的目的在于,keep-alive内部的组件切换时,不用重新创建组件实例,而是直接使用缓存中的示例,一方面能够避免创建组件带来的开销,另一方面可以保留组件的状态.

keep-alive具有includeexclude属性,通过他们可以控制哪些组件进入缓存.另外它还提供了max属性,通过它可以设置最大缓存数,当缓存的实例超过max时,会移除最久没有使用的组件缓存.

keep-alive影响,其内部所嵌套的组件都具有两个生命周期钩子函数,分别是activeddeactived,他们分别在组件激活和失活时触发.第一次actived触发是在mounted之后

在具体的实现上,keep-alive在内部维护了一个key数组和一个缓存对象.

1
2
3
4
5
// keep-alive 内部的生命周期函数
created() {
this.cache = Object.create(null);
this.keys = [];
}

key数组记录目前缓存的组件key值,如果组件没有指定key值,则会为其自动生成一个唯一的key值.

cache对象以key值为键,vnode为值,用于缓存组件对应的虚拟DOM

keep-alive的渲染函数中,其基本逻辑是判断当前渲染的vnode是否有对应的缓存,如果有,从缓存中读取到对应的组件实例,如果没有,则将其缓存.

当缓存数量超过max值时,keep-alive会移除key数组的第一个元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
render() {
const slot = this.$slots.default; // 获取默认插槽
const vnode = getFirstComponentChild(slot); // 得到插槽中的第一个组件
const name = getComponentName(vnode.componentOptions); // 获取组件名称
const { cache, keys } = this;
const key = ...; // 获取组件的key值,若没有,会按照规则,自动生成
if (cache[key]) {
// 有缓存,重用实例
vnode.componentInstance = cache[key].componentInstance;
remove(keys, key); // 删除key
// 将key数组加入到数组末尾,这样是为了保证最近使用的组件在数组中靠后,反之靠前.
keys.push(key);
}else {
// 无缓存,进行缓存
cache[key] = vnode;
keys.push(key);
if (this.max && keys.length > parseInt(this.max)) {
// 超过最大缓存数量,移除第一个key对应的缓存
pruneCacheEntry(cache, keys[0],keys,this._vnode)
}
}
return vnode;
}

长列表优化

Uniapp原理

Uniapp是由DCloud推出的基于Vue.js的跨平台前端开发框架,能够通过一套代码实现多端(iOS, Android, H5, 小程序, 快应用等)运行.其核心实现方式主要依赖于编译时转换运行时适配,结合Native渲染和Web渲染模式,最终达到跨平台的效果.

1. Uniapp的核心架构

Uniapp主要由以下几个核心部分组成:

  1. 编译器(Compiler)

    负责将Vue代码转换成各个平台可运行的代码,如微信小程序WXML, 支付宝小程序AXML,H5 HTML.

  2. 运行时(Runtime)

    负责在不同平台上运行,并提供API适配组件适配,以确保一套代码可以兼容多个平台.

  3. 渲染层(Rendering Layer)

    视平台选择WebViewNative组件渲染,在H5端使用DOM,在小程序端使用小程序的自定义组件,在App端使用Native组件

  4. 通信桥(Bridge)

JS层(业务逻辑)Native层(平台能力)之间建立通信桥,使得JS可以调用平台原生能力,如相机,文件存储等.

2. Uniapp如何实现跨平台