你不知道的 Virtual DOM(二):Virtual DOM 的更新

一、前言

目前最流行的两大前端框架,React 和 Vue,都不约而同的借助 Virtual DOM 技术提高页面的渲染效率。那么,什么是 Virtual DOM ?它是通过什么方式去提升页面渲染效率的呢?本系列文章会详细讲解 Virtual DOM 的创建过程,并实现一个简单的 Diff 算法来更新页面。本文的内容脱离于任何的前端框架,只讲最纯粹的 Virtual DOM 。敲单词太累了,下文 Virtual DOM 一律用 VD 表示。

这是 VD 系列文章的第二篇,以下是本系列其它文章的传送门:

你不知道的 Virtual DOM(一):Virtual DOM 介绍

你不知道的 Virtual DOM(二):Virtual DOM 的更新

你不知道的 Virtual DOM(三):Virtual DOM 更新优化

你不知道的 Virtual DOM(四):key 的作用

你不知道的 Virtual DOM(五):自定义组件

你不知道的 Virtual DOM(六):事件处理 & 异步更新

本文将会实现一个简单的 VD Diff 算法,计算出差异并反映到真实的 DOM 上去。

二、思路

使用 VD 的框架,一般的设计思路都是页面等于页面状态的映射,即UI = render(state)。当需要更新页面的时候,无需关心 DOM 具体的变换方式,只需要改变state即可,剩下的事情(render)将由框架代劳。我们考虑最简单的情况,当 state 发生变化时,我们重新生成整个 VD ,触发比较的操作。上述过程分为以下四步:
- state 变化,生成新的 VD
- 比较 VD 与之前 VD 的异同
- 生成差异对象(patch
- 遍历差异对象并更新 DOM
差异对象的数据结构是下面这个样子,与每一个 VDOM 元素一一对应:

{
    type,
    vdom,
    props: [{
               type,
               key,
               value 
            }]
    children
}

最外层的 type 对应的是 DOM 元素的变化类型,有 4 种:新建、删除、替换和更新。props 变化的 type 只有 2 种:更新和删除。枚举值如下:

const nodePatchTypes = {
    CREATE: 'create node',
    REMOVE: 'remove node',
    REPLACE: 'replace node',
    UPDATE: 'update node'
}

const propPatchTypes = {
    REMOVE: 'remove prop',
    UPDATE: 'update prop'
}

三、代码实现

我们做一个定时器,500 毫秒运行一次,每次对 state 加 1。页面的li元素的数量随着 state 而变。

let state = { num: 5 };
let timer;
let preVDom;

function render(element) {
    // 初始化的 VD
    const vdom = view();
    preVDom = vdom;

    const dom = createElement(vdom);
    element.appendChild(dom);

    
    timer = setInterval(() => {
        state.num += 1;
        tick(element);
    }, 500);
}

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
}

function view() {
    return (
        <div>
            Hello World
            <ul>
                {
                    // 生成元素为0到n-1的数组
                    [...Array(state.num).keys()]
                        .map( i => (
                            <li id={i} class={`li-${i}`}>
                                第{i * state.num}
                            </li>
                        ))
                }
            </ul>
        </div>
    );
}

接下来,通过对比 2 个 VD,生成差异对象。

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);
}

function diff(oldVDom, newVDom) {
    // 新建 node
    if (oldVDom == undefined) {
        return {
            type: nodePatchTypes.CREATE,
            vdom: newVDom
        }
    }

    // 删除 node
    if (newVDom == undefined) {
        return {
            type: nodePatchTypes.REMOVE
        }
    }

    // 替换 node
    if (
        typeof oldVDom !== typeof newVDom ||
        ((typeof oldVDom === 'string' || typeof oldVDom === 'number') && oldVDom !== newVDom) ||
        oldVDom.tag !== newVDom.tag
    ) {
       return {
           type: nodePatchTypes.REPLACE,
           vdom: newVDom
       } 
    }

    // 更新 node
    if (oldVDom.tag) {
        // 比较 props 的变化
        const propsDiff = diffProps(oldVDom, newVDom);

        // 比较 children 的变化
        const childrenDiff = diffChildren(oldVDom, newVDom);
        
        // 如果 props 或者 children 有变化,才需要更新
        if (propsDiff.length > 0 || childrenDiff.some( patchObj => (patchObj !== undefined) )) {
            return {
                type: nodePatchTypes.UPDATE,
                props: propsDiff,
                children: childrenDiff
            }   
        }
        
    }
}

