Vue2.0源码阅读计划(四)——虚拟DOM__Vue.js__前端
发布于 2 个月前 作者 banyungong 194 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

highlight: an-old-hope theme: vue-pro

——你要是愿意,我就永远爱你

前言

虚拟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)

image.png

所以操作 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文件中的templatestyle编译到js(编译到render函数),并混合到你在.vueexport出来的Object中。组件的本质是可复用的vue实例(通过Vue.extend构建出的Vue子类的实例)。

以上述为例,在app实例挂载阶段执行vm._render函数生成vnode的过程中,内部函数createElement,判定当前标签不是一个普通的 html 标签,就调用createComponent 方法,先构造出子类构造函数(Vue.extend),并通过installComponentHooks()安装钩子函数(initprepatchinsertdestroy),最后创建一个组件 VNode并返回。然后在执行vm._update()的时候也就是patch阶段,会执行init钩子函数,拿到之前构建好的Vue子类并实例化,Vue子类实例化时会执行实例上的_init()函数,又会编译、执行vm._update(vm._render()),内部碰到组件又会不断重复以上操作,到这里应该就可以理解组件的本质是可复用的vue实例。回到原来,组件整个patch完成后最终调用insert钩子函数,完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM的插入顺序是先子后父。

image.png

通过上面的大致了解,我们也可以很容易理解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中。
  • 判断是否为注释节点,只需判断VNodeisComment属性是否为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方法
    }
  }

更新节点

更新节点最为复杂,这是重中之重。当某些节点在newVNodeoldVNode中都有时,我们就需要细致的对比,找出不同的地方进行更新。

  • newVNodeoldVNode均为静态节点
<p>我是不会变化的文字</p>

这种就是静态节点,没有任何可变的变量。静态节点无论数据发生任何变化都与它无关,所以遇到就直接跳过,无需处理。

  • newVNode是文本节点(包含变量)
  1. oldVNode也为文本节点 比较两个文本是否相同,不同就把oldVNode里的文本改为newVNode中的文本。

  2. oldVNode为文本节点外任意节点 直接调用setTextNode方法将oldVNode改成文本节点,并放入newVNode的文本内容。

  • newVNode是元素节点
  1. newVNode包含子节点 ①oldVNode为空节点,将newVNode的子节点创建一份然后插入到oldVNode里面
    oldVNode为文本节点,将文本清空,然后把newVNode的子节点创建一份然后插入到oldVNode里面
    oldVNode包含子节点,递归对比更新子节点

2.newVNode不包含子节点
newVNode是元素节点的同时也不包含子节点,那此时newVNode就是空节点,直接将oldVNode中的内容清空。

更新子节点

上面讲到newVNodeoldVNode均包含子节点时,递归对比更新子节点。这个过程具体是怎样的,下面我们详细来看。

首先,通过代码分析一下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无序列表,里面嵌套了3li,如果没有设置keykey被设置为index(这两种情况异曲同工),此时你在第2个后面又加了一个li,新旧VNode通过sameVnode对比就会判定:newVNode的第3lioldVNode的第3li相同,进而再执行updateChildren更新子节点,这里的子节点变动可能比较大,最后在末尾再创建出一个新的li出来并插入。

如果设置了keykey不为index,那么就会判定:newVNode的第3lioldVNode中找不到与之相同的节点,而newVNode的第4lioldVNode的第3li相同,这里的子节点一般情况下没有变动或变动较小(此例我们这里就只是添加了个新节点),最后去创建出一个新的li并移动到指定位置。这种方式下,更新vnode更高效。你还可以再想想删除节点的情况。

image.png

总结: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种情况都试完如果还不同,那就按照之前循环的方式来查找更新节点。

结语

本文更多是偏向于笔记、总结,并加入了自己学习时的一些理解,大家一起加油啊!!!

参考:
Vue源码系列-Vue中文社区
Vue.js 技术揭秘

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 伟大的兔神 原文链接:https://juejin.im/post/6971010646527705119

回到顶部