虚拟dom和diff算法

一. 虚拟DOM

  • 什么是虚拟DOM
      Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。
      简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。

  • 虚拟dom渲染函数
class Element {
  constructor (type, props, children) {
    this.type = type
    this.props = props
    this.children = children
  }
}

function createElement (type, props, children) {
  return new Element (type, props, children)
}

createElement ('ul', { class: 'list' }, [
  createElement ('li', { class: 'item' }, ['a']),
  createElement ('li', { class: 'item' }, ['b']),
  createElement ('li', { class: 'item' }, ['c']),
])
  • 虚拟dom


    渲染函数创建出的虚拟dom
  • 真实dom


    将虚拟dom树转换成真实dom树
  • 虚拟dom的作用/优点
      在Web早期,页面的交互比较简单,不太需要频繁的操作DOM,随着时代的发展,页面上的功能越来越多,我们需要实现的需求也越来越复杂,DOM的操作也越来越频繁。通过js操作DOM的代价很高,因为会引起页面的重排重绘,增加浏览器的性能开销,降低页面渲染速度。
       有了虚拟dom之后,我们可以在虚拟节点映射到视图的过程之前,将虚拟节点与上一次渲染视图所使用的虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,避免了不必要的DOM操作,从而节省了浏览器的性能开销使得页面的渲染速度得到提升。


二. diff算法

  • 当数据发生变化时,vue是怎么更新节点的?
       我们先根据真实DOM生成一颗virtual DOM树,当virtual DOM某个节点的发生改变后会生成一个新的Vnode,然后新老节点进行对比,对比的过程就是调用名为patch的函数,patch函数会生成一个补丁包,这个补丁包就是用来描述新老节点改变的内容,然后将这个补丁打到真实dom上更新dom。
       在react进行patch时,是打包所有修改然后放入队列后集中处理,但是这样在早期浏览器上操作DOM时性能会有损失,因为 diff 过程中会遍历一次整棵树,patch 的时候又会遍历整棵树。而早期vue也是以这种形式对真实DOM进行patch,而现在vue中的patch是即时的,也就是 在diff的同时进行patch。 不过不管那种方式,现代浏览器对这样的DOM操作做了优化,二者已经并无太大差别。

  • diff的比较方式?
    diff算法在比较新老节点的时候,比较只会在同层级进行, 不会跨层级比较。

    image.png

    层级相同的节点位置发生变化,diff时会复用这些节点而不是重新生成新的节点(通过节点的key来实现)

    采用先序深度优先遍历

    • patch补丁包
      1.patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点,在比较新老节点生成patch补丁包之前会先判断这两个节点是否值得深入比较
function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code 
    return vnode
}

sameVnode会根据新老节点的标签类型、key等确定这两个节点是否一致。如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode。

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

当确定两个节点值得比较之后就会把两个节点作为参数传入到patchVnode方法中

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el // 找到对应的真实dom
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return //如果Vnode和oldVnode同一个对象,那么直接return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) { //
        api.setTextContent(el, vnode.text) //如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) { //如果两者都有子节点,则执行updateChildren函数比较子节点
            updateChildren(el, oldCh, ch)
        }else if (ch){ //如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
            createEle(vnode) //create el's children dom
        }else if (oldCh){ //如果oldVnode有子节点而Vnode没有,则删除el的子节点
            api.removeChildren(el)
        }
    }
}

updateChildren源码,oldVnode和vnode的子节点进行对比

  updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        }else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx]
        }else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx]
        }else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 使用key时的比较
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

首先,在新老两个VNode节点的左右头尾两侧都有一个变量标记


在遍历时,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode会先进行两两比较,一共有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置。即:

  • oldStartVnode和newStartVnode匹配,位置不动,oldStartIdx,newStartIdx 指针后移。
  • oldEndVnode和newEndVnode匹配,位置不动,oldEndIdx,newEndIdx 指针前移。
  • oldStartVnode和newEndVnode匹配,oldStartVnode移动到newEndVnode所在位置,oldStartIdx指针前移,newEndIdx 指针后移。
  • oldEndVnode和oldStartVnode匹配,oldEndVnode移动到newStartVnode所在位置,oldEndIdx指针后移,newStartIdx 指针前移。

此时已完成了新旧节点首位子节点的匹配,倘若以上4种方式都没能匹配上,如果设置了key,就会用key进行比较,遍历剩下的节点,如果在newVnode中找到一致key的旧的VNode节点,并且同时满足sameVnode,patchVnode,那么这个节点将得到复用。
key 的作用 主要是 :
1.决定节点是否可以复用
2.建立key-index的索引,主要是替代遍历,提升性能
小提示:循环数据时尽量不使用index作为key,除非你能保证index的唯一性。

最后 通过 oldStartIdx > oldEndIdx ,来判断 oldCh 和 newCh 哪一个先遍历完成
oldStartIdx > oldEndIdx表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去。
StartIdx > EndIdx表示vCh先遍历完,那么就在真实dom中将区间的多余节点删掉

附源码地址
https://github.com/vuejs/vue/blob/a702d1947b856cf3b9d6ca5fb27b2271a78a9a5b/src/core/vdom/patch.js#L70

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