前言

在四月份的时候,为了准备面试,把用过一年时间的 Vue 源码来看了看,最近准备总结一下,在之前我写过一篇文章字节面试官:小勾同学来说说defineProperty,介绍了 Vue 实现响应式的核心原理,然后本篇文章准备再说说 Vue 另外的核心原理—— 虚拟DOMDiff算法参考原文

正文

  1. 真实 DOM 和 解析流程

    ​ 本节我们主要介绍真实 DOM 的解析过程,通过介绍其解析过程以及存在的问题,从而引出为什么需要虚拟DOM。一图胜千言,如下图为 webkit 渲染引擎工作流程图

    image-20200714231035265

    由上图可看出来浏览器渲染引擎工作流程大致分为5步:创建 DOM 树——> 创建 style Rules ——> 构建 render 树 ——> 布局Layout ——> 绘制 Painting

    1. 第一步:构建DOM树,用HTML分析器,分析HTML元素,构建一颗DOM树
    2. 第二步,生成样式表:用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表
    3. 第三步,构建Render树:将DOM树和样式表关联起来,构建一个Render树(Attachment)。每个DOM节点都有attch方法,接受样式信息,返回一个render对象(又名renderer),这些render对象最总会被构建成一颗render树。
    4. 第四步,确定节点坐标:根据render树结构,为每个render树上的节点确定一个在显示屏上出现的精确坐标
    5. 第五步,绘制页面:根据Render树 和 节点显示坐标,然后调用每个节点的paint 方法,将他们绘制出来。

    注意点:

    1. DOM树的构建是文档加载完成开始的?构建 DOM 树是一个渐进的过程,为达到更好的用户体验,渲染引擎会尽快将内容显示在屏幕上,他不必等到整个 HTML 文档解析完成之后才开始构建 render树 和布局的
    2. Render 树是 DOM 树和 CSS 样式表构建完毕后才开始构建的? 这三个过程在实际进行的时候并不是完全独立的,而是会有交叉,会一边加载,一边解析,以及一边渲染。
    3. CSS 的解析注意点? CSS 的解析是从右往左逆向解析的,嵌套标签越多,解析越慢。
    4. JS 操作真实 DOM 的代价? 用我们传统的开发模式,原生 JSJQ 操作 DOM 时,浏览器会从构建 DOM 树开始从头到尾执行一遍流程。在一次操作中,我需要更新 10 个 DOM 节点,浏览器收到第一个 DOM 请求后并不知道还有 9 次更新操作,因此会马上执行流程,最终执行10 次。例如,第一次计算完,紧接着下一个 DOM 更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算 DOM 节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作 DOM 的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验
  2. Vue 源码 Virtual-DOM 解析

    Vue中,通过JS对象来对页面上的真实DOM来表示,实现页面的更新主要有:创建DOM——> Diff——> patch

    1. 虚拟DOM的好处

      ​ 虚拟 DOM 就是为了解决浏览器性能问题而被设计出来的。如前,若一次操作中有 10 次更新 DOM 的动作,虚拟 DOM 不会立即操作 DOM,而是将这 10 次更新的 diff 内容保存到本地一个 JS 对象中,最终将这个 JS 对象一次性 attchDOM 树上,再进行后续操作,避免大量无谓的计算量。所以,用 JS 对象模拟 DOM 节点的好处是,页面的更新可以先全部反映在 JS 对象(虚拟 DOM )上,操作内存中的 JS 对象的速度显然要更快,等更新完成后,再将最终的 JS 对象映射成真实的 DOM,交由浏览器去绘制。

    2. VNode模拟DOM树

      Vue.js 中,Virtual DOM 是用 VNode 这个 Class 去描述,它定义在 src/core/vdom/vnode.js 中 ,从以下代码块中可以看到 Vue.js 中的 Virtual DOM 的定义较为复杂一些,因为它这里包含了很多 Vue.js 的特性。实际上 Vue.jsVirtual DOM 是借鉴了一个开源库 snabbdom 的实现,然后加入了一些 Vue.js 的一些特性。

      export default class VNode {
        tag: string | void;
        data: VNodeData | void;
        children: ?Array<VNode>;
        text: string | void;
        elm: Node | void;
        ns: string | void;
        context: Component | void; // rendered in this component's scope
        key: string | number | void;
        componentOptions: VNodeComponentOptions | void;
        componentInstance: Component | void; // component instance
        parent: VNode | void; // component placeholder node
      
        // strictly internal
        raw: boolean; // contains raw HTML? (server only)
        isStatic: boolean; // hoisted static node
        isRootInsert: boolean; // necessary for enter transition check
        isComment: boolean; // empty comment placeholder?
        isCloned: boolean; // is a cloned node?
        isOnce: boolean; // is a v-once node?
        asyncFactory: Function | void; // async component factory function
        asyncMeta: Object | void;
        isAsyncPlaceholder: boolean;
        ssrContext: Object | void;
        fnContext: Component | void; // real context vm for functional nodes
        fnOptions: ?ComponentOptions; // for SSR caching
        devtoolsMeta: ?Object; // used to store functional render context for devtools
        fnScopeId: ?string; // functional scope id support
      
        constructor (
          tag?: string,
          data?: VNodeData,
          children?: ?Array<VNode>,
          text?: string,
          elm?: Node,
          context?: Component,
          componentOptions?: VNodeComponentOptions,
          asyncFactory?: Function
        ) {
          this.tag = tag
          this.data = data
          this.children = children
          this.text = text
          this.elm = elm
          this.ns = undefined
          this.context = context
          this.fnContext = undefined
          this.fnOptions = undefined
          this.fnScopeId = undefined
          this.key = data && data.key
          this.componentOptions = componentOptions
          this.componentInstance = undefined
          this.parent = undefined
          this.raw = false
          this.isStatic = false
          this.isRootInsert = true
          this.isComment = false
          this.isCloned = false
          this.isOnce = false
          this.asyncFactory = asyncFactory
          this.asyncMeta = undefined
          this.isAsyncPlaceholder = false
        }
      }

      这里千万不要因为 VNode 的这么属性而被吓到,或者咬紧牙去摸清楚每个属性的意义,其实,我们主要了解其几个核心的关键属性就差不多了,例如:

      • tag 属性即这个vnode的标签属性
      • data 属性包含了最后渲染成真实dom节点后,节点上的classattributestyle以及绑定的事件
      • children 属性是vnode的子节点
      • text 属性是文本属性
      • elm 属性为这个vnode对应的真实dom节点
      • key 属性是vnode的标记,在diff过程中可以提高diff的效率

      源码创建VNode过程:

      • 初始化Vue

        我们在实例化一个 Vue 实例 , 也就是 new Vue() 时,实际上是执行 src/core/instance/index.js 中定义的函数

        function Vue (options) {
          if (process.env.NODE_ENV !== 'production' &&
            !(this instanceof Vue)
          ) {
            warn('Vue is a constructor and should be called with the `new` keyword')
          }
          this._init(options)
        }

        通过查看 Vuefunction,我们知道 Vue 只能通过 new 关键字初始化,然后调用 this._init 方法,该方法在 src/core/instance/init.js 中定义。

          Vue.prototype._init = function (options?: Object) {
            const vm: Component = this
        
            // 省略一系列其它初始化的代码
        
            if (vm.$options.el) {
              console.log('vm.$options.el:',vm.$options.el);
              vm.$mount(vm.$options.el)
            }
          }
      • Vue 实例挂载

        Vue 中是通过 $mount 实例方法去挂载 dom 的,下面我们通过分析 compiler 版本的 mount 实现,相关源码在目录 src/platforms/web/entry-runtime-with-compiler.js 文件中定义。

        const mount = Vue.prototype.$mount
        Vue.prototype.$mount = function (
          el?: string | Element,
          hydrating?: boolean
        ): Component {
          el = el && query(el)
        
           // 省略一系列初始化以及逻辑判断代码  
        
          return mount.call(this, el, hydrating)
        }

        我们发现最终还是调用用原先原型上的 $mount 方法挂载 ,原先原型上的 $mount 方法在 src/platforms/web/runtime/index.js 中定义 。

        Vue.prototype.$mount = function (
          el?: string | Element,
          hydrating?: boolean
        ): Component {
          el = el && inBrowser ? query(el) : undefined
          return mountComponent(this, el, hydrating)
        }

        我们发现$mount 方法实际上会去调用 mountComponent 方法,这个方法定义在 src/core/instance/lifecycle.js 文件中

        export function mountComponent (
          vm: Component,
          el: ?Element,
          hydrating?: boolean
        ): Component {
          vm.$el = el
          // 省略一系列其它代码
          let updateComponent
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
            updateComponent = () => {
              // 生成虚拟 vnode   
              const vnode = vm._render()
              // 更新 DOM
              vm._update(vnode, hydrating)
        
            }
          } else {
            updateComponent = () => {
              vm._update(vm._render(), hydrating)
            }
          }
        
          // 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法  
          new Watcher(vm, updateComponent, noop, {
            before () {
              if (vm._isMounted && !vm._isDestroyed) {
                callHook(vm, 'beforeUpdate')
              }
            }
          }, true /* isRenderWatcher */)
          hydrating = false
        
          return vm
        }
        

        从上面的代码可以看到,mountComponent 核心就是先实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法,在此方法中调用 vm._render 方法先生成虚拟 Node,最终调用 vm._update 更新 DOM

      • 创建虚拟DOM

        Vue_render 方法是实例的一个私有方法,它用来把实例渲染成一个虚拟 Node。它的定义在 src/core/instance/render.js 文件中:

         Vue.prototype._render = function (): VNode {
            const vm: Component = this
            const { render, _parentVnode } = vm.$options
            let vnode
            try {
              // 省略一系列代码  
              currentRenderingInstance = vm
              // 调用 createElement 方法来返回 vnode
              vnode = render.call(vm._renderProxy, vm.$createElement)
            } catch (e) {
              handleError(e, vm, `render`){}
            }
            // set parent
            vnode.parent = _parentVnode
            console.log("vnode...:",vnode);
            return vnode
          }
        

        Vue.js 利用 _createElement 方法创建 VNode,它定义在 src/core/vdom/create-elemenet.js 中:

        export function _createElement (
          context: Component,
          tag?: string | Class<Component> | Function | Object,
          data?: VNodeData,
          children?: any,
          normalizationType?: number
        ): VNode | Array<VNode> {
        
          // 省略一系列非主线代码
        
          if (normalizationType === ALWAYS_NORMALIZE) {
            // 场景是 render 函数不是编译生成的
            children = normalizeChildren(children)
          } else if (normalizationType === SIMPLE_NORMALIZE) {
            // 场景是 render 函数是编译生成的
            children = simpleNormalizeChildren(children)
          }
          let vnode, ns
          if (typeof tag === 'string') {
            let Ctor
            ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
            if (config.isReservedTag(tag)) {
              // 创建虚拟 vnode
              vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
              )
            } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
              // component
              vnode = createComponent(Ctor, data, context, children, tag)
            } else {
              vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
              )
            }
          } else {
            vnode = createComponent(tag, data, context, children)
          }
          if (Array.isArray(vnode)) {
            return vnode
          } else if (isDef(vnode)) {
            if (isDef(ns)) applyNS(vnode, ns)
            if (isDef(data)) registerDeepBindings(data)
            return vnode
          } else {
            return createEmptyVNode()
          }
        }
        
    3. 实例查看

      为了更直观查看我们平时写的 Vue 代码如何用 VNode 类来表示,我们通过一个实例的转换进行更深刻了解。

        var app = new Vue({
          el: '#app',
          render: function (createElement) {
            return createElement('div', {
              attrs: {
                id: 'app',
                class: "class_box"
              },
            }, this.message)
          },
          data: {
            message: 'Hello Vue!'
          }
        })
      
      
  ```

  ![image-20200714235052030](img/image-20200714235052030.png)
  1. vue源码调用diff逻辑

    Vue.js 源码实例化了一个 watcher,这个 ~ 被添加到了在模板当中所绑定变量的依赖当中,一旦 model 中的响应式的数据发生了变化,这些响应式的数据所维护的 dep 数组便会调用 dep.notify() 方法完成所有依赖遍历执行的工作,这包括视图的更新,即 updateComponent 方法的调用。watcherupdateComponent方法定义在 src/core/instance/lifecycle.js 文件中 。

    export function mountComponent (
      vm: Component,
      el: ?Element,
      hydrating?: boolean
    ): Component {
      vm.$el = el
      // 省略一系列其它代码
      let updateComponent
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        updateComponent = () => {
          // 生成虚拟 vnode   
          const vnode = vm._render()
          // 更新 DOM
          vm._update(vnode, hydrating)
    
        }
      } else {
        updateComponent = () => {
          vm._update(vm._render(), hydrating)
        }
      }
    
      // 实例化一个渲染Watcher,在它的回调函数中会调用 updateComponent 方法  
      new Watcher(vm, updateComponent, noop, {
        before () {
          if (vm._isMounted && !vm._isDestroyed) {
            callHook(vm, 'beforeUpdate')
          }
        }
      }, true /* isRenderWatcher */)
      hydrating = false
    
      return vm
    }
    

    完成视图的更新工作事实上就是调用了vm._update方法,这个方法接收的第一个参数是刚生成的Vnode,调用的vm._update方法定义在 src/core/instance/lifecycle.js中。

     Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
        const vm: Component = this
        const prevEl = vm.$el
        const prevVnode = vm._vnode
        const restoreActiveInstance = setActiveInstance(vm)
        vm._vnode = vnode
        if (!prevVnode) {
          // 第一个参数为真实的node节点,则为初始化
          vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
        } else {
          // 如果需要diff的prevVnode存在,那么对prevVnode和vnode进行diff
          vm.$el = vm.__patch__(prevVnode, vnode)
        }
        restoreActiveInstance()
        // update __vue__ reference
        if (prevEl) {
          prevEl.__vue__ = null
        }
        if (vm.$el) {
          vm.$el.__vue__ = vm
        }
        // if parent is an HOC, update its $el as well
        if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
          vm.$parent.$el = vm.$el
        }
      }
    

    在这个方法当中最为关键的就是 vm.__patch__ 方法,这也是整个 virtual-dom 当中最为核心的方法,主要完成了prevVnodevnodediff 过程并根据需要操作的 vdom 节点打 patch,最后生成新的真实 dom 节点并完成视图的更新工作。

    接下来,让我们看下 vm.__patch__的逻辑过程, vm.__patch__ 方法定义在 src/core/vdom/patch.js 中。

    function patch (oldVnode, vnode, hydrating, removeOnly) {
        ......
        if (isUndef(oldVnode)) {
          // 当oldVnode不存在时,创建新的节点
          isInitialPatch = true
          createElm(vnode, insertedVnodeQueue)
        } else {
          // 对oldVnode和vnode进行diff,并对oldVnode打patch  
          const isRealElement = isDef(oldVnode.nodeType)
          if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // patch existing root node
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
          } 
        ......
      }
    }
    

    patch 方法中,我们看到会分为两种情况,一种是当 oldVnode 不存在时,会创建新的节点;另一种则是已经存在 oldVnode ,那么会对 oldVnodevnode 进行 diffpatch 的过程。其中 patch 过程中会调用 sameVnode 方法来对对传入的2个 vnode 进行基本属性的比较,只有当基本属性相同的情况下才认为这个2个vnode 只是局部发生了更新,然后才会对这2个 vnode 进行 diff,如果2个 vnode 的基本属性存在不一致的情况,那么就会直接跳过 diff 的过程,进而依据 vnode 新建一个真实的 dom,同时删除老的 dom节点。

    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)
      )
    }
    
    
  ```

  ```js
    function patchVnode (oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
      ...... 
      const elm = vnode.elm = oldVnode.elm
      const oldCh = oldVnode.children
      const ch = vnode.children
      // 如果vnode没有文本节点
      if (isUndef(vnode.text)) {
        // 如果oldVnode的children属性存在且vnode的children属性也存在  
        if (isDef(oldCh) && isDef(ch)) {
          // updateChildren,对子节点进行diff  
          if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        } else if (isDef(ch)) {
          if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(ch)
          }
          // 如果oldVnode的text存在,那么首先清空text的内容,然后将vnode的children添加进去  
          if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        } else if (isDef(oldCh)) {
          // 删除elm下的oldchildren
          removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (isDef(oldVnode.text)) {
          // oldVnode有子节点,而vnode没有,那么就清空这个节点  
          nodeOps.setTextContent(elm, '')
        }
      } else if (oldVnode.text !== vnode.text) {
        // 如果oldVnode和vnode文本属性不同,那么直接更新真是dom节点的文本元素
        nodeOps.setTextContent(elm, vnode.text)
      }
      ......
    }
  ```

  从以上代码得知,

  `diff` 过程中又分了好几种情况,`oldCh` 为 `oldVnode`的子节点,`ch` 为 `Vnode`的子节点:

  - 首先进行文本节点的判断,若 `oldVnode.text !== vnode.text`,那么就会直接进行文本节点的替换;
  - 在`vnode`  没有文本节点的情况下,进入子节点的 `diff`;
  - 当 `oldCh` 和 `ch` 都存在且不相同的情况下,调用 `updateChildren` 对子节点进行 `diff`;
  - 若 `oldCh`不存在,`ch` 存在,首先清空 `oldVnode` 的文本节点,同时调用 `addVnodes` 方法将 `ch` 添加到`elm`真实 `dom` 节点当中;
  - 若 `oldCh`存在,`ch`不存在,则删除 `elm` 真实节点下的 `oldCh` 子节点;
  - 若 `oldVnode` 有文本节点,而 `vnode` 没有,那么就清空这个文本节点。
  1. 子节点的流程

    1. 无key的diff过程

      首先从第一个节点开始比较,不管是 oldCh 还是 newCh 的起始或者终止节点都不存在 sameVnode ,同时节点属性中是不带 key标记的,因此第一轮的 diff 完后,newChstartVnode 被添加到 oldStartVnode的前面,同时 newStartIndex前移一位;

      image-20200715105635779

      第二轮的 diff中,满足 sameVnode(oldStartVnode, newStartVnode),因此对这2个 vnode 进行diff,最后将 patch 打到 oldStartVnode 上,同时 oldStartVnodenewStartIndex 都向前移动一位 ;

      image-20200715105734676

      第三轮的 diff 中,满足 sameVnode(oldEndVnode, newStartVnode),那么首先对 oldEndVnodenewStartVnode 进行 diff,并对 oldEndVnode进行 patch,并完成 oldEndVnode 移位的操作,最后newStartIndex前移一位,oldStartVnode 后移一位;

      image-20200715105921588

      第四轮的 diff中,过程同步骤3;

      image-20200715105947237

      第五轮的 diff 中,同过程1;

      image-20200715110000580

      遍历的过程结束后,newStartIdx > newEndIdx,说明此时 oldCh 存在多余的节点,那么最后就需要将这些多余的节点删除。

      image-20200715110042409

最后更新: 2020年09月21日 15:48

原始链接: http://www.kweku.top/2020/07/14/09.vue-vnode/