Vue源码解读:03虚拟Dom篇

754 阅读9分钟

Vue源码解读:03虚拟Dom篇

目录

image.png

第一节·先看目录结构

image.png

第二节·虚拟dom简述

1.什么是虚拟dom?

前端三大主流框架中,React和Vue都涉及到虚拟dom,那么虚拟dom是啥呢?程序的世界,见名知意,是一个被广泛认可的编码命名规则。从见名知意这个角度,虚拟dom就是dom的一种虚拟,假的dom。开发中,真实dom通常使用html来描述,而虚拟dom采用的虚拟技术通常是JavaScript,也就是说使用JavaScript来编写模拟dom,这样并非真实的dom就是虚拟dom。简单的说,虚拟dom是指用JavaScript编写的伪dom。例如下面这样。

//真实dom:html标签描述的dom节点 
<div id="divNode">真实dom节点</div> 

//虚拟dom:js描述的dom节点 
Element({ 
    tagName:'div', 
    attr:{ id:'divNode', value:'虚拟dom节点' } 
})

2.要你虚拟dom何用?

React在众多前端框架中,性能算是它的优点,其中缘由之一就是虚拟dom,React使用的JSX语法将HTML、CSS,全部使用JS表示,可以说将虚拟dom演绎到了极致。因为操作真实的dom性能消耗很大,很大是多大,俺也不知道也不敢问。可以看下,下面这张将一个普通div节点使用JS打印出来的结果,粗略感受一下。

image.png

总之吧,就是说操作真实dom的开销很大,而使用虚拟dom,可以减少重排重绘,减少操作真实dom。基于性能优化的需求,选择了虚拟dom。所以,在v-show和v-if,两个指令中,我会尽可能的选择使用v-show。因为v-show的切换不会频繁创建和销毁dom节点,它是利用HTML的display属性实现的。重排和重绘的概念,这里不多说。

第三节·Vue中的虚拟dom

1.虚拟节点

组成虚拟dom的重要一环是虚拟节点,vue节点分为组件节点、元素节点、函数组件节点、注释节点、文本节点、克隆节点。下面是vnode.js的相关代码。


//源码位置 src/core/vdom/vnode.js

export default class VNode {
    tag: string | void;
    data: VNodeData | void;
    children: ?Array<VNode>;
    text: string | void;
    elm: Node | void;
    ns: string | void;
    context: Component | void; // rendered in this component's scope
    key: string | number | void;
    componentOptions: VNodeComponentOptions | void;
    componentInstance: Component | void; // component instance
    parent: VNode | void; // component placeholder node

    // strictly internal
    raw: boolean; // contains raw HTML? (server only)
    isStatic: boolean; // hoisted static node
    isRootInsert: boolean; // necessary for enter transition check
    isComment: boolean; // empty comment placeholder?
    isCloned: boolean; // is a cloned node?
    isOnce: boolean; // is a v-once node?
    asyncFactory: Function | void; // async component factory function
    asyncMeta: Object | void;
    isAsyncPlaceholder: boolean;
    ssrContext: Object | void;
    fnContext: Component | void; // real context vm for functional nodes
    fnOptions: ?ComponentOptions; // for SSR caching
    devtoolsMeta: ?Object; // used to store functional render context for devtools
    fnScopeId: ?string; // functional scope id support

    constructor (
        tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
) {
    this.tag = tag //标签名
    this.data = data //数据信息
    this.children = children //子节点,一个数组
    this.text = text //节点的文本信息
    this.elm = elm //虚拟节点对应的真实节点
    this.ns = undefined //当前节点的名字空间
    this.context = context //当前节点对应的Vue实例
    this.fnContext = undefined //函数组件对应的Vue实例
    this.fnOptions = undefined //函数组件实例的配置项
    this.fnScopeId = undefined //函数组件的
    this.key = data && data.key //函数组件scopeId
    this.componentOptions = componentOptions //组件配置项
    this.componentInstance = undefined //组件实例对象
    this.parent = undefined //其父节点
    this.raw = false //
    this.isStatic = false //是否为静态节点
    this.isRootInsert = true //是否作为根节点插入
    this.isComment = false //是否为注释节点
    this.isCloned = false //是否为克隆节点
    this.isOnce = false //是否含有v-once指令
    this.asyncFactory = asyncFactory  //异步工厂模式
    this.asyncMeta = undefined //异步元
    this.isAsyncPlaceholder = false //是否有异步占位符
}

