虚拟DOM和diff算法

  • ***当前阶段的笔记 ***

「面向实习生阶段」https://www.aliyundrive.com/s/VTME123M4T9 提取码: 8s6v
点击链接保存,或者复制本段内容,打开「阿里云盘」APP ,无需下载极速在线查看,视频原画倍速播放。

文章目录

    • 一. 虚拟DOM
    • 二. diff算法
      • 实现虚拟DOM
      • 传统diff
      • Vue优化的diff策略
        • Vue虚拟节点
        • patch
        • patchVnode
        • updateChildren
  • 0. 文章结构预览
    • 0.1 虚拟[DOM](https://so.csdn.net/so/search?q=DOM&spm=1001.2101.3001.7020)如何被渲染函数(h函数)产生
    • 0.2 diff算法的原理
    • 0.3 虚拟DOM如何通过diff变成真正的DOM
  • 1. 介绍
    • 1.1 diff算法
    • 1.2 虚拟DOM
    • 1.3 关系——diff是发生在虚拟DOM上的
  • 2. snabbdom 简介 及 准备工作
    • 2.1 简介
    • 2.2 搭建初始环境
      • 1. 安装snabbdom
      • 2. 安装webpack5并配置
      • 3. 复制官方demo [Example](https://so.csdn.net/so/search?q=Example&spm=1001.2101.3001.7020)
  • 3. h函数的介绍与使用
    • 3.1 介绍
    • 3.2 使用h函数 创建虚拟节点
    • 3.3 使用patch函数 将虚拟节点上DOM树
    • 3.4 h函数嵌套使用,得到虚拟DOM树(重要)
  • 4. 手写h函数
    • vnode.js
    • h.js
    • index.js
    • 效果展示
  • 5. 手写diff算法准备
    • 5.1 diff算法原理
    • 5.2 手写diff预备
      • 5.2.1 源码中如何定义“同一个节点”
      • 5.2.2 源码中创建子节点,需要递归
  • 6. 手写diff——首次上DOM树`patch(container, myVnode1)`
    • 6.0 DOM 预备知识
      • 6.0.1 Node.insertBefore()
      • 6.0.2 Node.appendChild()
      • 6.0.3 Element.tagName
      • 6.0.4 Node.removeChild
      • 6.0.5 document.createElement
    • 6.1 patch.js
    • 6.2 createElement.js
    • 6.3 index.js
    • 6.4 展示效果
  • 7. 处理新旧节点 是同一个节点 的情况
    • 7.1 分析
    • 7.2 实现 新旧节点text不同情况
      • 7.2.1 patch.js
      • 7.2.3 patchVnode.js
      • 7.2.2 index.js
    • 7.3 展示
  • 8. 分析 diff算法 更新子节点操作(重要)
    • 8.1 循环四种命中查找
      • 1. 比较 ① 新前newStart 与 旧前oldStart
      • 2. 比较 ② 新后newEnd 与 旧后 oldEnd
      • 3. 比较 ③ 新后newEnd 与 旧前oldStart
        • 命中③复杂情况举例——倒序
      • 4. 比较 ④ 新前newStart 与 旧后oldEnd
      • 5. 四种都没命中 遍历oldVnode中的key
    • 8.2 循环结束
  • 9. 手写diff更新子节点操作
    • patchVnode.js
    • updateChildren.js

一. 虚拟DOM

为什么使用虚拟DOM?
起初我们在使用JS/JQuery时,不可避免的会大量操作DOM,而DOM的变化又会引发回流或重绘,从而降低页面渲染性能。那么怎样来减少对DOM的操作呢?此时虚拟DOM应用而生,所以虚拟DOM出现的主要目的就是为了减少频繁操作DOM而引起回流重绘所引发的性能问题的!

虚拟DOM的作用是什么?

  1. 兼容性好。因为Vnode本质是JS对象,所以不管Node还是浏览器环境,都可以操作;
  2. 减少了对Dom的操作。页面中的数据和状态变化,都通过Vnode对比,只需要在比对完之后更新DOM,不需要频繁操作,提高了页面性能;

虚拟DOM和真实DOM的区别?

说到这里,那么虚拟DOM和真实DOM的区别是什么呢?总结大概如下:

  • 虚拟DOM不会进行回流和重绘;
  • 真实DOM在频繁操作时引发的回流重绘导致性能很低;
  • 虚拟DOM频繁修改,然后一次性对比差异并修改真实DOM,最后进行依次回流重绘,减少了真实DOM中多次回流重绘引起的性能损耗;
  • 虚拟DOM有效降低大面积的重绘与排版,因为是和真实DOM对比,更新差异部分,所以只渲染局部;

总损耗 = 真实DOM增删改 + (多节点)回流/重绘; // 计算使用真实DOM的损耗
总损耗 = 虚拟DOM增删改 + (diff对比)真实DOM差异化增删改 + (较少节点)回流/重绘; // 计算使用虚拟DOM的损耗

可以发现,都是围绕频繁操作真实DOM引起回流重绘,导致页面性能损耗来说的。不过框架也不一定非要使用虚拟DOM,关键在于看是否频繁操作会引起大面积的DOM操作。那么虚拟DOM究竟通过什么方式来减少了页面中频繁操作DOM呢?这就不得不去了解DOM Diff算法了。

Diff算法就是在虚拟DOM树从上至下进行同层比对,如果上层已经不同了,那么下面的DOM全部重新渲染。这样的好处是算法简单,减少比对次数,加快算法完成速度。

虚拟DOM和diff算法_第1张图片

key值的作用: 由于key值用于循环输出的DOM,比如ul下的li,那么这些li在同一层。在旧虚拟DOM和新虚拟DOM比对时,需要标识比对对象。比如上图,旧的4需要和新的4比对,如果没有key就需要遍历查找比较对象,耗费性能。由此可见,key关系到虚拟DOM的比对对象查找,所以key需要唯一且不可变,所以说index作为key值不合适,因为index可能会随li的个数的改变而改变

虚拟dom的作用/优点
 在Web早期,页面的交互比较简单,不太需要频繁的操作DOM,随着时代的发展,页面上的功能越来越多,我们需要实现的需求也越来越复杂,DOM的操作也越来越频繁。通过js操作DOM的代价很高,因为会引起页面的重排重绘,增加浏览器的性能开销,降低页面渲染速度。
 有了虚拟dom之后,我们可以在虚拟节点映射到视图的过程之前,将虚拟节点与上一次渲染视图所使用的虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,避免了不必要的DOM操作,从而节省了浏览器的性能开销使得页面的渲染速度得到提升。

二. diff算法

实现虚拟DOM

​ 例如一个真实的DOM节点。

虚拟DOM和diff算法_第2张图片

​ 真实DOM

​ 我们用JS来模拟DOM节点实现虚拟DOM。

虚拟DOM和diff算法_第3张图片

​ 虚拟DOM

​ 其中的Element方法具体怎么实现的呢?

虚拟DOM和diff算法_第4张图片

​ Element方法实现

​ 第一个参数是节点名(如div),第二个参数是节点的属性(如class),第三个参数是子节点(如ul的li)。除了这三个参数会被保存在对象上外,还保存了key和count。其相当于形成了虚拟DOM树。

虚拟DOM和diff算法_第5张图片

​ 虚拟DOM树

​ 有了JS对象后,最终还需要将其映射成真实DOM

虚拟DOM和diff算法_第6张图片

​ 虚拟DOM对象映射成真实DOM

​ 我们已经完成了创建虚拟DOM并将其映射成真实DOM,这样所有的更新都可以先反应到虚拟DOM上,如何反应?需要用到Diff算法

  • 当数据发生变化时,vue是怎么更新节点的?
     我们先根据真实DOM生成一颗virtual DOM树,当virtual DOM某个节点的发生改变后会生成一个新的Vnode,然后新老节点进行对比,对比的过程就是调用名为patch的函数,patch函数会生成一个补丁包,这个补丁包就是用来描述新老节点改变的内容,然后将这个补丁打到真实dom上更新dom。
     在react进行patch时,是打包所有修改然后放入队列后集中处理,但是这样在早期浏览器上操作DOM时性能会有损失,因为 diff 过程中会遍历一次整棵树,patch 的时候又会遍历整棵树。而早期vue也是以这种形式对真实DOM进行patch,而现在vue中的patch是即时的,也就是 在diff的同时进行patch。 不过不管那种方式,现代浏览器对这样的DOM操作做了优化,二者已经并无太大差别。

diff的比较方式?
diff算法在比较新老节点的时候,比较只会在同层级进行, 不会跨层级比较。

虚拟DOM和diff算法_第7张图片

层级相同的节点位置发生变化,diff时会复用这些节点而不是重新生成新的节点(通过节点的key来实现)

虚拟DOM和diff算法_第8张图片

采用先序深度优先遍历

虚拟DOM和diff算法_第9张图片

传统diff

计算两颗树形结构差异并进行转换,传统diff算法是这样做的:循环递归每一个节点

虚拟DOM和diff算法_第10张图片

比如左侧树a节点依次进行如下对比,左侧树节点b、c、d、e亦是与右侧树每个节点对比
算法复杂度能达到O(n^2),n代表节点的个数

a->e、a->d、a->b、a->c、a->a

查找完差异后还需计算最小转换方式,这其中的原理我没仔细去看,最终达到的算法复杂度是O(n^3)

Vue优化的diff策略

既然传统diff算法性能开销如此之大,Vue做了什么优化呢?

  • 跟react一样,只进行同层级比较,忽略跨级操作

react以及Vue在diff时,都是在对比虚拟dom节点,下文提到的节点都指虚拟节点。Vue是怎样描述一个节点的呢?

Vue虚拟节点
// body下的 
对应的 oldVnode 就是 { el: div //对真实的节点的引用,本例中就是document.querySelector('#id.classA') tagName: 'DIV', //节点的标签 sel: 'div#v.classA' //节点的选择器 data: null, // 一个存储节点属性的对象,对应节点的el[prop]属性,例如onclick , style children: [], //存储子节点的数组,每个子节点也是vnode结构 text: null, //如果是文本节点,对应文本节点的textContent,否则为null }
patch

diff时调用patch函数,patch接收两个参数vnode,oldVnode,分别代表新旧节点。

function patch (oldVnode, vnode) {
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el
        let parentEle = api.parentNode(oEl)
        createEle(vnode)
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
            api.removeChild(parentEle, oldVnode.el)
            oldVnode = null
        }
    }
    return vnode
}

patch函数内第一个if判断sameVnode(oldVnode, vnode)就是判断这两个节点是否为同一类型节点,以下是它的实现:

function sameVnode(oldVnode, vnode){
  //两节点key值相同,并且sel属性值相同,即认为两节点属同一类型,可进行下一步比较
    return vnode.key === oldVnode.key && vnode.sel === oldVnode.sel
}

也就是说,即便同一个节点元素比如div,他的className不同,Vue就认为是两个不同类型的节点,执行删除旧节点、插入新节点操作。这与react diff实现是不同的,react对于同一个节点元素认为是同一类型节点,只更新其节点上的属性。

patchVnode

对于同类型节点调用patchVnode(oldVnode, vnode)进一步比较:

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el  //让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化。
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return  //新旧节点引用一致,认为没有变化
    //文本节点的比较
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        //对于拥有子节点(两者的子节点不同)的两个节点,调用updateChildren
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){  //只有新节点有子节点,添加新的子节点
            createEle(vnode) //create el's children dom
        }else if (oldCh){  //只有旧节点内存在子节点,执行删除子节点操作
            api.removeChildren(el)
        }
    }
}
updateChildren

