Vue3 - 虚拟DOM&diff 源码

为什么使用虚拟dom

有效地减少对真实DOM的操作, 虚拟DOM就是目前较为流行的一个解决方案,

虚拟DOM带来了一个重要的优势,那就是我们可以在完全不访问真实DOM的情况下,掌握DOM的结构,这为框架自动优化DOM操作提供了可能。举例来说,如果我们本打算手动进行三次真实DOM操作,而框架在分析了虚拟DOM的结构后,把这三次DOM操作简化成了一次,这不就带来了性能上的提升吗?

虚拟DOM设计的核心就是用高效的 js 操作,来减少低性能的DOM操作,以此来提升网页性能。

通过patch 操作对 真实的dom 进行少次精准的操作

虚拟DOM

节点

虚拟DOM(Virtual DOM) 是对DOM的JS抽象表示,它们是JS对象,能够描述DOM的结构和关系。应用的各种状态变化会体现虚拟DOM上,最终映射到真实DOM。

在渲染虚拟DOM之前,我们要做一些准备工作,通过观察真实DOM和组件我们可以知道:

  • 虚拟DOM需要有自己的类型,例如:HTML标签、纯本文、组件(组件又分为class组件和function组件)等,因为我们是实现一个简单的虚拟DOM,所以我们只实现纯文本和HTML标签

  • 我们知道真实DOM类似一个树形结构,所以我们需要知道DOM子元素的类型,是单个子元素、多个子元素还是空(纯文本或者为空)

  • 同时,虚拟DOM是对真实DOM的描述,那么对于单个元素我们可以简单的概括为:标签、属性(属性包括样式、id、class、点击事件等)

所以我们首先要定义一些常量对上述进行区分

//虚拟DOM的类型
    const vnodeType={
        HTML:'HTML',//html标签
        TEXT:'TEXT',//纯文本
        COMPONENT:'COMPONENT',//function组件
        CLASS_COMPONENT:'CLASS_COMPONENT',//class组件
    }
// 子元素的类型
    const childType = {
        EMPTY:'EMPTY',//子元素为空(这里单纯指纯文本的情况)
        SINGLE:'SINGLE',//单个子元素
        MULTIPLE:'MULTIPLE'//存在多个子元素
    }

因为我们以后做更新操作的时候需要经常用到vnode对应的真实dom,所以我们定义一个字段用来存储dom。在后续更新操作中,我们需要用到key这个属性,以便新旧dom更新的时候进行diff算法

/**
 * 新建虚拟DOM
 * @param {string} tag 因为是实现简单的虚拟DOM,所以这个tag是简单的元素标签名
 * @param {obj} data 属性
 * @param {obj} children 子元素
 */
function createElement(tag, data, children = null) {
    //新建虚拟DOM
   ..........................................
    //返回虚拟DOM:vnode
    return {
        flag,//用来标记当前vnode的类型
        tag,//标签:div, 文本(为空),组件(一个函数,暂时不涉及)
        data,//数据
        children,//子元素
        childrenFlag,//children的类型
        el:null//存储真实dom,开始默认为null
    }
}
/**
 * 处理children变为文本类型的vnode
 * @param {*} text children元素
 */
function createTextVnode(text) {
    return {
        flag: vnodeType.TEXT,
        tag: null,
        key:data&&data.key,
        children: text,
        childrenFlag: childType.EMPTY,
        el: null
    }
}

渲染

有结点之后,开始渲染节点

 function render(){
        //首先我们需要区分首次渲染和再次渲染
        mount(vnode,container)
    }

    /**
    * 首次渲染虚拟DOM,挂载
    * @param {obj} vnode 虚拟DOM
    * @param {*} container 渲染容器
    * @param {} flagNode 用来判断时是否进行插入操作
    */
    function mount(vnode,container,flagNode){
        //首先,根据vnode的flag进行渲染
        let {flag} = vnode
        //这里根据vnode的类型判断执行方式的挂载
        if(flag === vnodeType.HTML){
            //如果为html标签
            mountElement(vnode,container,flagNode)
        }else if(flag ===vnodeType.TEXT){
            //如果为text纯文本
            mountText(vnode,container)
        }
    }

    /**
    * 挂载html类型的虚拟DOM
    * @param {obj} vnode 虚拟DOM
    * @param {*} container 容器
    * @param {} flagNode 用来判断时是否进行插入操作
    */
    function mountElement(vnode,container,flagNode){
        //html类型的vnode要根据标签名称创建dom
        let dom = document.createElement(vnode.tag)
        //在初次挂载的时候就将当前vnode对应的真实dom挂载到当前vnode上,以便后面挂载子元素的时候使用
        vnode.el =dom
        let {data,children,childrenFlag} = vnode

        //挂载属性
        if (data) {
            for (let key in data) {
                //挂载data
                patchData(vnode.el, key, null, data[key])
            }
        }
        //开始挂载子元素
        if(childrenFlag !==childType.EMPTY){
            //如果子元素不为空
            if(childrenFlag===childType.SINGLE){
                //挂载子元素
                mount(children,vnode.el)
            }else if(childrenFlag===childType.MULTIPLE){
                for(let i=0;i<children.length;i++){
                    mount(children[i],vnode.el)
                }
            }
        }
        //挂载dom
        flagNode?container.insertBefore(el, flagNode):container.appendChild(dom)
    }
    /**
    * 挂载纯文本类型的虚拟DOM
    * @param {odj} vnode 虚拟DOM
    * @param {*} container 容器
    */
    function mountText(vnode, container) {
        //纯文本类型的vnode,子元素就是文本,所以直接执行
        let dom = document.createTextNode(vnode.children)
        vnode.el = dom
        container.appendChild(vnode.el)
    }

