编程语言
首页 > 编程语言> > Vue 之虚拟 DOM 及 Diff 算法详解

Vue 之虚拟 DOM 及 Diff 算法详解

作者:互联网

Virtual DOM

1、背景信息总结

  1. DOM 操作非常耗时耗性能(且现代前端框架要求不手动操作 DOM,可以大大提高开发效率)---- 因为会引起页面的回流或重绘
  2. JS 的执行很快(浏览器V8引擎的出现更加加快了JS的计算速度)
  3. 实现更好的跨平台(如浏览器端渲染、 Node.js 实现 SSR 服务端渲染、安卓/IOS、小程序等)

推出 ==> Virtual DOM(虚拟DOM)

将真实 DOM 抽象成一个以 JS 对象为节点的虚拟 DOM 树,DOM 的变化过程中不需要操作真实DOM,只需要操作 JS 对象(虚拟 DOM),再与真实 DOM比较差异,最后只对变化的 DOM 进行操作来更新真实 DOM,减少了 DOM 操作,大大提升了性能。

 2、VDOM(Virtual DOM简写)是什么?

VDOM是个啥?:用 JS 对象模拟的 DOM 结构(树形结构)

一个用JS 对象表示 DOM 结构的简单例子:

    <!-- DOM 结构 -->
    <div id="cc" class="bigCc">
        <p>DOM</p>
        <ul style="font-size: 24px">
            <li>huohuoit</li>
        </ul>
    </div>
    <!-- JS 对象表示 -->
    {
        tag: 'div',
        data: {
            id: 'cc',
            className: 'bigCc'
        },
        children: [
            {
                tag: 'p'
                text: 'DOM'
            },
            {
                tag: 'ul'
                data: { style: 'font-size: 20px' }
                children: [
                    {
                        tag: 'li',
                        text: 'huohuoit'
                    }
                ]
            }
        ]
    }

它的优势

3、实现原理

用 JS对象 模拟 DOM 结构,然后比较出差异对象,再去操作 DOM

来看看整体的流程:

  1. 虚拟 DOM 发生变化时,比较两颗 DOM 树的差异,生成差异对象
  2. 根据差异对象更新真实 DOM
  3. 把虚拟 DOM 转换成真实 DOM 插入页面中
  4. 用 JS 对象模拟真实 DOM 结构

4、Vue 对 VDOM  的源码实现

1、描述

关注:使用 VNode 这个 Class 来描述 VDOM(VNode 由 Vue.js 的 _createElement 方法创建)

这里先关注一些主要描述点:

再看看 VNode 的创建过程:

  1. 初始化 Vue:new Vue( ),调用this._init方法
  2. Vue 实例挂载:通过$mount 方法挂载 DOM => 调用mountComponent 方法(实例化一个渲染 Watcher,并回调updateComponent 方法) => 调用mountComponent 方法(实例化一个渲染 Watcher,并回调updateComponent 方法)在updateComponent 方法中调用 vm._render 方法先生成VNode,最终调用 vm._update 更新 DOM
  3. 创建VNode:使用_render 方法(_render 方法把实例渲染成VNode,这里调用了_createElement 方法(即 h 函数))

2、使用 h 函数创建Virtual DOM Tree

3、diff 算法(性能关键):比较新旧Virtual DOM Tree找出差异并更新

4、createElm 函数将 VNode 转化为真实 DOM

Diff 算法

简述理解

理解:比对新旧 VDOM 的变化,然后将变化的部分更新到视图(真实 DOM)上           一边比较新旧VDOM,一边改变真实 Dom节点

diff 的比较方式

比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较(时间复杂度O(n))

diff 流程理解(Vue2.0源码)

1、开始

数据发生改变时,被订阅者的 setter 会调用Dep.notify通知所有订阅者 Watcher,订阅者调用 patch 给真实 DOM 打补丁,更新视图

使用patch (oldVNode, VNode) 比较新旧节点差异:

function patch (oldVnode, vnode) {
    // ...
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // ...
    return vnode
}

如何比较呢?

