都2020年了,我还不懂虚拟DOM

3,080 阅读14分钟

作者:小土豆biubiubiu
博客园:www.cnblogs.com/HouJiao/
掘金:juejin.im/user/243617…

前言

2020年,vue3.0 betavue3 rc陆续发布,优秀的人也早已开始各种实践新版本的新特性,而我还不懂虚拟DOM,所以赶紧跟学起来。

🐱 黑发不知勤学早,白首方悔读书也不迟

简单理解虚拟DOM

当我们打开一个页面,点击查看元素,就能在开发中工具中看到页面对应的DOM节点

假如我们将这些DOM节点使用一个js对象去表示,那这个js对象就可以被称之为虚拟DOM

举个栗子

下面有这样一段DOM节点。

<div id="app" >
    <h3>内容</h3>
    <ul class="list">
        <li>选项一</li>
        <li>选项二</li>
    </ul>
</div>

我将这段DOM节点手动转化为一个JS对象。

vdom = {
    type: 'div',  // 节点的类型,也就是节点的标签名
    props: {      // 节点设置的所有属性
        'id': 'content'
    },
    children: [   // 当前节点的子节点
        {
            type: 'h3',
            props: '',
            children:['内容']
        },
        {
            type: 'ul',
            props: {
                'class': 'list'
            },
            children: {
                {
                    type: 'li',
                    props: '',
                    children: ['选项一']
                },
                {
                    type: 'li',
                    props: '',
                    children: ['选项二']
                }
            }
        }
    ]
}

手动转化出来的vdom对象就是我们所描述的虚拟DOM

虚拟DOM的代码实现

前面我们手动将DOM节点转化虚拟DOM,那这一节将使用代码实现这个转化。

项目环境搭建

本篇文章的示例使用npm进行搭建,最终的一个目录结构如下:

virtual-dom
    | dist                webpack打包后的文件目录
    | node_modules   
    | src                 源代码目录
    | index.html          测试的html文件
    | index.js            打包的入口文件
    | package-lock.json
    | package.json
    | webpack.config.js   webpack配置文件

定义虚拟DOM的数据结构

首先我们先将虚拟DOM的三个属性定义出来:typepropschildren

// 代码位置:/virtual-dom/src/virtualDOM.js
/*
*   @params: {String} type      标签元素的类型,也就是标签名称
*   @params: {Object} props     标签元素设置的属性
*   @params: {Array}  children  标签元素的子节点
*/
function VirtualDOM(type, props, children){
    this.type = type;
    this.props = props;
    this.children = children;   
}

接着定义一个创建虚拟dom的方法。

// 代码位置:/virtual-dom/src/virtualDOM.js
/*
*  创建虚拟DOM的方法
*  @method create
*  @return {VirtualDOM} 返回创建出来的虚拟DOM对象
*/
function create(type, props, children){
    return new VirtualDOM(type, props, children)
}

export { VirtualDOM, create } 

该方法用来创建虚拟DOM对象,这样就不用我们每次都使用new关键字进行创建

最后就是调用create方法,传入对应的参数。

// 代码位置:/virtual-dom/index.js
import {create} from './src/virtualDOM'

let vdom = create('div', {'class': 'content'}, [
    create('h3', {}, ['内容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['选项一']),
                create('li', {}, ['选项二'])
    ])
])

console.log(vdom);

最后我们看一下代码生成的结果:

可以看到跟我们前面手动转化的vdom结果一致。

将虚拟DOM转化为真实节点

虚拟DOM它实际就是存储在内存中的一个数据,那终极目标是需要将这个数据转化为真实的DOM节点展示到浏览器上,所以接下来我们再来实现一下将虚拟DOM转化为真实的DOM节点。

将虚拟DOM转化为真实节点的思路和步骤大致如下:

根据type属性创建节点 
设置节点属性  
处理子节点:根据子节点的type创建子节点、设置子节点属性,添加子节点到父节点中

