Vue源码:虚拟DOM和diff算法

本文深入探讨虚拟DOM的概念,介绍了著名库snabbdom的使用,并手写了虚拟节点(vnode)和h函数。接着详细讲解了diff算法,包括如何判断节点是否相同、如何进行文本和子节点的更新。通过实例演示了diff过程中的最小量更新策略,以及在遇到节点新增、删除和移动情况时的处理。最后,实现了完整的patch过程,展示了如何通过diff算法将虚拟DOM转换为实际DOM。
摘要由CSDN通过智能技术生成

简单介绍一下虚拟DOM和diff算法

需求

在这里插入图片描述

方法一:拆了重建

在这里插入图片描述

方法二:diff
在这里插入图片描述

在这里插入图片描述

主要内容

在这里插入图片描述

snabbdom简介和测试环境搭建

snabbdom简介

  • snabbdom是瑞典语单词,单词原意“速度”;

    在这里插入图片描述

  • snabbdom 是著名的虚拟DOM 库,是diff 算法的鼻祖,Vue 源码借鉴了snabbdom ;

  • 官方git :snabbdom

安装snabbdom

  • 在git 上的snabbdom 源码是用TypeScript写的,git 上并不提供编译好的JavaScript 版本;

  • 如果要直接使用build出来的JavaScript 版的snabbdom库,可以从npm上下载:

    npm i -S snabbdom
    
  • 学习库底层时,建议大家阅读原汁原味的TS代码,最好带有库作者原注释,这样对你的源码阅读能力会有很大的提升。

snabbdom测试环境搭建

  • snabbdom 库是DOM库,不能在nodejs环境运行,所以需要搭建webpack和webpack-dev-server开发环境,不需要安装任何loader

  • 这里需要注意,必须安装最新版webpack@5,不能安装webpack@4,因为webpack4中没有读取exports的能力

    npm i -S webpack@5 webpack-cli@3 webpack-dev-server@3
    
  • 参考webpack官网,书写好webpack.config.js文件

跑通官方git首页的demo程序

  • 跑通snabbdom官方git首页的demo程序,即证明调试环境已经搭建成功

  • 不要忘记在index.html中放置一个div#container

虚拟DOM和h函数

在这里插入图片描述

虚拟DOM类似于mustache中的token

diff是发生在虚拟DOM上的

在这里插入图片描述

课程不研究DOM如何变成虚拟DOM

在这里插入图片描述

研究内容

  • 虚拟函数如何被渲染函数(h函数)产生?

    手写h函数

  • diff算法原理?

    手写diff算法

  • 虚拟DOM如何通过diff变为真正的DOM的

    事实上,虚拟DOM变回真正的DOM是涵盖在diff算法里面

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

h函数用来产生虚拟节点

h函数用来产生虚拟节点(vnode)

比如这样会调用h函数

在这里插入图片描述

将得到这样的虚拟节点

在这里插入图片描述

{
    "sel": "a",
    "data": {
        props: {
            href: "http://www.atguigu.com"
        }
    },
    "text": "尚硅谷"
}

它表示的真正DOM节点

在这里插入图片描述

一个虚拟节点都有哪些属性

{
    children: undefined,	// 子元素
    data: {},	// 属性、样式
    elm: undefined,	// 对应真正DOM节点,如果为undefined,表示该节点还未上树
    key: undefined,	// 节点唯一标识
    sel: "div",	// 选择器
    text: "我是一个盒子"	// 文字
}

h函数可以嵌套使用,从而得到虚拟DOM树(🧨)

比如这样嵌套使用h函数

在这里插入图片描述

将得到这样的虚拟DOM树

在这里插入图片描述

h函数用法很活

例如

在这里插入图片描述

手写h函数

vnode

vnode.js

/**
 * vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
 */
export default function (sel, data, children, text, elm) {
  return {
    sel, data, children, text, elm
  };
}

h函数

h.js

import vnode from "./vnode";

