# vue 虚拟 dom 的理解
# 1. 什么是虚拟 dom?
虚拟 dom 本质上就是一个普通的 JS 对象,用于描述视图的界面结构
在 vue 中,每个组件都有一个render函数,每个render函数都会返回一个虚拟 dom 树,这也就意味着每个组件都对应一棵虚拟 DOM 树
# 2. 为什么需要虚拟 dom?
在vue中,渲染视图会调用render函数,这种渲染不仅发生在组件创建时,同时发生在视图依赖的数据更新时。如果在渲染时,直接使用真实DOM,由于真实DOM的创建、更新、插入等操作会带来大量的性能损耗,从而就会极大的降低渲染效率。
因此,vue在渲染时,使用虚拟 dom 来替代真实 dom,主要为解决渲染效率的问题。
# 3. 虚拟 dom 是如何转换为真实 dom 的?
在一个组件实例首次被渲染时,它先生成虚拟 dom 树,然后根据虚拟 dom 树创建真实 dom,并把真实 dom 挂载到页面中合适的位置,此时,每个虚拟 dom 便会对应一个真实的 dom。
如果一个组件受响应式数据变化的影响,需要重新渲染时,它仍然会重新调用 render 函数,创建出一个新的虚拟 dom 树,用新树和旧树对比,通过对比,vue 会找到最小更新量,然后更新必要的虚拟 dom 节点,最后,这些更新过的虚拟节点,会去修改它们对应的真实 dom
这样一来,就保证了对真实 dom 达到最小的改动。
# 4. 模板和虚拟 dom 的关系
vue 框架中有一个compile模块,它主要负责将模板转换为render函数,而render函数调用后将得到虚拟 dom。
编译的过程分两步:
- 将模板字符串转换成为
AST - 将
AST转换为render函数
如果使用传统的引入方式,则编译时间发生在组件第一次加载时,这称之为运行时编译。
如果是在vue-cli的默认配置下,编译发生在打包时,这称之为模板预编译。
编译是一个极其耗费性能的操作,预编译可以有效的提高运行时的性能,而且,由于运行的时候已不需要编译,vue-cli在打包时会排除掉vue中的compile模块,以减少打包体积
模板的存在,仅仅是为了让开发人员更加方便的书写界面代码
vue 最终运行的时候,最终需要的是 render 函数,而不是模板,因此,模板中的各种语法,在虚拟 dom 中都是不存在的,它们都会变成虚拟 dom 的配置
# vue 中v-model 的原理
v-model即可以作用于表单元素,又可作用于自定义组件,无论是哪一种情况,它都是一个语法糖,最终会生成一个属性和一个事件
当其作用于表单元素时,vue会根据作用的表单元素类型而生成合适的属性和事件。例如,作用于普通文本框的时候,它会生成value属性和input事件,而当其作用于单选框或多选框时,它会生成checked属性和change事件。
v-model也可作用于自定义组件,当其作用于自定义组件时,默认情况下,它会生成一个value属性和input事件。
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :value="data" @input="data=$event" />
2
3
开发者可以通过组件的model配置来改变生成的属性和事件
// Comp
const Comp = {
model: {
prop: "number", // 默认为 value
event: "change", // 默认为 input
},
// ...
};
2
3
4
5
6
7
8
<Comp v-model="data" />
<!-- 等效于 -->
<Comp :number="data" @change="data=$event" />
2
3
# vue 响应式原理
面试题:请阐述
vue2响应式原理
响应式数据的最终目标,是当对象本身或对象属性发生变化时,将会运行一些函数,最常见的就是 render 函数。
在具体实现上,vue 用到了几个核心部件:
- Observer
- Dep
- Watcher
- Scheduler
# Observer
Observer 要实现的目标非常简单,就是把一个普通的对象转换为响应式的对象
为了实现这一点,Observer 把对象的每个属性通过Object.defineProperty转换为带有getter和setter的属性,这样一来,当访问或设置属性时,vue就有机会做一些别的事情。
Observer 是 vue 内部的构造器,我们可以通过 Vue 提供的静态方法Vue.observable( object )间接的使用该功能。
在组件生命周期中,这件事发生在beforeCreate之后,created之前。
具体实现上,它会递归遍历对象的所有属性,以完成深度的属性转换。
由于遍历时只能遍历到对象的当前属性,因此无法监测到将来动态增加或删除的属性,因此vue提供了$set和$delete两个实例方法,让开发者通过这两个实例方法对已有响应式对象添加或删除属性。
对于数组,vue会更改它的隐式原型,之所以这样做,是因为 vue 需要监听那些可能改变数组内容的方法
总之,Observer 的目标,就是要让一个对象,它属性的读取、赋值,内部数组的变化都要能够被 vue 感知到。
# Dep
这里有两个问题没解决,就是读取属性时要做什么事,而属性变化时要做什么事,这个问题需要依靠 Dep 来解决。
Dep 的含义是Dependency,表示依赖的意思。
Vue会为响应式对象中的每个属性、对象本身、数组本身创建一个Dep实例,每个Dep实例都有能力做以下两件事:
- 记录依赖:是谁在用我
- 派发更新:我变了,我要通知那些用到我的人
当读取响应式对象的某个属性时,它会进行依赖收集:有人用到了我
当改变某个属性时,它会派发更新:那些用我的人听好了,我变了
# Watcher
这里又出现一个问题,就是 Dep 如何知道是谁在用我?
要解决这个问题,需要依靠另一个东西,就是 Watcher。
当某个函数执行的过程中,用到了响应式数据,响应式数据是无法知道是哪个函数在用自己的
因此,vue 通过一种巧妙的办法来解决这个问题
我们不要直接执行函数,而是把函数交给一个叫做 watcher 的东西去执行,watcher 是一个对象,每个这样的函数执行时都应该创建一个 watcher,通过 watcher 去执行
watcher 会设置一个全局变量,让全局变量记录当前负责执行的 watcher 等于自己,然后再去执行函数,在函数的执行过程中,如果发生了依赖记录dep.depend(),那么Dep就会把这个全局变量记录下来,表示:有一个 watcher 用到了我这个属性
当 Dep 进行派发更新时,它会通知之前记录的所有 watcher:我变了
每一个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 次更新:
state.a = "new data";
state.b = "new data";
state.c = "new data";
state.d = "new data";
2
3
4
这样显然是不合适的,因此,watcher 收到派发更新的通知后,实际上不是立即执行对应函数,而是把自己交给一个叫调度器的东西
调度器维护一个执行队列,该队列同一个 watcher 仅会存在一次,队列中的 watcher 不是立即执行,它会通过一个叫做nextTick的工具方法,把这些需要执行的 watcher 放入到事件循环的微队列中,nextTick 的具体做法是通过Promise完成的
nextTick 通过
this.$nextTick暴露给开发者nextTick 的具体处理方式见:https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97
也就是说,当响应式数据变化时,render函数的执行是异步的,并且在微队列中
# 总体流程

