笔记Vue源码解析之虚拟DOM和diff算法

Posted ThinkerWing

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了笔记Vue源码解析之虚拟DOM和diff算法相关的知识,希望对你有一定的参考价值。

笔记简介

本文为尚硅谷视频学习笔记,参考博客学习速度更快,跟着视频记录笔记加深印象及补充视频中讲师所讲到的一些知识点,扩充了部分,加上遇到的问题及解决方案。

视频链接
https://www.bilibili.com/video/BV1v5411H7gZ?p=3
博客参考链接
https://blog.csdn.net/weixin_44972008/article/details/115620198
snabbdom库
https://github.com/snabbdom/snabbdom/tree/master/src
git笔记https://gitee.com/thinkerwing/study/tree/master/vue2/%E8%99%9A%E6%8B%9FDOM%E5%92%8Cdiff%E7%AE%97%E6%B3%95/study-snabbdom

注意可能会遇到的问题
1.可能是端口占用的情况导致的404
2.可能是缓存的情况导致新写入的逻辑无法执行,sources中查看
3.如果没有key的话,容易被判断成同一节点,key的作用主要是为了高效的更新虚拟DOM,通过源码学习能更深的感受到这一点的重要性

diff算法和虚拟DOM简介

diff算法
在这里插入图片描述
diff算法可以进行精细化比对,实现最小量更新。h3 和 ul 没有变化就可以不用动,只插入span和雪碧。

虚拟DOM
diff算法是发生在虚拟 DOM 上的
DOM如何变为虚拟DOM属于模板编译原理范畴,mustache
在这里插入图片描述

snabbdom简介和测试环境搭建

npm install -S snabbdom

在这里插入图片描述

npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3

配置webpack.config.js,参考官网配置
https://www.webpackjs.com/

下面附上几个配置

  • src/index.js
  • www/index.html
  • webpack.config.js

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
  

www/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="container"></div>
    <script src="/xuni/bundle.js"></script>
</body>
</html>

webpack.config.js

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

更改package.json中script配置,通过npm run test 启动

 "scripts": {
    "test": "webpack-dev-server"
  },

启动如果遇到404要注意是否端口占用
cmd 查找80端口占用情况

netstat -ano|findstr "80"

在这里插入图片描述

taskkill/pid 117884 -t -f  // PID为117884的进程被杀掉

如果删不掉,可以考虑更换一下端口

虚拟 DOM 和 h 函数

  • 研究1:虚拟 DOM 如何被渲染函数(h函数)产生?
    我们要手写h函数
  • 研究2:diff算法原理?
    我们要手写diff算法
  • 研究3:虚拟 DOM 如何通过diff变为真正的 DOM 的
    事实上,虚拟 DOM 变回真正的 DOM,是涵盖在diff算法里面的

h函数用来产生虚拟节点(vnode)
在这里插入图片描述

虚拟节点vnode的属性

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

使用h函数 创建虚拟节点

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

在这里插入图片描述

使用patch函数 将虚拟节点上DOM树
一个容器只能让一个虚拟节点上树,除非有内嵌的

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

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

// 一个容器只能让一个虚拟节点上树
const myVnode2 = h('div', {class: {'box': true}},'我是一个盒子')

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

h函数嵌套使用,得到虚拟DOM树(重要)
在这里插入图片描述
一定要套h函数,并且里面可以继续嵌套

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

手写h函数

  • 看源码的TS版代码,然后仿写JS代码

在这里插入图片描述

h.js

import vnode from "./vnode";
/**
 * 产生虚拟DOM树,返回的一个对象
 * 低配版本的h函数,这个函数必须接受三个参数,缺一不可
 * 调用只有三种形态 文字、数组、h函数
 * ① h('div', {}, '文字')
 * ② h('div', {}, [])
 * ③ h('div', {}, h())
 */