patchVnode中有一个重要的概念updateChildren,这是Vue diff实现的核心:

 function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    var oldStartIdx = 0;
    var newStartIdx = 0;
    var oldEndIdx = oldCh.length - 1;
    var oldStartVnode = oldCh[0];
    var oldEndVnode = oldCh[oldEndIdx];
    var newEndIdx = newCh.length - 1;
    var newStartVnode = newCh[0];
    var newEndVnode = newCh[newEndIdx];
    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    // removeOnly is a special flag used only by 
    // to ensure removed elements stay in correct relative positions
    // during leaving transitions
    var canMove = !removeOnly;

    {
      checkDuplicateKeys(newCh);
    }
    // 如果索引正常
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 当前的开始旧节点没有定义,进入下一个节点
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
        // 当前的结束旧节点没有定义,进入上一个节点
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
        // 如果旧的开始节点与新的开始节点相同,则开始更新该节点,然后进入下一个节点
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
     // 更新节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
        // 如果旧的结束节点与新的结束节点相同,则开始更新该节点,然后进入下一个节点
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
        // 如果旧的开始节点与新的结束节点相同,更新节点后把旧的开始节点移置节点末尾
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
        // 如果旧的结束节点与新的开始节点相同,更新节点后把旧的结束节点移置节点开头
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        canMove && 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);
        // 如果没有定义index,则创建新的新的节点元素
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
        } else {
          vnodeToMove = oldCh[idxInOld];
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined;
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    // 如果旧节点的开始index大于结束index,则创建新的节点  如果新的开始节点index大于新的结束节点则删除旧的节点
    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);
    }
  }

