目录

虚拟dom是什么?它的引入解决了什么问题

虚拟DOM就是使用js的object模拟真实的dom,当状态发生变化,更新之前做diff,达到最少操作dom的效果

<body>
    <!-- 真实dom -->
    <div id="app">
        <p>节点1</p>
    </div>

    <script>
    <!--虚拟dom -->
    const vnode = {
        tag: 'div',
        data: {id: 'app'},
        children: {
            tag: 'p',
            data: {},
            children: '节点1'
        }
    } 
    </script>
</body>

我们知道访问DOM是非常昂贵的,会造成相当多得性能浪费,所以我们试想,当某个状态发生变化时,只更新与这个状态相关联的DOM节点。虚拟DOM就是解决方案之一。

三大主流框架Vue.js、angular.js、React.js中,Angular使用脏值检测,React使用虚拟DOM,Vue.js1.0通过细粒度绑定,2.0开始引入虚拟DOM。

所以虚拟DOM旨在避免不必要的DOM操作

vue为什么引入虚拟DOM?

  • vue1.0响应式粒度太细,Object.defineProperty()每个数据的修改都会通知watcher,进而通知dom去改变,对大型项目来说是一个噩梦!内存开销非常大。
  • vue2.0引入虚拟dom,通过diff之后再通知dom去改变,相对于1.0响应式的级别修改了,watcher只到组件,组件内部使用虚拟dom

vue中虚拟DOM干了啥?

虚拟dom没有想象中的那么复杂,它只做两件事:

  1. 提供与真实dom节点对应的虚拟节点vnode
  2. 状态发生变化时,对比新旧两个vnode,更新视图。

vue中的虚拟DOM如何创建?

vue-loader 允许我们直接在template中编写模板字符串,将内容提取出来给vue-template-compiler,Vue.js通过编译器将模板转换成渲染函数中的内容,执行这个函数可以得到一个vnode树,使用这个虚拟节点树就可以渲染页面了。
例如:

<template>
    <img src="../image.png">
</template>

将会被编译成:

createElement('img', {
  attrs: {
    src: require('../image.png')
  }
})

注意,实际过程中编译器最终生成的是一个代码字符串,类似with(this){return _c('div',{attr:{"id":"el"}},[_v("hello"+ _s(name))])},这个代码字符串被包装在渲染函数执行,生成一份Vnode。

类似的,render函数中的h就相当于上面提到的createElement()函数

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

具体的Vnode的生成过程可以参考 Vue源码之模板编译原理

vue中的Vnode类

vnode类型有:

  • 注释节点
  • 文本节点
  • 元素节点
  • 组件节点
  • 函数是组件
  • 克隆节点

不同类型的vnode表示不同类型的真实DOM元素。

var VNode = function VNode (
    tag,
    data,
    children,
    text,
    elm,
    context,
    componentOptions,
    asyncFactory
  ) {
    this.tag = tag;
    this.data = data;
    this.children = children;
    this.text = text;
    this.elm = elm;
    this.ns = undefined;
    this.context = context; // 当前组件的Vue实例
    this.fnContext = undefined;// 函数式组件实例
    this.fnOptions = undefined;// 函数式组件选项参数
    this.fnScopeId = undefined;
    this.key = data && data.key;
    this.componentOptions = componentOptions; // 组件节点的选项参数,propsData、tag、children
    this.componentInstance = undefined;// 组件实例
    this.parent = undefined;
    this.raw = false;
    this.isStatic = false;
    this.isRootInsert = true;
    this.isComment = false; // true 代表注释节点
    this.isCloned = false; // true 代表克隆节点
    this.isOnce = false;
    this.asyncFactory = asyncFactory;
    this.asyncMeta = undefined;
    this.isAsyncPlaceholder = false;
  };

patch

patch对比新旧vnode之间的差异,目的其实是修改DOM节点。
对DOM进行修改需要做三件事:

  • 创建新增的节点
    1. oldVnode不存在而vnode存在时
    2. 当vnode和oldVnode完全不是同一个节点时
  • 删除已废弃的节点
    1. vnode 中不存在的节点
  • 修改需要更新的节点
    1. vnode和oldVnode是同一个节点,进行详细对比

