Vue 源码学习 —— snabbdom 虚拟 DOM 和 diff 算法原理

前言

  • snabbdom 是著名的虚拟DOM库,是 diff 算法的奠基者,Vue也借用了它的思想
  • 因此,在学习Vue虚拟DOM和diff算法之前,先学习snabbdom能加更容易理解其设计思想

snabbdom

  • 安装snabbdom
npm i -S snabbdom@2
  • webpack环境配置
  • webpack安装
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
  • 目录:
    在这里插入图片描述

  • webpack.config.js

module.exports = {
    entry: './src/index.js',
    output: {
        publicPath: 'xuni',
        filename: 'bundle.js'
    },
    devServer: {
        port: 8080,
        contentBase: 'www'
    }
}
  • package.json
  ...
  "scripts": {
    ...
    "dev": "webpack-dev-server"
  }
  ...
  • 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>

前置知识

  • 虚拟DOM
    在这里插入图片描述

  • diff: diff 算法是发生在 新旧虚拟DOM之上的,算出如何最小更新,最后反映到真实DOM
    在这里插入图片描述

  • h函数: 用于生成虚拟结点,如下

import {h} from 'snabbdom/h'

let myVNode = h('a', {props: {href: 'http://www.baidu.com'}}, 'click me')
console.log(myVNode)

在这里插入图片描述

  • patch函数: 让虚拟结点上树
import {init} from 'snabbdom/init'
import {classModule} from 'snabbdom/modules/class'
import {propsModule} from 'snabbdom/modules/props'
import {styleModule} from 'snabbdom/modules/style'
import {eventListenersModule} from 'snabbdom/modules/eventlisteners'
import {h} from 'snabbdom/h'
// 生成 patch 函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule])

let myVNode = h('a', {props: {href: 'http://www.baidu.com'}}, 'click me')

const container = document.getElementById('container')
// 将虚拟结点渲染到容器中
patch(container, myVNode)

在这里插入图片描述

手写简单源码

  • 目录结构
    在这里插入图片描述

  • h 函数:用于生成虚拟DOM,是对 vnode 函数的封装

  • 需知:为了简单,不允许虚拟节点同时存在textchildren

import vnode from './vnode'

// c 要么是text 要么是 children
export default function h (sel, data, c) {
	// 如果传入的 c 是 string 或 number ,则算作 虚拟节点的 text
    if (typeof c == 'string' || typeof c == 'number') {
        return vnode(sel, data, undefined, c, undefined)
    } 
    // 如果传入的 c 是 array,则算作 虚拟节点的 children
    else if (Array.isArray(c)) {
        return vnode (sel, data, c, undefined, undefined)
    } 
    // 如果传入的 c 是 vnode,则将其他包装成数组,再算作虚拟节点的 children
    else if ( typeof c == 'object' && c.hasOwnProperty('sel')) {
        return vnode (sel, data, [c], undefined, undefined)
    }
}
  • vnode 函数:功能简明,即将传入的参数封装成对象
// elm 为 vnode 对应的 rnode (真实节点)
export default function (sel, data, children, text, elm) {
    const {key} = data
    return {sel, data, children, text, elm, key}
}
  • 有了以上两个函数,就可以创建虚拟DOM了,如下
    在这里插入图片描述
    在这里插入图片描述
  • 创建出虚拟DOM之后,下一步是如何利用虚拟DOM生成真实DOM
  • createElement 函数
// 创建真实DOM
export default function createElement (vnode) {
    let DOMNode = document.createElement(vnode.sel)
    let {children=[]} = vnode
    vnode.elm = DOMNode

    // 如果 该结点中只有文字
    if (vnode.text != '' && children.length == 0) {
        DOMNode.innerText = vnode.text

    }
    // 如果该结点中只有孩子 
    else if (children.length) {
        // 它内部是子节点,就要递归创建节点
        for (let i = 0; i < children.length; i++) {
            // 得到当前这个children
            let ch = children[i];
            // 创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点。
            let chDOM = createElement(ch);
            ch.elm = chDOM
            // 上树
            DOMNode.appendChild(chDOM);
        }
    }
    return vnode.elm
}
  • 现在可以创建出真实DOM了,下一步是:当虚拟DOM更新时,如何最小量更新真实DOM

  • patch函数

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

