vue源码之虚拟DOM和diff算法

本文详细解析了Vue中虚拟DOM的工作原理,介绍了snabbdom库的基础知识,包括虚拟节点属性、h函数实现、diff算法核心及优化策略。通过手写h函数和实际操作,探讨了如何通过diff算法最小化DOM更新,提升性能。
摘要由CSDN通过智能技术生成

vue源码之虚拟DOM和diff算法

虚拟DOM和diff算法

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

snabbdom 库

snabbdom是著名的虚拟dom库,是diff算法的最早,vue中借鉴了snabbdom;
github 地址 snabbdom

注意:git上的snabbdom是用ts写的,git并不支持编译好的js版本;
建议从npm中下载 npm i snabbdom -D

需要搭建webpack和webpack-dev-server 开发环境,不用安装任何loader

注意:必须安装webpack@5 不能安装webpack@4 因为webpack@4 没有读取身份证中exports 的能力,建议使用如下版本:

npm i webpack@5 webpack-cli@3 webpack-dev-server@3 -D // -D 开发时依赖 -S 生产时依赖

然后再写webpack.config.js文件

步骤如下:

  • 首先 npm init
  • 安装 npm i snabbdom -D
  • 安装 npm i webpack@5 webpack-cli@3 webpack-dev-server@3 -D
  • 新建webpack.config.js文件
// https://webpack.docschina.org 
const path = require('path');

module.exports = {
  // 入口
  entry: './src/index.js',
  // 出口
  output: {
    // 虚拟打包文件 就是说文件夹不会真正的生成,而是在8080端口虚拟生成
    publicPath:'xuni',
    // 打包出来的文件名
    filename: 'bundle.js',
  },
  devServer:{
    // 端口号
    port:8080,
    // 静态资源文件夹
    contentBase:'www'
  }
};
  • 新建src和index.js
  • 新建www和index.html

虚拟DOM :用javascript对象描述DOM的层次结构,DOM中的一切属性都在虚拟DOM中对应的属性;

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

DOM变为虚拟DOM,属于模板编译原理;

1、虚拟DOM如何被渲染函数产生?h函数
2、diff算法原理;
3、虚拟DOM如何通过diff 变为真正的DOM;

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

h('a', { props:{ 'http://www.baidu.com' }}, '百度')
// 第一个参数 代表标签的名字 第二个参数:对象props properties 属性的意思 第三个参数

将得到这样的虚拟节点

{ "sel", "a", "data":{ props: {'http://www.baidu.com'}}, "test":"百度" }

它真正的DOM的节点:

<a href="http://www.baidu.com">百度</a>

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

{
	children: undefined,
	data: {},
	elm: undefined,
	key: undefined,
	sel: "div",
	text: "我是一个盒子"
}

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])

// 创建虚拟节点
var myVnode = h('a', { props: {href:'http://www.baidu.com' }}, "百度")
console.log(myVnode);

// 让虚拟节点上树
const container = document.getElementById('container')
patch(container, myVnode)

在这里插入图片描述


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])

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

const myVnode2 = h('div', {}, '我是一个div盒子')
// const myVnode2 = h('div', '我是一个div盒子')

// 嵌套使用 h 函数
const myVnode3 = h('ul', {}, [
  h('li',{}, '牛奶') ,
  h('li',{}, '豆浆') ,
  h('li',{}, '油条') ,
  // 当有一个子元素,可以直接写,两个需要数组
  h('li', h('div','包子')) ,
])
console.log(myVnode3 )
// 得到这样的虚拟DOM
/**
 * {
 *    "sel": "ul",
 *    "data": {},
 *    "children": [
 *      {"sel": "li","text":"牛奶"},
 *      {"sel": "li","text":"豆浆"},
 *      {"sel": "li","text":"油条"}
 *    ]
 * } 
*/ 
// 让虚拟节点上树
const container = document.getElementById('container')
patch(container, myVnode3)

在这里插入图片描述

手写h函数

先看源码中 vonde.ts
在这里插入图片描述
h.ts 中:
在这里插入图片描述
实现下面三个参数:

h('div', {}, [])
h('div', {}, '文字')
h('div', {}, h())

1、新建vnode.js文件

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

