Skip to content

前言

在Vue中虚拟DOM和diff算法时面试的常考题,为了面试你可能粗浅了解过一些虚拟DOM和diff算法的相关知识或者一些公众号的文章,至少见过下面的图片

image.png

但为了面试而去应付的了解,真能能让我们能够认识到虚拟DOM和diff算法嘛?

image.png

本章的目的主要是为了更好的了解虚拟DOM和diff算法?如何弄懂?手动将底层代码敲出来

先简单介绍一下虚拟DOM和diff算法

image.png

图中显示,我们有三个改动的地方,那我们要如何改造呢?

image.png

这样代价太高了,这时候就可以利用diff算法。

image.png

这就好比我们DOM上的操作.

image.png

真实DOM变为虚拟DOM,标签是sel、标签上的参数是data、子元素是children、文本是text。 image.png

diff算法是发生在虚拟dom上的,通过在虚拟dom上的比较。算出如何最小量更新,最后反映在真实DOM上。 image.png

snabbdom

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

官方git:https://github.com/snabbdom/snabbdom

项目搭建

• snabbdom库是DOM库,当然不能在nodejs环境运行,所以我们需要搭建webpack和webpack-dev-server开发环境,好消息是不需要安装任何loader

• 这里需要注意,必须安装最新版webpack@5,不能安装webpack@4,这是因为webpack4没有读取身份证中exports的能力,建议大家使用这样的版本:

npm i -D webpack@5.11.0 webpack-cli@3.3.12 webpack-dev-server@3.11.0

• 参考webpack官网,书写好webpack.config.js文件

js
const path = require("path")
module.exports ={
    entry:"./src/index.js",
    output:{
        publicPath:"zxx",
        filename:"bundle.js"
    },
    devServer:{
        port:8080,
        contentBase:"www"
    }
}
const path = require("path")
module.exports ={
    entry:"./src/index.js",
    output:{
        publicPath:"zxx",
        filename:"bundle.js"
    },
    devServer:{
        port:8080,
        contentBase:"www"
    }
}

snabbdom的测试环境就搭建好了,下面我们来用一下snabbdom的用力吧,可以直接去他的官网看一下

体验h函数

js
import { h } from "snabbdom";
  

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

console.log(vnode1)
import { h } from "snabbdom";
  

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

console.log(vnode1)

打印结果:

image.png

体验diff算法

js
import {
    init,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule,
    h,
  } from "snabbdom";
  
  // 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');

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

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

patch(container, vnode1);

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

// 点击按钮时,将vnode1变为vnode2
btn.onclick = function () {
    patch(vnode1, vnode2);
};
import {
    init,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule,
    h,
  } from "snabbdom";
  
  // 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');

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

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

patch(container, vnode1);

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

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

下面我们先手写H函数吧,一个个来。

h函数

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 = []

        for (let i = 0; i < c.length; i++) {
            if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
                throw new Error("传入的数组参数中有项不是h函数")

            children.push(c[i])
        }

        return vnode(sel, data, children, undefined, undefined)
    } else if (typeof c == "object" && c.hasOwnProperty("sel")) {
        let children = [c]
        return vnode(sel, data, children, undefined, undefined)
    } else {
        throw new Error("传入的第三个参数类型不对")
    }
}
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 = []

        for (let i = 0; i < c.length; i++) {
            if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
                throw new Error("传入的数组参数中有项不是h函数")

            children.push(c[i])
        }

        return vnode(sel, data, children, undefined, undefined)
    } else if (typeof c == "object" && c.hasOwnProperty("sel")) {
        let children = [c]
        return vnode(sel, data, children, undefined, undefined)
    } else {
        throw new Error("传入的第三个参数类型不对")
    }
}

这样函数主要是用来模板的转为我们所需要的虚拟DOM对象参数

vnode函数

js
export default function (sel, data, children, text, elm) {
    const key = data.key;
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}
export default function (sel, data, children, text, elm) {
    const key = data.key;
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}

返回虚拟DOM结构对象

patch函数

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

