• 【前端源码解析】虚拟 DOM 核心原理


    参考:Vue 源码解析系列课程

    源码:https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study

    大纲:

    • snabbdom 简介
    • snabbdom 的 h 函数如何工作
    • diff 算法原理
    • 手写 diff 算法

    简单介绍 Dom 和 diff

    简单介绍虚拟 DOM 和 diff 算法:

    diff 是发生在虚拟 DOM 上的:

    snabbdom 简介和环境搭建

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

    git 地址:https://github.com/snabbdom/snabbdom

    snabbdom 安装注意事项:

    • git 上的 snabbdom 源码是用 TypeScript写 的,git 上并不提供编译好的JavaScript版本
    • 如果要直接使用 build 出来的 JavaScript 版的 snabbdom 库,可以从 npm 上下载
    npm i -D snabbdom
    
    • 1

    snabbdom 的测试环境搭建:

    • snabbdom 库是 DOM 库,当然不能在 nodejs 环境运行,需要搭建 webpack和 webpack-dev-server 开发环境
    • 注意:必须安装 webpack@5 以上的版本,因为旧版本 webpack 没有读取身份证中 exports 的能力
    npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
    
    • 1
    npm init
    
    npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
    
    npm i -D snabbdom
    
    • 1
    • 2
    • 3
    • 4
    • 5

    webpack.config.js:

    const path = require('path');
    
    module.exports = {
      // 入口
      entry: './src/index.js',
      // 出口
      output: {
        // 虚拟打包路径,文件夹不会真正生成,而是在 8080 端口虚拟生成
        publicPath: 'xuni',
        // 打包出来的文件名
        filename: 'bundle.js',
      },
      devServer: {
        // 端口号
        port: 8080,
        // 静态资源文件夹
        contentBase: 'www'
      }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    将 snabbdom 仓库首页的测试代码放到 index.js,看到以下界面则环境搭建成功:

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

    源码学习

    虚拟 DOM 和 h 函数

    diff 是发生在虚拟 DOM 上的:

    这门课程并不研究 真实 DOM 如何变成虚拟 DOM

    这门课程研究的内容:

    1. 虚拟 DOM 是如何被渲染函数(h 函数)产生?
    2. diff 算法原理
    3. 虚拟 DOM 如何通过 diff 变成真实 DOM

    本课程不研究 真实 DOM => 虚拟 DOM,但是 虚拟 DOM => 真实 DOM 是包含在 diff 算法中

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

    1、调用 h 函数

    var vnode = h('a', { props: { href: 'http://www.atguigu.com' }}, '尚硅谷');
    
    • 1

    2、得到的虚拟节点如下:

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

    3、它表示的真正的 DOM 节点:

    <a href="http://www.atguigu.com">尚硅谷</a>
    
    • 1

    一个虚拟节点具有以下属性:

    • children 子虚拟节点
    • data 节点附带的一些数据,比如 propsclass
    • elm 对应的真实 DOM 节点
    • key 节点的唯一标识
    • sel 对应的标签
    • text 标签内容

    虚拟节点使用实例:
    1、创建 patch 函数
    2、创建虚拟节点
    3、虚拟节点上树

    // 创建出 patch 函数
    var patch = init([classModule, propsModule, styleModule, eventListenersModule]);
    
    // 创建虚拟节点
    var myVnode1 = h(
      "a",
      {
        props: {
          href: "https://www.baidu.com ",
          target: "_blank",
        },
      },
      "百度"
    );
    
    // const myVnode2 = h('div', {}, '我是一个盒子')
    const myVnode2 = h('div', '我是一个盒子') // 没有属性可以简写
    
    const myVnode3 = h('ul', [
      h('li', '苹果'),
      h('li', '香蕉'),
      h('li', '西瓜'),
      h('li', [h('span', '火龙果'), h('span', '榴莲')])
    ])
    
    
    // 让虚拟节点上树
    const container = document.getElementById("container");
    patch(container, myVnode3);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    h 函数的嵌套使用:

    h 函数用法很灵活:

    手写 h 函数

    我们实现的是低配版的 h 函数,不考虑过多的函数重载功能,满足以下三种用法:

    • h('div', {}, 'hello'),第三个参数为 字符串数字
    • h('div', {}, [],第三个参数为 数组(且数组元素都是 h 函数)
    • h('div', {}, h()),第三个参数就是一个 h 函数

    h 函数的用法展示:

    var myVnode1 = h('div', {}, 'hello')
    
    var myVnode2 = h('div', {}, [
        h('p', {}, 'A'),
        h('p', {}, 'B'),
        h('p', {}, h('span', {}, 'C')),
    ])
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    vnode.js

    /**
     * 该函数的功能非常简单,就是把传入的 5 个参数组合成对象返回
     */
    export default function(sel, data, children, text, elm) {
        const key = data.key
        return {
            sel, data, children, text, elm, key
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    h.js

    // 低配版本的 h 函数,必须接受 3 个参数,弱化它的重载功能
    // 对于函数 h(sel, data, c) 有三种形态:
    // 形态1:h('div', {}, 'hello')
    // 形态2:h('div', {}, [])
    // 形态3:h('div', {}, h())
    export default function h(sel, data, c) {
      // 检查参数的个数
      if (arguments.length != 3) {
        throw new Error("对不起,h 函数必须传入 3 个参数,这是低配版 h 函数");
      }
      // 检查参数 c 的类型
      if (typeof c == "string" || typeof c == "number") {
        // 调用 h 函数是形态1 h('div', {}, 'hello')
        return vnode(sel, data, undefined, c, undefined);
      } else if (Array.isArray(c)) {
        // 调用 h 函数是形态2 h('div', {}, [])
        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.push(c[i])
        }
        // 循环结束了,说明 children 收集完毕,返回虚拟节点,具有 children 属性
        return vnode(sel, data, children, undefined, undefined)
      } else if (typeof c == "object" && c.hasOwnProperty("sel")) {
        // 调用 h 函数是形态3 h('div', {}, h())
        // 即,传入的 c 是唯一的 children
        let children = [c]
        return vnode(sel, data, children, undefined, undefined)
      } else {
        throw new Error("传入的第三个参数类型不对");
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    diff 算法

    diff 算法的心得:

    • key 很重要,key 是节点的唯一标识,它会告诉 diff 算法,在更改前后它们是同一个 DOM 节点
    • 只有是同一个虚拟节点,才会进行精细化比较,否则就是暴力删除旧的,插入新的
    • 只进行同层比较,不会进行跨层比较,否则还是暴力删除旧的,插入新的

    后面两个操作在实际的 Vue 开发中,基本不会遇见,这是合理的优化机制

    patch 函数的整体逻辑:

    如何定义 “相同节点” 这个概念?

    /**
     * 判断 vnode1 和 vnode2 是不是同一个节点
     */
    function sameVnode(vnode1, vnode2) {
    	// 旧节点的 key === 新节点的 key 且 旧节点的选择器 === 新节点的选择器
        return vnode1.key == vnode2.key && vnode1.sel == vnode2.sel
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    patch.js

    export default function patch(oldVnode, newVnode) {
        // 判断传入的第一个参数,是 DOM 节点还是虚拟节点
        if (!oldVnode.sel) {
            // 如果是 DOM 节点,则包装为虚拟节点
            oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
        }
        // 判断 oldVnode 和 newVnode 是不是同一个节点
        if (sameVnode(oldVnode, newVnode)) {
            // 是同一个节点,精细化比较
            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)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    createElement.js:根据 vnode 创建真实的 DOM

    /**
     * 根据 vnode 创建 DOM 节点
     */
    export default function createElement(vnode){
        // 创建一个 DOM 节点,这个节点现在还是孤儿节点
        let domNode = document.createElement(vnode.sel);
        // 判断内部是文本还是有子节点
        if (vnode.text && (!vnode.children|| !vnode.children.length )){
            // 内部是文本(让文字上树)
            domNode.innerText = vnode.text
        } else if (Array.isArray(vnode.children) && vnode.children.length) {
            // 内部是子节点,递归创建节点
            for (let i = 0; i < vnode.children.length; i++) {
                // 得到当前子节点
                let ch = vnode.children[i]
                // 创建出它的 DOM,一旦调用 createElement 函数,就会创建出一个 DOM 节点,但是还没有上树
                let chDOM = createElement(ch)
                // 上树
                domNode.appendChild(chDOM)
            }
        }
        // 补充 elm 属性,elm 是一个纯 DOM 对象
        vnode.elm = domNode
        return domNode
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    精细化比较的逻辑:

    patchVnode.js

    diff 的难点与核心抽取到 updateChildren 函数中,在后面介绍

    /**
     * 对比同一个虚拟节点,精细化比较
     */
    export default function patchVnode(oldvnode, newvnode) {
        // 判断新旧 vnode 是否是同一个节点
        if (oldvnode == newvnode) return
        // 判断新 vnode 是否有 text 属性
        if (newvnode.text && (!newvnode.children || !newvnode.children.length)) {
            // 新 vnode 有 text 属性
            console.log('新 vnode 有 text 属性');
            if (newvnode.text != oldvnode.text) {
                // 新vnode 和 旧vnode 的 text 属性不一样,则更新 text 
                // 如果 旧node 是 children 属性,会消失掉
                oldvnode.elm.innertext = newvnode.text
    	        newVnode.elm = oldVnode.elm // 补充
            }
        } else {
            // 新 vnode 没有 text 属性,有 children
            console.log('新 vnode 没有 text 属性');
            if (oldvnode.children && oldvnode.children.length) {
                // 新老节点都有 children,最复杂的情况
                // 这里是 diff 的难点与核心!!!!!!
                updateChildren(oldvnode.elm, oldvnode.children, newvnode.children)
            } else {
                // 老的没有 children,新的有 children
                // 清空老的节点的内容
                oldvnode.elm.innertext = ''
                // 遍历新 vnode 的子节点,创建 DOM,上树
                for (let i = 0; i < newvnode.children.length; i++) {
                    let dom = createElement(newvnode.children[i])
                    oldvnode.elm.appendChild(dom)
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    diff 中四种命中查找:

    1. 新前与旧前,命中则,同时下移
    2. 新后与旧后,命中则,同时上移
    3. 新后与旧前,命中则,新前指向的节点,移动到旧后之后
    4. 新前与旧后,命中则,新前指向的节点,移动到旧前之前

    命中一种就不再进行判断,未命中则从上往下依次判断

    如果都没有命中,就需要用循环来进行寻找

    这个地方还有很多疑问???需要后面再好好看看

    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[oldStartIdx]
        // 旧后节点
        let oldEndVnode = oldCh[oldEndIdx]
        // 新前节点
        let newStartVnode = newCh[newStartIdx]
        // 新后节点
        let newEndVnode = newCh[newEndIdx]
    
        let keyMap = {}
    
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    
            // 首先不是判断 1234 命中,而是要略过已经加undefined标记的东西
            if (!oldStartVnode || !oldCh[oldStartIdx]) {
                oldStartVnode = oldCh[++oldStartIdx]
            } else if (!oldEndVnode || !oldCh[oldEndIdx]) {
                oldEndVnode = oldCh[--oldEndIdx]
            } else if (!newStartVnode || !newCh[newStartIdx]) {
                newStartVnode = newCh[++newStartIdx]
            } else if (!newEndVnode || !newCh[newEndIdx]) {
                newEndVnode = newCh[--newEndIdx]
            }
            // 开始依次判断 1234 命中
            else if (sameVnode(oldStartVnode, newStartVnode)) {
                // 命中1 新前 和 旧前
                console.log('1 新前 和 旧前');
                patchVnode(oldStartVnode, newStartVnode)
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            } else if (sameVnode(oldEndVnode, newEndVnode)) {
                // 命中2 新后 和 旧后
                console.log('2 新后 和 旧后');
                patchVnode(oldEndVnode, newEndVnode)
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(newEndVnode, oldStartVnode)) {
                // 命中3 新后 和 旧前
                console.log('3 新后 和 旧前');
                patchVnode(oldStartVnode, newEndVnode)
                // 移动节点,移动新前指向的节点到旧后的后面
                parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(newStartVnode, oldEndVnode)) {
                // 命中4 新前 和 旧后
                console.log('4 新前 和 旧后');
                patchVnode(oldEndVnode, newStartVnode)
                // 移动节点,移动新前指向的节点到旧前的前面
                parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            } else {
                // 四种命中都没有命中
                console.log('5 都没有匹配');
                // 寻找 key 的 map
                if (!keyMap) {
                    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                        const key = oldCh[i].key
                        if (key) keyMap[key] = i
                    }
                }
                console.log(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];
            }
        }
    
        // 继续看看有没有剩余的节点
        if (newStartIdx <= newEndIdx) {
            console.log('还有剩余节点没有处理');
            // 遍历新的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);
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
  • 相关阅读:
    rabbitMQ的生产与消费由Kettle实现
    第七章TCP/IP——ARP网络攻击与欺骗
    SpringMVC工作流程(详-小白版)
    python爬虫实战:获取电子邮件和联系人信息
    ETL基本介绍【博学谷学习记录】
    MCollections——15
    设计模式学习(二十二):解释器模式
    springboot多模块下swaggar界面出现异常(Knife4j文档请求异常)或者界面不报错但是没有显示任何信息
    Vue Router最佳实践,以确保你的Vue.js应用的路由管理清晰、可维护和高效
    MySQL数据库之Java中如何使用数据库【JDBC编程】
  • 原文地址:https://blog.csdn.net/weixin_43734095/article/details/125469803