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

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

系列笔记:

大纲:

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

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

简单介绍 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

snabbdom 的测试环境搭建:

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

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

npm i -D snabbdom

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'
  }
};

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

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

源码学习

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

虚拟 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' }}, '尚硅谷');

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

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

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

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

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

  • 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);

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

vnode.js

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

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("传入的第三个参数类型不对");
  }
}

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
}

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

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
}

精细化比较的逻辑:

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

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);
            }
        }
    }
}

猜你喜欢

转载自blog.csdn.net/weixin_43734095/article/details/125469803
今日推荐