虚拟DOM与Diff算法

虚拟DOM与Diff算法

一、什么是DOM与虚拟DOM?

① DOM(文档对象模型):是针对HTML和XML文档的一个API(应用程序编程接口)。DOM描绘了一个层次化的节点树,允许开发人员添加、移除和修改。真实DOM结构如下:

<div class="box">
  <h3>我是一个标题</h3>
  <ul>
    <li>牛奶</li>
    <li>咖啡</li>
    <li>可乐</li>
  </ul>
</div>

② 虚拟DOM(Virtual DOM):是对DOM的js抽象表示,用js对象来描述DOM的层次,是以js对象作为基础的树。虚拟DOM结构如下:

{
  "sel": "div",
  "data": {
    "class": { "box": true }
  },
  "children": [
    {
      "sel": "h3",
      "data": {},
      "text": "我是一个标题"
    },
    {
      "sel": "ul",
      "data": {},
      "children": [
        { "sel": "li", "data": {}, "text": "牛奶" },
        { "sel": "li", "data": {}, "text": "咖啡" },
        { "sel": "li", "data": {}, "text": "可乐" }
      ]
    }
  ]
}

一个虚拟DOM有哪些属性?

{
  children: undefined, // 子节点,undefined表示没有子节点
  data: {}, // 属性样式等
  elm: undefined, // 该元素对应的真正的DOM节点,undefined表示它还没有上树
  key: undefined, // 节点唯一标识
  sel: 'div', // selector选择器 节点类型(现在它是一个div)
  text: '我是一个容器' // 文本内容
}

封装一个vnode.js用于构造虚拟DOM对象

// 函数的功能非常简单,就是把传入的5个参数组合成对象返回
export default function(sel, data, children, text, elm) {
    const key = data.key;
    return {
        // sel: 选择器,即标签名;data: 属性;children:子节点;text:文本内容;elm:真实的DOM;key:唯一标识
        sel, data, children, text, elm, key
    };
}

二、为什么要使用虚拟DOM

1、关于回流与重绘

① 回流:元素的大小或者位置发生改变(当页面布局发生改变的时候),触发了重新布局导致渲染树重新计算布局和渲染

  • 添加或删除可见的DOM元素;

  • 元素的位置发生变化;

  • 元素的尺寸发生变化、 内容发生变化(如文本变化或图片被另一个不同尺寸的图片所代替);

  • 页面一开始渲染的时候(无法避免);

    注意:因为回流是根据视口大小来计算元素的位置和大小的,所以浏览器窗口尺寸变化也会引起回流。

② 重绘:只改变自身样式,不会影响到其他元素,如元素样式的改变(但宽高、大小、位置不变)

注意:回流一定会触发重绘,而重绘不一定会回流

2、为什么使用虚拟DOM

①在虚拟DOM出现之前,前端开发者最常用的方式是使用原生的JS或JQuery直接操作真实DOM,列如使用document.getElementById()或者$()来咔咔一顿操作。但由于JavaScript需要借助浏览器提供的DOM接口才能操作真实DOM,所以操作真实DOM的代价往往是比较大的(这其中还涉及C++与JavaScript数据结构的转换问题)。DOM的变化会引发回流或重绘,从而降低页面渲染性能,所以一般来说,DOM操作越多,网页的性能就越差。

② 虚拟DOM的作用

  • 减少了DOM的操作,优化了性能:减少了对DOM的操作。页面中的数据和状态变化,都通过Vnode对比,只需要在比对完之后更新DOM,不需要频繁操作,提高了页面性能。虚拟DOM的频繁修改操作不会引发回流和重绘,它会一次性对比差异并修改真实DOM,最后才依次进行回流重绘,减少了真实DOM中多次回流重绘引起的性能损耗。
  • 跨平台性:Vnode本质是JS对象,所以不管Node还是浏览器环境,都可以操作;它抽象了原本的渲染过程,实现了跨平台的能力,它不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是小程序,也可以是各种GUI。