/*
* 编写一个低配版本的h函数,这个函数必须要接收3个参数,缺一不可 —— 重载功能较弱
* 也就是说,调用的时候形态必须是下面三种之一:
* 形态① h('div', {}, '文字')
* 形态② h('div', {}, [])
* 形态③ h('div', {}, h())
* */
export default function (sel, data, c) {
  // 检查参数的个数
  if (arguments.length !== 3)
    throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
  // 检查参数c的类型
  if (typeof c === 'string' || typeof c === 'number') {
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明现在调用h函数是形态②
    let children = [];
    // 遍历c,手机children
    for (let i = 0; i < c.length; i++) {
      // 检查c[i]必须是一个对象
      if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel')))
        throw new Error('传入的数组参数中有项不是h函数');
      // 这里不用执行c[i],因为测试语句中已经执行了
      // 只需要收集好children
      children.push(c[i]);
    }
    // 循环结束了,说明children收集完毕了,此时可以返回虚拟节点,有children节点
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
    // 说明现在调用h函数是形态③
    // 即传入的c是唯一的children. 不用执行c,因为测试语句中已经执行了c
    return vnode(sel, data, [c], undefined, undefined);
  } else {
    throw new Error('传入的参数类型有误');
  }
};

看TS代码,写JS代码

  • 看源码的TS版代码,然后仿写JS代码
  • 只要主干功能,放弃实现一些细节

感受diff算法

通过更改li标签内容得知,diff算法为最小量更新

通过key可以唯一标识节点,服务于最小量更新

import {init} from 'snabbdom/init';
import {classModule} from "snabbdom/modules/class";
import {propsModule} from "snabbdom/modules/props";
import {styleModule} from "snabbdom/modules/style";
import {eventListenersModule} from "snabbdom/modules/eventlisteners";
import {h} from 'snabbdom/h';

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

// 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');

// 创建虚拟节点
const vnode1 = h('ul', {}, [
  h('li', {key: 'A'}, 'A'),
  h('li', {key: 'B'}, 'B'),
  h('li', {key: 'C'}, 'C'),
  h('li', {key: 'D'}, 'D')
])


patch(container, vnode1)

const vnode2 = h('ul', {}, [
  h('li', {key: 'E'}, 'E'),
  h('li', {key: 'A'}, 'A'),
  h('li', {key: 'B'}, 'B'),
  h('li', {key: 'C'}, 'C'),
  h('li', {key: 'D'}, 'D'),
])

// 点击按钮时,将vnode1变为vnode2
btn.onclick = () => {
  patch(vnode1, vnode2)
}

心得

  • 最小量更新非常厉害!真的是最小量更新,当然,key很重要

    key是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。

  • 只有是同一个虚拟节点,才能进行精细化比较。,否则就是暴力删除旧的、插入新的。

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

  • 只进行同层比较,不会进行跨层比较。。即使是同一片虚拟节点,但是跨层了,对不起,精细化比较不diff 你,而是暴力删除旧的、然后插入新的。

diff 并不是那么的“无微不至”啊!真的影响效率么??
答:上面2、3操作在实际Vue 开发中,基本不会遇见,所以这是合理的优化机制。

同层比较示意图

在这里插入图片描述

// 创建虚拟节点
const vnode1 = h('ul', {}, [
  h('li', {key: 'A'}, 'A'),
  h('li', {key: 'B'}, 'B'),
  h('li', {key: 'C'}, 'C'),
  h('li', {key: 'D'}, 'D')
])


patch(container, vnode1)

const vnode2 = h('ul', {}, h('section', {}, [
  h('li', {key: 'E'}, 'E'),
  h('li', {key: 'A'}, 'A'),
  h('li', {key: 'B'}, 'B'),
  h('li', {key: 'C'}, 'C'),
  h('li', {key: 'D'}, 'D'),
]))

如上述代码中操作,增加一层节点,将不再进行最小量更新,而是重新构造。

diff算法处理新旧节点不是同一个节点时

在这里插入图片描述

如何定义"同一个节点"

在这里插入图片描述

旧节点的key要和新节点的key相同

旧节点的选择器要和新节点的选择器相同

创建节点时,所有子节点都需要递归创建

在这里插入图片描述

手写第一次上树时

patch.js

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

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数,是DOM节点还是虚拟节点
  if (oldVnode.sel === '' || oldVnode.sel === undefined) {
    // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
  }
  // 判断oldVnode和newVnode是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // 是同一个节点
    // TODO:精细化比较
  } else {
    // 不是同一个节点
    createElement(newVnode, oldVnode.elm);
  }
};

createElement.js

