虚拟dom和diff算法

一、什么是虚拟dom

虚拟DOM 其实就是一棵以 JavaScript 对象 (VNode 节点) 作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。

//真实dom
<div id="app"> hello world</div>
//虚拟dom
{ tagName:'div', attrs:{id:'app' },children:[ 'hello world']}
二、为什么要使用虚拟dom

1、减少直接操作 dom 次数,从而提高程序性能
js 层面上,DOM 的操作并不慢。DOM 操作慢是慢在浏览器渲染的过程里,改变一行数据就要全部重新渲染,在大多数情况下虚拟 DOM 比 DOM 快,是因为需要更新的 DOM 节点要比原生 DOM 操作更新的节点少,浏览器重绘的时间更短。而且虚拟 DOM 的优势不在于单次的操作,用对比的算法,它可以将多次操作合并成一次操作,在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。
真实的dom上有一堆的属性和方法,直接操作DOM的话性能会变得很慢。频繁操作虚拟DOM不存在性能问题,等数据全部更新完之后只会去更新真实dom树需要更新改变的地方
直接操作真的的DOM每操作一次就会导致一次重绘和回流。使用虚拟dom,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制,从而提高性能!
2、跨平台
它的本质就是一个 JavaScript 对象,并不依赖真实平台环境,所以使它具有了跨平台的能力。它在浏览器上可以变成 DOM,在其他平台里也可以变成相应的渲染对象。同一VNode 节点可以渲染成不同平台上的对应的内容,比如:渲染在浏览器是 dom 元素节点,渲染在 Native( iOS、Android) 变为对应的控件、可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等 、渲染到 WebGL 中等等。
Vue3 中允许开发者基于 VNode 实现自定义渲染器(renderer),以便于针对不同平台进行渲染

三、DOM diff

snabbdom一个虚拟 DOM 库,专注提供简单、模块性的体验,以及强大的功能和性能。
1、snabbdom基本使用

//安装 
npm i snabbdom -D
//使用
import {
    init,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule,
    h,
  } from "snabbdom";
  const patch = init([
    // 通过传入模块初始化 patch 函数
    classModule, // 开启 classes 功能
    propsModule, // 支持传入 props,允许在dom上设置属性
    styleModule, // 支持内联样式同时支持动画
    eventListenersModule, // 添加事件监听
  ]);
//  创建虚拟节点
const myVnode=h('div',{props:{href:"https:/'/baidu.com"}, on: { click: someFn },style:{color:'red'}},'我是替换后的内容')
function someFn(){
console.log(myVnode);
}
//让虚拟节点上树
const container=document.getElementById('container')
patch(container,myVnode)
//获取到的虚拟节点的内容为下面的对象
//让虚拟节点上树
const container=document.getElementById('container')
patch(container,myVnode1)
//再次调用 `patch`
const  myVnode2=h('div',{},'test2')
patch(myVnode1,myVnode2)

2、核心算法
h函数:把传入的节点信息转化为虚拟dom

