Vue源码解析04——手写虚拟DOM

手写虚拟DOM

什么是虚拟DOM

概念

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

虚拟DOM的优点

  • 虚拟DOM轻量、快速。当虚拟DOM发生变化时通过新旧虚拟DOM的对比,得到最小的DOM操作量,从而提升性能和用户体验。

  • 跨平台:虚拟DOM是可以实现跨平台的,将虚拟DOM转换为不同平台运行时的操作来实现跨平台。

  • 兼容性:还可以加入兼容性的代码增强兼容性

虚拟DOM的创建

在react和Vue中,虚拟dom的创建都是由模板或者JSX完成,模版到compile和render函数的转译和JSX到新建虚拟dom的转译是由工程化(webpack、loader)完成。
既然是手写,那么我们就用最原始的方式来完成。

createElement 创建虚拟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'//存在多个子元素
    }

注意!这里可能存在一个概念上的误区。vnodeType是指当前元素虚拟DOM的类型,主要是用来描述当前元素。
chileType我们可以简单的概括为描述子元素的个数,这个childType主要是在处理子元素更新的时候使用。

  • 这里还得顺便提一个问题,因为我们以后做更新操作的时候需要经常用到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
    let flag //用来标记当前vnode类型
    if (typeof tag === 'string') {
        //这是一个普通的html标签
        flag = vnodeType.HTML
    } else if (typeof tag === 'function') {
        //如果是一个function,我们只写简版的,这里不做区分
        flag = vnodeType.COMPONENT
    } else {
        flag = vnodeType.TEXT
    }
    let childrenFlag //标记children的类型,(因为我们在更新的时候会涉及到子元素的更新,所以这里也要标记一些children的类型)
    if (children == null) {
        //如果为空
        childrenFlag = childType.EMPTY
    }else if (Array.isArray(children)) {
        //如果为数组
        let length = children.length
        if(length==0){
            childrenFlag = childType.EMPTY
        }else{
            childrenFlag = childType.MULTIPLE
        }
    }else {
        //其他情况认为是文本,文本按单个元素处理
        childrenFlag = childType.SINGLE
        //将children处理一下,返回文本类型的vnode
        children = createTextVnode(children+'')
    }
    //返回虚拟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
    }
}

虚拟DOM的渲染

渲染操作是虚拟DOM的核心操作。我们这里只实现简版的渲染方便理清思路。

    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简单的渲染方法,处理完毕,我们可以进行一个简单的测试:
    <style>
        .m-item{
            font-size: 18px;
            color: purple;
            border: 1px solid orchid;
        }
    style>
    <div id="app">
    div>
    <script>
        let vnode = createElement('div', {
            id: 'test'
        }, [
            createElement('p', {key:'a',style:{color:'red'}}, '纯文本1'),
            createElement('p', {key:'b','@click':()=>alert('文本2')}, '纯文本2'),
            createElement('p', {key:'c','class':'m-item'}, '纯文本3'),
            createElement('p', {key:'d'}, '纯文本4')
        ]);
        render(vnode, document.getElementById('app'))
    script>

运行上述代码,我们手写的js文件就会将上面createElement中的内容渲染到界面中,说明代码功能正常。那么我们接着进行下一步的操作。

虚拟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操作比较简单,这里不进行赘述

    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)
    }

  • 这里pacth的核心在于更新vnode是’HTML’的情况
    function patchElement(prev, next, container) {
    if (prev.tag !== next.tag) {
        //如果两者标签类型不同,直接记性替换操作
        replaceVnode(prev, next, container)
        return
    }
    let el = (next.el = prev.el)
    let prevData = prev.data
    let nextData = next.data
    if (nextData) {
        //实现更新和新增属性
        for (let key in nextData) {
            let prevVal = prevData[key]
            let nextVal = nextData[key]
            patchData(el, key, prevVal, nextVal)
        }
    }

    if (prevData) {
        //删除newData中不存在的属性
        for (let key in prevData) {
            let prevVal = prevData[key]
            if (prevVal && !nextData.hasOwnProperty(key)) {
                patchData(el, key, prevVal, null)
            }
        }
    }
    //data更新完毕
    //开始更新子元素
    patchChildren(
        prev.childrenFlag,//子元素的类型
        next.childrenFlag,
        prev.children,//子元素
        next.children,
        el,//当前元素
    )
}

这里的patchElement简单来说实现了两个功能,就是更新了dom的属性和子元素,其中最重要的部分也是所有使用虚拟DOM框架的核心部分,就是patchChildren.一般情况下,不同的框架会在这个方法的diff算法上进行区分.

patchChildren

patchChildren顾名思义就是对children进行更新,考虑到新旧vnode中children的类型(即single,empty,multiple三种),这里新老vnode两两结合会出现九种情况.其中新旧vnode的children都为数组(mutiple)的情况是最为复杂的.其他的八种情况我们在这里只是做一些简单的替换和删除操作.

