理解虚拟DOM,自己实现虚拟DOM

Posted 得着顶尖1德凿成就

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了理解虚拟DOM,自己实现虚拟DOM相关的知识,希望对你有一定的参考价值。

1.为什么需要虚拟DOM

DOM是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。如果对前端工作进行抽象的话,主要就是维护状态和更新视图;而更新视图和维护状态都需要DOM操作。近三四年来,前端的框架主要发展方向就是解放DOM操作的复杂性【先是react、后有vue学react的思想也搞了一套】。

在jQuery出现以前,我们直接操作DOM结构,这种方法复杂度高,兼容性也较差;有了jQuery强大的选择器以及高度封装的API,我们可以更方便的操作DOM,jQuery帮我们处理兼容性问题,同时也使DOM操作变得简单;但是聪明的程序员不可能满足于此,各种MVVM框架应运而生,有angularJS、avalon、vue.js等,MVVM使用数据双向绑定,使得我们完全不需要操作DOM了,更新了状态视图会自动更新,更新了视图数据状态也会自动更新,可以说MMVM使得前端的开发效率大幅提升,但是其大量的事件绑定使得其在复杂场景下的执行性能堪忧;有没有一种兼顾开发效率和执行效率的方案呢?ReactJS就是一种不错的方案,虽然其将JS代码和html代码混合在一起的设计有不少争议,但是其引入的Virtual DOM(虚拟DOM)却是得到大家的一致认同的。

2.理解虚拟DOM

虚拟的DOM的核心思想是:对复杂的文档DOM结构,提供一种方便的工具,进行最小化地DOM操作。先别晕,前面这句话也许过于抽象,却基本概况了虚拟DOM的设计思想。后面还会具体bb一下的:

(1) 提供一种方便的工具,使得开发效率得到保证 [关键字:开发]
(2) 保证最小化的DOM操作,使得执行效率得到保证 【关键字:执行】

(1).用JS表示DOM结构

DOM很慢,而javascript很快,用javascript对象可以很容易地表示DOM节点。DOM节点包括标签、属性和子节点,通过VElement表示如下。 代码建议粘贴到网页中试试


  1. //辅助类 Util

  2. var util = {};

  3. util.type = function(obj) {

  4.    return Object.prototype.toString.call(obj).replace(/\[object\s|\]/g, '');

  5. }

  6. util.isArray = function(list) {

  7.    return util.type(list) === 'Array';

  8. }

  9. util.each = function(array, fn) {

  10.    for (var i = 0, len = array.length; i < len; i++) {

  11.        fn(array[i], i);

  12.    }

  13. }

  14. //虚拟dom,参数分别为标签名、属性对象、子DOM列表

  15. var VElement = function(tagName, props, children) {

  16.    //保证只能通过如下方式调用:new VElement

  17.    if (!(this instanceof VElement)) {

  18.        return new VElement(tagName, props, children);

  19.    }

  20.    //可以通过只传递tagName和children参数

  21.    if (util.isArray(props)) {

  22.        children = props;

  23.        props = {};

  24.    }

  25.    //设置虚拟dom的相关属性

  26.    this.tagName = tagName;

  27.    this.props = props || {};

  28.    this.children = children || [];

  29.    this.key = props ? props.key : void 666;

  30.    var count = 0;

  31.    util.each(this.children, function(child, i) {

  32.        if (child instanceof VElement) {

  33.            count += child.count;

  34.        } else {

  35.            children[i] = '' + child;

  36.        }

  37.        count++;

  38.    });

  39.    this.count = count;

  40. }

通过VElement,我们可以很简单地用javascript表示DOM结构。比如

  1. var vdom = VElement('div', { 'id': 'container' }, [

  2.        VElement('h1', { style: 'color:red' }, ['simple virtual dom']),

  3.        VElement('p', ['hello world']),

  4.        VElement('ul', [VElement('li', ['item #1']), VElement('li', ['item #2'])]),

  5.    ]);

  6. console.log(vdom); //看看我产生的对象结构

