Vue源码学习之虚拟DOM和diff算法

Vue源码学习之虚拟DOM和diff算法

虚拟DOM和diff算法

  • snabbdom简介——介绍宏观背景、历史沿革
  • snabbdom的h函数如何工作——先学会怎么用
  • diff算法原理——研究底层机理
  • 手写diff算法——手写掌握

snabbdom 简介

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

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

  • 在git上的snabbdom源码是用TypeScript写的,git上并不提供编译好的javascript版本

虚拟DOM 和 h函数

  • 虚拟dom:用JavaScript对象描述DOM的层次结构。DOM中的一切属性都在虚拟DOM中有对应的属性

真实DOM:

<div class="box">
    <h3>标题h3>
    <ul>
        <li>牛奶li>
        <li>香蕉li>
    ul>
div>

虚拟DOM:

const dom = {
    "sel":"div",
    "data":{
        "class":{"box":true}
    },
    "children":[
        {
            "sel":"h3",
            "data":{}
            "text":"标题"
        }
        {
            "sel":"ul",
            "data":{},
            "children":[
                {"sel":"li","data":{},"text":"牛奶"}
                {"sel":"li","data":{},"text":"香蕉"}
            ]
        }
    ]
}

diff是发生在虚拟DOM上的

新虚拟DOM和老虚拟DOM进行diff(精细化比较),算出应该如何最小量更新,最后反应到真正的DOM上

DOM如何变为虚拟DOM,属于模板编译原理范畴。请看上一节内容

使用h函数来产生虚拟节点

//调用
h('a',{props:{href:'http://www.baidu.com'}},'百度')
//获得的虚拟节点
let dom = {"sel":"a","data":{props:{href:'http://www.baidu.com'}},"text":"百度"}
//真正的虚拟dom节点
<a herf ="http://www.baidu.com">百度</a>

虚拟节点需要有的属性

{
    children:undefind
    data:{}
    elm:undefined
    key:undefined
    sel:"div"
    text:"我是一个盒子"
}

使用snabbdom需要下载 npm i snabbdom -D

运用snabbdom

//引入snabbdom的方法
import { init } from 'sanbbdom/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])

//创建虚拟节点
const myVnode1 = h('a',{props:{href:'http://www.baidu.com'}},'百度')

//让虚拟节点上树
const container = document.getElementById('container')
patch(container,myVnode1)

h函数可以嵌套使用,从而得到虚拟DOM
例如这样:

h('ul',{},[
    h('li',{},'苹果'),
    h('li',{},'香蕉'),
    h('li',{},'雪梨')
])

从而转换为下面的形式

{
    "sel":"ul",
    "data":{},
    "children":[
        {"sel":"li","text":"苹果"},
        {"sel":"li","text":"香蕉"},
        {"sel":"li","text":"雪梨"}
    ]
}

手写h函数

创建一个index.js作为入口文件

import h from './h.js'
let myVode = h('div',{},[
    h('p',{},'哈哈哈'),
    h('p',{},'哈哈哈'),
    h('p',{},'哈哈哈')
])

创建一个vnode.js

//这个函数的功能非常简单,就是将传入的数据返回为对象
export default function(sel,data,children,text,elm){
    return {
        sel,data,children,text,elm
    }
}

创建h.js

//编写的是一个低版本的h函数,必须接收3个参数缺一不可
//形态《1》 h('div',{},'文字')
//形态《2》 h('div',{},[])
//形态《3》 h('div',{},h())
import vnode from './vnode.js'
export default function (sel,data,c){
    //检查参数的个数
    if(arguments.length != 3){
        throw new Error('传入的参数不符合条件')
    }
    //检测c的类型
    if(typeof c == 'string' || typeof c == 'number'){
        //形态1
        return vnode(sel,data,undefined,c,undefined)
    }else if(Array.isArray(c)){
        //形态2
        let children = []
        //遍历c
        for(let i = 0 ; i<c.length;i++){
            //检查c[i]必须是一个对象,如果不满足
            if(!(typeof c == 'object' && c.hasOwnProperty('sel'))){
                throw new Error('传入的数组参数中有项不是h函数')
            }
            //此时只需要收集好就好了
            children.push(c[i])
        }
        //循环结束了,就说明children收集完毕了,此时可以返回虚拟节点了,他有children属性
        return vnode(sel,data,children,undefined,undefined);
    }else if(typeof c == 'object' && c.hasOwnProperty('sel')) {
        //形态3
        let children = [c]
        return vnode(sel,data,children,undefined,undefined);
    }else{
        throw new Error('传入的第三个类型错误')
    }
}