    // DEPRECATED: alias for componentInstance for backwards compat.
    //不推荐:用于向后兼容的组件实例的别名
    /* istanbul ignore next */
    get child (): Component | void {
        return this.componentInstance
    }
}

可以看到源码中不同类型的虚拟节点,其实都是VNode实例,通过入参控制,决定生成什么类型的节点。有了虚拟节点,就有了虚拟dom。前文提到选择虚拟dom的理由是减少开销,其实比起操作真实dom,操作虚拟dom,就是使用js计算性能消耗替换操作真实dom的性能消耗。都是性能消耗,简单的说操作js单价比较便宜,而操作dom的消耗单价比较贵。也就是说操作真实dom的消耗更大,大到值得选择虚拟dom替代真实dom。

虚拟节点在性能优化上的优势,除了操作js相比dom的开销小外,就是差异更新和缓存原理了。在视图渲染之前,先把编译模板编译成虚拟节点,并且进行缓存,新生成的虚拟节点,会和上一次缓存下来的虚拟节点进行差异比较,有差异的虚拟节点,即为需要更新到真实dom上的部分。没有差异的节点,则不更新,这样就减少了开销。具体的差异比较原理后面会说。

2.vue中的dom-diff

我们知道vue中在视图更新的时候,使用了diff算法,diff算法想必大家并不陌生。比如git中diff,linux中的difff,感受一下git diff算法的结果。

image.png

对一个文件使用git的diff命令后,得出的是该文件新旧版本的diff算法后的差异部分,其余并不展示,可以比较清晰展示出变化情况。而在vue中diff算法,是用在视图更新过程,具体上是新旧虚拟节点的比较,使用缓存结合diff算法更新,而不是全部更新的目的之一就是为了减少开销,提升性能。我们知道前端提升性能的方向之一,就是减少dom的操作,减少浏览器的重排。而使用虚拟dom和diff差异更新就可以达到减少操作dom,减少重排,减少开销,提升性能的目的。

3.vnode的patch过程

①前言

vnode的比较更新过程,可以称之为patch过程,根据有道翻译,patch有很多解释,这里取“打补丁”之意,打补丁使用Windows系统的同学想必不陌生。打补丁本质上是一种更新。简单地说,新旧vnode比较找差异更新过程就是dom-diff过程,而dom-diff可以说是整个虚拟dom的核心。那么vue中的dom-diff,vnode的patch在源码是怎样的呢。

②patch过程

patch过程就干了一件事,就是对比新旧vnode找差异并更新,具体上分为三部分,创建节点,删除节点,更新节点。新旧vnode更新过程是以新生成的vnode为基准,给旧vnode打补丁,最终使用的是打补丁后的vnode,即旧的vnode。新vnode中有而旧vnode没有的节点,则在旧vnode中创建节点;新vnode中没有而旧vnode中有的vnode,则在旧vnode中删除该节点;新旧vnode中都有的节点,则使用新vnode中的节点替换旧vnode中的。

③回到源码

创建节点

前文中提到虚拟节点有6种,但从源码来看,创建之后执行insert()方法,将节点插入到dom中的节点只有三种,即元素节点,注释节点,文本节点。而判断应当创建哪种节点,则由传入的参数节点。但在创建这三种节点前会先尝试创建组件节点,创建成功则退出程序。

//源码位置 src/core/vdom/patch.js

//添加节点
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx, insertedVnodeQueue) {
    for (; startIdx <= endIdx; ++startIdx) {
        createElm(vnodes[startIdx], insertedVnodeQueue, parentElm, refElm, false, vnodes, startIdx)
    }
}