// 比较 props 的变化
function diffProps(oldVDom, newVDom) {
    const patches = [];

    const allProps = {...oldVDom.props, ...newVDom.props};

    // 获取新旧所有属性名后,再逐一判断新旧属性值
    Object.keys(allProps).forEach((key) => {
            const oldValue = oldVDom.props[key];
            const newValue = newVDom.props[key];

            // 删除属性
            if (newValue == undefined) {
                patches.push({
                    type: propPatchTypes.REMOVE,
                    key
                });
            } 
            // 更新属性
            else if (oldValue == undefined || oldValue !== newValue) {
                patches.push({
                    type: propPatchTypes.UPDATE,
                    key,
                    value: newValue
                });
            }
        }
    )

    return patches;
}

// 比较 children 的变化
function diffChildren(oldVDom, newVDom) {
    const patches = [];
    
    // 获取子元素最大长度
    const childLength = Math.max(oldVDom.children.length, newVDom.children.length);

    // 遍历并diff子元素
    for (let i = 0; i < childLength; i++) {
        patches.push(diff(oldVDom.children[i], newVDom.children[i]));
    }

    return patches;
}

计算得出的差异对象是这个样子的:

{
    type: "update node",
    props: [],
    children: [
        null, 
        {
            type: "update node",
            props: [],
            children: [
                null, 
                {
                    type: "update node",
                    props: [],
                    children: [
                        null, 
                        {
                            type: "replace node",
                            vdom: 6
                        }
                    ]
                }
            ]
        },
        {
            type: "create node",
            vdom: {
                tag: "li",
                props: {
                    id: 5,
                    class: "li-5"
                },
                children: ["第", 30]
            }
        }
    ]
}

下一步就是遍历差异对象并更新 DOM 了:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();

    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 给 DOM 打个补丁
    patch(element, patchObj);
}

// 给 DOM 打个补丁
function patch(parent, patchObj, index=0) {
    if (!patchObj) {
        return;
    }

    // 新建元素
    if (patchObj.type === nodePatchTypes.CREATE) {
        return parent.appendChild(createElement(patchObj.vdom));
    }

    const element = parent.childNodes[index];

    // 删除元素
    if (patchObj.type === nodePatchTypes.REMOVE) {
        return parent.removeChild(element);
    }

    // 替换元素
    if (patchObj.type === nodePatchTypes.REPLACE) {
        return parent.replaceChild(createElement(patchObj.vdom), element);
    }

    // 更新元素
    if (patchObj.type === nodePatchTypes.UPDATE) {
        const {props, children} = patchObj;

        // 更新属性
        patchProps(element, props);

        // 更新子元素
        children.forEach( (patchObj, i) => {
            // 更新子元素时,需要将子元素的序号传入
            patch(element, patchObj, i)
        });
    }
}

// 更新属性
function patchProps(element, props) {
    if (!props) {
        return;
    }

    props.forEach( patchObj => {
        // 删除属性
        if (patchObj.type === propPatchTypes.REMOVE) {
            element.removeAttribute(patchObj.key);
        } 
        // 更新或新建属性
        else if (patchObj.type === propPatchTypes.UPDATE) {
            element.setAttribute(patchObj.key, patchObj.value);
        }
    })
}

到此为止,整个更新的流程就执行完了。可以看到页面跟我们预期的一样,每 500 毫秒刷新一次,构造渲染树和绘制页面花的时间也非常少。

作为对比,如果我们在生成新的 VD 后,不经过比较,而是直接重新渲染整个 DOM 的时候,会怎样呢?我们修改一下代码:

function tick(element) {
    if (state.num > 20) {
        clearTimeout(timer);
        return;
    }

    const newVDom = view();
    newDom = createElement(newVDom);

    element.replaceChild(newDom, dom);

    dom = newDom;

    /*
    // 生成差异对象
    const patchObj = diff(preVDom, newVDom);

    preVDom = newVDom;

    // 给 DOM 打个补丁
    patch(element, patchObj);
    */
}

效果如下:

可以看到,构造渲染树(Rendering)和绘制页面(Painting)的时间要多一些。但另一方面花在 JS 计算(Scripting)的时间要少一些,因为不需要比较节点的变化。如果算总时间的话,重新渲染整个 DOM 花费的时间反而更少,这是为什么呢?

其实原因很简单,因为我们的 DOM 树太简单了!节点很少,使用到的 css 也很少,所以构造渲染树和绘制页面就花不了多少时间。VD 真正的效果还是要在真实的项目中才体现得出来。

四、总结

本文详细介绍如何实现一个简单的 VD Diff 算法,再根据计算出的差异去更新真实的 DOM 。然后对性能做了一个简单的分析,得出使用 VD 在减少渲染时间的同时增加了 JS 计算时间的结论。基于当前这个版本的代码还能做怎样的优化呢,请看下一篇的内容:你不知道的 Virtual DOM(三):Virtual DOM 更新优化

P.S.: 想看完整代码见这里,如果有必要建一个仓库的话请留言给我:代码