2、新建myh.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)) {
    // 说明调用的是第二种
    let children = []
    // 编写 c
    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], 因为测试语句中已经有了执行调用 c[i]()
      // 此时只需要收集好
      children.push(c[i])
    }
    // 循环结束,就说明children收集完毕 ,此时可以返回虚拟节点,它有children属性
    return vnode(sel, data, children, undefined, undefined)
  } else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
    // 说明调用的是第三种
    // 即 传入的c是唯一的children 不用执行c ,因为测试中已经执行
    let children = [c]
    return vnode(sel, data, children, undefined, undefined)
  } else {
    throw new Error('传入第三个参数不正确')
  }
}

3、在index.js中引入使用

import myh from './myh'

// var myVnode1 = myh('div', {}, '文字')
var myVnode1 = myh('div', {}, [
  myh('div', {}, 'niuniu'),
  myh('div', {}, 'fqniu'),
  myh('div', {}, 'niufq'),
  myh('div', {}, myh('div',{},'包子')),
])

console.log(myVnode1);

在这里插入图片描述

diff 算法心得

1、最小量的更新,当然key最重要,key是唯一节点,是为了告诉diff算法,在更改前后他们是同一个dom节点 ;

2、只有是同一个虚拟节点,才进行精细比较,否则就删除旧的,插入新的延伸问题,如何定义同一个虚拟节点,选择器相同且key相同

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

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

在这里插入图片描述
在这里插入图片描述
创建节点时,所有子节点需要递归创建出来的
在这里插入图片描述

在这里插入图片描述
新建index .js文件

import myh from './myh';
import patch from './patch';


const myVnode1 = myh('h1', {}, '你好');
const container = document.getElementById('container');

patch(container, myVnode1)

新建patch .js文件

import vnode from './vnode';
import createElement from './createElement';
import patchVnode from './patchVNode';

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

在这里插入图片描述

新建createElement .js文件(简易版)

// 真正创建节点 将vnode 创建为 DOM 是孤儿节点,不进行插入
export default function createElement(vnode) {
  // console.log('目的是 把虚拟节点 ,', 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) {
    // 他内部是子节点,就要递归创建节点
    for (let i = 0; i < vnode.children.length; i++) {
      // 得到当前这个children
      let ch = vnode.children[i];
      console.log(ch);
      // 创建出他的D欧美,一旦调用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) {
  // 判断新旧节点是否是同一个对象
  if (oldVnode === newVnode) return;
  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) {
      console.log(1);
      // 老的有children ,新的有children, 此时最为复杂,就是新老都有children
      updateChildren(oldVnode.elm,oldVnode.children,newVnode.children);
    } else {
      console.log(2);
      // 老的没有children,新的有children
      // 清空老的节点的内容
      oldVnode.elm.innerHTML = '';
      // 遍历新的vnode的节点, 创建DOM 循环上树
      for (let i = 0; i < newVnode.children.length; i++) {
        let dom = createElement(newVnode.children);
        oldVnode.elm.appendChild(dom);
      }
    }
  }
}

diff算法的子节点更新优化策略

四种命中查找(命中一种就不再进行命中判断,当然如果都没有命中,则需要循环寻找):

1、新前与旧前;
2、新后与旧后;
3、新后与旧前;
4、新前与旧后;

需要四个指针:旧前、旧后、新前、新后;

1、新增:如果是旧节点先循环完毕,说明新节点中有要插入的节点;
2、删除:如果是新节点先循环完毕,如果老节点中还有剩余节点,说明他们是要删除的节点;

当3新后和旧前命中时,此时要移动节点,移动新前指向的这个节点到老节点的 旧后的后面
当4新前与旧后命中时,此时要移动节点,移动到新前指向的这个节点到老节点的 旧前的前面

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

创建updateChildren.js

import pathVnode from './patchVNode';
import createElement from './createElement'
import patchVnode from './patchVNode';

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