上面的javascript代码可以用于表示如下DOM结构(后面会说如何根据这个js对象生成下面的dom结构):

<div id="container">
    <h1 style="color:red">simple virtual dom</h1>
    <p>hello world</p>
    <ul>
        <li>item #1</li>
        <li>item #2</li>
    </ul>   
</div>

同样我们可以很方便地根据虚拟DOM树构建出真实的DOM树。具体思路:根据虚拟DOM节点的属性和子节点递归地构建出真实的DOM树。见如下代码:

  1. //辅助类 Util

  2. VElement.prototype.render = function() {

  3.  //创建标签

  4.  var el = document.createElement(this.tagName);

  5.  //设置标签的属性

  6.  var props = this.props;

  7.  for (var propName in props) {

  8.      var propValue = props[propName]

  9.      util.setAttr(el, propName, propValue);

  10.  }

  11.  //依次创建子节点的标签

  12.  util.each(this.children, function(child) {

  13.      //如果子节点仍然为velement,则递归的创建子节点,否则直接创建文本类型节点

  14.      var childEl = (child instanceof VElement) ? child.render() : document.createTextNode(child);

  15.      el.appendChild(childEl);

  16.  });

  17.  return el;

  18. }

  19. //设置属性的工具

  20. util.setAttr = function(node, key, value) {

  21.    switch (key) {

  22.        case 'style':

  23.            node.style.cssText = value;

  24.            break;

  25.        case 'value':

  26.            var tagName = node.tagName || '';

  27.            tagName = tagName.toLowerCase();

  28.            if (tagName === 'input' || tagName === 'textarea') {

  29.                node.value = value;

  30.            } else {

  31.                node.setAttribute(key, value);

  32.            }

  33.            break;

  34.        default:

  35.            node.setAttribute(key, value);

  36.            break;

  37.    }

  38. }

  39. console.log(vdom.render())

对一个虚拟的DOM对象VElement,调用其原型的render方法,就可以产生一颗真实的DOM树。

console.log(vdom.render())

既然我们可以用JS对象表示DOM结构,那么当数据状态发生变化而需要改变DOM结构时,我们先通过JS对象表示的虚拟DOM计算出实际DOM需要做的最小变动,然后再操作实际DOM,从而避免了粗放式的DOM操作带来的性能问题。


【建议下载完整代码来看,代码不多,包含了下面将要介绍的优化dom操作的方法: https://github.com/foio/vdom-demo 】


(2).比较两棵虚拟DOM树的差异

在用JS对象表示DOM结构后,当页面状态发生变化而需要操作DOM时,我们可以先通过虚拟DOM计算出对真实DOM的最小修改量,然后再修改真实DOM结构(因为真实DOM的操作代价太大)。

如下图所示,两个虚拟DOM之间的差异已经标红:

为了便于说明问题,我当然选取了最简单的DOM结构,两个简单DOM之间的差异似乎是显而易见的,但是真实场景下的DOM结构很复杂,我们必须借助于一个有效的DOM树比较算法。

设计一个diff算法有两个要点:

如何比较两个两棵DOM树
如何记录节点之间的差异

<1> 如何比较两个两棵DOM树

计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历:

function diff(oldTree, newTree) {
    //节点的遍历顺序
    var index = 0; 
    //在遍历过程中记录节点的差异
    var patches = {}; 
    //深度优先遍历两棵树
    dfsWalk(oldTree, newTree, index, patches); 
    return patches; 
}

<2>如何记录节点之间的差异

由于我们对DOM树采取的是同级比较,因此节点之间的差异可以归结为4种类型:

修改节点属性, 用PROPS表示
修改节点文本内容, 用TEXT表示
替换原有节点, 用REPLACE表示
调整子节点,包括移动、删除等,用REORDER表示