export default function (sel, data, c) {
  // 检查参数个数
  if (arguments.length !== 3) {
    throw new Error("h函数必须传入3个参数");
  }
  // 检查第参数c的类型
  if (typeof c === "string" || typeof c === "number") {
    // 说明现在调用h函数是形态1
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明现在调用h函数是形态2 数组
    let children = [];
    // 遍历 c 数组,收集children
    for (let item of c) {
      // 检查c[i]必须是一个对象,如果不满足 
      if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
        throw new Error("传入的数组参数中有项不是h函数");
      }
      // 不用执行item, 因为测试语句中已经有了执行,此时只要收集数组中的每一个对象
      children.push(item);
    }
    //循环结束了,就说明children收集完毕了,此时可以返回虚拟节点了,它有children属性
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === "object" && c.hasOwnProperty("sel")) {
    // 说明现在调用h函数是形态3 即,传入的c是唯一的children,不用执行c
    let children = [c];
    return vnode(sel, data, children, undefined, undefined);
  } else {
    throw new Error("传入的第三个参数类型不对");
  }
}

vnode.js

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

index.js

import h from "./mysnabbdom/h";

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

在这里插入图片描述

感受diff算法

1. 最小量更新,key很关键。key是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。
2.只有是同一个虚拟节点才进行精细化比较,否则就是暴力删除旧的、插入新的。
延伸问题:如何定义是同一个虚拟节点?答:选择器相同且key相同
3.只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的

下面通过三个案例来对比

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

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

var vnode2 = h(
    "ul", {}, [
        h('li', {}, 'A'),
        h('li', {}, 'B'),
        h('li', {}, 'C'),
        h('li', {}, 'D'),
        h('li', {}, 'E')
    ]
);

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

在这里插入图片描述
用案例测试最小量更新,点击按钮,并没有刷新变回B。

再用一个新的案例测试前面插入E,全部都会更新,因为原来的A变成了E,B变成了A

var vnode2 = h(
    "ul", {}, [
        h('li', {}, 'E'),
        h('li', {}, 'A'),
        h('li', {}, 'B'),
        h('li', {}, 'C'),
        h('li', {}, 'D')
    ]
);

在这里插入图片描述
再来第三个测试

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

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

var 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')
    ]
);

在这里插入图片描述

只进行同层比较,不会跨层比较
在这里插入图片描述
diff处理新旧节点不是同一节点时

在这里插入图片描述

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

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  const isSameKey = vnode1.key === vnode2.key;
  const isSameIs = vnode1.data?.is === vnode2.data?.is;
  const isSameSel = vnode1.sel === vnode2.sel;

  return isSameSel && isSameKey && isSameIs;
}

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

if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i];
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
          }
        }
      } else if (is.primitive(vnode.text)) {
        api.appendChild(elm, api.createTextNode(vnode.text));
      }

手写上树

  • patch方法是虚拟dom核心中的核心。在VNode(虚拟节点)改变后和初始化前都会调用。
  • patch的本质是将新旧vnode进行比较,创建、删除或者更新DOM节点/组件实例。
    了解一下递归的过程
    这一层调用下一层一直往下调,遇见文本节点,一层一层往回调。
    在这里插入图片描述
    index.js
import h from './mysnabbdom/h.js'
import patch from './mysnabbdom/patch.js'

const myVnode1 = h(
    "ul", {}, [
        h('li', {}, 'A'),
        h('li', {}, 'B'),
        h('li', {}, [
            h('div', {}, [
                h('ol', {}, [
                    h('li', {}, 'C1'),
                    h('li', {}, 'C2'),
                    h('li', {}, 'C3'),
                ])
            ])
        ]),
        h('li', {}, 'D')
    ]
);

const container = document.getElementById('container')
patch(container, myVnode1)

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(), // sel
      {}, // data
      [], // children
      undefined, // text
      oldVnode // elm
    );
  }
  // 判断 oldVnode 和 newVnode 是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log("是同一个节点,需要精细化比较");
  } else {
    console.log("不是同一个节点,暴力插入新节点,删除旧节点");
    let newVnodeElm = createElement(newVnode);
    let oldVnodeElm = oldVnode.elm;
    // 以oldVnodeElm为标杆
    if (oldVnodeElm.parentNode && newVnodeElm) {
      // 判断newVnodeElm是存在的,插入到老节点之前
      oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
    }
    // 删除老节点
    oldVnodeElm.parentNode.removeChild(oldVnodeElm);
  }
}