过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。

0. 文章结构预览

在这里插入图片描述

0.1 虚拟DOM如何被渲染函数(h函数)产生

目标:手写h函数

0.2 diff算法的原理

目标:手写diff算法

0.3 虚拟DOM如何通过diff变成真正的DOM

1. 介绍

1.1 diff算法

精细化比较
在这里插入图片描述

1.2 虚拟DOM

本课程不涉及DOM如何变成虚拟DOM
这属于模板编译原理范畴
但是虚拟节点变成DOM节点在diff中可以做到
在这里插入图片描述

1.3 关系——diff是发生在虚拟DOM上的

新虚拟DOM和旧虚拟DOM进行diff(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM上

在这里插入图片描述

2. snabbdom 简介 及 准备工作

2.1 简介

snabbdom(瑞典语,“速度”)是著名的虚拟DOM库,是diff算法的鼻祖
Vue源码借鉴了snabbdom
源码使用TypeScript写的https://github.com/snabbdom/snabbdom
从npm下载的是build出来的JavaScript版本

npm install -D snabbdom
1

2.2 搭建初始环境

1. 安装snabbdom

cnpm install -S snabbdom
1

在这里插入图片描述

2. 安装webpack5并配置

cnpm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
1

配置webpack5

module.exports = {
  // webpack5 不用配置mode
  // 入口
  entry: "./src/index.js",
  // 出口
  output: {
    // 虚拟打包路径,文件夹不会真正生成,而是在8080端口虚拟生成
    publicPath: "xuni",
    // 打包出来的文件名
    filename: "bundle.js",
  },
  // 配置webpack-dev-server
  devServer: {
    // 静态根目录
    contentBase: 'www',
    // 端口号
    port: 8080,
  },
};
12345678910111213141516171819

3. 复制官方demo Example

src/index.js

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([
  // Init patch function with chosen modules
  classModule, // makes it easy to toggle classes
  propsModule, // for setting properties on DOM elements
  styleModule, // handles styling on elements with support for animations
  eventListenersModule, // attaches event listeners
]);

const container = document.getElementById("container");

const vnode = h("div#container.two.classes", { on: { click: function () { } } }, [
  h("span", { style: { fontWeight: "bold" } }, "This is bold"),
  " and this is just normal text",
  h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);

const newVnode = h(
  "div#container.two.classes",
  { on: { click: function () { } } },
  [
    h(
      "span",
      { style: { fontWeight: "normal", fontStyle: "italic" } },
      "This is now italic type"
    ),
    " and this is still just normal text",
    h("a", { props: { href: "/bar" } }, "I'll take you places!"),
  ]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
123456789101112131415161718192021222324252627282930313233343536373839404142

在这里插入图片描述

3. h函数的介绍与使用

3.1 介绍

用来产生虚拟节点(vnode)
在这里插入图片描述
虚拟节点vnode的属性

{
	children: undefined// 子元素 数组
	data: {} // 属性、样式、key
	elm: undefined // 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
	key: // 唯一标识
	sel: "" // 选择器
	text: "" // 文本内容
}
12345678

3.2 使用h函数 创建虚拟节点

// 创建虚拟节点
var myVnode1 = h('a', { props: { href: 'https://www.baidu.com' } }, 'YK菌')
console.log(myVnode1)
123

在这里插入图片描述

3.3 使用patch函数 将虚拟节点上DOM树

// 创建patch函数
const patch = init([
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
]);

// 创建虚拟节点
var myVnode1 = h(
  "a",
  { props: { href: "https://www.baidu.com", target: "_blank" } },
  "YK菌"
);

// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode1);
123456789101112131415161718

在这里插入图片描述

3.4 h函数嵌套使用,得到虚拟DOM树(重要)

在这里插入图片描述

const myVnode3 = h('ul', [
  h('li', '苹果'),
  h('li', '香蕉'),
  h('li', '西瓜'),
  h('li', '番茄'),
])

// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode3);
12345678910

在这里插入图片描述

const myVnode3 = h('ul', [
  h('li', '苹果'),
  h('li', [
    h('div', [
      h('p', '香蕉'),
      h('p', '草莓')
    ])
  ]),
  h('li', h('span','西瓜')),
  h('li', '番茄'),
])
1234567891011

在这里插入图片描述
在这里插入图片描述

4. 手写h函数

vnode.js

将传入的参数组合成对象返回

/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
export default function(sel, data, children, text, elm) {
  const key = data.key;
  return { sel, data, children, text, elm, key };
}

h.js

产生虚拟DOM树,返回的是一个对象

import vnode from "./vnode";
/**
 * 产生虚拟DOM树,返回的一个对象
 * 低配版本的h函数,这个函数必须接受三个参数,缺一不可
 * @param {*} sel
 * @param {*} data
 * @param {*} c
 * 调用只有三种形态 文字、数组、h函数
 * ① h('div', {}, '文字')
 * ② h('div', {}, [])
 * ③ h('div', {}, h())
 */
export default function (sel, data, c) {
  // 检查参数个数
  if (arguments.length !== 3) {
    throw new Error("请传且只传入三个参数!");
  }
  // 检查第三个参数 c 的类型
  if (typeof c === "string" || typeof c === "number") {
    // 说明现在是 ① 文字
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明是 ② 数组
    let children = [];
    // 遍历 c 数组
    for (let item of c) {
      if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
        throw new Error("传入的数组有不是h函数的项");
      }
      // 不用执行item, 只要收集数组中的每一个对象
      children.push(item);
    }
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === "object" && c.hasOwnProperty("sel")) {
    // 说明是 ③ h函数 是一个对象(h函数返回值是一个对象)放到children数组中就行了
    let children = [c];
    return vnode(sel, data, children, undefined, undefined);
  } else {
    throw new Error("传入的参数类型不对!");
  }
}
1234567891011121314151617181920212223242526272829303132333435363738394041

index.js

import h from "./my_snabbdom/h";

const myVnode1 = h("div", {}, [
  h("p", {}, "嘻嘻"),
  h("p", {}, "哈哈"),
  h("p", {}, h('span', {}, '呵呵')),
]);
console.log(myVnode1);
12345678

效果展示

在这里插入图片描述

5. 手写diff算法准备

5.1 diff算法原理

最小量更新,key很关键。key是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。

只是同一个虚拟节点,才进行精细化比较(往ul中的 li 添加 li),否则就是暴力删除旧的、插入新的(ul中的li 换到在 ol 中去)

问题: 如何定义是同一个虚拟节点
答:选择器相同且key相同

只进行同层比较,不会进行跨层比较。即使是同一片 虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的

在这里插入图片描述

在这里插入图片描述

5.2 手写diff预备

在这里插入图片描述

5.2.1 源码中如何定义“同一个节点”

在这里插入图片描述

5.2.2 源码中创建子节点,需要递归

在这里插入图片描述

6. 手写diff——首次上DOM树patch(container, myVnode1)

6.0 DOM 预备知识

6.0.1 Node.insertBefore()

var insertedNode = parentNode.insertBefore(newNode, referenceNode);
1

insertedNode :被插入节点(newNode)
parentNode :新插入节点的父节点
newNode :用于插入的节点
referenceNodenewNode 将要插在这个节点之前
在当前节点下增加一个子节点 Node,并使该子节点位于参考节点的前面。

6.0.2 Node.appendChild()

element.appendChild(aChild)
1

将一个节点附加到指定父节点的子节点列表的末尾处。
如果将被插入的节点已经存在于当前文档的文档树中,那么 appendChild() 只会将它从原先的位置移动到新的位置(不需要事先移除要移动的节点)。

6.0.3 Element.tagName

返回当前元素的标签名

elementName = element.tagName
1

elementName 是一个字符串,包含了element元素的标签名.
在HTML文档中, tagName会返回其大写形式

6.0.4 Node.removeChild

从DOM中删除一个子节点。返回删除的节点

let oldChild = node.removeChild(child);

//OR

element.removeChild(child);
12345
child` 是要移除的那个子节点.
`node` 是child的父节点.
`oldChild`保存对删除的子节点的引用. `oldChild === child.

6.0.5 document.createElement

var element = document.createElement(tagName[, options]);1

tagName:指定要创建元素类型的字符串, 创建元素时的 nodeName 使用 tagName 的值为初始化,该方法不允许使用限定名称(如:“html:a”),在 HTML 文档上调用 createElement() 方法创建元素之前会将tagName 转化成小写,在 Firefox、Opera 和 Chrome 内核中,createElement(null) 等同于 createElement(“null”)
返回 新建的元素(Element)

6.1 patch.js

import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数是 DOM节点 还是 虚拟节点
  if (oldVnode.sel == "" || oldVnode.sel === undefined) {
    // 说明oldVnode是DOM节点,此时要包装成虚拟节点
    oldVnode = vnode(
      oldVnode.tagName.toLowerCase(), // sel
      {}, // data
      [], // children
      undefined, // text
      oldVnode // elm
    );
  }
  // 判断 oldVnode 和 newVnode 是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log("是同一个节点,需要精细化比较");
  } else {
    console.log("不是同一个节点,暴力 插入新节点,删除旧节点");
    // 创建 新虚拟节点 为 DOM节点
    // 要操作DOM,所以都要转换成 DOM节点
    let newVnodeElm = createElement(newVnode);
    let oldVnodeElm = oldVnode.elm;
    // 插入 新节点 到 旧节点 之前
    if (newVnodeElm) {
      // 判断newVnodeElm是存在的 在旧节点之前插入新节点
      oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
    }
    // 删除旧节点
    oldVnodeElm.parentNode.removeChild(oldVnodeElm);
  }
}
123456789101112131415161718192021222324252627282930313233