export default function updataChildren(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 oldStartVNnode = oldCh[0];
  // 旧后节点
  let oldEndVNnode = oldCh[oldEndIdx];
  //  新前节点
  let newStartVNnode = newCh[0];
  // 新后节点
  let newEndVNnode = newCh[newEndIdx];
  // console.log(oldStartIdx, newEndIdx);
  let keyMap = null;

  // 开始while循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先判断略过加undefined 标记的东西
    if (oldStartVNnode == null || oldCh[oldStartIdx] == undefined) {
      oldStartVNnode = oldCh[++oldStartIdx]
    } else if (oldEndVNnode == null || oldCh[oldEndIdx] == undefined) {
      oldEndVNnode = oldCh[--oldEndIdx]
    } else if (newStartVNnode == null || newCh[newStartIdx] == undefined) {
      newStartVNnode = newCh[++oldStartIdx]
    } else if (newEndVNnode == null || newCh[newEndIdx] == undefined) {
      newEndVNnode = newdCh[--newEndIdx]
    }
    if (checkSameVnode(oldStartVNnode, newStartVNnode)) {
      console.log('1新前和旧前');
      pathVnode(oldStartVNnode, newStartVNnode);
      oldStartVNnode = oldCh[++oldStartIdx];
      newStartVNnode = newCh[++newStartIdx];
    } else if (checkSameVnode(oldEndVNnode, newEndVNnode)) {
      console.log('2新后和旧后');
      pathVnode(oldEndVNnode, newEndVNnode);
      oldEndVNnode = oldCh[--oldEndIdx];
      newEndVNnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldStartVNnode, newEndVNnode)) {
      console.log('3新后和旧前');
      pathVnode(oldStartVNnode, newEndVNnode);
      // 插入 当3新后和旧前命中时,此时要移动节点,移动新前指向的这个节点到老节点的 旧后的后面
      parentElm.insertBefore(oldStartVNnode.elm, oldEndVNnode.elm.nextSibling);
      oldStartVNnode = oldCh[++oldStartIdx];
      newEndVNnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldEndVNnode, newStartVNnode)) {
      console.log('4新前和旧后');
      pathVnode(oldEndVNnode, newStartVNnode);
      // 当4新前与旧后命中时,此时要移动节点,移动到新前指向的这个节点到老节点的 旧前的前面
      parentElm.insertBefore(oldEndVNnode.elm, oldStartVNnode.elm.nextSibling);
      oldEndVNnode = oldCh[--oldEndIdx];
      newStartVNnode = newCh[++newStartIdx];
    } else {
      // 都没有找到的情况 四种都没有命中的情况
      // 寻找keyMap
      if (!keyMap) {
        keyMap = {};
        // 从oldStartIndx开始,到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(newStartVNnode.key)
      console.log(idxInOld);
      if (idxInOld == undefined) {
        // 判断 如果idxInOld 是undefined 表示他是全新的项
        // 被加入的项是newStartVnode 不是真正的DOM节点
        parentElm.insertBefore(createElement(newStartVNnode),oldStartVNnode.elm)
      } else {
        // 如果不是undefined 不是全新的项,而是要移动
        const elmToMove = oldCh[idxInOld];
        if (elmToMove.elm.nodeType == 1) {
          patchVnode(elmToMove, newStartVNnode)
          // 把这项设置为undefined,表示我已经处理完这项了
          oldCh[idxInOld] = undefined;
          // 移动,调用insertBefore也可以实现移动
          parentElm.insertBefore(elmToMove.elm, oldStartVNnode.elm)
        }
      }
      // 指针下移,只移动新的头
      newStartVNnode = newCh[++newStartIdx]
    }
  }
  // 继续看下有没有剩余,循环结束 start还是比old小
  if (newStartIdx <= newEndIdx) {
    console.log('new还有剩余节点没有处理,要把所有剩余的节点,都要插入到oldStartIdx值之前');
    // 插入的标杆
    // const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
    // console.log(before);
    // 新增
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore 这个方法可以自动识别null,如果是null就会自动排到队尾去,和appendChild一致
      // newCh[i]现在还没有真正的DOM,所以要调用 createElement() 函数变为DOM
      parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx])
    }
  } else if (oldStartIdx <= oldEndIdx) {
    console.log('old还有剩余节点没有处理');
    // 批量删除oldStart和oldEnd之间的项
    // 删除 只能是内部的
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if(oldCh[i]){
        parentElm.removeChild(oldCh[i].elm)
      }
    }
  }
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值