// 真正创建节点。将vnode创建为DOM,插入到pivot元素之前
export default function (vnode, pivot) {
  // 目的是把虚拟节点vnode插入到标杆pivot之前
  // 创建一个DOM节点,这个节点目前还是孤儿节点
  let domNode = document.createElement(vnode.sel);
  // 有子节点还是文本
  if (vnode.text !== "" && (vnode.children === undefined || vnode.children.length === 0)) {
    // 内部是文字
    domNode.innerText = vnode.text;
    // 将孤儿节点上树。让标杆节点的父元素调用insertBefore方法,将新的孤儿节点插入到标签节点之前
    pivot.parentNode.insertBefore(domNode, pivot);
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {

  }
};

手写递归创建子节点

为了适应递归操作,将插入操作放入到patch.js中,而不是在createElement中进行

这里处理的是diff处理新旧节点不是同一个节点的情况,创建新的插入并暴力删除

index.js

import h from './mySnabbdom/h';
import patch from './mySnabbdom/patch'

const container = document.getElementById('container');
const btn = document.getElementById('btn');

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

const myVnode2 = h('ul', {}, [
  h('li', {}, 'A'),
  h('li', {}, 'B'),
  h('li', {}, [
    h('div', {}, [
      h('ol', {}, [
        h('li', {}, '哈哈哈'),
        h('li', {}, '嘿嘿嘿'),
        h('li', {}, '呵呵呵'),
      ])
    ])
  ]),
  h('li', {}, 'D'),
])

const myVnode3 = h('section', {}, [
  h('h1', {}, '我是新的h1'),
  h('h2', {}, '我是新的h2'),
  h('h3', {}, '我是新的h3'),
])

patch(container, myVnode2);

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

createElement.js

// 真正创建节点。将vnode创建为DOM,是孤儿节点,不进行插入
export default function createElement(vnode) {
  // console.log(`目的是把虚拟节点${vnode}变成真正的DOM`)
  // 创建一个DOM节点,这个节点目前还是孤儿节点
  let domNode = document.createElement(vnode.sel);
  // 有子节点还是文本
  if (vnode.text !== "" && (vnode.children === undefined || vnode.children.length === 0)) {
    // 内部是文字
    domNode.innerText = vnode.text;
    // 补充elm属性
    vnode.elm = domNode;
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 它内不是子节点,就要递归创建节点
    for (let i = 0; i < vnode.children.length; i++) {
      // 得到当前的children
      let ch = vnode.children[i];
      // 创建它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点
      let chDom = createElement(ch);
      // 上树
      domNode.appendChild(chDom);
    }
  }
  // 补充elm属性
  vnode.elm = domNode;
  // 返回elm,elm是一个纯DOM对象
  return vnode.elm;
};

patch.js

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

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数,是DOM节点还是虚拟节点
  if (oldVnode.sel === '' || oldVnode.sel === undefined) {
    // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
  }
  // 判断oldVnode和newVnode是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // 是同一个节点
    // TODO:精细化比较
  } else {
    // 不是同一个节点
    let newVnodeElm = createElement(newVnode);
    // 插入到老节点之前
    if (oldVnode.elm.parentNode && newVnodeElm)
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
    // 删除老节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm);
  }
};

diff处理新旧节点是同一个节点时候

在这里插入图片描述

手写新旧节点text的不同情况

patch.js

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

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数,是DOM节点还是虚拟节点
  if (oldVnode.sel === '' || oldVnode.sel === undefined) {
    // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
  }
  // 判断oldVnode和newVnode是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log('是同一个节点')
    // 判断新旧node是否是同一个对象
    if (oldVnode === newVnode) return;
    // 判断newVnode有没有text属性
    if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
      // 新vnode有text属性
      // console.log('新vnode有text属性')
      if (newVnode.text !== oldVnode.text)
        // 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
        oldVnode.elm.innerText = newVnode.text;
    } else {
      // 新vnode没有text属性,有children
      // console.log('新vnode没有text属性')
      // 判断老的有没有children
      if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
        // 老的有children,此时就是最复杂的情况。就是新老都有children
      } else {
        // 老的没有children,新的有children
        // 清空老的节点的内容
        oldVnode.elm.innerHTML = '';
        // 遍历newVnode的子节点,创建dom,循环上树
        newVnode.children.forEach(node => {
          let dom = createElement(node);
          oldVnode.elm.appendChild(dom);
        })
      }
    }
  } else {
    // 不是同一个节点
    let newVnodeElm = createElement(newVnode);
    // 插入到老节点之前
    if (oldVnode.elm.parentNode && newVnodeElm)
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
    // 删除老节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm);
  }
};