export default function patch(oldVnode, newVnode) {
    //判断第一个传入参数,是DOM节点还是虚拟节点?
    if (oldVnode.sel == '' || oldVnode.sel == undefined) {
        //传入的第一个参数是DOM节点,此时要包装为虚拟节点
        oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, {}, undefined, oldVnode)
    }

    if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
        console.log("是同一个节点")
        patchVnode(oldVnode,newVnode)
    } else {
        console.log("不是同一个节点。暴力插入新的,删除旧的")
        let newVnodeElm = createElement(newVnode)

        if (oldVnode.elm.parentNode && newVnodeElm) {
            oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
        }
        // console.log("旧节点打印",oldVnode.elm)
        oldVnode.elm.parentNode && oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}
import vnode from "./vnode";
import createElement from "./createElement";
import patchVnode from "./patchVnode";

export default function patch(oldVnode, newVnode) {
    //判断第一个传入参数,是DOM节点还是虚拟节点?
    if (oldVnode.sel == '' || oldVnode.sel == undefined) {
        //传入的第一个参数是DOM节点,此时要包装为虚拟节点
        oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, {}, undefined, oldVnode)
    }

    if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
        console.log("是同一个节点")
        patchVnode(oldVnode,newVnode)
    } else {
        console.log("不是同一个节点。暴力插入新的,删除旧的")
        let newVnodeElm = createElement(newVnode)

        if (oldVnode.elm.parentNode && newVnodeElm) {
            oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
        }
        // console.log("旧节点打印",oldVnode.elm)
        oldVnode.elm.parentNode && oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}

diff算法入口;渲染虚拟DOM上树,通过去比较,实现最小量更新,流程如下图:

流程图.png

createElement函数

js
//将vnode创建为真实dom
export default function createElement(vnode) {
    
    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++) {
            
            let ch = vnode.children[i]
            let chDom = createElement(ch)

            domNode.appendChild(chDom)
        }
    }

    vnode.elm = domNode

    return vnode.elm
}
//将vnode创建为真实dom
export default function createElement(vnode) {
    
    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++) {
            
            let ch = vnode.children[i]
            let chDom = createElement(ch)

            domNode.appendChild(chDom)
        }
    }

    vnode.elm = domNode

    return vnode.elm
}

根据虚拟DOM创建真实DOM

patchVnode函数

js
import createElement from "./createElement"
import updateChildren from "./updateChildren"
//对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode){
    
    if(oldVnode === newVnode ) return
    console.log("我捡来了",oldVnode,newVnode)
    if(newVnode.text  !== undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
        console.log("新vnode有text属性")
        if(newVnode.text !== oldVnode.text){
            oldVnode.elm.innerText = newVnode.text
        }
    }else{
        console.log("新vnode没有text属性")
        if(oldVnode.children !== undefined && oldVnode.children.length > 0){
            console.log("新老vnode都有children")
            updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
        }else{
            //老的没有children 新的有children
            oldVnode.elm.innerHTML = ''
            for(let i =0;i<newVnode.children.length;i++){
                let dom = createElement(newVnode.children[i])
                oldVnode.elm.appendChild(dom)
            }
        }
    } 
}
import createElement from "./createElement"
import updateChildren from "./updateChildren"
//对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode){
    
    if(oldVnode === newVnode ) return
    console.log("我捡来了",oldVnode,newVnode)
    if(newVnode.text  !== undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
        console.log("新vnode有text属性")
        if(newVnode.text !== oldVnode.text){
            oldVnode.elm.innerText = newVnode.text
        }
    }else{
        console.log("新vnode没有text属性")
        if(oldVnode.children !== undefined && oldVnode.children.length > 0){
            console.log("新老vnode都有children")
            updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
        }else{
            //老的没有children 新的有children
            oldVnode.elm.innerHTML = ''
            for(let i =0;i<newVnode.children.length;i++){
                let dom = createElement(newVnode.children[i])
                oldVnode.elm.appendChild(dom)
            }
        }
    } 
}

用于对比同一节点的是否有变化

updateChildren函数