// 比较函数
function sameVnode (a, b) {
    return (
        a.key === b.key &&  // key值
        a.tag === b.tag &&  // 标签名
        a.isComment === b.isComment &&  // 是否为注释节点
        // 是否都定义了data,data包含一些具体信息,例如onclick , style
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b) // 当标签是<input>的时候,type必须相同
    )
}

针对比较出的结果分别做处理:

function patchVnode(oldVnode, vnode) {
    // 当前oldVnode 和 Vnode对应的真实 DOM 节点
    const el = vnode.el = oldVnode.el;
    let i, oldCh = oldVnode.children, ch = vnode.children;
    // 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return
    if (oldVnode === vnode) return;
    // 都有文本节点且不相等,那么将el的文本节点设置为Vnode的文本节点
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text);
    } else {
        updateEle(el, vnode, oldVnode);
        if (oldCh && ch && oldCh !== ch) {
        // 两者都有子节点,则执行updateChildren函数比较子节点
            updateChildren(el, oldCh, ch);
        } else if (ch) {
        // oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
            createEle(vnode);
        } else if (oldCh) {
        // oldVnode有子节点而Vnode没有,则删除el的子节点
            api.removeChildren(el);
        }
    }
}

2、updateChildren 函数(diff 算法核心)

注意到 updateChildren 函数 ,来看看它做了什么

它是怎么做的?

遍历Vnode的子节点vCh(源码中为参数 newCh)和oldVnode的子节点oldCh,进行比较和更新

先来看看相关源码(Vue2.0)

    // Vue2.0 diff 算法核心的理解
    function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
        let oldStartIdx = 0
        let newStartIdx = 0
        let oldEndIdx = oldCh.length - 1    // 以下简化一下
        let oldStartVnode = oldCh[0]        // oldS:子节点 oldCh 的头部指针
        let oldEndVnode = oldCh[oldEndIdx]  // oldE:子节点 oldCh 的尾部指针
        let newEndIdx = newCh.length - 1
        let newStartVnode = newCh[0]        // S:子节点 vCh 的头部指针
        let newEndVnode = newCh[newEndIdx]  // E:子节点 vCh 的尾部指针
        let oldKeyToIdx, idxInOld, vnodeToMove, refElm

        // removeOnly is a special flag used only by <transition-group>
        // to ensure removed elements stay in correct relative positions
        // during leaving transitions
        const canMove = !removeOnly

        if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(newCh)
        }
        // 遍历 Vnode 的子节点 vCh(这里记为 newCh 方便理解比较) 和 oldVnode 的子节点 oldCh,进行比较和更新
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  // 循环条件:oldS <= oldE && S <= E
            // 1、检查 oldS oldE 非空
            if (isUndef(oldStartVnode)) {
                oldStartVnode = oldCh[++oldStartIdx] // oldS 向左移动
            } else if (isUndef(oldEndVnode)) {       // oldE 向右移动
                oldEndVnode = oldCh[--oldEndIdx]
            }
            // 2、两两比较 oldS、oldE、S、E(4种情况)
            //   2.1 oldS = S
            else if (sameVnode(oldStartVnode, newStartVnode)) {
                patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)    // 执行 patch,不需要移动 dom
                oldStartVnode = oldCh[++oldStartIdx]    // 匹配上的两个指针都向中间移动(向左)
                newStartVnode = newCh[++newStartIdx]
            }
            //   2.2 oldE = E
            else if (sameVnode(oldEndVnode, newEndVnode)) {
                patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)    // 执行 patch,不需要移动 dom
                oldEndVnode = oldCh[--oldEndIdx]    // 匹配上的两个指针向中间移动(向右)
                newEndVnode = newCh[--newEndIdx]
            }
            //   2.3 oldS = E
            else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right    
                patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
                // 把获得更新后的 (oldS/E) 的 DOM 右移,移动到oldE 对应的 DOM 的右边
                // 注意一下这里的 DOM 移动
                // 2.3.1 oldS = E,显然是 vnode 右移了
                // 2.3.2 第一轮 while 循环:移到 oldE(oldEndVnode.elm)右边就是最右边
                // 2.3.3 非第一轮 while 循环:比较过程是两头向中间移动,两头比较过的位置(也即真实DOM位置)是正确的,这次 DOM 是移动到 oldE(oldEndVnode.elm)右边
                canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
                oldStartVnode = oldCh[++oldStartIdx]    // 匹配上的两个指针向中间移动
                newEndVnode = newCh[--newEndIdx]
            }
            //   2.4 oldE = S
            else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left    
                patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
                // 同上,左移更新后的 DOM
                canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            }
            // 3、上面四种情况都不存在,通过 key 值查找对应 vnode 进行对比
            else {
                // 3.1 oldKeyToIdx不存在,创建 oldCh 中 vnode 的 key 到 index 的映射(即根据oldCh的key生成一张hash表),方便我们之后通过 key 去拿下标
                if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
                // 3.2 拿到下标,用 S 的 key 与 hash表做匹配
                idxInOld = isDef(newStartVnode.key)
                    ? oldKeyToIdx[newStartVnode.key]
                    : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
                    // 3.2.1 下标不存在(匹配失败),说明 S(newStartVnode) 是原来没有(新)的 vnode
                if (isUndef(idxInOld)) {
                    // 为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
                }
                    // 3.2.2 下标存在(匹配成功),说明 oldCh 中有相同 key 的 vnode
                else {
                    vnodeToMove = oldCh[idxInOld]
                        // (1) type 相同(且key相同),说明是相同的 vnode,执行 patch
                    if (sameVnode(vnodeToMove, newStartVnode)) {    
                        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
                        oldCh[idxInOld] = undefined     // 被匹配oldCh中的节点置为 undefined
                        canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)  // 直接将S生成新的节点插入真实DOM
                    } else {
                        // (2) type 不同(且key相同),作为新元素处理。为 S(newStartVnode) 创建 DOM节点 并插入到 oldS(oldStartVnode.elm) 的前面
                        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
                    }
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
        // 比较的过程中,指针会往中间靠,直到达到匹配完成条件(即退出循环条件)oldS <= oldE && S <= E
        if (oldStartIdx > oldEndIdx) {  // oldS > oldE:oldCh 先遍历完
            refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
            addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 将多余的 vCh 节点根据 index 添加到 DOM 中去
        } else if (newStartIdx > newEndIdx) {   // S > E:vCh 先遍历完
            removeVnodes(oldCh, oldStartIdx, oldEndIdx) // 在真实 DOM 中将未遍历(区间为[oldS, oldE])的多余节点删掉
        }
    }

