聊聊虚拟 DOM 也许没你想的那么难

120 阅读5分钟

前言

说到虚拟DOM大家都不陌生,其实就是用一个对象来描述dom节点。例如Vue中的h方法,第一个参数是标签,第二个参数是属性,以后所有标签都是子节点,如下:

h(
    'div', 
    {id: 'testDom', data: '测试属性'}, 
    h('span', {style: {color: 'red'}}, '我是span内容'), 
    '测试内容'
)

实际上渲染出来就是:

<div id="testDom" data="测试属性">
    <span style="color: red"> 我是span内容 </span>
    测试内容
</div>

而用虚拟DOM表示出来就是:

{
    type: 'div',
    props: {id: 'testDom', data: '测试属性'},
    children: [
        {
            type: 'span',
            props: {style : {color: 'red'}},
            children: [
                text: '我是span内容'
            ]
        },
        {
            type: '',
            props: '',
            children: [],
            text: '测试内容'
        }
    ]
}

接下来我们来具体看下实现过程,可能如你所想就是那么简单~~

createElement() 方法实现

/**
 * 创建标签
 * @param {*} type 类型
 * @param {*} props 节点属性
 * @param  {...any} children 所有孩子
 */
function createElement(type, props={}, ...children){
    let key;
    if(props.key){
        key = props.key;
        delete props.key;
    }
    
    return{
        type,
        props,
        key,
        children
        text: undefined
    }
}

由上可知:key可以通过props属性传入,且方法最终返回一个对象。但为了操作方便,我们可以把返回对象的操作单独提出一个方法叫vnode来单独处理, 如下:

/**
 * 创建标签
 * @param {*} type 类型
 * @param {*} props 节点属性
 * @param  {...any} children 所有孩子
 */
function createElement(type, props={}, ...children){
    let key;
    if(props.key){
        key = props.key;
        delete props.key;
    }
    
    return vnode(type, props, key, children);
}

function vnode(type,props,key,children,text){
    return {
        type,
        props,
        key,
        children,
        text
    }
}

测试下我们写的代码,如下:

let vd = createElement(
    'div',
    {id: 'testDom', data: '测试属性', key: 'testKey'},
    createElement('span', {style: {color: 'red'}}, '我是span内容'),
    '测试内容'
)

console.log(vd);

输出结果如下:

1655102134525.jpg

可以看到部分节点已经被转换成虚拟节点了,但children里有点问题,纯文本节点没有被处理成虚拟节点,这里需要将createElement优化一下,如下:

function createElement(type, props, ...children){
   let key;
    if(props.key){
        key = props.key;
        delete props.key;
    }

    // 将不是虚拟节点的处理成虚拟节点
    children = children.map(child => {
        if(typeof child === 'string'){
            return vnode(undefined, undefined, undefined, undefined, child);
        }else{
            return child;
        }
    })

    return vnode(type, props, key, children);
}

优化后所有节点已经被处理成虚拟节点了,输出结果如下:

image.png

render() 方法

createElement()方法只是创建虚拟节点,我们要把创建好的虚拟节点渲染到页面上,这里就需要使用render()方法了,如下:

/**
 * 渲染虚拟节点
 * @param {*} vnode 用户写的虚拟节点
 * @param {*} container 要渲染到哪个容器中
 */
function render(vnode, container){
    // 将虚拟节点转换为真实节点
    let ele  = createDomElementFromVnode(vnode);
    // 简洁点渲染到容器中
    container.appendChild(ele);
}

//  通过虚拟的对象,创建一个真实的dom元素
function createDomElementFromVnode(vnode){
    let {type, key, props, children, text} = vnode;
    // 如果有类型,则是一个标签
    if(type){
        // 建立虚拟节点和真实元素的一个关系,后边可用来更新真实的dom
        vnode.domElement = document.createElement(type);
    }
    // 否则就是一个文本
    else{
        vnode.domElement = document.createElement(text);
    }

    return vnode.domElement;
}

写到这里我们测试下我们写的代码,如下

html ------

<template>
    <div id="test_container"> </div>
</template>

js ------

let vd = createElement(
    'div',
    {id: 'testDom', data: '测试属性', key: 'testKey'},
    createElement('span', {style: {color: 'red'}}, '我是span内容'),
    '测试内容'
)

window.onload = function(){
    let div = document.getElementById('test_container');
    render(vd, div)
}

渲染结果如下:

image.png

如上:在容器test_containern内创建了一个空的div

updateProperties() 方法

我们已经可以在指定容器中创建一个标签,但标签的属性及子标签并没有创建,所以接下来我们需要把属性和子标签更新上,如下:

//  通过虚拟的对象,创建一个真实的dom元素
function createDomElementFromVnode(vnode){
    let {type, key, props, children, text} = vnode;
    // 如果有类型,则是一个标签
    if(type){
        // 建立虚拟节点和真实元素的一个关系,后边可用来更新真实的dom
        vnode.domElement = document.createElement(type);
        //根据当前的虚拟节点的属性更新真实的dom元素
        updateProperties(vnode);
    }
    // 否则就是一个文本
    else{
        vnode.domElement = document.createElement(text);
    }

    return vnode.domElement;
}

/**
 * 更新属性:会根据老的属性和新的属性重新更新节点
 * @param {*} newVnode 新节点
 * @param {*} oldVnode 旧节点
 */
function updateProperties(newVnode, oldVnode = {}){
    let domElement = newVnode.domElement; // 当前真实dom元素
    let newProps = newVnode.props; // 当前虚拟节点中的属性

    // 如果老的里面有,新的里面没有,则移除这个属性
    for(let oldPropName in oldProps){
        if(!newProps[oldPropName]){
            delete domElement[oldPropName]
        }
    }

    let newStyleObj = newProps.style || {};
    let oldStyleObj = oldProps.style || {};

    // 比对新旧连个style, 新的里面没有则删除此属性
    for(let propName in oldStyleObj){
        if(!newStyleObj[propName]){
            domElement.style[propName] = '';
        }
    }

    // 如果如果老的里面没有,新的里面有,则添加这个属性
    for(let newPropsName in newProps){
        // 这里只单独处理下 style, 其他的 @click 之类的暂不处理
        if(newPropsName === 'style'){
            let styleObj = newProps.style;
            for(let s in styleObj){
                domElement.style[s] = styleObj[s];
            }
        }else{
            // 用新节点的属性直接覆盖掉点老节点的属性即可
            domElement[newPropsName] = newPropsName;
        }
    }
}

写到此:第一层的div的属性应该加上了,如下图:

image.png

我们还是只创建了第一层的标签,即子标签并没有创建,该怎么创建子标签呢?其实也很简单:遍历一下子节点即可,如下:

//  通过虚拟的对象,创建一个真实的dom元素
function createDomElementFromVnode(vnode){
    let {type, key, props, children, text} = vnode;
    // 如果有类型,则是一个标签
    if(type){
        // 建立虚拟节点和真实元素的一个关系,后边可用来更新真实的dom
        vnode.domElement = document.createElement(type);
        // 根据当前的虚拟节点的属性更新真实的dom元素
        updateProperties(vnode);
        // 递归遍历子的虚拟节点
        children.forEach(child => render(child, vnode.domElement));
    }
    // 否则就是一个文本
    else{
        vnode.domElement = document.createTextNode(text);
    }

    return vnode.domElement;
}

这时渲染的节点如下图:

image.png

到这里简单的虚拟Dom咱们就说完了,希望对你有帮助~~ \

下篇文章 聊聊 Vue 的 Diff 算法也许没你想的那么难 欢迎阅读

欢迎评论留言~~~