三、Diff 算法

1、为什么要使用Diff算法

① 对于 DOM 树来讲,如果仅仅是新增了一个标签或者修改了某一个标签的属性或内容。那么引起整个 DOM 树的重新渲染显然是对性能和资源的极大浪费。实际上,我们只需要找出新旧DOM树存在差异的地方,只针对这一块区域进行重新渲染就可以了。就像我们要装修房子,如果我们只是想要在客厅新添一座沙发或者将卧室的床换个位置,因为局部的一些小改动去将整个房子重新翻修这显然是不切实际的,我们通常的做法是在原先装修的基础上做微小的改动即可。所以 Diff 算法应运而生,diff 取自 different (不同的),Diff算法的作用,总结来说,就是:精细化比较,最小量更新

2、什么是 DOM-Diff 算法

DOM-Diff:diff是发生在虚拟 DOM 上的,将新虚拟 DOM 和老虚拟 DOM 进行 diff (精细化比较),算出应该如何最小量更新,最后反映到真实的 DOM 上的一个过程,这个过程其实是一个patch(补丁)过程,即指对旧的VNode进行修补,打补丁从而得到新的VNode。

① patch

  • patch核心:在研究patch的过程中,我们一定要把握住一个思想:即所谓旧的VNode(即oldVNode)其实就是数据变化之前视图所对应的虚拟DOM节点,而新的VNode(即newVNode)是数据变化之后将要渲染的新的视图所对应的虚拟DOM节点,所以我们要以生成的新的VNode为基准,去对比旧的oldVNode。

    总结:以新的VNode为基准,去改造旧的oldVNode使之成为跟新的VNode一样,这就是patch过程要干的事

  • patch的工作内容,概括起来无非三件事,即:

    • 创建节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
    • 删除节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
    • 更新节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode。

    关于DOM节点patch处理的整个过程我们可以看如下流程图:

流程图

② 上述流程图我们应该分为上下两部分来看,我们能清楚的知道diff 的处理过程可以分为两个方向,即新旧节点不是同一个节点时新旧节点是同一个节点时,我们先来分析一下流程图上半部分的内容,如下:

image-20210726171409863

新旧节点不是同一个节点时:处理简单复杂,暴力删除旧的、插入新的

新旧节点是同一个节点时:处理过程复杂,需要进行精细化比较

  • diff 算法的目的就是为了实现最小量更新,这其中key就扮演着很重要的角色 ,key 是每个节点的唯一标识,它会告诉 diff 算法,更改前后的节点是否为同一个 DOM 节点。
  • 只有是同一个虚拟节点,才进行精细化比较,否则直接就是暴力删除旧的、插入新的。延伸问题:如何定义同一个虚拟节点?答:选择器相同且 key 相同
  • 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是如果跨层了,那么 diff 算法也不会进行精细化比较。而是暴力删除旧的、然后插入新的。

③ 新旧节点是同一个节点,如何进行精细化比较?

​ 具体的几种可能性上方完整流程图的下半部分已经解释的很清楚,此处就不赘述了,接下来我们就来说说最复杂的情况:新老VNode都有children的情况,此情况下就需要我们去进行最优雅的diff了。