# vue 的 diff 算法
参考回答:
当组件创建和更新时,vue 均会执行内部的 update 函数,该函数使用 render 函数生成的虚拟 dom 树,将新旧两树进行对比,找到差异点,最终更新到真实 dom
对比差异的过程叫 diff,vue 在内部通过一个叫 patch 的函数完成该过程
在对比时,vue 采用深度优先、同层比较的方式进行比对。
在判断两个节点是否相同时,vue 是通过虚拟节点的 key 和 tag 来进行判断的
具体来说,首先对根节点进行对比,如果相同则将旧节点关联的真实 dom 的引用挂到新节点上,然后根据需要更新属性到真实 dom,然后再对比其子节点数组;如果不相同,则按照新节点的信息递归创建所有真实 dom,同时挂到对应虚拟节点上,然后移除掉旧的 dom。
在对比其子节点数组时,vue 对每个子节点数组使用了两个指针,分别指向头尾,然后不断向中间靠拢来进行对比,这样做的目的是尽量复用真实 dom,尽量少的销毁和创建真实 dom。如果发现相同,则进入和根节点一样的对比流程,如果发现不同,则移动真实 dom 到合适的位置。
这样一直递归的遍历下去,直到整棵树完成对比。
# diff的时机
当组件创建时,以及依赖的属性或数据变化时,会运行一个函数,该函数会做两件事:
- 运行
_render生成一棵新的虚拟 dom 树(vnode tree) - 运行
_update,传入虚拟 dom 树的根节点,对新旧两棵树进行对比,最终完成对真实 dom 的更新
核心代码如下:
// vue构造函数
function Vue() {
// ... 其他代码
var updateComponent = () => {
this._update(this._render());
};
new Watcher(updateComponent);
// ... 其他代码
}
2
3
4
5
6
7
8
9
diff就发生在_update函数的运行过程中
# _update函数在干什么
_update函数接收到一个vnode参数,这就是新生成的虚拟 dom 树
同时,_update函数通过当前组件的_vnode属性,拿到旧的虚拟 dom 树
_update函数首先会给组件的_vnode属性重新赋值,让它指向新树
然后会判断旧树是否存在:
不存在:说明这是第一次加载组件,于是通过内部的
patch函数,直接遍历新树,为每个节点生成真实 DOM,挂载到每个节点的elm属性上
存在:说明之前已经渲染过该组件,于是通过内部的
patch函数,对新旧两棵树进行对比,以达到下面两个目标:- 完成对所有真实 dom 的最小化处理
- 让新树的节点对应合适的真实 dom

