[Front-end source code analysis] The core principle of virtual DOM

Reference: Vue Source Code Analysis Series Courses

Series notes:

Outline:

  • Introduction to snabbdom
  • How snabbdom's h function works
  • diff algorithm principle
  • handwritten diff algorithm

Source code of this chapter: https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study

A brief introduction to Dom and diff

Briefly introduce virtual DOM and diff algorithm:

diff happens on the virtual DOM:

Introduction to snabbdom and environment construction

snabbdom is a famous virtual DOM library and the originator of the diff algorithm. Vue source code borrows from snabbdom

git address: https://github.com/snabbdom/snabbdom

snabbdom installation notes:

  • The snabbdom source code on git is written in TypeScript, and the compiled JavaScript version is not available on git
  • If you want to directly use the built JavaScript version of the snabbdom library, you can download it from npm
npm i -D snabbdom

The test environment of snabbdom is set up:

  • The snabbdom library is a DOM library. Of course, it cannot run in the nodejs environment. It needs to build a webpack and webpack-dev-server development environment
  • Note: The webpack@5above , because the old version of webpack does not have the ability to read the exports in the ID card
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'
  }
};

Put the test code on the homepage of the snabbdom warehouse into index.js, and the environment is successfully built when you see the following interface:

Don't forget to put adiv#container

Source code learning

Source code of this chapter: https://gitee.com/szluyu99/vue-source-learn/tree/master/Snabbdom_Study

Virtual DOM and h functions

diff happens on the virtual DOM:

This course does not examine how the real DOM becomes the virtual DOM .

Topics covered in this course:

  1. How is the virtual DOM generated by the rendering function (h function)?
  2. diff algorithm principle
  3. How virtual DOM becomes real DOM through diff

This course does not study real DOM => virtual DOM , but virtual DOM => real DOM is included in the diff algorithm

The h function is used to generate a virtual node (vnode) :

1. Call the h function

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

2. The obtained virtual nodes are as follows:

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

3. The real DOM node it represents:

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

A virtual node has the following properties:

  • children child virtual nodes
  • Some data attached to the dataprops node, such as ,class
  • The real DOM node corresponding to elm
  • The unique identifier of the key node
  • The label corresponding to sel
  • text tag content

Examples of using virtual nodes:
1. Create a patch function
2. Create a virtual node
3. Create a tree on a virtual node

// 创建出 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);

Nested use of the h function:

The h function usage is very flexible:

Handwritten h function

What we implement is a low-profile version of the h function, which does not consider too many function overloading functions, and satisfies the following three usages:

  • h('div', {}, 'hello'), the third parameter is a string or a number
  • h('div', {}, [], the third parameter is an array (and the array elements are all h functions)
  • h('div', {}, h()), the third parameter is an h function

The usage of the h function shows:

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 algorithm

Experience with diff algorithm:

  • The key is very important , the key is the unique identifier of the node, it will tell the diff algorithm that they are the same DOM node before and after the change
  • Only when it is the same virtual node, the refined comparison will be performed , otherwise, the old one will be violently deleted and the new one will be inserted
  • Only the same layer comparison is performed, and no cross-layer comparison is performed , otherwise, the old one is still violently deleted and the new one is inserted

The latter two operations are basically not encountered in actual Vue development, which is a reasonable optimization mechanism

The overall logic of the patch function:

How to define the concept of "same node"?

/**
 * 判断 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: Create real DOM based on vnode

/**
 * 根据 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
}

The logic of refined comparison:

patchVnode.js

The difficulty and core of diff are extracted into updateChildrenthe function , which will be introduced later

/**
 * 对比同一个虚拟节点,精细化比较
 */
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)
            }
        }
    }
}

Four kinds of hit lookups in diff:

  1. The new front and the old front , hit then, move down at the same time
  2. The new queen and the old queen , when hit, move up at the same time
  3. After the new post and the old front , if hit, the node pointed by the new front moves to the post after the old
  4. The new front and the old post , if hit, the node pointed by the new front moves to the front of the old front

If one hits, no judgment will be made, and if it misses, it will be judged from top to bottom

If there is no hit, you need to use a loop to find

Still have a lot of questions about this place? ? ?

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

Guess you like

Origin blog.csdn.net/weixin_43734095/article/details/125469803