前两个步骤很简单也很容易理解,最后一个步骤实际上是前两个步骤的重复执行,因此最后一个步骤我们会使用递归进行实现。

那么接下来就代码实现一下。

根据type属性创建节点

// 代码位置:/virtual-dom/src/render.js
/*
*   将虚拟节点转化为真实的DOM节点并返回
*   @method render
*   @params {VirtualDOM}  vdom    虚拟DOM对象
*   @return {HMTLElement} element 返回真实的DOM节点 
*/
function render(vdom){
    var type = vdom.type;
    var props = vdom.props;
    var children = vdom.children;
    // 根据type属性创建节点
    var element = document.createElement(vdom.type);

    return element;
}
export { render };

这里我们将逻辑写在render函数中,并且返回创建好的真实DOM节点

设置节点属性

// 代码位置:/virtual-dom/src/render.js
/*  
*   为DOM节点设置属性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的属性
*/
function setProps(element, props){
    for (var key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

设置节点的属性这个功能由setProps函数实现

然后我们需要在render函数中调用setProps方法,实现节点属性的设置。

// 代码位置:/virtual-dom/src/render.js
/*
*   将虚拟节点转化为真实的DOM节点并返回
*   @method render
*   @params {VirtualDOM}  vdom    虚拟DOM对象
*   @return {HMTLElement} element 返回真实的DOM节点 
*/
function render(vdom){
    var type = vdom.type;
    var props = vdom.props;
    var children = vdom.children;
    // 根据type属性创建节点
    var element = document.createElement(vdom.type);

    // 设置属性
    setProps(element, props);
    
    return element;
}

/*  
*   为DOM节点设置属性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的属性
*/
function setProps(element, props){
    for (var key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

处理子节点

// 代码位置:/virtual-dom/src/render.js
import { VirtualDOM } from './virtualDOM';
/*
*   将虚拟节点转化为真实的DOM节点并返回
*   @method render
*   @params {VirtualDOM}  vdom    虚拟DOM对象
*   @return {HMTLElement} element 返回真实的DOM节点 
*/
function render(vdom){
    let type = vdom.type;
    let props = vdom.props;
    let children = vdom.children;
    // 根据type属性创建节点
    let element = document.createElement(vdom.type);

    // 设置属性
    setProps(element, props);

    // 设置子节点
    children.forEach(child => {
        // 子节点是虚拟VirtualDOM的实例 递归创建节点、设置属性
        if(child instanceof VirtualDOM){
            let childEle = render(child);
        }else{
            // 子节点是文本
            let childEle = document.createTextNode(child); 
        }
        // 添加子节点到父节点中
        element.appendChild(childEle);
    });
    return element;
}

/*  
*   为DOM节点设置属性
*   @method setProps
*   @params {HTMLElement} element  dom元素
*   @params {Object}      props    元素的属性
*/
function setProps(element, props){
    for (let key in props) {
        element.setAttribute(key,props[key]);
    }
}

export { render };

在设置子节点的时候,有一个逻辑判断:判断子节点是否为虚拟VirtualDOM的实例,如果是的话,则需要递归调用render函数处理子节点;否则的话就说明子节点是文本内容。这个判断逻辑的处理是根据前面两节虚拟DOM创建的结果而定的。

这块逻辑判断不是固定的写法,假如前面在生成虚拟DOM时文本类型是另外一种表示方式,那这个逻辑判断也就是另外一种写法了。

整合逻辑

那最后一步我们把前面的virtualDOM.jsrender.js整合到一起,实现真实DOM转化为虚拟DOM,在将虚拟DOM转化为真实DOM,最后在将生成后的真实DOM添加到页面的body元素中。

// 代码位置:/virtual-dom/index.js
import { create} from './src/virtualDOM'
import { render } from './src/render'

// 创建虚拟DOM
let vdom = create('div', {'class': 'content'}, [
    create('h3', {}, ['内容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['选项一']),
                create('li', {}, ['选项二'])
    ])
])

// 将虚拟DOM转化为真实DOM
let realdom = render(vdom);

// 将真实DOM插入body元素中
document.body.appendChild(realdom);

最后浏览器中打开这个index.html文件。

可以看到,由vdom转化后的readldom插入到页面后和原始的真实DOM是一样的,说明我们这个转化是成功的。

dom-diff算法

前面总结了那么多关于虚拟DOM的内容,最后就是核心的dom-diff算法了。 dom-diff算法做的事情就是比较之前旧的虚拟DOM和当前新的虚拟DOM两者之间的差异,然后将这部分差异的内容进行更新到文档中。

上文描述的差异称之为补丁:patches

那差异是怎么进行比较的呢?回归到我们的实现的虚拟DOM上。

/*
*   @params: {String} type       标签元素的类型,也就是标签名称
*   @params: {Object} props     标签元素设置的属性
*   @params: {Array}  children  标签元素的子节点
*/
function VirtualDOM(type, props, children){
    this.type = type;
    this.props = props;
    this.children = children;   
}

虚拟DOM对象最基本的就三个属性:标签类型标签元素的属性标签元素的子节点,所以说当两个虚拟DOM对象进行一个差异比较时,比较的也就是这三个属性。

那具体怎么个比较法呢,接下来我手动比一比下面两个虚拟DOM

手动比较出来oldDomnewDom这两个的差异(patches):

这个是我们手动比较出来的两个DOM的差异,这些差异基本上包含了DOM属性的变化、文本内容的变化、DOM节点的删除以及替换。

这样的比较结果使用一个js数据去表示,大概是这样的结构:

patches = {
    '0': [
        {
            type: 'props',   // 属性发生变化
            props: {
                class: 'box',
                id: 'wapper'
            }
        }
    ],
    '1': [
    	{
          type: 'replace',   // 节点发生替换
          content: {
          	type: 'h4', 
            {}, 
            children: ['内容']
           }
    	}
    ],
    '5':[
        {
            type: 'text',  // 文本内容变化
            content: '内容一'

        }
    ],
    '6': [
        {
            type: 'remove',  // 节点被移除
        }
    ]
}

这样的比较结果也比较清晰明了,不过这个手动的比较结果怎么用代码去实现呢?这个就是我们大名鼎鼎的DOM-Diff算法。

DOM-diff算法发核心就是对虚拟DOM节点进行深度优先遍历并对每一个虚拟DOM节点进行编号,在遍历的过程中对同一个层级的节点进行比较,最终得到比较后的差异:patches

注意dom-diff在比较差异时只会对同一层级的节点进行比较,因为如果进行完全的比较,算法实际复杂度会过高,所以舍弃了这种完全的比较方式,而采用同层比较(这里参考其他文章,因为算法不精,没有具体研究过)

那话不多说,我们这就来用代码简单实现一下这个比较。

// 代码位置:/virtual-dom/src/diff.js
/**
 * @name: traversal
 * @description: 深度优先遍历虚拟DOM,计算出patches
 * @param {type} 参数
 * @return {type} 返回值
 */
function traversal(oldNode, newNode, o, patches){
    let currentPatches = [];
    if(newNode == undefined){
        //节点被删除
        currentPatches.push({'type': 'remove'});
        patches[o.nid] = currentPatches;
    }else if(oldNode instanceof VirtualDOM && newNode instanceof VirtualDOM){
        // 如果是VirtualDOM类型
        if(oldNode.type != newNode.type){
            // 节点发生替换
            currentPatches.push({'type': 'replace', 'content': newNode.type})
            patches[o.nid] = currentPatches;
        }else{
            let resultDiff = diffProps(oldNode, newNode);
            // 属性存在差异
            if(Object.keys(resultDiff).length != 0){
                currentPatches.push({'type': 'props', 'props': resultDiff})
                patches[o.nid] = currentPatches;
            }
        }
        oldNode.children.forEach((element,index) => {
            o.nid++;
            traversal(element, newNode.children[index], o, patches);
        });
    }else{
        // 文本类型
        if(!diffText(oldNode, newNode)){
            currentPatches.push({'type': 'text', 'content': newNode});
            patches[o.nid] = currentPatches;
        }
    }
}
function diff(oldNode, newNode){
    let patches = {}; //旧节点和新节点之间的差异结果
    let o = {nid: 0};    // 节点的编号
    // 递归遍历oldNode、newNode 将差异结果保存到patches中
    traversal(oldNode, newNode, o, patches)
    return patches;
}

export {diff};

最后在index.js中调用这个方法,看看生成的patches是否正确。

// 创建一个新的node
let newNode = create('div', {'class': 'wapper', 'id': 'box'}, [
    create('h4', {}, ['内容']),
    create('ul', { 'style': 'list-style-type: none;border: 1px solid;padding: 20px;'}, [
                create('li', {}, ['内容一'])
    ])
])

let patches = diff(vdom, newNode);
console.log("最终的patches");
console.log(patches);

最后我们将代码生成的patches和手动生成的patches进行一个对比,看看结果是否一样。

可以看到两者是一样的,所以证明我们的diff是成功实现了。

将patches应用到页面中

到此我简单画个图总结一下前面我们已经完成的功能。

那我们的最后一步就是将diff出来的patches应用到realdom上。

这里呢,我先直接将代码贴出来。

// 代码位置:/virtual-dom/src/patch.js
import {render} from './render'
/**
 * @name: walk
 * @description: 遍历patches 将差异应用到真实的DOM节点上
 * @param {HTMLElement} 真实的DOM节点
 * @param {Object} 虚拟节点的编号 编号从0开始,从patches中获取编号为o.nid的虚拟DOM的差异
 * @param {Object}  使用diff算法比较出来新的虚拟节点和旧的虚拟节点的差异
 */
function walk(realdom, o, patchs){
    // 获取当前节点的差异
    const currentPatch = patchs[o.nid];
    // 对当前节点进行DOM操作
    if (currentPatch) {
        applyPatch(realdom, currentPatch)
    }
    for(let i=0; i < realdom.childNodes.length; i++){
        let childNode = realdom.childNodes[i];
        o.nid++;
        walk(childNode, o, patchs); 
    }
}

/**
 * @name: applyPatch
 * @description: 应用差异到真实节点上
 * @param {HTMLElement} 需要更新的真实DOM节点
 * @param {Array}       节点需要更新的内容
 */
function applyPatch(currentRealNode, currentPatch){
    currentPatch.forEach(patch => {
        const type = patch['type'];
        switch(type){
            case 'props':
                const props = patch['props'];
                for(const propKey in props){
                    currentRealNode.setAttribute(propKey, props[propKey])
                }
                break;
            case 'replace':
                let content = patch['content'];
                let newEle = null;
                if(typeof(content) == "string"){
                    newEle = document.createTextNode(content);
                }else{
                    // 调用render将替换的节点渲染成真实的dom
                    newEle = render(content);
                }
                currentRealNode.parentNode.replaceChild(newEle, currentRealNode);
                break;
            case 'text':
                currentRealNode.textContent = patch['content']
                break;
            case 'remove':
                currentRealNode.parentNode.removeChild(currentRealNode)
        }
    });
}

export {walk};

接下来我们就分析一下patch.js中的代码。

applyPatch

applyPatch函数的功能就是将差异对象应用到真实的DOM节点上。

函数的两个参数为:currentRealNodecurrentPatch,分别表示的是需要更新的真实DOM节点节点需要更新的内容

举个例子,如下:

前面我们生成的patches共有四种不同的类型,分别为:节点属性变化、节点类型被替换、节点被移除、节点文本内容变化,所以在applyPatch函数中使用switch语句分别处理这四种不同的情况。

节点属性发生变化
我们只需要将新的属性(patch['props'])设置到当前节点上即可。  
节点类型被替换
节点类型被替换以后,我们的patch['type']值为'replace',对应的patch['content']为替换后虚拟DOM节点。
对于我们这篇文章中的示例来说,当执行到h3节点的时候,currentPatch的值为:
 	[
    	{
          type: 'replace',   // 节点发生替换
          content: {
          	type: 'h4', 
            {}, 
            children: ['内容']
           }
    	}
    ]
所以我们需要将patch['content']这个虚拟节点转化为真实的节点,更新到整个文档节点中。

由于本次我们的示例将h3节点替换成了h4,实际上有可能替换成文本内容,在replace的逻辑中会patch['content']的类型做了判断,如果替换成文本内容,则只需要创建文本节点即可。

节点文本内容变化
节点文本内容发生变化,只需要为文本节点的textContet属性赋新值即可。
节点被移除
节点被移除,调用当前节点的父级节点的removeChild移除当前节点即可。

applyPatch方法内部都是一些操作原生DOM节点的逻辑

总结

到此本篇文章就结束了,在此我们做一个简单的总结。

关于什么是虚拟DOM

将真实的DOM节点抽象成为一个js对象,这个js对象就称之为是虚拟DOM

关于dom-diff算法

dom-diff算法核心的几个点就是:

1.将真实的DOM节点使用虚拟DOM表示(create)  
2.将虚拟DOM渲染到浏览器页面上(render)  
3.当用户操作界面修改数据后,会生成一个新的虚拟DOM,将新的虚拟DOM和旧的虚拟DOM进行对比,生成差异对象patches(diff)  
4.将差异对象应用到真实的DOM节点上(patch)  

为什么需要虚拟DOM

那在了解了虚拟DOM以及和虚拟DOM相关的dom-diff算法以后,我们肯定会思考为什么需要虚拟DOM这样的东西。

原因一:跨平台

虚拟DOM基于JavaScript对象,而真实的DOM要基于浏览器平台,所以虚拟DOM可以跨平台使用。

原因二:提高操作DOM的性能

我们都知道浏览器将一个HTML文档转化为真实的内容呈现到浏览器上的整个过程是需要经历一系列的步骤:构建DOM树、构建CSS规则树、基于DOM树CSS规则树构建呈现树(呈现树是文档的可视化表示)、根据呈现树进行布局绘制。当有用户交互需要改变文档结构时,很大程度上会再一次触发这一系列的操作。

假如用户在一次交互中修改了10次DOM结构,那么就会触发10次上述的步骤,所以说操作DOM的代价是很大的。

所以我们使用一个js对象来表示真实的DOM,当用户在一次交互中修改了10DOM结构时,我们就可以将这10次的修改映射到这个js对象,之后比较之前的虚拟DOM和修改后的虚拟DOM,最后在将比较的差异应用到文档中。那这样的操作显然会比直接更新10次真实的DOM要节省性能。

最后

本篇文章只针对虚拟DOMdom-diff做了简单的总结和实践,而vue框架内部在diff的时候还有一些更细节的处理,后续在vue源码学习时会在做总结。

示例代码

本文的源代码可以 戳这里 获取

参考文章

深入剖析:Vue核心之虚拟DOM
让虚拟DOM和DOM-diff不再成为你的绊脚石
vue核心之虚拟DOM(vdom)
详解Vue中的虚拟DOM

相关文章推荐

你还不知道Vue的生命周期吗?带你从Vue源码了解Vue2.x的生命周期(初始化阶段)
1W字长文+多图,带你了解vue2.x的双向数据绑定源码实现

作者:小土豆biubiubiu
博客园:www.cnblogs.com/HouJiao/
掘金:juejin.im/user/243617…