# patch函数的对比流程
术语解释:
- 「相同」:是指两个虚拟节点的标签类型、
key值均相同,但input元素还要看type属性 - 「新建元素」:是指根据一个虚拟节点提供的信息,创建一个真实 dom 元素,同时挂载到虚拟节点的
elm属性上 - 「销毁元素」:是指:
vnode.elm.remove() - 「更新」:是指对两个虚拟节点进行对比更新,它仅发生在两个虚拟节点「相同」的情况下。具体过程稍后描述。
- 「对比子节点」:是指对两个虚拟节点的子节点进行对比,具体过程稍后描述
详细流程:
根节点比较
patch函数首先对根节点进行比较如果两个节点:
「相同」,进入**「更新」流程**
将旧节点的真实 dom 赋值到新节点:
newVnode.elm = oldVnode.elm对比新节点和旧节点的属性,有变化的更新到真实 dom 中
当前两个节点处理完毕,开始**「对比子节点」**
不「相同」
- 新节点递归「新建元素」
- 旧节点「销毁元素」
「对比子节点」
在「对比子节点」时,vue 一切的出发点,都是为了:
- 尽量啥也别做
- 不行的话,尽量仅改动元素属性
- 还不行的话,尽量移动元素,而不是删除和创建元素
- 还不行的话,删除和创建元素
# vue 生命周期详解
面试题:
new Vue之后,发生了什么?数据改变后,又发生了什么?
创建 vue 实例和创建组件的流程基本一致
首先做一些初始化的操作,主要是设置一些私有属性到实例中
运行生命周期钩子函数
beforeCreate进入注入流程:处理属性、computed、methods、data、provide、inject,最后使用代理模式将它们挂载到实例中
运行生命周期钩子函数
created生成
render函数:如果有配置,直接使用配置的render,如果没有,使用运行时编译器,把模板编译为render运行生命周期钩子函数
beforeMount创建一个
Watcher,传入一个函数updateComponent,该函数会运行render,把得到的vnode再传入_update函数执行。在执行
render函数的过程中,会收集所有依赖,将来依赖变化时会重新运行updateComponent函数在执行
_update函数的过程中,触发patch函数,由于目前没有旧树,因此直接为当前的虚拟 dom 树的每一个普通节点生成 elm 属性,即真实 dom。如果遇到创建一个组件的 vnode,则会进入组件实例化流程,该流程和创建 vue 实例流程基本相同,最终会把创建好的组件实例挂载 vnode 的
componentInstance属性中,以便复用。运行生命周期钩子函数
mounted
重渲染?
数据变化后,所有依赖该数据的
Watcher均会重新运行,这里仅考虑updateComponent函数对应的WatcherWatcher会被调度器放到nextTick中运行,也就是微队列中,这样是为了避免多个依赖的数据同时改变后被多次执行运行生命周期钩子函数
beforeUpdateupdateComponent函数重新执行在执行
render函数的过程中,会去掉之前的依赖,重新收集所有依赖,将来依赖变化时会重新运行updateComponent函数在执行
_update函数的过程中,触发patch函数。新旧两棵树进行对比。
普通
html节点的对比会导致真实节点被创建、删除、移动、更新组件节点的对比会导致组件被创建、删除、移动、更新
当新组件需要创建时,进入实例化流程
当旧组件需要删除时,会调用旧组件的
$destroy方法删除组件,该方法会先触发生命周期钩子函数beforeDestroy,然后递归调用子组件的$destroy方法,然后触发生命周期钩子函数destroyed当组件属性更新时,相当于组件的
updateComponent函数被重新触发执行,进入重渲染流程,和本节相同。运行生命周期钩子函数
updated
# vue computed 详解
面试题:computed 和 methods 有什么区别
标准而浅显的回答
- 在使用时,computed 当做属性使用,而 methods 则当做方法调用
- computed 可以具有 getter 和 setter,因此可以赋值,而 methods 不行
- computed 无法接收多个参数,而 methods 可以
- computed 具有缓存,而 methods 没有
更接近底层原理的回答
vue 对 methods 的处理比较简单,只需要遍历 methods 配置中的每个属性,将其对应的函数使用 bind 绑定当前组件实例后复制其引用到组件实例中即可
而 vue 对 computed 的处理会稍微复杂一些。
当组件实例触发生命周期函数
beforeCreate后,它会做一系列事情,其中就包括对 computed 的处理它会遍历 computed 配置中的所有属性,为每一个属性创建一个 Watcher 对象,并传入一个函数,该函数的本质其实就是 computed 配置中的 getter,这样一来,getter 运行过程中就会收集依赖
但是和渲染函数不同,为计算属性创建的 Watcher 不会立即执行,因为要考虑到该计算属性是否会被渲染函数使用,如果没有使用,就不会得到执行。因此,在创建 Watcher 的时候,它使用了 lazy 配置,lazy 配置可以让 Watcher 不会立即执行。
收到
lazy的影响,Watcher 内部会保存两个关键属性来实现缓存,一个是value,一个是dirty
value属性用于保存 Watcher 运行的结果,受lazy的影响,该值在最开始是undefined
dirty属性用于指示当前的value是否已经过时了,即是否为脏值,受lazy的影响,该值在最开始是trueWatcher 创建好后,vue 会使用代理模式,将计算属性挂载到组件实例中
当读取计算属性时,vue 检查其对应的 Watcher 是否是脏值,如果是,则运行函数,计算依赖,并得到对应的值,保存在 Watcher 的 value 中,然后设置 dirty 为 false,然后返回。
如果 dirty 为 false,则直接返回 watcher 的 value
巧妙的是,在依赖收集时,被依赖的数据不仅会收集到计算属性的 Watcher,还会收集到组件的 Watcher
当计算属性的依赖变化时,会先触发计算属性的 Watcher 执行,此时,它只需设置
dirty为 true 即可,不做任何处理。由于依赖同时会收集到组件的 Watcher,因此组件会重新渲染,而重新渲染时又读取到了计算属性,由于计算属性目前已为 dirty,因此会重新运行 getter 进行运算
而对于计算属性的 setter,则极其简单,当设置计算属性时,直接运行 setter 即可
# keep-alive 件的作用和原理
keep-alive (opens new window) 组件是 vue 的内置组件,用于缓存内部组件实例。这样做的目的在于,keep-alive 内部的组件切回时,不用重新创建组件实例,而直接使用缓存中的实例,一方面能够避免创建组件带来的开销,另一方面可以保留组件的状态。
keep-alive 具有 include 和 exclude 属性,通过它们可以控制哪些组件进入缓存。另外它还提供了 max 属性,通过它可以设置最大缓存数,当缓存的实例超过该数时,vue 会移除最久没有使用的组件缓存。
受 keep-alive 的影响,其内部所有嵌套的组件都具有两个生命周期钩子函数,分别是activated和deactivated,它们分别在组件激活和失活时触发。第一次activated触发是在mounted之后
在具体的实现上,keep-alive 在内部维护了一个 key 数组和一个缓存对象
// keep-alive 内部的声明周期函数
created () {
this.cache = Object.create(null)
this.keys = []
}
2
3
4
5
key 数组记录目前缓存的组件 key 值,如果组件没有指定 key 值,则会为其自动生成一个唯一的 key 值
cache 对象以 key 值为键,vnode 为值,用于缓存组件对应的虚拟 DOM
在 keep-alive 的渲染函数中,其基本逻辑是判断当前渲染的 vnode 是否有对应的缓存,如果有,从缓存中读取到对应的组件实例;如果没有则将其缓存。
当缓存数量超过 max 数值时,keep-alive 会移除掉 key 数组的第一个元素
render(){
const slot = this.$slots.default; // 获取默认插槽
const vnode = getFirstComponentChild(slot); // 得到插槽中的第一个组件的vnode
const name = getComponentName(vnode.componentOptions); //获取组件名字
const { cache, keys } = this; // 获取当前的缓存对象和key数组
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
← vue3后台项目搭建 虚拟dom的本质 →