Vue渲染器(三):简单diff算法

渲染器(三):简单diff算法

我们将介绍渲染器的核心Diff算法。简单来说就是当新旧vnode的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点。

1.减少DOM操作的性能开销:

核心Diff算法只关心新旧虚拟节点都存在一组子节点的清空。前面我们针对这个,采用了简单粗暴的方式完成,即卸载全部旧子节点,再挂载全部新子节点。这么做确实可以完成更新,但是没有实现DOM复用,所以会产生较大的性能开销。

以下面新旧vnode为例:

// 旧 vnode 
const oldVNode = {
    type: 'div',
    children: [
        { type: 'p', children: '1' },
        { type: 'p', children: '2' },
        { type: 'p', children: '3' }
    ]
}

// 新 vnode 
const newVNode = {
    type: 'div',
    children: [
        { type: 'p', children: '4' },
        { type: 'p', children: '5' },
        { type: 'p', children: '6' }
    ]
}

按照之前的做法,当更新子节点时,需要执行6次DOM操作:

  • 卸载所有旧子节点,3次DOM删除;
  • 挂载所有新子节点,3次DOM添加。

但是观察上面新旧vnode的子节点,可以发现:

  • 更新前后的所有子节点都是p标签,即标签元素不变;
  • 只有p标签的子节点(文本节点)会发生变化。

所以最理想的更新方式:直接更新这个p标签的文本节点的内容,这样只需要3次DOM操作。

在动手完善代码之前,来思考一个问题:新旧两组子节点的数量未必相同。

  • 当新vnode < 旧vnode时,多余旧vnode删除。
  • 当新vnode > 旧vnode时,挂载新增节点。
  • 在进行新旧vnode更新时,应该遍历其中长度较短的那一组。这样才能够尽可能多调用patch函数进行更新。
function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        // 重新实现两组子节点的更新方式,新旧children
        const oldChildren = n1.children;
        const newChildren = n2.children;
        const oldLen = oldChildren.length;
        const newLen = newChildren.length;
        // 两组子节点的公共长度,取两者中较短的一组
        const commonLength = Math.min(oldLen, newLen);
        // 遍历旧的children
        for (let i = 0; i < commonLength; i++) {
            patch(oldChildren[i], newChildren[i], container);
        }
        // 如果newLen > oldLen , 说明有新子节点需要挂载
        if (newLen > oldLen) {
            for (let i = commonLength; i < newLen; i++) {
                patch(null, newChildren[i], container);
            }
        } else if (newLen < oldLen) {
            // 有旧子节点需要卸载
            for (let i = commonLength; i < oldLen; i++) {
                unmount(oldChildren[i]);
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

这样便实现了无论新旧两组子节点的数量关系如何,渲染器都能够正确地挂载或卸载它们。

2.DOM复用与key的作用:

在前面,通过减少DOM操作的次数来提升更新性能,但是还有可优化空间。假设新旧两组子节点的内容如下:

// oldChildren 
[
    { type: 'p' },
    { type: 'div' },
    { type: 'span' }
]
// newChildren 
[
    { type: 'span' },
    { type: 'p' },
    { type: 'div' }
]

如果使用上一节介绍的算法来完成上述两组子节点的更新,需要移动6次DOM操作。

但是可以明显看到,上面的新旧vnode只是顺序不同,最优解:通过DOM的移动来完成子节点的更新,这更节约性能。那该如何移动呢?

得保证一个前提:新旧vnode确实存在可复用的节点。那如何确定新vnode有没有出现在旧vnode中呢?即如何保证上面例子中 新vnode中的第一个子节点 { type: 'span' }与旧vnode中的第三个子节点相同?

一种解决方案是通过vnode.type的值是否相同来判断,但这种方式不可靠,举例:

// oldChildren 
[
    { type: 'p', children: '1' },
    { type: 'p', children: '2' },
    { type: 'p', children: '3' }
]

// newChildren 
[
    { type: 'p', children: '3' },
    { type: 'p', children: '1' },
    { type: 'p', children: '2' }
]

上面案例可以通过移动DOM的方式来完成更新。但是所有节点的 vnode.type属性值都相同,这导致无法确定新旧vnode中节点的对应关系,也就不知道该进行怎样的DOM移动才能更新。

这时,key的作用旧体现出来了。

  • key:vnode的唯一标识。
  • 当两个vnode的 type 和 key都相同,才认为它们是相同的,就可以进行DOM复用了。

如下代码:

// oldChildren 
[
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 }
]

// newChildren 
[
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 }
]