vnode中添加key

如果data中存在key,将key也绑定在vnode上

vnode.js

/**
 * vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
 */
export default function (sel, data, children, text, elm) {
  const key = data.key;
  return {
    sel, data, children, text, elm, key
  };
}

尝试书写diff更新子节点

patchVnode.js

import createElement from "./createElement";

export default function patchVnode(oldVnode, newVnode) {
// 判断新旧node是否是同一个对象
  if (oldVnode === newVnode) return;
  // 判断newVnode有没有text属性
  if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
    // 新vnode有text属性
    // console.log('新vnode有text属性')
    if (newVnode.text !== oldVnode.text)
      // 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
      oldVnode.elm.innerText = newVnode.text;
  } else {
    // 新vnode没有text属性,有children
    // console.log('新vnode没有text属性')
    // 判断老的有没有children
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // 老的有children,此时就是最复杂的情况。就是新老都有children
      // 所有未处理的节点的开头
      let un = 0;
      for (let i = 0; i < newVnode.children.length; i++) {
        let ch = newVnode.children[i];
        // 再次遍历,看看oldVnode中有没有节点和它是same的
        let isExist = false;
        for (let j = 0; j < oldVnode.children.length; j++) {
          if (oldVnode.children[j].sel === ch.sel && oldVnode.children[j].key === ch.key) {
            isExist = true;
          }
        }
        if (!isExist) {
          let dom = createElement(ch);
          ch.elm = dom;
          if (un < oldVnode.children.length)
            oldVnode.elm.insertBefore(dom, oldVnode.children[un].elm);
          else
            oldVnode.elm.appendChild(dom);
        } else {
          // 让处理的节点下移一位
          un++;
          // 判断前后节点位置是否一致
        }
      }
    } else {
      // 老的没有children,新的有children
      // 清空老的节点的内容
      oldVnode.elm.innerHTML = '';
      // 遍历newVnode的子节点,创建dom,循环上树
      newVnode.children.forEach(node => {
        let dom = createElement(node);
        oldVnode.elm.appendChild(dom);
      })
    }
  }
}

如下面三种情况,所有情况非常复杂,杂糅在一起,因此需要一个优秀的更新算法

新增的情况

新创建的节点要插入到所有未处理节点之前,而不是所有已处理节点之后。

例如途中,如果插入MN,所有已处理节点为AB,则MN会依次插入到B之后,与目的不一致。

在这里插入图片描述

删除的情况

在这里插入图片描述

更新的情况

在这里插入图片描述

diff算法的子节点更新策略

四种命中查找:

新前:新的虚拟节点当中的所有没有处理的开头的节点

新后:新的虚拟节点当中的所有没有处理的最后的节点

旧前:旧的虚拟节点当中的所有没有处理的开头的节点

旧后:旧的虚拟节点当中的所有没有处理的最后的节点