//创建元素节点
function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
) {
    if (isDef(vnode.elm) && isDef(ownerArray)) {
        vnode = ownerArray[index] = cloneVNode(vnode) // 克隆节点
    }

    vnode.isRootInsert = !nested // for transition enter check
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        //成功创建组件节点退出程序
        return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
        vnode.elm = vnode.ns
            ? nodeOps.createElementNS(vnode.ns, tag)
            : nodeOps.createElement(tag, vnode)//创建元素节点
        setScope(vnode)

        /* istanbul ignore if */
        if (__WEEX__) {
            //WEEX环境的代码省略...
        } else {//创建子节点
            createChildren(vnode, children, insertedVnodeQueue)
            if (isDef(data)) {
                invokeCreateHooks(vnode, insertedVnodeQueue)
            }
            insert(parentElm, vnode.elm, refElm)
        }

        if (process.env.NODE_ENV !== 'production' && data && data.pre) {
            creatingElmInVPre--
        }
    } else if (isTrue(vnode.isComment)) {//创建注释节点
        vnode.elm = nodeOps.createComment(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    } else {//创建文本节点
        vnode.elm = nodeOps.createTextNode(vnode.text)
        insert(parentElm, vnode.elm, refElm)
    }
}

image.png

删除节点

删除节点逻辑相对简单,新节点中没有而旧节点中有的,就执行删除。

//源码位置 src/core/vdom/patch.js

//删除节点
function removeNode (el) {
    // 获取父节点
    const parent = nodeOps.parentNode(el)
    // 调用父节点的removeChild方法,删除子节点
    if (isDef(parent)) {
        nodeOps.removeChild(parent, el)
    }
}

更新节点

image.png

//源码位置 src/core/vdom/patch.js

//比较节点,更新节点
function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
) {
    if (oldVnode === vnode) { //新旧节点全等,return结束比较过程
        return
    }

    if (isDef(vnode.elm) && isDef(ownerArray)) {
        // clone reused vnode 克隆重复使用的节点
        vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = vnode.elm = oldVnode.elm

    if (isTrue(oldVnode.isAsyncPlaceholder)) {
        if (isDef(vnode.asyncFactory.resolved)) {
            hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
        } else {
            vnode.isAsyncPlaceholder = true
        }
        return
    }

    //新旧节点都是同key名的静态节点,是则return退出程序。
    if (isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
        vnode.componentInstance = oldVnode.componentInstance
        return
    }

    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode)
    }

    const oldCh = oldVnode.children
    const ch = vnode.children
    if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
        if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    //新节点vnode的text属性未定义
    if (isUndef(vnode.text)) {
        //新旧节点都有子节点
        if (isDef(oldCh) && isDef(ch)) {
            //新旧子节点不同,则更新子节点
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        }
        //只有新节点有子节点
        else if (isDef(ch)) {
            //非生产环境,检查元素key是否重复
            if (process.env.NODE_ENV !== 'production') {
                checkDuplicateKeys(ch)
            }
            //新节点没有text属性,若旧节点有text属性,则将该属性的值设置为空字符串
            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
            //添加节点
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        }
        //只有旧节点有子节点,则删除dom中的该子节点
        else if (isDef(oldCh)) {
            removeVnodes(oldCh, 0, oldCh.length - 1)
        }
        //新节点没有text属性,而旧节点有text属性,则将元素的text设置为空串
        else if (isDef(oldVnode.text)) {
            nodeOps.setTextContent(elm, '')
        }
    }
    //新节点vnode的text属性存在
    else if (oldVnode.text !== vnode.text) {
        //新旧节点text不同,则将新节点的text替换旧的
        nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
}

第四节·篇章小结

本篇研究src/core/vdom中的代码,介绍了:

①虚拟dom的定义。虚拟dom是指用JavaScript编写的伪dom。

②虚拟dom的优势。操作真实dom的开销很大,而使用虚拟dom,可以减少重排重绘,减少操作真实dom。基于性能优化的需求,选择了虚拟dom。

③虚拟dom的组成要素。虚拟dom的组成要素虚拟节点,分为组件节点、元素节点、函数组件节点、注释节点、文本节点、克隆节点,共6种。

④vue中的dom-diff过程,详细介绍了patch,patch过程干了三件事,分别是创建节点,删除节点,更新节点。