3、diff 算法的子节点更新策略
  • 四种命中查找(经典的diff算法优化策略):
    ① 新前与旧前
    ② 新后与旧后
    ③ 新后与旧前(此种命中,涉及移动节点,那么旧前指向的节点,移动到旧后之后
    ④ 新前与旧后(此种命中,涉及移动节点,那么旧后指向的节点,移动到旧前之前
  • 以上四种策略命中一种就不在进行余下的判断了
  • 如果都没有命中,就需要用循环来寻找了

双指针实现方式如下:

image-20210727104520116

四指针实现方式如下:

image-20210727104554214

注意:因为采用双指针实现的方式,会使得新增、删除、修改各种复杂的判断杂糅在一起(需要判断oldVnode 中有没有节点和newVnode是 same 的,还要再判断位置是否相同,是否要移动节点等),实现起来很麻烦,所以提出了四指针实现策略,提供了四种命中查找策略。

4、更新策略的实际运用

原则:当新前旧前两个指针对应的节点相同时,指针向下移,反之,当新后旧后两个指针对应的节点相同时,指针向上移。

① 新增

image-20210727110407920

② 删除一个

image-20210727110853013

③ 删除多个

image-20210727111126365

④ 复杂情况1

image-20210727113000974

⑤ 复杂情况2

image-20210727113053440

5、具体代码实现
import patchVnode from './patchVnode.js';
import createElement from './createElement.js';

// 判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key;
};

export default function updateChildren(parentElm, oldCh, newCh) {
    console.log('我是updateChildren');
    console.log(oldCh, newCh);

    // 旧前
    let oldStartIdx = 0;
    // 新前
    let newStartIdx = 0;
    // 旧后
    let oldEndIdx = oldCh.length - 1;
    // 新后
    let newEndIdx = newCh.length - 1;
    // 旧前节点
    let oldStartVnode = oldCh[0];
    // 旧后节点
    let oldEndVnode = oldCh[oldEndIdx];
    // 新前节点
    let newStartVnode = newCh[0];
    // 新后节点
    let newEndVnode = newCh[newEndIdx];

    let keyMap = null;

    // 开始大while了
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        console.log('★');
        // 首先不是判断①②③④命中,而是要略过已经加undefined标记的东西
        if (oldStartVnode == null || oldCh[oldStartIdx] == undefined) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (oldEndVnode == null || oldCh[oldEndIdx] == undefined) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (newStartVnode == null || newCh[newStartIdx] == undefined) {
            newStartVnode = newCh[++newStartIdx];
        } else if (newEndVnode == null || newCh[newEndIdx] == undefined) {
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
            // 新前和旧前
            console.log('①新前和旧前命中');
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            // 新后和旧后
            console.log('②新后和旧后命中');
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            // 新后和旧前
            console.log('③新后和旧前命中');
            patchVnode(oldStartVnode, newEndVnode);
            // 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
            // 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            // 新前和旧后
            console.log('④新前和旧后命中');
            patchVnode(oldEndVnode, newStartVnode);
            // 当④新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
            // 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            // 四种命中都没有命中
            // 制作keyMap一个映射对象,这样就不用每次都遍历老对象了。
            if (!keyMap) {
                keyMap = {};
                // 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    const key = oldCh[i].key;
                    if (key != undefined) {
                        keyMap[key] = i;
                    }
                }
            }
            console.log(keyMap);
            // 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号
            const idxInOld = keyMap[newStartVnode.key];
            console.log(idxInOld);
            if (idxInOld == undefined) {
                // 判断,如果idxInOld是undefined表示它是全新的项
                // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
            } else {
                // 如果不是undefined,不是全新的项,而是要移动
                const elmToMove = oldCh[idxInOld];
                patchVnode(elmToMove, newStartVnode);
                // 把这项设置为undefined,表示我已经处理完这项了
                oldCh[idxInOld] = undefined;
                // 移动,调用insertBefore也可以实现移动。
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
            }
            // 指针下移,只移动新的头
            newStartVnode = newCh[++newStartIdx];
        }
    }

    // 继续看看有没有剩余的。循环结束了start还是比old小
    if (newStartIdx <= newEndIdx) {
        console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStartIdx之前');
        // 遍历新的newCh,添加到老的没有处理的之前
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
            // newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
            parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
        }
    } else if (oldStartIdx <= oldEndIdx) {
        console.log('old还有剩余节点没有处理,要删除项');
        // 批量删除oldStart和oldEnd指针之间的项
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            if (oldCh[i]) {
                parentElm.removeChild(oldCh[i].elm);
            }
        }
    }
};
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值