function patchChildren(
    prevChildrenFlag,//子元素的类型
    nextChildrenFlag,
    prevChildren,//子元素
    nextChildren,
    container,//当前元素
) {
    //更新子元素

    //先根据prevChildrenFlag进行区分
    switch (prevChildrenFlag) {
        case childType.SINGLE:
            switch (nextChildrenFlag) {
                case childType.SINGLE:
                    //两个都为single,则直接调用patch更新
                    patch(prevChildren,nextChildren,container)
                    break;
                case childType.EMPTY:
                    container.removeChild(prevChildren.el)
                    break;
                case childType.MULTIPLE:
                    //如果新的为多个,老得是单个,我们直接进行简化处理
                    container.removeChild(prevChildren.el)
                    for(let i=0; i<nextChildren.length;i++){
                        mount(nextChildren[i],container)
                    }
                    break;
            }
            break
        case childType.EMPTY:
            //老得是空的,新的直接添加就好
            switch (nextChildrenFlag) {
                case childType.SINGLE:
                    mount(nextChildren,container)
                    break;
                case childType.EMPTY:
                    break;
                case childType.MULTIPLE:
                    for(let i = 0;i<nextChildren;i++){
                        mount(nextChildren[i],container)
                    }
                    break;
            }
            break;

        case childType.MULTIPLE:
            //如果老得是数组的情况
            switch (nextChildrenFlag) {
                case childType.SINGLE:
                    //我们这里直接简化处理,删掉之前的,添加新的
                    for(let i=0;i<prevChildren.length;i++){
                        container.removeChild(prevChildren[i].el)
                    }
                    mount(nextChildren,container)
                    break;
                case childType.EMPTY:
                    for(let i=0;i<prevChildren.length;i++){
                        container.removeChild(prevChildren[i].el)
                    }
                    break;
                case childType.MULTIPLE:
                    //众多虚拟DOM在这里进行区分,每家优化算法不同
                    let lastIndex=0;
                    for (let i=0;i<nextChildren.length;i++){
                        let nextVnode = nextChildren[i]
                        let j ;
                        //用来标记某个新老children是否同时存在,如果同时存在则对其进行更新操作,如果老children不存在,则进行新增操作
                        find =false;

                        for (j;j<prevChildren.length;j++){
                            let prevVnode = prevChildren[j]
                            if( prevVnode.key===nextVnode.key){
                                find = true;
                                //key相同,我们认为是同一个元素
                                //patch更新自己的内容
                                patch(prevVnode,nextVnode,container)
                                if(j<lastIndex){
                                    //需要移动
                                    //insertBefore移动元素
                                    //找到要修改元素的下一个兄弟元素
                                    let flagNode = nextChildren[i-1].el.nextSibling
                                    container.insertBefore(prevVnode.el,flagNode)
                                    break;
                                }else{
                                    lastIndex=j
                                }
                            }
                            if(!find){
                                //需要新增的
                                let flagNode =i==0?prevChildren[0].el:nextChildren[i-1].el;
                                mount(nextVnode,container,flagNode)
    
                            }
                        }
                    }
                    //移除不需要的元素
                    for(let i=0;i<prevChildren.length;i++){
                        const prevVnode = prevChildren[i]
                        const has  = nextChildren.find(next=>next.key===prevVnode.key)
                        if(!has){
                            container.removeChild(prevVnode.el)
                        }
                    }
                    break;
            }
            break;

    }
}

上述代码是对children的更新操作,其中最核心的部分就是当新老dom的子元素都有多个的时候,在这里我们采用了React 15中的处理算法,这里介绍一下简化版.

  • 首先我们可以简单的把dom的children看作一个数组:[1,2,3,4],假设新的数组是[2,1,4,5]
  • 我们需要做的是遍历两个数组进行对比,我们通过对比发现21的前面,那么我们只需要把老数组中的1移动到2的后面
  • 按照上面的原理可以完成排序操作,其中新元素进行新增,新数组中没有出现的元素进行删除操作。

当然,React中真正的diff操作,要比这个复杂的多。因为我们是自己手写一个简版的,所以只是又一个简单概念即可。

以上便是完整的更新操作了,我们可以通过代码测试一下:

<style>
        .m-item{
            font-size: 18px;
            color: purple;
            border: 1px solid orchid;
        }
    </style>
<div id="app">
</div>
    <script>
        let vnode = createElement('div', {
            id: 'test'
        }, [
            createElement('p', {key:'a',style:{color:'red'}}, '纯文本1'),
            createElement('p', {key:'b','@click':()=>alert('文本2')}, '纯文本2'),
            createElement('p', {key:'c','class':'m-item'}, '纯文本3'),
            createElement('p', {key:'d'}, '纯文本4')
        ]);

        let vnode1 = createElement('div', {
            id: 'test'
        }, [
            createElement('p', {key:'c'}, '纯文本3'),
            createElement('p', {key:'d','@click':()=>alert('文本1'),'class':'m-item'}, '纯文本4'),
            createElement('p', {key:'a',style:{color:'blue'}}, '纯文本1'),
            createElement('p', {key:'e',style:{color:'#ccc'}}, '纯文本5')
        ]);
        render(vnode, document.getElementById('app'))
        setTimeout(()=>{
            render(vnode1,document.getElementById('app'))
        },1000)
    </script>

总结

虚拟DOM就是一个可对真实dom进行描述的JS对象。因为真实dom中存在海量的属性,没次更新时都会造成大量的开销。
使用虚拟DOM进行diff算法可以在最小操作量的情况下进行dom更新。从而提升用户体验和性能。

♣以上,是个人对虚拟DOM一点浅显的了解,在此做一下记录。

你可能感兴趣的:(Vue,虚拟DOM,Vue源码,虚拟DOM,diff算法)