createElement.js

  // 真正创建节点。将vnode虚拟节点创建为DOM,是孤儿节点,不进行插入
 export default function createElement(vnode) {
    console.log('目的是把虚拟节点', vnode, '真正变为DOM');
    // 根据虚拟节点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 i = 0; i < vnode.children.length; i++) {
        // 得到当前这个children
        let ch = vnode.children[i]
        // 创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,
        // 但是还没上树,是一个孤儿节点
        console.log(ch);
        let chDOM = createElement(ch)
        // n+1层就可以被n层创建回来
        // 上树
        domNode.appendChild(chDOM)
      }
    }
    // 补充虚拟节点的elm属性
    vnode.elm = domNode;
    // 返回domNode 和 vnode.elm 是一样的 引用类型值内存中同一个对象,elm的属性是一个纯DOM对象
    return domNode;
  }

测试结果:
在这里插入图片描述

尝试书写diff更新子节点

在这里插入图片描述
在这里插入图片描述
代码
index.js

import h from './mysnabbdom/h.js'
import patch from './mysnabbdom/patch.js'

const myVnode1 = h(
    "ul", {}, [
        h('li', { key: 'A' }, 'A'),
        h('li', { key: 'B' }, 'B'),
        h('li', { key: 'C' }, 'C')
    ]
);

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

// 第一次上树
patch(container, myVnode1)

// 新节点
const myVnode2 = h(
    "ul", {}, [
        h('li', { key: 'A' }, 'A'),
        h('li', { key: 'B' }, 'B'),
        h('li', { key: 'D' }, 'D'),
        h('li', { key: 'D' }, 'E'),
        h('li', { key: 'C' }, 'C'),
        h('li', { key: 'C' }, 'F'),
        h('li', { key: 'G' }, 'G'),
    ] 
)

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

patch.js

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

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数是 DOM节点 还是 虚拟节点
  if (oldVnode.sel == '' || oldVnode.sel === undefined) {
    // 传入的第一个参数是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('是同一个节点');
    patchVnode(oldVnode, newVnode)
  } else {
    console.log("不是同一个节点,暴力插入新节点,删除旧节点");
    let newVnodeElm = createElement(newVnode);
    let oldVnodeElm = oldVnode.elm;
    // 以oldVnodeElm为标杆
    if (oldVnodeElm.parentNode && newVnodeElm) {
      // 判断newVnodeElm是存在的,插入到老节点之前
      oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
    }
    // 删除老节点
    oldVnodeElm.parentNode.removeChild(oldVnodeElm);
  }
}

patchVNode.js

import createElement from "./createElement";

export default function patchVnode(oldVnode, newVnode) {
    console.log("是同一个节点,需要精细化比较");
    // 判断新旧vnode是否是同一个对象
    if (oldVnode === newVnode) return;
    // 判断新的vnode有没有text属性
    if (newVnode.text !== undefined && newVnode.children == undefined || newVnode.
      children.length == 0) {
        console.log('新vnode有text属性');
        if (newVnode.text !== oldVnode.text) {
          // 如果新虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可。
          // 如果老的elm中是children,那么也会立即消失。
          oldVnode.elm.innerText = newVnode.text
        }
      } else {
        // 新vnode没有text属性
        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) {
                  console.log('ch', ch, i);
                  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 = ''
          // 遍历新的vnode的子节点,创建DOM,上树
          for (let i = 0; i < newVnode.children.length; i++) {
            let dom = createElement(newVnode.children[i])
            oldVnode.elm.appendChild(dom)
          }
        }
      }
}

测试
在这里插入图片描述

以上是关于笔记Vue源码解析之虚拟DOM和diff算法的主要内容,如果未能解决你的问题,请参考以下文章

面试中的网红Vue源码解析之虚拟DOM,你知多少呢?深入解读diff算法

面试中的网红Vue源码解析之虚拟DOM,你知多少呢?深入解读diff算法

Vue 虚拟DOM和Diff算法源码解析

Vuejs571- Vue 虚拟DOM和Diff算法源码解析

Vue源码解析 | 虚拟DOM和diff算法视频教程发布!

Snabbdom:虚拟DOM和Diff算法