js
import patchVnode from "./patchVnode"
import createElement from "./createElement"
//判断是否为同一个虚拟节点
function checkSameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key
}


export default function updateChildren(parentElm, oldCh, newCh) {
    console.log(parentElm, oldCh, newCh)

    //旧前
    let oldStartIdx = 0
    //新前
    let newStartIdx = 0
    //旧后
    let oldEndIdx = oldCh.length - 1
    //新后
    let newEndIdx = newCh.length - 1
    //旧前节点
    let oldStartVnode = oldCh[0]
    //旧后节点
    let oldEndVnode = oldCh[oldEndIdx]
    //新前节点
    let newStartVnode = newCh[0]
    //新后节点
    let newEndVnode = newCh[newEndIdx]

    let keyMap = null


    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // console.log("☆")
        console.log("首先不是判断是否命中的,而是略过已经加undefined标记的项")
        if(oldStartVnode == null && oldCh[oldStartIdx] == undefined){
            oldStartVnode = oldCh[++oldStartIdx]
        }else if(oldEndVnode == null && oldCh[oldEndIdx] == undefined){
            oldEndVnode = oldCh[--oldEndIdx]
        }else if(newStartVnode == null  && newCh[newStartIdx] == undefined){
            newStartVnode = newCh[++newStartIdx]
        }else if(newEndVnode == null && newCh[newEndIdx] == undefined){
            newEndVnode = newCh[--newEndIdx]
        }else  if (checkSameVnode(oldStartVnode, newStartVnode)) {
            console.log("①新前与旧前命中", oldStartVnode, newStartVnode)
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            console.log("②新后与旧后命中")
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            console.log("③新后与旧前命中")
            patchVnode(oldStartVnode, newEndVnode)
            // 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
            //为什么是移到老老节点的旧后(此旧后非老节点中最后一个)的后面呢,而不是直接老节点中最后一个节点的的后面?
            //因为如果说前面已经命中过了新后与旧后,你的新后和旧后的下标是不是移动了,如果你还直接老节点的最后一个,顺序就不是正常的效果了
            //譬如A、B、C、D和A、C、B、D 。A和B分别命中新前与旧前 、新后与新后。当B的时候只能命中到新后与旧前。这是如果插到老节点的最后一个节点的后面,输出顺序就会变为A、C、D、B了
            // 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            //四种都没有命中
            //制作keyMap一个映射对象,这样就不用每次都遍历老对象了
            if (!keyMap) {
                keyMap = {}
                for (let i = oldStartIdx; i < oldEndIdx; i++) {
                    const key = oldCh[i]
                    if (key !== undefined) {
                        keyMap[key] = i
                    }
                }
            }
            console.log(keyMap)
            //寻找当前这项(newStartIdx)在keyMap中的映射的位置序号
            const idxInOld = keyMap[newStartVnode.key]
            if (idxInOld == undefined) {
                // 判断,如果idxInOld是undefined表示它是全新的项
                // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
            } else {
                //如果不是全新项,则需要进行移动
                const elmToMove = oldCh[idxInOld]
                patchVnode(elmToMove, newStartVnode)
                //把当前这项设置为undefined,表示我已经处理过这个了
                oldCh[idxInOld] = undefined
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
            }

            newStartVnode = newCh[++newStartIdx]
        }
    }

    //继续看还有没有剩余的节点,循环结束了start 比old小
    if(newStartIdx  <= newEndIdx){
        console.log("new还有剩余节点没有处理,要加项,要把所有的剩余节点放到oldStartIdx之前")

        for(let i =newStartIdx;i<newEndIdx;i++){
            parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx].elm)
        }
    }else if(oldStartIdx <= oldEndIdx){
        console.log("old还有剩余节点没有处理,要删除项")
        for(let i =oldStartIdx;i<oldEndIdx;i++){
            if(oldCh[i]){
                parentElm.removeChild(oldCh[i].elm)
            }
        }
    }
}
import patchVnode from "./patchVnode"
import createElement from "./createElement"
//判断是否为同一个虚拟节点
function checkSameVnode(a, b) {
    return a.sel == b.sel && a.key == b.key
}