对于节点之间的差异,我们可以很方便地使用上述四种方式进行记录,比如当旧节点被替换时:

{type:REPLACE,node:newNode}

而当旧节点的属性被修改时:

{type:PROPS,props: newProps}

在深度优先遍历的过程中,每个节点都有一个编号,如果对应的节点有变化,只需要把相应变化的类别记录下来即可。下面是具体实现:

  1. function dfsWalk(oldNode, newNode, index, patches) {

  2.    var currentPatch = [];

  3.    if (newNode === null) {

  4.        //依赖listdiff算法进行标记为删除

  5.    } else if (util.isString(oldNode) && util.isString(newNode)) {

  6.        if (oldNode !== newNode) {

  7.            //如果是文本节点则直接替换文本

  8.            currentPatch.push({

  9.                type: patch.TEXT,

  10.                content: newNode

  11.            });

  12.        }

  13.    } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {

  14.        //节点类型相同

  15.        //比较节点的属性是否相同

  16.        var propsPatches = diffProps(oldNode, newNode);

  17.        if (propsPatches) {

  18.            currentPatch.push({

  19.                type: patch.PROPS,

  20.                props: propsPatches

  21.            });

  22.        }

  23.        //比较子节点是否相同

  24.        diffChildren(oldNode.children, newNode.children, index, patches, currentPatch);

  25.    } else {

  26.        //节点的类型不同,直接替换

  27.        currentPatch.push({ type: patch.REPLACE, node: newNode });

  28.    }

  29.    if (currentPatch.length) {

  30.        patches[index] = currentPatch;

  31.    }

  32. }

比如对上文图中的两颗虚拟DOM树,可以用如下数据结构记录它们之间的变化:

var patches = {
        1:{type:REPLACE,node:newNode}, //h1节点变成h5
        5:{type:REORDER,moves:changObj} //ul新增了子节点li
    }

(3).对真实DOM进行最小化修改

通过虚拟DOM计算出两颗真实DOM树之间的差异后,我们就可以修改真实的DOM结构了。上文深度优先遍历过程产生了用于记录两棵树之间差异的数据结构patches, 通过使用patches我们可以方便对真实DOM做最小化的修改。

  1. //将差异应用到真实DOM

  2. function applyPatches(node, currentPatches) {

  3.    util.each(currentPatches, function(currentPatch) {

  4.        switch (currentPatch.type) {

  5.            //当修改类型为REPLACE时

  6.            case REPLACE:

  7.                var newNode = (typeof currentPatch.node === 'String')

  8.                 ? document.createTextNode(currentPatch.node)

  9.                 : currentPatch.node.render();

  10.                node.parentNode.replaceChild(newNode, node);

  11.                break;

  12.            //当修改类型为REORDER时

  13.            case REORDER:

  14.                reoderChildren(node, currentPatch.moves);

  15.                break;

  16.            //当修改类型为PROPS时

  17.            case PROPS:

  18.                setProps(node, currentPatch.props);

  19.                break;

  20.            //当修改类型为TEXT时

  21.            case TEXT:

  22.                if (node.textContent) {

  23.                    node.textContent = currentPatch.content;

  24.                } else {

  25.                    node.nodeValue = currentPatch.content;

  26.                }

  27.                break;

  28.            default:

  29.                throw new Error('Unknow patch type ' + currentPatch.type);

  30.        }

  31.    });

  32. }

至此,虚拟DOM的基本原理已经基本讲解完成了;我们也一起实现了一个基本可用的虚拟DOM。




以上是关于理解虚拟DOM,自己实现虚拟DOM的主要内容,如果未能解决你的问题,请参考以下文章

阐述一下你对虚拟DOM和Dom-Diff理解?

全面理解虚拟DOM,实现虚拟DOM

47前端 | 全面理解虚拟DOM,实现虚拟DOM

如何编写自己的虚拟DOM

从0到1实现一个虚拟DOM

简谈对虚拟Dom的理解