事实上,只有三种类型的节点会被创建并插入到DOM中:元素、文本、注释。

判断节点类型:

  1. 判断vnode是否为元素节点,只需要看它是否具有tag属性。
  2. 不具有tag属性则是文本或者注释节点,根据vnode的isComment属性是true可以判断vnode是注释节点,反之是文本节点。
创建新增节点

我们在patch的过程中,发现有变化(如:需要新增、删除或者修改),则根据最新的vnode,比对oldVnode,对真实DOM进行更新操作。要知道,patch的目的就是修改DOM

当前环境下,为创建不同类型真实DOM节点定义了不同的方法

  • createElement ——元素
  • createTextNode ——文本
  • createComment ——注释

判断vnode的类型,并使用各自的方法创建真实的DOM节点,最后将DOM节点插入到指定的父节点中(parentNode.appendChild)。

元素通常还有子节点,创建子节点是一个递归的过程,vnode的children属性保存了所有子vnode。

删除废弃的节点

removeVnodes用于删除vnode数组中从startIdx到endIdx的一组节点。这里精简了源码

function removeVnodes (vnodes, startIdx, endIdx) {
      for (; startIdx <= endIdx; ++startIdx) {
        var ch = vnodes[startIdx];
        if (isDef(ch)) {
           removeNode(ch.elm);
        }
      }
}

var nodeOps = {
    removeChild: removeChild,
    appendChild: appendChild,
    parentNode: parentNode
 }
function removeNode (el) {
      var parent = nodeOps.parentNode(el);
      // element may have already been removed due to v-html / v-text
      if (isDef(parent)) {
        nodeOps.removeChild(parent, el);
      }
    }

修改需要更新的节点

如果节点是静态节点,则直接跳过更新步骤。例如这样的
<p>我是静态节点,不需要发生变化</p>

我们以新的虚拟节点vnode为标准,分以下几种情况,(注意前提是新旧vnode是相同的节点类型或者标签一样):

  1. vnode有文本属性,则无论oldVnode子节点是什么,直接setTextContent方法更新文本
  2. vnode是无children的元素,则oldVnode中无论有子节点还是文本,直接都删除即可。
  3. vnode是有children的元素
    1. oldVnode没有children,那么oldVnode要么是空标签,要么有文本子节点,则直接清空,并将vnode中的children直接插入到视图中。
    2. oldVnode有children,则需要详细的对比,可能会新增、删除、或者移动。

在源码中,真实的过程如下图所示:

更新节点的具体实现过程

这里主要讨论最后一种,当newChildren和oldChildren都存在时的情况。

更新策略

对比两个子节点列表,首先要循环newChildren,每循环到一个新的子节点,就去oldChildren中找到相同的旧子节点,

  • 找不到,则新增子节点,并插入到oldChildren中所有未处理节点对应的DOM前面。

看下面这张图:

新增子节点

  • 找到,并且位置相同。直接更新DOM
  • 找到,但是位置变了。这时需要移动子节点(Node.insertBefore()),只需要将节点移动到所有未处理节点对应DOM的最前面。

看下面这张图

移动子节点

当newChildren循环结束后,如果oldChildren中还剩下没有处理的节点,那就删除这些节点即可。

vue中虚拟DOM的优化

新旧节点都是拥有多个子节点的节点时,这里的diff操作,是框架之间优化点的区别之处,也是影响性能的关键之处。

具体思路

最直接的对比差异的方法时嵌套两层循环,但是针对某些简单的数据操作,可能不需要循环就知道哪个节点被修改了。

Vue的优化策略是:
尝试几种可能的变化,快速查找差异,减少不必要的循环。

主要运用了4中查找方式,并按从上到下的顺序查找:

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前
  4. 新前与旧后
  • 新前:newChildren中所有未处理的第一个节点
  • 新后:newChildren中所有未处理的最后一个节点
  • 旧前:oldChildren中所有未处理的第一个节点
  • 旧后:oldChildren中所有未处理的最后一个节点