diff上树过程

Vue源码学习之虚拟DOM和diff算法_第1张图片

diff处理新旧节点不是同一个节点时

如何判断“是否为同一节点呢”

  • 旧节点的key要和新节点的key相同
  • 旧节点的选择器和新节点的选择器要相同

创建节点时,所有子节点需要递归创建的

diff处理新旧节点是同一个节点时

需要遵循下面的思路
Vue源码学习之虚拟DOM和diff算法_第2张图片

手写第一次上树时

新建一个index.js作为入口文件

import h from './h.js'
import patch from './patch.js'

const container = document.getElementById('container')
const myVnode1 = h('h1',{},'你好')

patch(container,myVnode1)

新建一个patch.js,编写上树函数

import vnode from './vnode.js'
import patchVnode from './patchVnode.js'

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

    //判断oldVnode和newVnode是不是同一个节点
    if(oldVnode.key == newVnode.key && oldVnode.sel == newVnode.key){
        patchVnode(oldVnode,newVnode)
    }else{
        //不是同一个节点
        let newVnodeElm = createElement(newVnode)
        if(oldVnode.elm.parentNode && newVnodeElm){
            oldVnode.elm.parentNode.inserBefore(newVnodeElm,oldVnode.elm)
        }
        //删除老节点
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}

新建一个createElement.js用于创建新节点

//真正创建节点,将vnode创建为DOM,是孤儿节点就不进行插入
export default function createElement(vnode){
    //创建一个dom节点,这个节点现在还是孤儿节点
    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]
            //创建他的dom,一旦调用了createElement意味着:创建出dom;并且他的elm属性指向了创建出DOM,但还没上树,是个孤儿节点
            let chDom = createElement(ch)
            domNode.appendChild(chDom)
        }
    }
    //补充elm属性
    vnode.elm = domNode
    return vnode.elm 
}

经典的diff算法优化策略

四种命中查找:

  • 新前 与 旧前
  • 新后 与 旧后
  • 新后 与 旧前 (如果发生了,那么新前指向的节点,移动到旧后之后,旧前所对应的值变为undefined,并移动旧前新后)
  • 新前 与 旧后 (如果发生了,那么新前指向的节点,移动到旧前之前)

是按顺序命中查找的。需要准备四个指针。

  • 循环条件为 新前<=新后 && 旧前 <= 旧后
  • 如果是旧节点先循环完毕,证明新节点有剩余节点,说明他们是新增的节点
  • 如果是新节点先循环完毕,证明老节点中还有剩余节点(被卡住的节点),说明他们是要被删除的节点
  • 如果都没有被命中,则需要用循环来寻找了。
    • **在旧节点找到新前指向的这个节点,移动到旧前前面。**旧节点对应节点的位置变为undefined,移动新前的指针
    • 如果没有找到,直接将新前对应的节点,复制一份移动到旧前的前面
  • 命中了或移动了才移动指针,指针移动后又重新使用命中查找的顺序

新建一个patchVnode.js用于处理是同一个节点的清况

import createElement from './createElement.js'
import updateChildren from './updateChildren.js'

export default patchVnode(oldVnode,newVnode){
    //是同一个节点
    //newVnode 和 oldVnode 如果是内存中的同一对象,啥也不用做,否则下一步
    if(oldVnode === newVnode) return;
    //判段newVnode有没有text属性
    if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
        //此时有text属性
        //如果新旧node的text相同,则啥也不用做
        //不同则进行下一步
        if(newVnode.text != oldVnode.text){
            //如果新虚拟节点中的text和老虚拟节点的text不同,那么直接让新的text写入老的elm中即可。
            //如果老的elm中是children,那么也会直接消失了
            oldVnode.elm.innerText = newVnode.text
        }
    }else{
        //此时没有text属性

        //判断老的有没有children
        if(oldVnode.children != undefined && oldVnode.children.length > 0){
            //此时老的有children,较为复杂。新老都有children
            updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
        }else{
            //此时老的没有children,新的有
            //先把老的节点的内容清空
            oldVnoed.elm.innerHTML = ''
            //遍历新的子节点,创建dom,上树
            for(let i = 0;i<newVnode.children;i++){
                let dom = createElement(newVnode.children[i])
                oldVnode.elm.appendChild(dom)
            }
        }
    }
}

新建一个updateChildren.js用于更新子节点的函数

updateChildrenpatchVnode 是个互相嵌套的过程

