Virtual DOM

288 阅读5分钟

来源文章如何理解虚拟DOM?

为什么虚拟 dom 会提高性能?

虚拟 dom 相当于在 js 和真实 dom 中间加了一个缓存,利用 dom diff 算法避免了没有必要的 dom 操作,从而提高性能。

用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异把 2 所记录的差异应用到步骤 1 所构建的真正的 DOM 树上,视图就更新了。

javascript对象与DOM树

真正的DOM元素是非常庞大,属性之多,操作它们可能会导致页面重排。如下把简单的div的属性都打印出来

相对于DOM对象,原生的javascript对象处理起来更快,而且简单。DOM树上的结构、属性信息我们都可以很容易的用javascript对象表示出来。

    var element = {
        tagName: ul,
        props: {
            id: 'list',
        },
        children: [
            {tagName: 'li', props: {class: 'item', children: ['item 1']}},
            {tagName: 'li', props: {class: 'item', children: ['item 2']}},
            {tagName: 'li', props: {class: 'item', children: ['item 3']}},
        ]
    }

上面对应的HTML写法是:

<ul id="list">
    <li class="item">item 1</li>
    <li class="item">item 2</li>
    <li class="item">item 3</li>
</ul>

既然真正的DOM树的信息都可以用javascript对象来表示,反过来,你就可以根据这个用javascript对象表示的树结构来构建一颗真正的DOM树。

Virtual DOM算法步骤

Virtual DOM算法的步骤:

  • 1、用js对象结构表示DOM树结构;然后用这个树构建一个真正的DOM树,插入到文档中
  • 2、当状态变更的时候,重新构造一颗新的js对象树。然后用新的树和旧的树进行比较,记录两颗树差异
  • 3、把2步骤所记录的差异应用到步骤1构建的真正的DOM树上,视图就更新了

Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然DOM这么慢,我们就在它们JS和DOM之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。

Virtual DOM算法的实现

步骤1:用js对象模拟DOM树

用js来表示一个DOM节点是很简单的事情,你只需要记录它的节点类型、属性,还有子节点:

element.js

function Element(tagName, props, children){
    this.tagName = tagName;
    this.props = props;
    this.children = children;
}
module.exports = function(tagName, props, children){
    return new Element(tagName, props, children);
}

例如上面的DOM结构就可以简单的表示:

var el = require('./element');
var ul = el(
    'ul', 
    {id: 'list'}, 
    [
        el('li', {class: 'item'}, ['item 1']),
        el('li', {class: 'item'}, ['item 2']),
        el('li', {class: 'item'}, ['item 3']),
    ])

现在ul只是一个js对象表示的DOM结构,页面上还没有这个结构,我们可以根据这个ul构建真正的ul:

//渲染真正的DOM
Element.prototype.render = function(){
    var el = document.createElement(this.tagName);
    
    var props = this.props || {};
    for(let propName in props){
        var propValue = props[propName];
        el.setAttribute(propName, propValue);
    }
    
    var children = this.children || [];
    children.forEach(function(child){
        var childEl = (child instanceof Element) ? child.render() : document.createTextNode(child);
        el.appendChild(childEl);
    });
    
    return el;
}

render方法会根据tagName构建一个真正的DOM节点,然后设置这个节点的属性,最后递归的把自己的子节点构建起来,所以只需要:

var ulRoot = ul.render();
document.body.appendChild(ulRoot);

把ulRoot真正的DOM节点塞到文档中

步骤2:比较两颗虚拟DOM树的差异

比较两颗DOM树的差异是Virtual DOM算法最核心的部分,即Virtual DOM的diff算法。两颗树的完全的diff算法是一个时间复杂度为O(n^3)的问题。但是当中,你很少会跨越层级的移动DOM元素,所以Virtual DOM只会对同一个层级的元素进行对比。这样算法复杂度就可以到O(n)

在实际的代码中,会对新旧两颗树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:

每遍历到一个节点就把该节点和新的树进行对比,如果有差异就记录到一个对象里面

步骤3:把差异应用到真正的DOM树上

因为步骤1所构建的js对象树和render出来真正的DOM树的信息、结构是一样的。所以我们可以对那颗DOM树也进行深度优先的遍历,遍历的时候从步骤2生成的js对象中找出当前遍历的节点差异,然后进行DOM操作

function patch(node, patches){
    var walker = {index: 0};
    dfsWalk(node, walker, patches);
}

function dfsWalk(node, walker, patches){
    var currentPatches = patches[walker.index]; // 从patches拿出当前节点的差异

    var len = node.childrenNodes ? node.childrenNodes.length : 0;
    for (var i = 0; i < len; i++) {// 深度遍历子节点
        var child = node.childrenNodes[i];
        walker.index++;
        dfsWalk(child, walker, patches);
    }

    if (currentPatches){
        applyPatches(node, currentPatches); // 对当前节点进行DOM操作
    }
}

applyPatches,根据不同类型的差异对当前节点进行DOM操作

function applyPatches(node, currentPatches){
    currentPatches.forEach(currentPatch => {
        switch (currentPatch.type) {
            case REPLACE://替换
                node.parentNode.replaceChild(currentPatch.node.render(), node); //新节点,旧节点
                break;
            case REORDER://重新排序
                reorderChildren(node, currentPatch.moves);
                break;
            case PROPS:
                setProps(node, currentPatch.props);
                break;
            case TEXT: 
                node.textContent = currentPatch.content;
                break;
            default:
                throw new Error('Unknown patch type ' + currentPatch.type);
        }
        
    });
}

使用实例

Virtual DOM算法主要实现上面步骤的三个函数:element、diff、patch

// 1. 构建虚拟DOM
var tree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: blue'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li')])
])

// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)

// 3. 生成新的虚拟DOM
var newTree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: red'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li'), el('li')])
]);

// 4. 比较两棵虚拟DOM树的不同
var patches = diff(tree, newTree)

// 5. 在真正的DOM元素上应用变更
patch(root, patches)

完整内容源码git地址