挂载属性

渲染虚拟DOM的时候我们还需要一个方法来渲染其中的data属性,也就是属性的挂载。挂载属性的时候我们需要对其进行区分,不同的属性进行不同的处理。

/**
    * 挂载属性
    * @param {*} dom 节点真实dom
    * @param {string} key data对应的key
    * @param {obj} preData data老值
    * @param {obj} newData data新值
    */
    function patchData(dom, key, preData, newData) {
        //根据不同类型的属性实现不同方式的渲染
        switch (key) {
            case 'style':
                for (let k in newData) {
                    //挂载style相应的属性
                    dom.style[k] = newData[k]
                }
                //patch的时候需要删除某些属性
                break;
            case 'class':
                dom.className = newData
                break;
            default:
                if (key[0] === '@') {
                    //存在@符号我们认为是点击事件
                    if (newData) {
                        dom.addEventListener(key.split(1), newData)
                    }
                } else {
                    //否则,这里我们用粗暴的方式处理一下
                    dom.setAttribute(key, newData)
                }
                break;
        }
    }

虚拟DOM更新

之前的代码实现了虚拟DOM的初次挂载。但是当我们对虚拟DOM进行更改时需要的是更新操作。同时更新操作也是render过程中比较复杂的部分。

更改渲染方法

 function render(vnode, container) {
        //首先我们需要区分首次渲染和再次渲染
        if(container.vnode){
            patch(container.vnode,vnode,container)
        }else{
            mount(vnode, container)
        }
        //挂载完毕后将vnode挂载到container中,以此判断,是第一次渲染还是后续更新渲染
        container.vnode = vnode
    }
更新方法:patch

该函数主要的作用是根据新老vnode中的flag属性区分实现何种更新操作,其中替换操作和更新text操作比较简单
无论是初始化还是更新都是靠patch来完成的

 function patch(prev,next,container){
        let nextFlag = next.flag
        let prevFlag = prev.flag
        //根据新老虚拟DOM的类型进行不同的处理
        if(nextFlag!==prevFlag){
            //如果flag类型不同,我们直接执行替换操作
            replaceVnode(prev,next,container);
        }else if(nextFlag==vnodeType.HTML){
            //更新element
            patchElement(prev,next,container)
        }else if(nextFlag==vnodeType.TEXT){
            //更新text
            patchText(prev,next)
        }
    }

    /**
    * 更新Text
    * @param {ovj} prev 旧的vnode
    * @param {*} next 新的vnode
    */
    function patchText(prev,next){
        let el = (next.el = prev.el)
        if(next.children!==prev.children){
            //直接更改dom中的text
            el.nodeValue = next.children
        }
    }
    /**
    * 更新虚拟DOM的替换操作
    * @param {obj} prev 旧的vnode
    * @param {obj} next 新的vnode
    * @param {*} container 容器
    */
    function replaceVnode(prev,next,container){
        //直接进行替换操作
        container.removeChild(prev.el)
        mount(next,container)
    }
const patchVNode = (oldVNode, newVNode) => {
    // 元素标签相同,进行patch
    if (oldVNode.tag === newVNode.tag) {
        // 元素类型相同,那么旧元素肯定是进行复用的
        let el = newVNode.el = oldVNode.el
        // 新节点的子节点是文本节点
        if (newVNode.text) {
            // 移除旧节点的子节点
            if (oldVNode.children) {
                oldVNode.children.forEach((item) => {
                    el.removeChild(item.el)
                })
            }
            // 文本内容不相同则更新文本
            if (oldVNode.text !== newVNode.text) {
                el.textContent = newVNode.text
            }
        } else {
            // ...
        }
    } else { // 不同使用newNode替换oldNode
        // ...
    }
}

更新新节点这里用到了 diff 新旧节点的对比

1、获取子节点和子节点的类型,新节点 n2 和 旧节点 n1 中,一共有三种类型的子节点:文本节点,数组节点,空节点
2、如果 新节点是 文本节点,而 老节点 是数组节点的话,就删除老姐点,这样老节点就是空,然后插入文本节点
3、如果 新节点是 文本节点,老节点 是文本节点,比较新旧节点 文本 是否一致,否就 替换文本内容
4、如果 老节点是数组节点,而新节点也是数组节点的话,进入 patchKeyedChildren ,也就是 diff 的过程
5、如果 老节点是数组节点,而新节点是空节点 的话,进入 删除老节点
6、这样比较下来,剩下的情况就是 : 之前的节点 要么是 文本节点,或者为空,而新的节点 要么是 数组,要么是空
7、所以如果之前的节点是文本节点,删除 老节点的文本内容
8、如果 新节点是数组,就把新节点添加到 dom 树上去
9、最后就剩下新节点是空节点,不做任何操作(在第7点已经把老节点删除了)

参考

深入浅出虚拟 DOM 和 Diff 算法,及 Vue2 与 Vue3 中的区别

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值