//使用形式
h('div')
h('div','文字’)
h('div',{},'文字’)
h('div',{},[])
h('div',{},h())
//嵌套使用,得到有children的虚拟节点
h('ul',{},[
   h('li',{},'牛奶‘),
   h('li',{},'咖啡‘)
])

patch函数:把虚拟dom转化为真实dom

// 调用h函数
 h('a',{props:{href:'https://baidu.com'}},'点击跳转')
//产生虚拟节点
{"sel":"a","data":{"props":{"href":"https://baidu.com"}},"text":"点击跳转"}
// 真正的dom节点
<a href="https://baidu.com">点击跳转</a>
四、手写diff算法

①、key 节点的唯一标识,使节点最小量更新
②、只有同一个虚拟节点才会进行精细化比较(老节点的标签和新节点的标签相同且新老节点的key相同)
③、只进行同层比较,不会进行跨层比较
1、实现h函数
vnode.js
把传入的js参数组合成对象返回

//{
//   children: undefined,//子元素
//   data: props: {href: "https:/'/baidu.com"}},//属性&属性值对象
//   elm: undefined,//元素对应的真正的元素节点
//   key: undefined,//唯一标示
//   sel: "a",//选择器
//   text: "test",//内容文本
//}
export default function(sel,data,children,text,elm){
    let key=data?.key??undefined
    return {sel,data,children,text,elm,key}
}

h.js
低配版,只支持三个参数的情况
h('div',{},'文字’)
h('div',{},[])
h('div',{},h())

import vNode from './vnode'
// 将内容转化为虚拟节点
 function h(sel,data,c){
    if(arguments.length!==3){
     throw new Error ('参数有误')
    }else if(typeof c!=='object'){
     return vNode(sel,data,undefined,c,undefined)
    }else if(Array.isArray(c)){
     //检查c[i]中必须是一个对象
     let children=[]
     for(let i=0;i<c.length;i++){
         if(!(typeof c[i]==='object'&&c[i].hasOwnProperty('sel'))){
             throw new Error ('传入数组参数中有项不是h函数')
         }else{
             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 ('第三个参数有误')
    }
 }
 export default h

2、patch函数
createElement.js
真正创建节点,将vnode创建为dom,孤儿节点不进行插入

//创建节点,将vnode转化为dom插入到pivot元素之前
export default function createElement (vnode) {
    //创建一个节点
    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]
            let chDom=createElement(ch)
            domNode.appendChild(chDom)
        }
    }
    vnode.elm=domNode
    return  vnode.elm
}

pathVnode.js
对比节点
// 1、新节点是文本属性,使用innerText直接替换老节点
// 2、老节点是文本属性,直接用新节点的内容替换
// 3、新老节点都不是文本属性,diff判断最小量更新

import createElement from './createElement';
import updateChildren from './updateChildren';

export default function (oldVnode, newVnode) {
    if (newVnode.text && (!newVnode.children || !newVnode.children.length)) {
        //场景1
        if (newVnode.text !== oldVnode.text) {
            oldVnode.elm.innerText = newVnode.text
        }
    } else if (oldVnode.text && (!oldVnode.children || !oldVnode.children.length)) {
        //场景2
        //清空老节点的文字
        oldVnode.elm.innerHtml = ''
        for (let i; i < newVnode.children.length; i++) {
            let ch = newVnode.children[i]
            let newVnodeElm = createElement(ch)
            //把新节点塞入老节点中
            oldVnode.elm.appendChild(newVnodeElm)
        }
    } else if (newVnode.children && newVnode.children.length && oldVnode.children && oldVnode.children.length) {
         //场景3
        let parentElm = oldVnode.elm
        let oldCh = oldVnode.children
        let onewCh = newVnode.children
        updateChildren(parentElm,oldCh,onewCh)
    }
}

updateChildren.js
diff对比新旧节点


import pathVnode from "./pathVnode"
import createElement from "./createElement"
//判断两个节点是否是同一个节点
function checkSomeVnode (vnode1, vnode2) {
    return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
export default function (parentElm, oldCh, newCh) {
    //  新前 旧前
    //  新后 旧后
    //  新后 旧前
    //  新前 旧后
    // 命中其中一个就停止,如果都没有命中遍历查找

    //旧前 位置
    let oldStartIdx = 0
    //旧前 节点
    let oldStartVnode = oldCh[0]
    //旧后 位置
    let oldEndIdx = oldCh.length - 1
    //旧后 节点
    let oldEndVnode = oldCh[oldEndIdx]
    //新前 位置
    let newStartIdx = 0
    //新前 节点
    let newStartVnode = newCh[0]
    //新后 位置
    let newEndIdx = newCh.length - 1
    //新后 节点
    let newEndVnode = newCh[newEndIdx]
    let keyMap = null
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        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 (checkSomeVnode(oldStartVnode, newStartVnode)) {
                //判断新前和新后是否匹配
            //对比同一个虚拟节点
            console.log('新前和旧前中');
            pathVnode(oldStartVnode, newStartVnode)
            //后移 新前和旧前的指针,重新赋值节点
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } else if (checkSomeVnode(oldEndVnode, newEndVnode)) {
            //对比新后和旧后
            console.log('新后和旧后命中');
            pathVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSomeVnode(oldStartVnode, newEndVnode)) {
            //对比新后和旧前
            console.log('新后和旧前命中');
            pathVnode(oldStartVnode, newEndVnode)
            //只要插入一个已经在dom树上的节点就会被移动
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } else if (checkSomeVnode(oldEndVnode, newStartVnode)) {
            //对比新前和旧后,节点移动到旧后节点之后
            console.log('新前和旧后命中');
            pathVnode(oldEndVnode, newStartVnode)
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } else {
            //四种情况都没有命中的情况
            console.log('四种情况都没有命中');
        
            if (!keyMap) {
                //循环存储key值对应的下标
                keyMap = {}
                for (let i = 0; i <= oldEndIdx; i++) {
                    const key = oldCh[i].key
                    if (key !== undefined) {
                        keyMap[key] = i
                    }
                }
            }
            // 找到当前这项在keymap中的位置序号
            let idxInOld = keyMap[newStartVnode.key]
            if (idxInOld) {
                //当该项在旧的虚拟列表中存在
                const elmToMove = oldCh[idxInOld]
                pathVnode(elmToMove, newStartVnode)
                // 把该项移动到旧的虚拟列表的对应位置
                oldCh[idxInOld] = undefined;
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
            }else{
                  //当该项在旧的虚拟列表中不存在,创建节点并插入
                  parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)

            }
            newStartVnode = newCh[++newStartIdx]
        }
    }
    console.log('newStartIdx',newStartIdx,'newEndIdx',newEndIdx)
        //插入节点的情况
        if (newStartIdx <= newEndIdx) {
            // console.log('还有新的节点没有处理')
            for (let i = newStartIdx; i <= newEndIdx; i++) {
                console.log('oldCh[oldStartIdx]',oldCh[oldStartIdx])
                parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx]?.elm)
            }
        } else if (oldStartIdx <= oldEndIdx) {
            //需要删除的场景
            for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                if (oldCh[i]) {
                    parentElm.removeChild(oldCh[i].elm)
                }
            }

        }

    }