6.2 createElement.js

创建节点。将vnode虚拟节点创建为DOM节点

/**
 * 创建节点。将vnode虚拟节点创建为DOM节点
 * 是孤儿节点,不进行插入操作
 * @param {object} vnode
 */
export default function createElement(vnode) {
  // 根据虚拟节点sel选择器属性 创建一个DOM节点,这个节点现在是孤儿节点
  let domNode = document.createElement(vnode.sel);
  // 判断是有子节点还是有文本
  if (
    vnode.text !== "" &&
    (vnode.children === undefined || vnode.children.length === 0)
  ) {
    // 说明没有子节点,内部是文本
    domNode.innerText = vnode.text;
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 说明内部是子节点,需要递归创建节点 
    // 遍历数组
    for (let ch of vnode.children) {
      // 递归调用 创建出它的DOM,一旦调用createElement意味着创建出DOM了。并且它的elm属性指向了创建出的dom,但是没有上树,是一个孤儿节点
      let chDOM = createElement(ch); // 得到 子节点 表示的 DOM节点 递归最后返回的一定是文本节点
      console.log(ch);
      // 文本节点 上domNode树
      domNode.appendChild(chDOM);
    }
  }
  // 补充虚拟节点的elm属性
  vnode.elm = domNode;
  // 返回domNode DOM对象
  return domNode;
}
12345678910111213141516171819202122232425262728293031