export default function patch (oldVnode, newVnode) {

    // 如果 oldVnode 是 DOM 结点,则先转换成 Vnode
    if (oldVnode.sel == '' || oldVnode.sel == undefined) {
        oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
    }
    // 如果 新旧节点的 key 跟 sel 都相同,那么再进行细致比较
    if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
        patchVnode(oldVnode, newVnode)
    }
    // 否则,直接创建新结点,替换旧结点 
    else {
        let newVnodeElm = createElement(newVnode)
        oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}
  • 如果选择器selkey 有一个不匹配,则认为是不同的节点,需要重新创建真实DOM

  • 而如果选择器 selkey 完全匹配,则认为是可能相同的节点,需要使用 patchVnode 进行细致比较,尽可能的进行复用

  • patchVnode 函数

import createElement from "./createElement"
import updateChildren from './updateChildren'

export default function patchVnode (oldVnode, newVnode) {
    let oldChildren = oldVnode.children || []
    let newChildren = newVnode.children || []
    // 如果新旧Vnode是堆中同一对象
    if (oldVnode === newVnode) return

    // 新节点仅有 text
    if (newVnode.text && !newChildren.length) {
        // text 不相等,则用新的替换旧的
        if (newVnode.text != oldVnode.text) {
            oldVnode.elm.innerText = newVnode.text
        }
    }
    // 新节点仅有 children
    else {
        // 旧节点仅有 children
        if (oldChildren.length) {
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
        }
        //  旧节点没有children (可能有 text)
        else {
            oldVnode.elm.innerText = ''
            for (let i=0; i<newChildren.length; i++) {
                let childDOM = createElement(newChildren[i])
                newChildren[i].elm = childDOM
                oldVnode.elm.appendChild(childDOM)
            }
        }
    }
}
  • 上面最复杂的情况是:新旧节点都有children,因为这时,我们需要比较哪些真实DOM是可以复用的,借助另一个函数来处理:updateChildren
  • updateChildren 函数
import createElement from "./createElement"
import patchVnode from "./patchVnode"

function checkSameVnode (a, b) {
    return a.sel === b.sel && a.key === b.key
}

export default function updateChildren (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]

    // 记录 oldVnode 的 key - index
    let keyMap = null

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (!oldStartVnode) {
            oldStartVnode = oldCh[++oldStartIdx]
        } else if (!oldEndVnode) {
            oldEndVnode = oldCh[--oldEndIdx]
        } else if (!newStartVnode) {
            newStartVnode = newCh[++newStartIdx]
        } else if (!newEndVnode) {
            newEndVnode = newCh[--newEndIdx]
        }
        if (checkSameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            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 {
            if (!keyMap) {
                keyMap = {}

                for (let i=oldStartIdx; i<=oldEndIdx; i++) {
                    let key = oldCh[i].key
                    keyMap[key] = i
                }
                const idxInOld = keyMap[newStartVnode.key]
                // 新增节点
                if (!idxInOld) {
                    parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
                } 
                // 需要移动
                else {
                    const elmToMove = oldCh[idxInOld]
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = undefined
                    parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    
    // 需要新增
    if (newStartIdx <= newEndIdx) {
        for (let i=newStartIdx; i<=newEndIdx; i++) {
            parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx]?.elm)
        }
    } 
    // 需要删除
    else if (oldStartIdx <= oldEndIdx) {
        for (let i=oldStartIdx; i<=oldEndIdx; i++) {
            if (oldCh[i].elm) {
                oldCh[i].elm.remove()
                oldCh[i].elm = undefined
            }
        }
    }
}
  • updateChildren 使用了四个指针(索引),分别指向新前、新后,旧前、旧后
  • 然后依次判断:新前跟后前、新后跟旧后、新后跟旧前、新前跟旧后是否命中。
  • 前两者命中,则需要使用patchVnode,进行精细比较,并将指针移动。
  • 后两者命中,也需要使用patchVnode,进行精细比较,并将指针移动,同时还需要移动真实DOM
  • 如果四种都不命中,则建立旧虚拟DOM中,keyindexmap
  • 然后从新前开始,判断新前是否在旧的虚拟DOM中,如果不在,则创建新的并插入,如果在,则进行移动
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tanleiDD

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值