经典的diff算法优化策略

  • 新前与旧前:如果是旧节点先循环完毕,说明新节点中有要插入的节点。
  • 新后与旧后:如果是新节点先循环完毕,如果老节点中还有剩余节点(旧前和新后指针中间的节点),说明他们是要被删除的节点。
  • 新后与旧前:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧后之后
  • 新前与旧后:(此种发生了,涉及移动节点,那么新前指向的节点,移动的旧前之前

命中一种就不再进行命中判断了

如果都没有命中,就需要用循环来寻找了。移动到oldStartIdx之前。

新增的情况

在这里插入图片描述

在这里插入图片描述

删除的情况

在这里插入图片描述

多删除的情况

在这里插入图片描述

复杂的情况

在这里插入图片描述

在这里插入图片描述

手写子节点更新策略

updateChildren.js

import patchVnode from "./patchVnode";
import createElement from "./createElement";

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

export default function updateChildren(parentElm, oldCh, newCh) {
  // console.log('我是updateChildren')
  // console.log(oldCh, newCh)
  // 定义旧前、新前、旧后、新后 编号
  let oldStartIdx = 0, newStartIdx = 0, oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;
  // 定义旧前、旧后、新前、新后 节点
  let oldStartVnode = oldCh[oldStartIdx], oldEndVnode = oldCh[oldEndIdx], newStartVnode = newCh[newStartIdx],
    newEndVnode = newCh[newEndIdx];
  let keyMap = null;
  // 开始大while
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先不是判断①②③④命中,而是要略过已经加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(newStartVnode, oldStartVnode)) {
      // 新前与旧前
      console.log("①新前和旧前命中")
      patchVnode(oldStartVnode, newStartVnode);
      newStartVnode = newCh[++newStartIdx];
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (checkSameVnode(newEndVnode, oldEndVnode)) {
      // 新后与旧后
      console.log("②新后与旧后命中")
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(newEndVnode, oldStartVnode)) {
      // 新后与旧前
      console.log("③新后与旧前命中")
      patchVnode(oldStartVnode, newEndVnode);
      // 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
      // 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      newEndVnode = newCh[--newEndIdx];
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (checkSameVnode(newStartVnode, oldEndVnode)) {
      // 新前与旧后
      console.log("④新前与旧后命中")
      patchVnode(oldEndVnode, newStartVnode);
      // 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
      // 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      newStartVnode = newCh[++newStartIdx];
      oldEndVnode = oldCh[--oldEndIdx];
    } else {
      // 四种命中都没有命中
      // 制作key的map映射对象,这样就不用每次都遍历老对象了。
      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);
      // 寻找当前这项(nextStartIdx)这项在keyMap中的映射的位置序号
      const idxInOld = keyMap[newStartVnode.key];
      // console.log(idxInOld);
      if (idxInOld === undefined) {
        // 判断,如果idxInOlx是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];
    }
  }
  // while结束后,需要继续看看有没有剩余,判断是否需要删除或新增节点
  if (newStartIdx <= newEndIdx) {
    // console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStart之前');
    // 循环结束后,start还是比old小
    // before是插入的标杆
    // const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
    // 遍历新的newCh,添加到老的没有处理的之前
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
      // newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
      // parentElm.insertBefore(createElement(newCh[i]), before);
      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);
  }
};

完整版手写

在这里插入图片描述

patch.js

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

export default function patch(oldVnode, newVnode) {
  // 判断传入的第一个参数,是DOM节点还是虚拟节点
  if (oldVnode.sel === '' || oldVnode.sel === undefined) {
    // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
  }
  // 判断oldVnode和newVnode是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // console.log('是同一个节点')
    patchVnode(oldVnode, newVnode);
  } else {
    // 不是同一个节点
    let newVnodeElm = createElement(newVnode);
    // 插入到老节点之前
    if (oldVnode.elm.parentNode && newVnodeElm)
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
    // 删除老节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm);
  }
};

vnode.js

/**
 * vnode函数的功能非常简单,就是把传入的5个参数组合对象返回
 */
export default function (sel, data, children, text, elm) {
  const key = data.key;
  return {
    sel, data, children, text, elm, key
  };
}

createElement.js

// 真正创建节点。将vnode创建为DOM,是孤儿节点,不进行插入
export default function createElement(vnode) {
  // console.log(`目的是把虚拟节点${vnode}变成真正的DOM`)
  // 创建一个DOM节点,这个节点目前还是孤儿节点
  let domNode = document.createElement(vnode.sel);
  // 有子节点还是文本
  if (vnode.text !== "" && (vnode.children === undefined || vnode.children.length === 0)) {
    // 内部是文字
    domNode.innerText = vnode.text;
    // 补充elm属性
    vnode.elm = domNode;
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 它内不是子节点,就要递归创建节点
    for (let i = 0; i < vnode.children.length; i++) {
      // 得到当前的children
      let ch = vnode.children[i];
      // 创建它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点
      let chDom = createElement(ch);
      // 上树
      domNode.appendChild(chDom);
    }
  }
  // 补充elm属性
  vnode.elm = domNode;
  // 返回elm,elm是一个纯DOM对象
  return vnode.elm;
};

patchVnode.js

import createElement from "./createElement";
import updateChildren from "./updateChildren";

// 对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode) {
// 判断新旧node是否是同一个对象
  if (oldVnode === newVnode) return;
  // 判断newVnode有没有text属性
  if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
    // 新vnode有text属性
    // console.log('新vnode有text属性')
    if (newVnode.text !== oldVnode.text)
      // 如果新的虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可,如果老的elm中是children,那么也会立即消失掉
      oldVnode.elm.innerText = newVnode.text;
  } else {
    // 新vnode没有text属性,有children
    // console.log('新vnode没有text属性')
    // 判断老的有没有children
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // 老的有children,新的也有children,此时就是最复杂的情况。就是新老都有children
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
    } else {
      // 老的没有children,新的有children
      // 清空老的节点的内容
      oldVnode.elm.innerHTML = '';
      // 遍历newVnode的子节点,创建dom,循环上树
      newVnode.children.forEach(node => {
        let dom = createElement(node);
        oldVnode.elm.appendChild(dom);
      })
    }
  }
}