详细的比较更新过程

代码有点多,我们一步步来理解它...

首先在 updateChildren 函数的开始定义了一些辅助变量,这里我将几个重点变量名简化表示:

我们通过上面的头尾指针遍历 Vnode 的子节点 vCh(源码中为 newCh ) 和 oldVnode 的子节点 oldCh,进行两两比较和更新

这里头尾指针一共有 4 种比较匹配方式(只要其中两个能匹配上,真实Dom中的相应节点就移到Vnode相应的位置)

1、头尾对应匹配(执行 patch,不需要移动 dom)

2、头尾交叉匹配

这里关于 DOM 移动注意点(oldS = E为例)

比较的过程中,指针会往中间靠,直到达到匹配完成条件(退出循环)

如果不匹配以上 4 种比较方式,如果设置了key,就会用key进行比较

1、oldCh 和 vCh  都存在 key

根据oldCh的key生成一张hash表,用S的key与hash表做匹配。匹配成功就判断S和匹配的旧节点是否为相同节点。同时被匹配oldCh中的节点置为 undefined。

2、不存在 key

直接为 S 创建 DOM节点 并插入到 oldS的前面的位置

note1:为什么 v-for 的时候需要设置 key ?

如果没有key那么就只会做四种匹配,匹配不上就直接创建新节点插入 DOM ,这样指针中间有可复用的节点都不能被复用了

note2:Vue3.0对这里的优化

如果都没有key,新的节点会到旧的children队列里通过samenode对比剩下的节点看看是否可以有重用的节点。不是直接插入

 

标签:Vue,DOM,VNode,oldCh,oldVnode,Diff,vnode,节点
来源: https://blog.csdn.net/huohuoit/article/details/115520780