export default function updateChildren(parentElm, oldCh, newCh) {
    console.log(parentElm, oldCh, newCh)

    //旧前
    let oldStartIdx = 0
    //新前
    let newStartIdx = 0
    //旧后
    let oldEndIdx = oldCh.length - 1
    //新后
    let newEndIdx = newCh.length - 1
    //旧前节点
    let oldStartVnode = oldCh[0]
    //旧后节点
    let oldEndVnode = oldCh[oldEndIdx]
    //新前节点
    let newStartVnode = newCh[0]
    //新后节点
    let newEndVnode = newCh[newEndIdx]

    let keyMap = null


    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // console.log("☆")
        console.log("首先不是判断是否命中的,而是略过已经加undefined标记的项")
        if(oldStartVnode == null && oldCh[oldStartIdx] == undefined){
            oldStartVnode = oldCh[++oldStartIdx]
        }else if(oldEndVnode == null && oldCh[oldEndIdx] == undefined){
            oldEndVnode = oldCh[--oldEndIdx]
        }else if(newStartVnode == null  && newCh[newStartIdx] == undefined){
            newStartVnode = newCh[++newStartIdx]
        }else if(newEndVnode == null && newCh[newEndIdx] == undefined){
            newEndVnode = newCh[--newEndIdx]
        }else  if (checkSameVnode(oldStartVnode, newStartVnode)) {
            console.log("①新前与旧前命中", oldStartVnode, newStartVnode)
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            console.log("②新后与旧后命中")
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            console.log("③新后与旧前命中")
            patchVnode(oldStartVnode, newEndVnode)
            // 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
            //为什么是移到老老节点的旧后(此旧后非老节点中最后一个)的后面呢,而不是直接老节点中最后一个节点的的后面?
            //因为如果说前面已经命中过了新后与旧后,你的新后和旧后的下标是不是移动了,如果你还直接老节点的最后一个,顺序就不是正常的效果了
            //譬如A、B、C、D和A、C、B、D 。A和B分别命中新前与旧前 、新后与新后。当B的时候只能命中到新后与旧前。这是如果插到老节点的最后一个节点的后面,输出顺序就会变为A、C、D、B了
            // 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            //四种都没有命中
            //制作keyMap一个映射对象,这样就不用每次都遍历老对象了
            if (!keyMap) {
                keyMap = {}
                for (let i = oldStartIdx; i < oldEndIdx; i++) {
                    const key = oldCh[i]
                    if (key !== undefined) {
                        keyMap[key] = i
                    }
                }
            }
            console.log(keyMap)
            //寻找当前这项(newStartIdx)在keyMap中的映射的位置序号
            const idxInOld = keyMap[newStartVnode.key]
            if (idxInOld == undefined) {
                // 判断,如果idxInOld是undefined表示它是全新的项
                // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
            } else {
                //如果不是全新项,则需要进行移动
                const elmToMove = oldCh[idxInOld]
                patchVnode(elmToMove, newStartVnode)
                //把当前这项设置为undefined,表示我已经处理过这个了
                oldCh[idxInOld] = undefined
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
            }

            newStartVnode = newCh[++newStartIdx]
        }
    }

    //继续看还有没有剩余的节点,循环结束了start 比old小
    if(newStartIdx  <= newEndIdx){
        console.log("new还有剩余节点没有处理,要加项,要把所有的剩余节点放到oldStartIdx之前")

        for(let i =newStartIdx;i<newEndIdx;i++){
            parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx].elm)
        }
    }else if(oldStartIdx <= oldEndIdx){
        console.log("old还有剩余节点没有处理,要删除项")
        for(let i =oldStartIdx;i<oldEndIdx;i++){
            if(oldCh[i]){
                parentElm.removeChild(oldCh[i].elm)
            }
        }
    }
}

这个就是整个diff 算法中最为核心的部分,传说中的新前与旧前、新后与旧后、新后与旧前、新前与旧后的过程都在这里了。

c8cfc549beca44be3707efc4369cab5.png

总结

这样简易版的snabbdom就完成了。