dabai
  • 3.8k

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

欢迎关注我的公众号睿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(六):事件处理&异步更新

今天,我们继续在之前项目的基础上进行优化。用过React或者Vue的朋友都知道在渲染数组元素的时候,编译器会提醒加上key这个属性,那么key是用来做什么的呢?

二、key的作用

在渲染数组元素时,它们一般都有相同的结构,只是内容有些不同而已,比如:

<ul>
    <li>
        <span>商品:苹果</span>
        <span>数量:1</span>
    </li>
    <li>
        <span>商品:香蕉</span>
        <span>数量:2</span>
    </li>
    <li>
        <span>商品:雪梨</span>
        <span>数量:3</span>
    </li>
</ul>

可以把这个例子想象成一个购物车。此时如果想往购物车里面添加一件商品,性能不会有任何问题,因为只是简单的在ul的末尾追加元素,前面的元素都不需要更新:

<ul>
    <li>
        <span>商品:苹果</span>
        <span>数量:1</span>
    </li>
    <li>
        <span>商品:香蕉</span>
        <span>数量:2</span>
    </li>
    <li>
        <span>商品:雪梨</span>
        <span>数量:3</span>
    </li>
     <li>
        <span>商品:橙子</span>
        <span>数量:2</span>
    </li>
</ul>

但是,如果我要删除第一个元素,根据VD的比较逻辑,后面的元素全部都要进行更新的操作。dom结构简单还好说,如果是一个复杂的结构,那页面渲染的性能将会受到很大的影响。

<ul>
    <li>
        <span>商品:香蕉</span>
        <span>数量:2</span>
    </li>
    <li>
        <span>商品:雪梨</span>
        <span>数量:3</span>
    </li>
     <li>
        <span>商品:橙子</span>
        <span>数量:2</span>
    </li>
</ul>

有什么方式可以降低这种性能的损耗呢?

最直观的方法肯定是直接删除第一个元素然后其它元素保持不变了。但程序没有这么智能,可以像我们一样一眼就看出变化。程序能做到的是尽量少的修改元素,通过移动元素而不是修改元素来达到更新的目的。为了告诉程序要怎么移动元素,我们必须给每个元素加上一个唯一标识,也就是key。

<ul>
    <li key="apple">
        <span>商品:苹果</span>
        <span>数量:1</span>
    </li>
    <li key="banana">
        <span>商品:香蕉</span>
        <span>数量:2</span>
    </li>
    <li key="pear">
        <span>商品:雪梨</span>
        <span>数量:3</span>
    </li>
    <li key="orange">
        <span>商品:橙子</span>
        <span>数量:2</span>
    </li>
</ul>

当把苹果删掉的时候,VD里面第一个元素是香蕉,而dom里面第一个元素是苹果。当元素有key属性的时候,框架就会尝试根据这个key去找对应的元素,找到了就将这个元素移动到第一个位置,循环往复。最后VD里面没有第四个元素了,才会把苹果从dom移除。

三、代码实现

在上一个版本代码的基础上,主要的改动点是diffChildren这个函数。原来的实现很简单,递归的调用diff就可以了:

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

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

现在,我们要对这个函数进行一个大改造,让他支持key的查找:

function diffChildren(newVDom, parent) {
    // 有key的子元素
    const nodesWithKey = {};
    let nodesWithKeyCount = 0;

    // 没key的子元素
    const nodesWithoutKey = [];
    let nodesWithoutKeyCount = 0;

    const childNodes = parent.childNodes,
          nodeLength = childNodes.length;

    const vChildren = newVDom.children,
          vLength = vChildren.length;

    // 用于优化没key子元素的数组遍历
    let min = 0;

    // 将子元素分成有key和没key两组
    for (let i = 0; i < nodeLength; i++) {
        const child = childNodes[i],
              props = child[ATTR_KEY];

        if (props !== undefined && props.key !== undefined) {
            nodesWithKey[props.key] = child;
            nodesWithKeyCount++;
        } else {
            nodesWithoutKey[nodesWithoutKeyCount++] = child;
        }
    }

    // 遍历vdom的所有子元素
    for (let i = 0; i < vLength; i++) {
        const vChild = vChildren[i],
              vProps = vChild.props;
        let dom;

        vKey = vProps!== undefined ? vProps.key : undefined;
        // 根据key来查找对应元素
        if (vKey !== undefined) {
            if (nodesWithKeyCount && nodesWithKey[vKey] !== undefined) {
                dom = nodesWithKey[vKey];
                nodesWithKey[vKey] = undefined;
                nodesWithKeyCount--; 
            }
        } 
        // 如果没有key字段,则找一个类型相同的元素出来做比较
        else if (min < nodesWithoutKeyCount) {
            for (let j = 0; j < nodesWithoutKeyCount; j++) {
                const node = nodesWithoutKey[j];
                if (node !== undefined && isSameType(node, vChild)) {
                    dom = node;
                    nodesWithoutKey[j] = undefined;
                    if (j === min) min++;
                    if (j === nodesWithoutKeyCount - 1) nodesWithoutKeyCount--;
                    break;
                }
            }
        }

        // diff返回是否更新元素
        const isUpdate = diff(dom, vChild, parent);

        // 如果是更新元素,且不是同一个dom元素,则移动到原先的dom元素之前
        if (isUpdate) {
            const originChild = childNodes[i];
            if (originChild !== dom) {
                parent.insertBefore(dom, originChild);
            }
        }
    }

    // 清理剩下的未使用的dom元素
    if (nodesWithKeyCount) {
       for (key in nodesWithKey) {
           const node = nodesWithKey[key];
           if (node !== undefined) {
               node.parentNode.removeChild(node);
           }
       } 
    }
    // 清理剩下的未使用的dom元素
    while (min <= nodesWithoutKeyCount) {
        const node = nodesWithoutKey[nodesWithoutKeyCount--];
        if ( node !== undefined) {
            node.parentNode.removeChild(node);
        }
    }
}

代码比较长,主要是以下几个步骤:

  • 将所有dom子元素分为有key和没key两组
  • 遍历VD子元素,如果VD子元素有key,则去查找有key的分组;如果没key,则去没key的分组找一个类型相同的元素出来
  • diff一下,得出是否更新元素的类型
  • 如果是更新元素且子元素不是原来的,则移动元素
  • 最后清理删除没用上的dom子元素

diff也要改造一下,如果是新建、删除或者替换元素,返回false。更新元素则返回true:

function diff(dom, newVDom, parent) {
    // 新建node
    if (dom == undefined) {
        parent.appendChild(createElement(newVDom));
        return false;
    }

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

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

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

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

    return true;
}

为了看效果,view函数也要改造下:

const arr = [0, 1, 2, 3, 4];

function view() {
    const elm = arr.pop();

    // 用于测试能不能正常删除元素
    if (state.num !== 9) arr.unshift(elm);

    // 用于测试能不能正常添加元素
    if (state.num === 12) arr.push(9);

    return (
        <div>
            Hello World
            <ul myText="dickens">
                {
                    arr.map( i => (
                        <li id={i} class={`li-${i}`} key={i}>
                            第{i}
                        </li>
                    ))
                }
            </ul>
        </div>
    );
}

通过变换数组元素的顺序和适时的添加/删除元素,验证了代码按照我们的设计思路正确运行

四、总结

本文基于上一个版本的代码,加入了对唯一标识(key)的支持,很好的提高了更新数组元素的效率。基于当前这个版本的代码还能做怎样的优化呢,请看下一篇的内容:你不知道的Virtual DOM(五):自定义组件

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

阅读 4.3k更新于 1月16日
推荐阅读

2484 人关注
172 篇文章
专栏主页
目录