patch.js
将新旧两个节点对比后更新dom

import vNode from './vnode'
import createElement from './createElement';
import patchVnode from './pathVnode';
//判断两个节点是否是同一个节点
function isSomeVnode (vnode1, vnode2) {
    return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
//
export default function (oldVnode, newVnode) {
    let _oldVnode = oldVnode;
    //判断老节点是虚拟节点还是真实dom节点
    if (!_oldVnode.sel) {
        //oldVnode为真实dom节点,需要包装为虚拟节点
        _oldVnode = vNode(oldVnode.tagName.toLowerCase(0), {}, [], undefined, oldVnode)
    }
    //判断新老节点是否是同一个节点
    if (isSomeVnode(_oldVnode, newVnode)) {
        //同一个节点,需要进行精细化比较
        if (_oldVnode === newVnode) {
            return
        }
        // 1、新节点是文本属性,使用innerText直接替换老节点
        // 2、老节点是文本属性,直接用新节点的内容替换
        // 3、新老节点都不是文本属性,diff判断最小量更新
        patchVnode(_oldVnode, newVnode)

    } else {
        //直接暴力更新
        let newVnodeElm = createElement(newVnode)
        //把新节点插入到老节点之前
        _oldVnode.elm.parentNode.insertBefore(newVnodeElm, _oldVnode.elm)
        //删除老节点
        _oldVnode.elm.parentNode.removeChild(_oldVnode.elm)
    }
}
五、diff 算法中的key的作用

没有key或者key是索引index时,会采用就地更新原则,在顺序位置上同一个索引就会被认为是同一个元素,正常情况下效率确实会比较高,但是当顺序更改的时候,就会出现索引不一样,不必要的元素也要更新内容和属性。

六、vue2和vue3中diff的实现及区别

可以看这个大神的文章https://juejin.cn/post/7010594233253888013#heading-9

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容