import patchVnode from './patchVnode.js'
import createElement from './createElement.js'

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 newStartVnode = newCh[0]
    //旧后节点
    let oldEndVnode = oldCh[oldEndIdx]
    //新后节点
    let newEndVnode = newCh[newEndIdx]

    let keyMap = null
    //开始循环
    while( oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx ){

        //略过是undefined的值
        if(oldStartVnode == undefined){
            oldStartVnode = oldCh[++oldStartIdx]
        }else if(oldEndVnode == undefined){
            oldEndVnode = oldCh[--oldEndIdx]
        }else if(newEndVnode == undefined){
            newEndVnode = newCh[--newEndIdx]
        }else if(newStartVnode == undefined){
            newStartVnode = newCh[++newStartIdx]
        }

        //如果 新前 和 旧前 相同
        if(checkSameVnode(oldStartVnode,newStartVnode)){
            //相同则放回patchVnode中进行比较处理
            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)
            //新前指向的节点,移动到旧后之后,旧前所对应的值变为undefined
            parentElm.insertBefore(oldStartVode.elm,oldEndVode.elm.nextSibling)
            //移动指针,和指针指向
            oldStartVode = oldCh[++oldStartIdx]
            newEndVode = newCh[--newEndIdx]
        }else if(checkSameVnode(oldEndVnode,newStartVnode)){
            //如果 新前 和 旧后 相同
            patchVnode(oldEndVnode,newStartVnode)
            //新前指向的节点,移动到旧前之前
            parentElm.insertBefore(oldEndVode.elm,oldStartVode.elm.nextSibling)
            //移动指针,和指针指向
            oldEndVode = oldCh[--oldEndIdx]
            newStartVode = newCh[++newStartIdx]
        }else{
            //都没有命中
            //寻找key的map
            if(!keyMap){
                keyMap = {}
                //创建keymap映射对象,这样不用每次都遍历了
                for(let i = oldStartIdx;i<=oldEndIdx;i++){
                    const key = oldCh[i].key
                    if(key!=undefined){
                        keyMap[key] = i
                    }
                }
            }
            //寻找当前项newStartIdx 在keyMap中映射的位置序号
            const idxInOld = keyMap[newStartVnode.key]

            if(idxInOld == undefined){
                //表示是全新的项
                parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
            }else{
                //如果不是undefined 不是全新的项,要移动
                const elmToMove = oldCh[idxInOld]
                patchVnode(elmToMove,newStartVnode)
                //把这项设置为undefined
                oldCh[idxInOld] = undefined
                //移动到旧前前面
                parentElm.insertBefore(elmToMove.elm,oldStartVode.elm)
            }

            //完成后指针下移
            newStartVnode = newCh[++newStartIdx]
        }
    }

    //判断是否有剩余的节点
    if(oldStartIdx <= oldEndIdx){
        //证明旧的比新的多,要进行删除
        for(let i = oldStartIdx ; i<=oldEndIdx;i++){
            //当他不是undefined时
            if(oldCh[i]){
                parentElm.removeChild(oldCh[i].elm)
            }
        }
    }
    if(newStartIdx <= newEndIdx){
        //证明新的比旧的多,要进行插入
        //要插入的标杆
        const before = oldCh[oldStarIdx] == null ? null : oldCh[oldStarIdx].elm
        for(let i = newStartIdx ; i <= newEndIdx ; i++){
            //insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了
            //newCh[i]不是真正的dom,需要使用createElement创建实际的dom
            parentElm.insertBefore(createElement(newCh[i]),before)
        }
    }
}
      //移动到旧前前面
                parentElm.insertBefore(elmToMove.elm,oldStartVode.elm)
            }

            //完成后指针下移
            newStartVnode = newCh[++newStartIdx]
        }
    }

    //判断是否有剩余的节点
    if(oldStartIdx <= oldEndIdx){
        //证明旧的比新的多,要进行删除
        for(let i = oldStartIdx ; i<=oldEndIdx;i++){
            //当他不是undefined时
            if(oldCh[i]){
                parentElm.removeChild(oldCh[i].elm)
            }
        }
    }
    if(newStartIdx <= newEndIdx){
        //证明新的比旧的多,要进行插入
        //要插入的标杆
        const before = oldCh[oldStarIdx] == null ? null : oldCh[oldStarIdx].elm
        for(let i = newStartIdx ; i <= newEndIdx ; i++){
            //insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了
            //newCh[i]不是真正的dom,需要使用createElement创建实际的dom
            parentElm.insertBefore(createElement(newCh[i]),before)
        }
    }
}

你可能感兴趣的:(Vue源码学习,vue.js,javascript,前端)