下图展示了有key和无key时新旧两组子节点的映射情况:
在这里插入图片描述

这样,有key了就知道该如何进行相应的DOM移动操作了。

注意一点:DOM可复用并不意味着不需要更新,如:

const oldVnode = { type: 'p', key: 1, children: 'text1' }
const newVnode = { type: 'p', key: 1, children: 'text2' }

patchChildren 函数的修改:

function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        // 重新实现两组子节点的更新方式,新旧children
        const oldChildren = n1.children;
        const newChildren = n2.children;
        // 遍历新vnode
        for (let i = 0; i < newChildren.length; i++) {
            const newVnode = newChildren[i];
            for (let j = 0; j < oldChildren.length; j++) {
                const oldVnode = oldChildren[j];
                // 如果找到了具有相同key值的两个节点,说明可以复用
                // 但仍需调用patch函数更新
                if (newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container);
                    break; // 这里需要break
                }
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

在上面代码中,重新实现了新旧vnode的更新逻辑。外层循环遍历新vnode,内层循环逐个对比新旧vnode的key值,试图在旧vnode中找到可复用的节点。一旦找到则调用patch函数进行打补丁。经过这一步操作之后,就能够保证所有可复用的节点本身都已经更新完毕了。接下来就需要通过移动节点来完成真实DOM顺序的更新。

3.找到需要移动的元素:

在前面,我们已经通过key找到可复用的节点了。接下来要思考的是如何判断一个节点是否需要移动,以及如何移动?

先来解决第一个问题,反过来想,那什么时候节点不需要移动?当新旧vnode的节点顺序不变时,就不需要额外的移动操作,如下图:

在这里插入图片描述

来看更新算法:

  • 取新vnode中的第一个节点p-1,key为1,尝试在旧vnode中寻找具有相同key值的可复用节点,找到了,并且该节点在旧vnode中的索引为0。
  • 后两步一样。

在这个过程中,每一次寻找可复用vnode时,都会记录该可复用节点在旧vnode中的位置索引。如果把这些位置索引值按照先后顺序排列,就能得到一个序列:0、1、2。这是一个递增的序列,在这种情况下不需要移动任何节点。

再看另外一个例子:

执行更新算法:

  • 第一步:取新vnode中的第一个节点p-3,key为3。在旧vnode中找到具有相同key的可复用节点,找到了,并且该节点在旧vnode中的索引为2。
  • 第二步:同上寻找方式,该节点在旧vnode中的索引为0。
  • 第三步:该节点在旧vnode中的索引为1。

这时发现,索引递增的顺序被打破了(2、0、1)。这时就需要进行移动DOM了。这就是Diff算法在执行更新的过程中,判断节点是否需要移动的方式。

其实可以将节点p-3在旧vnode中的索引定义为:在旧vnode寻找具有相同key值节点的过程中,遇到的最大索引值。如果在后续寻找过程中,存在索引值比当前遇到的最大索引值还要小的节点,则意味着该节点需要移动。

lastIndex变量存储整个寻找过程中遇到的最大索引值,如下代码:

function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        const oldChildren = n1.children;
        const newChildren = n2.children;
        let lastIndex = 0;
        for (let i = 0; i < newChildren.length; i++) {
            const newVnode = newChildren[i];
            for (let j = 0; j < oldChildren.length; j++) {
                const oldVnode = oldChildren[j];
                if (newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container);
                    if (j < lastIndex) {
                        // 如果当前找到的节点在旧vnode中的索引小于最大索引值,
                        // 说明该节点对应的真实DOM需要移动
                    } else {
                        // 更新 lastIndex的值
                        lastIndex = j;
                    }
                    break; // 这里需要break
                }
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

如上代码,变量lastIndex始终存储着当前遇到的最大索引值。

现在,已经找到了需要移动的节点,下面旧开始讨论如何移动节点从而完成节点顺序的更新。

4.如何移动元素:

在前面讨论了如何判断节点是否需要移动。移动节点:移动一个虚拟节点所对应的真实DOM节点,并不是移动虚拟节点本身。

既然移动的是真实DOM节点,就得取到对它的引用才行。在代码中,我们可以通过旧vnode中的vnode.el属性取得它对应的真实DOM节点。

回顾一下之前的patchElement 函数:

function patchElement(n1, n2) {
    // 新vnode也引用了真实DOM元素
    const el = n2.el = n1.el;
}

这样无论是新vnode还是旧vnode,都存在对真实DOM的引用。在此基础上,就可以进行DOM移动操作了。

为了解释具体应该怎样移动DOM节点,采用上一节的更新案例:

在这里插入图片描述

更新步骤如下:

  • 第一步:取新vnode中的第一个节点p-3,key为3,在旧vnode中找到具有相同key值的可复用节点,此时lastIndex 值为0,旧vnode索引为2,2>0,所以节点p-3对应的真实DOM不需要移动。然后更新 lastIndex为2。

  • 第二步:新vnode第二个节点p-1,key为1,在旧vnode中找到具有相同key值的可复用节点,此时lastIndex 值为2,旧vnode索引为0,0<2,所以节点p-1对应的真实DOM需要移动。

    • 那么该如何移动呢?节点p-1对应的真实DOM需要移动,并且我们知道 新vnode的顺序其实就是更新后真实DOM节点应有的顺序。所以把节点p-1所对应的真实DOM移动到节点p-3所对应的真实DOM后面。
    • 具体如下图:在这里插入图片描述
  • 第三步与第二步类似,就不进行分析了。

开始着手实现代码:

function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        const oldChildren = n1.children;
        const newChildren = n2.children;

        let lastIndex = 0;
        for (let i = 0; i < newChildren.length; i++) {
            const newVnode = newChildren[i];
            for (let j = 0; j < oldChildren.length; j++) {
                const oldVnode = oldChildren[j];
                if (newVnode.key === oldVnode.key) {
                    patch(oldVnode, newVnode, container);
                    if (j < lastIndex) {
                        // 代码运行到这里,说明newVNode对应的真实DOM需要移动
                        // 先获取newVnode的前一个vnode,即 prevVnode
                        const prevVnode = newChildren[i - 1];
                        // 如果prevVnode不存在,则说明当前newVnode是第一个节点,不需要移动
                        if (prevVnode) {
                            // 将newVnode对应的真实DOM移动到 prevVnode所对应的真实DOM后面
                            // 所以需要获取prevVnode所对应真实DOM的下一个兄弟节点,将其作为锚点
                            const anchor = prevVnode.el.nextSibling;
                            // 调用insert将newVnode对应的真实DOM插入到锚点前面
                            insert(newVnode.el, container, anchor);
                        }
                    } else {
                        lastIndex = j;
                    }
                    break;
                }
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

在上面代码中,如果条件 j<lastIndex成立,则说明当前 newVnode对应的真实DOM需要移动。获取其前一个虚拟节点,然后调用insert函数完成节点的移动即可,其中insert函数依赖浏览器原生API,故需要抽离:

const renderer = createRenderer({
    createElement(tag) {
        return document.createElement(tag);
    },
    setElementText(el, text) {
        el.textContent = text;
    },
    insert(el, parent, anchor = null) {
        parent.insertBefore(el, anchor);
    },
    patchProps(el, key, prevValue, nextValue) {
        if (/^on/.test(key)) {
            let invokers = el._vei || (el._vei = {});
            let invoker = invokers[key];
            const name = key.slice(2).toLowerCase();
            if (nextValue) {
                if (!invoker) {
                    invoker = el._vei[key] = (e) => {
                        // e.timeStamp:事件发生的时间
                        // 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
                        if (e.timeStamp < invoker.attached) return;
                        if (Array.isArray(invoker.value)) {
                            invoker.value.forEach(fn => fn(e));
                        } else {
                            invoker.value(e);
                        }
                    }
                    invoker.value = nextValue;
                    // 添加 invoker.attached属性,存储事件处理函数被绑定的时间
                    invoker.attached = performance.now();
                    el.addEventListener(name, invoker);
                } else {
                    invoker.value = nextValue;
                }
            } else if (invoker) {
                el.removeEventListener(name, invoker);
            }
        } else if (key === 'class') {
            el.className = nextValue || ''
        } else if (shouldSetAsProps(el, key, nextValue)) {
            const type = typeof el[key];
            if (type === 'boolean' && nextValue === '') {
                el[key] = true;
            } else {
                el[key] = nextValue;
            }
        }
        else {
            el.setAttribute(key, nextValue);
        }
    },
    createText(text) {
        return document.createTextNode(text);
    },
    setText(el, text) {
        el.nodeValue = text;
    },
    insert(el, parent, anchor = null) {
        parent.insertBefore(el, anchor);
    }
})

5.添加新元素:

这节讨论添加新节点的情况,如图:
在这里插入图片描述

观察可知,在新vnode中,多出一个p-4,key值为4,该节点在旧vnode中不存在,因此将其视为新增节点。

对于新增节点,在更新时要将它挂载,这主要分为两步:

  • 想办法找到新增节点;
  • 将新增节点挂载到正确位置。

先来看一下如何找到新增节点,根据前面实现的简单diff算法来模拟下图中的例子:
在这里插入图片描述

此时的更新逻辑:前两步解释和前面一样。当第三步时,新vnode中的第三个节点为p-4,它的key值为4,在旧vnode中寻找可复用的节点,发现找不到,因此渲染器会把节点p-4看作新增节点并挂载它。那么应该挂载到哪里呢?

观察p-4在新vnode中的位置,它在p-1节点的后面,所以应该把它挂载到节点p-4所对应的真实DOM后面。第

四步移动操作完后真实DOM顺序是:p-3、p-1、p-4、p-2。

在这里插入图片描述

代码实现:

function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        const oldChildren = n1.children;
        const newChildren = n2.children;

        let lastIndex = 0;
        for (let i = 0; i < newChildren.length; i++) {
            const newVnode = newChildren[i];
            // 在第一层循环中定义find,代表是否在旧vnode中找到可复用节点
            // 初始值为false,则代表没找到
            let find = false;
            for (let j = 0; j < oldChildren.length; j++) {
                const oldVnode = oldChildren[j];
                if (newVnode.key === oldVnode.key) {
                    // 一旦找到可复用的节点,将变量find的值设为true
                    find = true;
                    patch(oldVnode, newVnode, container);
                    if (j < lastIndex) {
                        const prevVnode = newChildren[i - 1];
                        if (prevVnode) {
                            const anchor = prevVnode.el.nextSibling;
                            insert(newVnode.el, container, anchor);
                        }
                    } else {
                        lastIndex = j;
                    }
                    break;
                }
            }
            // 如果代码运行到这里,find仍然为false,
            // 说明新vnode没有在旧vnode中找到可复用的节点,此时说明新vnode是新增节点,执行挂载
            if (!find) {
                // 为了将节点挂载到正确位置,需要先获取锚点元素
                // 获取当前新vnode的前一个vnode节点
                const prevVnode = newChildren[i - 1];
                let anchor = null;
                if (prevVnode) {
                    // 如果有前一个vnode节点,则使用它的下一个兄弟节点作为锚点元素
                    anchor = prevVnode.el.nextSibling;
                } else {
                    // 如果没有前一个vnode节点,说明即将挂载的新vnode是第一个子节点
                    // 这时使用容器元素的 findChild 作为锚点
                    anchor = container.firstChild;
                }
                // 挂载新vnode
                patch(null, newVnode, container, anchor);
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

上面代码的解释都写在注释里了,并且由于目前实现的patch函数不支持传递第四个参数,修改patch函数:

    function patch(n1, n2, container, anchor) {
        if (n1 && n1.type !== n2.type) {
            unmount(n1);
            n1 = null;
        }
        const { type } = n2;
        if (typeof type === 'string') {
            if (!n1) {
                // 挂载时将锚点元素作为第三个参数传递给 mountElement
                mountElement(n2, container, anchor);
            } else {
                patchElement(n1, n2);
            }
        } else if (typeof type === 'Text') {
            if (!n1) {
                const el = n2.el = createTextNode(n2.children);
                insert(el, container);
            } else {
                const el = n2.el = n1.el;
                if (n2.children !== n1.children) {
                    setText(el, n2.children);
                }
            }
        } else if (type === 'Fragment') {
            if (!n1) {
                n2.children.forEach(c => patch(null, c, container));
            } else {
                patchChildren(n1, n2, container);
            }
        }
    }
// 它需要增加第三个参数,即锚点元素
function mountElement(vnode, container, anchor) {
	// 省略部分代码
	// 插入节点时,将锚点元素传递给insert函数
	insert(el, container, anchor);
}

6.移除不存在的元素:

在更新子节点时,不仅会遇到新增元素,还有可能遇到元素被删除的情况,如图:

在新vnode中,节点p-2不存在,这说明该节点被删除了。那么渲染器该如何找到那些需要删除的节点并正确地删除呢?

先看如何找到需要删除的节点,模拟执行更新的过程:

  • 第一步:跟前面一样。
  • 第二步:新vnode中的第二个节点p-1,在旧vnode中找到对应的,进行移动。

此时真实DOM的状态如图所示:
在这里插入图片描述

至此更新便结束了,但是p-2对应的真实DOM仍然存在,所以需要增加额外的逻辑来删除遗留节点。

思路很简单,当更新结束后,再遍历一遍旧vnode,然后去新vnode中寻找具有相同key的节点,找不到则删除该节点。如下代码:

function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        if (Array.isArray(n1.children)) {
            n1.children.forEach((c) => unmount(c));
        }
        setElementText(container, n2.children);
    } else if (Array.isArray(n2.children)) {
        const oldChildren = n1.children;
        const newChildren = n2.children;

        let lastIndex = 0;
        for (let i = 0; i < newChildren.length; i++) {
            const newVnode = newChildren[i];
            let find = false;
            for (let j = 0; j < oldChildren.length; j++) {
                const oldVnode = oldChildren[j];
                if (newVnode.key === oldVnode.key) {
                    // 一旦找到可复用的节点,将变量find的值设为true
                    find = true;
                    patch(oldVnode, newVnode, container);
                    if (j < lastIndex) {
                        const prevVnode = newChildren[i - 1];
                        if (prevVnode) {
                            const anchor = prevVnode.el.nextSibling;
                            insert(newVnode.el, container, anchor);
                        }
                    } else {
                        lastIndex = j;
                    }
                    break;
                }
            }
            if (!find) {
                const prevVnode = newChildren[i - 1];
                let anchor = null;
                if (prevVnode) {
                    anchor = prevVnode.el.nextSibling;
                } else {
                    anchor = container.firstChild;
                }
                // 挂载新vnode
                patch(null, newVnode, container, anchor);
            }
        }
        // 上一步的更新操作完成后,遍历旧vnode
        for (let i = 0; i < oldChildren.length; i++) {
            const oldVnode = oldChildren[i];
            // 拿旧node去信vnode中寻找有相同key值的节点
            const has = newChildren.find(
                vnode => vnode.key === oldVnode.key
            )
            if (!has) {
                // 如果没有中找到则删除该节点
                unmount(oldVnode);
            }
        }
    } else {
        if (Array.isArray(n1.children)) {
            n1.children.forEach(c => unmount(c));
        } else if (typeof n1.children === 'string') {
            setElementText(container, '');
        }
    }
}

如以上代码及注释所示。

7.总结:

在这一章中,我们讨论了Diff算法作用,主要是用来计算新旧vnode的差异,并尝试最大程度复用DOM元素。并解释了为什么需要Diff算法,以及DOM复用带来的好处,以及key的作用。

并通过几个例子讲解了渲染器是如何移动、添加、删除vnode所对应的DOM元素的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值