你会发现,循环对比都是针对“未处理节点”的

通过定义四个变量oldStartIdx、oldEndIdx、newStartIdx、newEndIdx来指向oldChildren和newChildren的开始位置下标和结束位置下标。每处理一个节点,就将下标向前或向后移动一个位置。

那么,通过如下条件可以确保循环体内的节点都是未处理的。

while(oldStartIdx <= oldChildren && newStartIdx <= newEndIdx){
    //...
}

只要oldChildren或者newChildren中的一个循环完毕,循环就结束。

  • 如果oldChildren先循环结束,那么newChildren中还剩的节点都是需要新增的节点,直接插入DOM就行。
  • 如果newChildren先循环结束,那么oldChildren中还剩的节点都是需要删除的节点,直接从DOM中移除。

由此可以看出,这种优化手段减少了循环次数,提升了性能。

具体实现

这里将源码精简,主要知晓其实现的过程,细节可以直接看源码。

 function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {
    // ... 变量定义
    
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (isUndef(oldEndVnode)) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // 新前与旧前是同一个节点
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // 新后与旧后是同一个节点
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) { 
            // 新后与旧前是同一个节点
            // 将节点移动到所有未处理节点的最后面,即nodeOps.nextSibling(oldEndVnode.elm的前面
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
            nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) { 
            // 新前与旧后是同一个节点
            // 将节点移动到所有未处理节点的前面,也就是oldStartVnode.elm的前面
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
            nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            //如果以上四种情况都不是,那么根据key进行查找,所以我们在列表中设置key是一种最佳实战
            if (isUndef(oldKeyToIdx)) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
            }
            idxInOld = isDef(newStartVnode.key)
                        ? oldKeyToIdx[newStartVnode.key]
                        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
            if (isUndef(idxInOld)) {  
                // 1.如果未找到相同key,则新增子节点
                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
            } else {
                // 2.找到后,对比是否为相同类型的节点或标签
                vnodeToMove = oldCh[idxInOld];
                if (sameVnode(vnodeToMove, newStartVnode)) {
                    // 相同则更新,并对比位置进行移动,移动到所有未处理节点DOM的前面
                    // 为了防止重复处理同一个节点,将oldChildren中对应的该节点设置为undefined,用来标记该节点已处理
                    patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
                    oldCh[idxInOld] = undefined;
                    canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
                } else {
                    // 相同key,但是不同类型或者标签,则直接新增节点
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx);
                }
            }
            newStartVnode = newCh[++newStartIdx];
        }
    }
    // 循环结束,创建newChildren中剩余的节点,或者删除oldChildren中剩余的节点
    if (oldStartIdx > oldEndIdx) {
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
}

最后,简单梳理下这四种查找的原理:

新前与旧前

通过对比新前和旧前两个节点,

  • 如果相同,直接进行更新操作。将新旧start位置下标向后移动一个位置,进入下一个循环
  • 如果不同,执行新后与旧后对比
新后与旧后

通过对比新后和旧后两个节点

  • 如果相同,直接进行更新操作。将新旧end位置下标向前移动一个位置,进入下一个循环
  • 如果不同,执行新后与旧前对比
新后与旧前

通过对比新后和旧前两个节点

  • 如果相同,直接进行更新操作。并将旧前对应的DOM移动到所有未处理节点最后面(可以根据oldEndIdx来找到最后一个未处理节点对应的DOM),将新后位置下标向前移动一个位置,旧前位置下标向后移动一个位置,进入下一个循环
  • 如果不同,执行新前与旧后对比

向右移动

新前与旧后

通过对比新前与旧后前两个节点

  • 如果相同,直接进行更新操作。并将旧后对应的DOM移动到所有未处理节点最前面(可以根据oldStartIdx来找到最前一个未处理节点对应的DOM),将旧后位置下标向前移动一个位置,新前位置下标向后移动一个位置,进入下一个循环
  • 如果不同,执行key比较

向左移动

Logo

前往低代码交流专区

更多推荐