6.3 index.js

import h from "./my_snabbdom/h";
import patch from "./my_snabbdom/patch";

let container = document.getElementById("container");
let btn = document.getElementById("btn");

// const myVnode1 = h("h1", {}, "你好");

const myVnode1 = h("ul", {}, [
  h("li", {}, "A"),
  h("li", {}, "B"),
  h("li", {}, "C"),
  h("li", {}, "D"),
]);
// 上树
patch(container, myVnode1);
12345678910111213141516

6.4 展示效果

在这里插入图片描述

7. 处理新旧节点 是同一个节点 的情况

7.1 分析

在这里插入图片描述

7.2 实现 新旧节点text不同情况

7.2.1 patch.js

// 判断 oldVnode 和 newVnode 是不是同一个节点if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {  console.log("是同一个节点,需要精细化比较");  patchVnode(oldVnode, newVnode);}12345

7.2.3 patchVnode.js

export default function patchVnode(oldVnode, newVnode) {  // 1. 判断新旧 vnode 是否是同一个对象  if (oldVnode === newVnode) return;  // 2. 判断 newVndoe 有没有 text 属性  if (    newVnode.text !== undefined &&    (newVnode.children === undefined || newVnode.children.length === 0)  ) {    // newVnode 有 text 属性    // 2.1 判断 newVnode 与 oldVnode 的 text 属性是否相同    if (newVnode.text !== oldVnode.text) {      // 如果newVnode中的text和oldVnode的text不同,那么直接让新text写入老elm中即可。      // 如果oldVnode中是children,也会立即消失      oldVnode.elm.innerText = newVnode.text;    }  } else {    // newVnode 没有text属性 有children属性    // 2.2 判断 oldVnode 有没有 children 属性    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {      // oldVnode有children属性 最复杂的情况,新老节点都有children     } else {      // oldVnode没有children属性 说明有text;  newVnode有children属性      // 清空oldVnode的内容      oldVnode.elm.innerHTML = "";      // 遍历新的vnode虚拟节点的子节点,创建DOM,上树      for (let ch of newVnode.children) {        let chDOM = createElement(ch);        oldVnode.elm.appendChild(chDOM);      }    }  }}123456789101112131415161718192021222324252627282930313233

7.2.2 index.js

import h from "./my_snabbdom/h";
import patch from "./my_snabbdom/patch";

let container = document.getElementById("container");
let btn = document.getElementById("btn");

const myVnode1 = h("h1", {}, "你好");

// 上树
patch(container, myVnode1);

const myVnode2 = h("ul", {}, [
  h("li", {}, "A"),
  h("li", {}, "B"),
  h("li", {}, "C"),
  h("li", {}, "D"),
]);

btn.onclick = function () {
  patch(myVnode1, myVnode2);
}
123456789101112131415161718192021

7.3 展示

在这里插入图片描述

8. 分析 diff算法 更新子节点操作(重要)

这里老师ppt有问题,③应该不是新前而是新后
在这里插入图片描述
在这里插入图片描述

8.1 循环四种命中查找

1. 比较 ① 新前newStart 与 旧前oldStart

如果命中①了,patch之后就移动头指针 newStart++ oldStart++
在这里插入图片描述

if (checkSameVnode(oldStartVnode, newStartVnode)) {
    // 新前与旧前
    console.log(" ①1 新前与旧前 命中");
    // 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
    patchVnode(oldStartVnode, newStartVnode);
    // 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
}
123456789

如果没命中就接着比较下一种情况

2. 比较 ② 新后newEnd 与 旧后 oldEnd

如果命中②了,patch后就移动尾指针 newEnd-- oldEnd–
在这里插入图片描述

if (checkSameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode);
    oldEndVnode = oldCh[--oldEndIdx];
    newEndVnode = newCh[--newEndIdx];
}
12345

