——你要是愿意,我就永远爱你
前言
虚拟DOM(Virtual DOM),在当下的前端三大框架中或多或少都有所涉及,且在前端内卷的环境下作为面试的高频考点,我们非常有必要来揭开它神秘的面纱,一探究竟。
为什么需要虚拟DOM
在虚拟DOM出现之前,jquery风靡全球的那个时代,我们通过ajax获取到数据后就直接去操作dom让浏览器去重新渲染,但在进行大量数据的操作时它的弊端就体现了出来,因为对 DOM 进行了大量频繁的操作,所以导致页面加载缓慢甚至卡顿,与此同时jquery也导致代码耦合度高让项目变得难以维护。
为什么频繁操作 DOM 会带来浏览器的性能问题?
原因就在于浏览器中的 DOM 是很“昂贵"的,一个 dom 元素上面就挂载了超多的属性,我们可以感受一下它的庞大:
var div = document.createElement('div')
var str = ''
for (let key in div) {
str += key + ' '
}
console.log(str)
所以操作 DOM 是非常耗费性能的。好在MVVM时代来临,带来了虚拟DOM的解决方案,本质就是用JS的计算性能来换取操作DOM所消耗的性能。利用JS模拟出一个DOM节点,称之为虚拟DOM节点。当数据发生变化时,我们对比变化前后的虚拟DOM节点,通过DOM-Diff算法计算出需要更新的地方,然后去更新需要更新的视图。这种方式让我们尽可能的减少了对DOM的操作,从而提升了性能。三大框架的出现,让代码逻辑性、组织能力更强,也降低了维护难度。
虚拟DOM的实现
上面说到 虚拟DOM 就是用一个原生的 JS 对象描述的 DOM 节点,对应到Vue中,它就是定义在src/core/vdom/vnode.js 中的一个VNode类:
export default class VNode {
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
this.tag = tag /*当前节点的标签名*/
this.data = data /*当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息*/
this.children = children /*当前节点的子节点,是一个数组*/
this.text = text /*当前节点的文本*/
this.elm = elm /*当前虚拟节点对应的真实dom节点*/
this.ns = undefined /*当前节点的名字空间*/
this.context = context /*当前组件节点对应的Vue实例*/
this.fnContext = undefined /*函数式组件对应的Vue实例*/
this.fnOptions = undefined
this.fnScopeId = undefined
this.key = data && data.key /*节点的key属性,被当作节点的标志,用以优化*/
this.componentOptions = componentOptions /*组件的option选项*/
this.componentInstance = undefined /*当前节点对应的组件的实例*/
this.parent = undefined /*当前节点的父节点*/
this.raw = false /*简而言之就是是否为原生HTML或只是普通文本,innerHTML的时候为true,textContent的时候为false*/
this.isStatic = false /*静态节点标志*/
this.isRootInsert = true /*是否作为跟节点插入*/
this.isComment = false /*是否为注释节点*/
this.isCloned = false /*是否为克隆节点*/
this.isOnce = false /*是否有v-once指令*/
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}
get child (): Component | void {
return this.componentInstance
}
}
这个VNode类中包含了描述一个真实DOM节点所需要的一系列属性,tag属性表示节点的标签名,data属性表示当前节点对应的数据对象(VNodeData类型,可以在flow/vnode.js文件中查看详细定义),children属性表示子级虚拟节点,这三个参数对应渲染函数中createElement的三个参数,其他的就不一一介绍啦,大致知道每个属性代表的含义就可以了。
通过这个类,我们就可以实例化出不同类型的虚拟DOM节点来描述出各种类型的真实DOM节点。VNode类型有:
- 文本节点
// 创建文本节点
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
// 只需要第四个参数就可以了
}
- 注释节点
// 创建注释节点
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text // 注释节点的内容
node.isComment = true // 标识注释节点的关键属性
return node
}
- 克隆节点
// 创建克隆节点
export function cloneVNode (vnode: VNode): VNode {
// 将传入的节点的属性复制一份到一个新的实例对象
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.fnContext = vnode.fnContext
cloned.fnOptions = vnode.fnOptions
cloned.fnScopeId = vnode.fnScopeId
cloned.asyncMeta = vnode.asyncMeta
cloned.isCloned = true // 标识克隆节点的关键属性
return cloned
}
-
元素节点 情况较复杂,参考渲染函数
createElement创建节点,详见src/core/vdom/create-element.js文件的createElement函数。 -
组件节点 组件节点除了有元素节点具有的属性之外,它还有两个特有的属性:
componentOptions:组件的option选项,如组件的props等
componentInstance:当前组件节点对应的Vue实例
详见src/core/vdom/create-component.js文件的createComponent函数。
- 函数式组件节点 函数式组件节点相较于组件节点,它又有两个特有的属性:
fnContext: 函数式组件对应的Vue实例
fnOptions: 组件的option选项
详见src/core/vdom/create-functional-component.js文件的createFunctionalComponent函数。
- 静态节点
isStatic属性为true的节点,在模板编译的优化阶段标记静态节点
DOM-Diff
我们通过对比新旧VNode,找出差异然后做最少对DOM的操作,这个找出差异的过程就是DOM-Diff算法实现的,这个可是重点哦。
DOM-Diff是在调用vm._update()函数的时候进行的,vm._update()内部调用vm.__patch__,vm.__patch__最终指向createPatchFunction返回的patch函数,所以我们又把DOM-Diff过程叫做patch过程。patch,意为“补丁”,即指对旧的VNode修补,打补丁从而得到新的VNode,这名字起得太贴合了。
整个patch简单来看,其实就干了三件事:
- 创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
- 删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
- 更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。
创建节点
VNode类可以描述6种类型的节点,而实际上只有3种类型的节点能够被创建并插入到DOM中,它们分别是:元素节点、文本节点、注释节点。因为只有这三种节点是对应真实DOM节点的。以组件节点为例,我们通过 createComponent 方法创建一个组件节点,vue2.0版本依赖的粒度大小为中等组件级,也就是说patch的级别是组件级,组件节点patch的过程本质上还是元素节点、文本节点、注释节点这三种节点在进行对比。
下面粗略讲一下组件原理,你会更容易理解:
组件原理
import Vue from 'vue'
import App from './App.vue'
var app = new Vue({
el: '#app',
// 这里的 h 是 createElement 方法
render: h => h(App)
})
我们平时写的单文件组件被引入时就是一个个组件节点,vue单文件组件通过vue-loader解析,而vue-loader做的事只是把.vue文件中的template与style编译到js(编译到render函数),并混合到你在.vue中export出来的Object中。组件的本质是可复用的vue实例(通过Vue.extend构建出的Vue子类的实例)。
以上述为例,在app实例挂载阶段执行vm._render函数生成vnode的过程中,内部函数createElement,判定当前标签不是一个普通的 html 标签,就调用createComponent 方法,先构造出子类构造函数(Vue.extend),并通过installComponentHooks()安装钩子函数(init、prepatch、insert、destroy),最后创建一个组件 VNode并返回。然后在执行vm._update()的时候也就是patch阶段,会执行init钩子函数,拿到之前构建好的Vue子类并实例化,Vue子类实例化时会执行实例上的_init()函数,又会编译、执行vm._update(vm._render()),内部碰到组件又会不断重复以上操作,到这里应该就可以理解组件的本质是可复用的vue实例。回到原来,组件整个patch完成后最终调用insert钩子函数,完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM的插入顺序是先子后父。
通过上面的大致了解,我们也可以很容易理解vue父子组件渲染时的生命周期顺序:
父beforeCreate => 父created => 父beforeMount => 子beforeCreate => 子created => 子beforeMount => 子mounted => 父mounted
总结:组件的原理就是,通过深度优先遍历整个嵌套组件,构建完成dom tree,最后一次性插入到真实dom中。
继续来讲创建节点,下面代码已简化:
// 源码位置: /src/core/vdom/patch.js
function createElm (vnode, parentElm, refElm) {
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
if (isDef(tag)) {
vnode.elm = nodeOps.createElement(tag, vnode) // 创建元素节点
createChildren(vnode, children, insertedVnodeQueue) // 创建元素节点的子节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else if (isTrue(vnode.isComment)) {
vnode.elm = nodeOps.createComment(vnode.text) // 创建注释节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
} else {
vnode.elm = nodeOps.createTextNode(vnode.text) // 创建文本节点
insert(parentElm, vnode.elm, refElm) // 插入到DOM中
}
}
- 判断是否为元素节点只需判断该
VNode节点是否有tag标签即可。如果有tag属性即认为是元素节点,则调用createElement方法创建元素节点,通常元素节点还会有子节点,通过createChildren递归遍历创建所有子节点,将所有子节点创建好之后insert插入到当前元素节点里面,最后把当前元素节点插入到DOM中。 - 判断是否为注释节点,只需判断
VNode的isComment属性是否为true即可,若为true则为注释节点,则调用createComment方法创建注释节点,再插入到DOM中。 - 如果既不是元素节点,也不是注释节点,那就认为是文本节点,则调用
createTextNode方法创建文本节点,再插入到DOM中。
删除节点
删除节点应该是最简单的了。判断oldVNode中有newVNode中没有,就通过removeNode 删除节点:
function removeNode (el) {
const parent = nodeOps.parentNode(el) // 获取父节点
if (isDef(parent)) {
nodeOps.removeChild(parent, el) // 调用父节点的removeChild方法
}
}
更新节点
更新节点最为复杂,这是重中之重。当某些节点在newVNode和oldVNode中都有时,我们就需要细致的对比,找出不同的地方进行更新。
newVNode与oldVNode均为静态节点
<p>我是不会变化的文字</p>
这种就是静态节点,没有任何可变的变量。静态节点无论数据发生任何变化都与它无关,所以遇到就直接跳过,无需处理。
newVNode是文本节点(包含变量)
-
oldVNode也为文本节点 比较两个文本是否相同,不同就把oldVNode里的文本改为newVNode中的文本。 -
oldVNode为文本节点外任意节点 直接调用setTextNode方法将oldVNode改成文本节点,并放入newVNode的文本内容。
newVNode是元素节点
newVNode包含子节点 ①oldVNode为空节点,将newVNode的子节点创建一份然后插入到oldVNode里面
②oldVNode为文本节点,将文本清空,然后把newVNode的子节点创建一份然后插入到oldVNode里面
③oldVNode包含子节点,递归对比更新子节点
2.newVNode不包含子节点
newVNode是元素节点的同时也不包含子节点,那此时newVNode就是空节点,直接将oldVNode中的内容清空。
更新子节点
上面讲到newVNode与oldVNode均包含子节点时,递归对比更新子节点。这个过程具体是怎样的,下面我们详细来看。
首先,通过代码分析一下vue是怎样判断两个节点相同的:
列表渲染为什么推荐写key
function sameVnode (a, b) {
return (
a.key === b.key && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
这里的key至关重要,如果key相同,才去接着判断tag、isComment、data等。试想一个ul无序列表,里面嵌套了3个li,如果没有设置key或key被设置为index(这两种情况异曲同工),此时你在第2个后面又加了一个li,新旧VNode通过sameVnode对比就会判定:newVNode的第3个li与oldVNode的第3个li相同,进而再执行updateChildren更新子节点,这里的子节点变动可能比较大,最后在末尾再创建出一个新的li出来并插入。
如果设置了key且key不为index,那么就会判定:newVNode的第3个li在oldVNode中找不到与之相同的节点,而newVNode的第4个li与oldVNode的第3个li相同,这里的子节点一般情况下没有变动或变动较小(此例我们这里就只是添加了个新节点),最后去创建出一个新的li并移动到指定位置。这种方式下,更新vnode更高效。你还可以再想想删除节点的情况。
总结:key的作用就是为了更高效的更新虚拟DOM。
继续正题,VNode实例上的children属性就是所包含的子节点数组。我们newVNode上的子节点数组记为newChildren,把oldVNode上的子节点数组记为oldChildren,现在需要把newChildren里面的元素与oldChildren里的元素一一进行对比,对比两个子节点数组肯定是要通过循环,外层循环newChildren数组(以新的为基准),内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点,这个过程将会存在以下四种情况:
- 创建子节点
如果
newChildren里面的某个子节点在oldChildren里找不到与之相同的子节点,那么说明newChildren里面的这个子节点是之前没有的,是需要此次新增的节点,那么我们就创建这个节点,创建好之后再把它插入到DOM中合适的位置,这个位置为所有未处理节点之前。 - 删除子节点
如果把
newChildren里面的每一个子节点都循环一遍,能在oldChildren数组里找到的就处理它,找不到的就新增,直到把newChildren里面所有子节点都过一遍后,发现在oldChildren还存在未处理的子节点,那就说明这些未处理的子节点是需要被废弃的,那么就将这些节点删除。 - 更新子节点
如果
newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,并且所处的位置也相同,那么就更新oldChildren里该节点,使之与newChildren里的该节点相同。子节点嵌套就递归去更新。 - 移动子节点
如果
newChildren里面的某个子节点在oldChildren里找到了与之相同的子节点,但是所处的位置不同,这说明此次变化需要调整该子节点的位置,那就以newChildren里子节点的位置为基准,调整oldChildren里该节点的位置,使之与在newChildren里的位置相同。
优化策略
双层循环虽然能解决问题,但是如果节点数量很多,这样循环算法的时间复杂度会呈指数增长,Vue也意识到了这点,所以这里进行了优化。
优化策略简单来讲就是,在循环比对的过程中,先尝试以下4种情况:
- 新前与旧前对比
- 新后与旧后对比
- 新后与旧前对比
- 新前与旧后对比
新前:newChildren数组里所有未处理子节点的第一个子节点
新后:newChildren数组里所有未处理子节点的最后一个子节点
旧前:oldChildren数组里所有未处理子节点的第一个子节点
旧后:oldChildren数组里所有未处理子节点的最后一个子节点
这4种情况的尝试能很大程度上避免极端情况,减少循环次数,提高更新效率。最后4种情况都试完如果还不同,那就按照之前循环的方式来查找更新节点。
结语
本文更多是偏向于笔记、总结,并加入了自己学习时的一些理解,大家一起加油啊!!!