h.js

import vnode from "./vnode";

/*
* 编写一个低配版本的h函数,这个函数必须要接收3个参数,缺一不可 —— 重载功能较弱
* 也就是说,调用的时候形态必须是下面三种之一:
* 形态① h('div', {}, '文字')
* 形态② h('div', {}, [])
* 形态③ h('div', {}, h())
* */
export default function (sel, data, c) {
  // 检查参数的个数
  if (arguments.length !== 3)
    throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
  // 检查参数c的类型
  if (typeof c === 'string' || typeof c === 'number') {
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明现在调用h函数是形态②
    let children = [];
    // 遍历c,手机children
    for (let i = 0; i < c.length; i++) {
      // 检查c[i]必须是一个对象
      if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel')))
        throw new Error('传入的数组参数中有项不是h函数');
      // 这里不用执行c[i],因为测试语句中已经执行了
      // 只需要收集好children
      children.push(c[i]);
    }
    // 循环结束了,说明children收集完毕了,此时可以返回虚拟节点,有children节点
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
    // 说明现在调用h函数是形态③
    // 即传入的c是唯一的children. 不用执行c,因为测试语句中已经执行了c
    return vnode(sel, data, [c], undefined, undefined);
  } else {
    throw new Error('传入的参数类型有误');
  }
};

updateChildren.js

import patchVnode from "./patchVnode";
import createElement from "./createElement";

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

export default function updateChildren(parentElm, oldCh, newCh) {
  // console.log('我是updateChildren')
  // console.log(oldCh, newCh)
  // 定义旧前、新前、旧后、新后 编号
  let oldStartIdx = 0, newStartIdx = 0, oldEndIdx = oldCh.length - 1, newEndIdx = newCh.length - 1;
  // 定义旧前、旧后、新前、新后 节点
  let oldStartVnode = oldCh[oldStartIdx], oldEndVnode = oldCh[oldEndIdx], newStartVnode = newCh[newStartIdx],
    newEndVnode = newCh[newEndIdx];
  let keyMap = null;
  // 开始大while
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先不是判断①②③④命中,而是要略过已经加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(newStartVnode, oldStartVnode)) {
      // 新前与旧前
      console.log("①新前和旧前命中")
      patchVnode(oldStartVnode, newStartVnode);
      newStartVnode = newCh[++newStartIdx];
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (checkSameVnode(newEndVnode, oldEndVnode)) {
      // 新后与旧后
      console.log("②新后与旧后命中")
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(newEndVnode, oldStartVnode)) {
      // 新后与旧前
      console.log("③新后与旧前命中")
      patchVnode(oldStartVnode, newEndVnode);
      // 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
      // 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      newEndVnode = newCh[--newEndIdx];
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (checkSameVnode(newStartVnode, oldEndVnode)) {
      // 新前与旧后
      console.log("④新前与旧后命中")
      patchVnode(oldEndVnode, newStartVnode);
      // 当④新前与旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
      // 如何移动节点??只要插入一个已经在DOm树上的节点,它就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      newStartVnode = newCh[++newStartIdx];
      oldEndVnode = oldCh[--oldEndIdx];
    } else {
      // 四种命中都没有命中
      // 制作key的map映射对象,这样就不用每次都遍历老对象了。
      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);
      // 寻找当前这项(nextStartIdx)这项在keyMap中的映射的位置序号
      const idxInOld = keyMap[newStartVnode.key];
      // console.log(idxInOld);
      if (idxInOld === undefined) {
        // 判断,如果idxInOlx是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];
    }
  }
  // while结束后,需要继续看看有没有剩余,判断是否需要删除或新增节点
  if (newStartIdx <= newEndIdx) {
    // console.log('new还有剩余节点没有处理,要加项。要把所有剩余的节点,都要插入到oldStart之前');
    // 循环结束后,start还是比old小
    // before是插入的标杆
    // const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
    // 遍历新的newCh,添加到老的没有处理的之前
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
      // newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
      // parentElm.insertBefore(createElement(newCh[i]), before);
      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);
  }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

KaiSarH

如果觉得文章不错,可以支持下~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值