如果没命中就接着比较下一种情况

3. 比较 ③ 新后newEnd 与 旧前oldStart

如果命中③了,将 新后newEnd 指向的节点移动到 旧后oldEnd 之后
在这里插入图片描述

if (checkSameVnode(oldStartVnode, newEndVnode)) {
    // 新后与旧前
    console.log(" ③3 新后与旧前 命中");
    patchVnode(oldStartVnode, newEndVnode);
    // 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
    // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}
12345678910
命中③复杂情况举例——倒序

在这里插入图片描述

如果没命中就接着比较下一种情况

4. 比较 ④ 新前newStart 与 旧后oldEnd

如果命中④了,将 新前newStart 指向的节点,移动到 旧前oldStart 之前
在这里插入图片描述

if (checkSameVnode(oldEndVnode, newStartVnode)) {
    // 新前与旧后
    console.log(" ④4 新前与旧后 命中");
    patchVnode(oldEndVnode, newStartVnode);
    // 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
    // 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}
12345678910

如果没命中就表示四种情况都没有命中

5. 四种都没命中 遍历oldVnode中的key

找到了就 移动位置 移动指针newStart++
在这里插入图片描述
没找的就是新节点,直接插入所有未处理旧节点之前

// 四种都没有匹配到,都没有命中
console.log("四种都没有命中");
// 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
if (!keyMap) {
  keyMap = {};
  // 记录oldVnode中的节点出现的key
  // 从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];
if (idxInOld === undefined) {
  // 如果 idxInOld 是 undefined 说明是全新的项,要插入
  // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
  parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
  // 说明不是全新的项,要移动
  const elmToMove = oldCh[idxInOld];
  patchVnode(elmToMove, newStartVnode);
  // 把这项设置为undefined,表示我已经处理完这项了
  oldCh[idxInOld] = undefined;
  // 移动,调用insertBefore也可以实现移动。
  parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}

// newStartIdx++;
newStartVnode = newCh[++newStartIdx];
123456789101112131415161718192021222324252627282930313233

8.2 循环结束

结束后

  1. newVnode中还有剩余
    新节点中剩余的都 插入 旧节点oldEnd后面 或 oldStart之前
    在这里插入图片描述
    在这里插入图片描述
  2. oldVnode中还有剩余节点
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
// 循环结束
if (newStartIdx <= newEndIdx) {
  // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    // insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
    parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
  }
} else if (oldStartIdx <= oldEndIdx) {
  // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    if (oldCh[i]) {
      parentElm.removeChild(oldCh[i].elm);
    }
  }
}
123456789101112131415

