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

30

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

目前最流行的两大前端框架,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的比较过程进行优化。

二、优化一:省略patch对象,直接更新dom

在上一个版本的代码里,我们是通过在diff过程中生成patch对象,然后在利用这个对象更新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);
}

实际上这步是多余的。既然在diff的时候就已经知道要如何操作dom了,那为什么不直接在diff里面更新呢?先来回顾下之前的diff代码:

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
            }   
        }
        
    }
}

diff最终返回的对象是这个数据结构:

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

现在,我们把生成对象的步骤省略掉,直接操作dom。这时候我们需要将父元素,还有子元素的索引传进来(原patch的逻辑):

function diff(oldVDom, newVDom, parent, index=0) {
    // 新建node
    if (oldVDom == undefined) {
        parent.appendChild(createElement(newVDom));
    }

    const element = parent.childNodes[index];

    // 删除node
    if (newVDom == undefined) {
        parent.removeChild(element);
    }

    // 替换node
    if (
        typeof oldVDom !== typeof newVDom ||
        ((typeof oldVDom === 'string' || typeof oldVDom === 'number') && oldVDom !== newVDom) ||
        oldVDom.tag !== newVDom.tag
    ) {
        parent.replaceChild(createElement(newVDom), element);
    }

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

        // 比较children的变化
        diffChildren(oldVDom, newVDom, element);
    }
}

function diffProps(oldVDom, newVDom) {
    const allProps = {...oldVDom.props, ...newVDom.props};

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

            // 删除属性
            if (newValue == undefined) {
                element.removeAttribute(key);
            } 
            // 更新属性
            else if (oldValue == undefined || oldValue !== newValue) {
                element.setAttribute(key, newValue);
            }
        }
    )
}

function diffChildren(oldVDom, newVDom, parent) {
    // 获取子元素最大长度
    const childLength = Math.max(oldVDom.children.length, newVDom.children.length);

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

本质上来说,这次的优化是将patch的逻辑整合进diff的过程中了。经过这次优化,JS计算的时间快了那么几毫秒。虽然性能的提升不大,但代码比原来的少了80多行,降低了逻辑复杂度,优化的效果还是不错的。

clipboard.png

三、优化二:VD与真实dom融合

在之前的版本里面,diff操作针对的是新旧2个VD。既然真实的dom已经根据之前的VD渲染出来了,有没办法用当前的dom跟新的VD做比较呢?

答案是肯定的,只需要按需获取dom中不同的属性就可以了。比如,当比较tag的时候,使用的是nodeType和tagName,比较文本的时候用的是nodeValue。

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

    const newVDom = view();

    // 比较并更新节点
    diff(newVDom, element);
    // diff(preVDom, newVDom, element);

    // preVDom = newVDom;
}

function diff(newVDom, parent, index=0) {
    
    const element = parent.childNodes[index];

    // 新建node
    if (element == undefined) {
        parent.appendChild(createElement(newVDom));
        return;
    }

    // 删除node
    if (newVDom == undefined) {
        parent.removeChild(element);
        return;
    }

    // 替换node
    if (!isSameType(element, newVDom)) {
        parent.replaceChild(createElement(newVDom), element);
        return;
    }

    // 更新node
    if (element.nodeType === Node.ELEMENT_NODE) {
        // 比较props的变化
        diffProps(newVDom, element);

        // 比较children的变化
        diffChildren(newVDom, element);
    }
}

// 比较元素类型是否相同
function isSameType(element, newVDom) {
    const elmType = element.nodeType;
    const vdomType = typeof newVDom;

    // 当dom元素是文本节点的情况
    if (elmType === Node.TEXT_NODE && 
        (vdomType === 'string' || vdomType === 'number') &&
        element.nodeValue == newVDom
    ) {
       return true; 
    }

    // 当dom元素是普通节点的情况
    if (elmType === Node.ELEMENT_NODE && element.tagName.toLowerCase() == newVDom.tag) {
        return true;
    }

    return false;
}

为了方便属性的比较,提高效率,我们将VD的props存在dom元素的__preprops_字段中:

const ATTR_KEY = '__preprops_';

// 创建dom元素
function createElement(vdom) {
    // 如果vdom是字符串或者数字类型,则创建文本节点,比如“Hello World”
    if (typeof vdom === 'string' || typeof vdom === 'number') {
        return doc.createTextNode(vdom);
    }

    const {tag, props, children} = vdom;

    // 1. 创建元素
    const element = doc.createElement(tag);

    // 2. 属性赋值
    setProps(element, props);

    // 3. 创建子元素
    children.map(createElement)
            .forEach(element.appendChild.bind(element));

    return element;
}

// 属性赋值
function setProps(element, props) {
     // 属性赋值
    element[ATTR_KEY] = props;

    for (let key in props) {
        element.setAttribute(key, props[key]);
    }
}

进行属性比较的时候再取出来:

// 比较props的变化
function diffProps(newVDom, element) {
    let newProps = {...element[ATTR_KEY]};
    const allProps = {...newProps, ...newVDom.props};

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

            // 删除属性
            if (newValue == undefined) {
                element.removeAttribute(key);
                delete newProps[key];
            } 
            // 更新属性
            else if (oldValue == undefined || oldValue !== newValue) {
                element.setAttribute(key, newValue);
                newProps[key] = newValue;
            }
        }
    )

    // 属性重新赋值
    element[ATTR_KEY] = newProps;
}

通过这种方式,我们不再需要用变量preVDom将上一次生成的VD存下来,而是直接跟真实的dom进行比较,灵活性更强。

四、总结

本文基于上一个版本的代码,简化了页面渲染的过程(省略patch对象),同时提供了更灵活的VD比较方法(直接跟dom比较),可用性越来越强了。基于当前这个版本的代码还能做怎样的优化呢,请看下一篇的内容:你不知道的Virtual DOM(四):key的作用

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

你可能感兴趣的

保持微笑 · 2018年08月29日

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

问题:
patch是会(多次) (最小化) 操作 真实dom吗?
意思是一次setData({}),它有可能在patch时多次操作dom吗?,因为觉得patchObj里会有多个值!

回复

0

patch是会多次操作dom的,以为是逐个对dom元素进行操作。但这对页面的性能不会有很大的影响,因为浏览器会自动合并多个dom操作后再进行重排和重绘。

dabai 作者 · 2018年08月29日
0

@dabai 不太明白,为什么这个时候,浏览器聪明了,进行合并操作

保持微笑 · 2018年08月29日
0

不能保证浏览器一定会进行合并,一般比较先进的浏览器都会做这个合并。有点像React的setState机制,并不是每次setState都会马上更新VD,而是会合并一个事务内的多次setState。

dabai 作者 · 2018年08月29日
载入中...