9. 手写diff更新子节点操作

patchVnode.js

// 2.2 判断 oldVnode 有没有 children 属性
if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
  // oldVnode有children属性 最复杂的情况,新老节点都有children
  updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
}
12345

updateChildren.js

import createElement from "./createElement";
import patchVnode from "./patchVnode";
/**
 * 
 * @param {object} parentElm Dom节点
 * @param {Array} oldCh oldVnode的子节点数组
 * @param {Array} newCh newVnode的子节点数组
 */
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 (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(" ①1 新前与旧前 命中");
      // 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
      patchVnode(oldStartVnode, newStartVnode);
      // 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
      // 新后与旧后
      console.log(" ②2 新后与旧后 命中");
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
      // 新后与旧前
      console.log(" ③3 新后与旧前 命中");
      patchVnode(oldStartVnode, newEndVnode);
      // 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
      // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
      // 新前与旧后
      console.log(" ④4 新前与旧后 命中");
      patchVnode(oldEndVnode, newStartVnode);
      // 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
      // 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 四种都没有匹配到,都没有命中
      console.log("四种都没有命中");
      // 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
      if (!keyMap) {
        keyMap = {};
        // 记录oldVnode中的节点出现的key
        // 从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];
      if (idxInOld === undefined) {
        // 如果 idxInOld 是 undefined 说明是全新的项,要插入
        // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
        parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
      } else {
        // 说明不是全新的项,要移动
        const elmToMove = oldCh[idxInOld];
        patchVnode(elmToMove, newStartVnode);
        // 把这项设置为undefined,表示我已经处理完这项了
        oldCh[idxInOld] = undefined;
        // 移动,调用insertBefore也可以实现移动。
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
      }

      // newStartIdx++;
      newStartVnode = newCh[++newStartIdx];
    }
  }
  // 循环结束
  if (newStartIdx <= newEndIdx) {
    // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
    // // 插入的标杆
    // const before =
    //   newCh[newEndIdx + 1] === null ? null : newCh[newEndIdx + 1].elm;
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
      parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
    }
  } else if (oldStartIdx <= oldEndIdx) {
    // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldCh[i]) {
        parentElm.removeChild(oldCh[i].elm);
      }
    }
  }
}

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

源码链接 https://gitee.com/ykang2020/vue_learn

在这里插入图片描述

你可能感兴趣的:(前端,javascript,开发语言)