vue3解读—虚拟dom与diff算法

263 阅读4分钟

1.虚拟dom与diff算法

前言

在上一章节《vue3解读—setup与render》中,针对内容情节对vdom进行了介绍,而diff算法就是在vdom的基础上进行前后比对,针对有变化的vdom元素进行内容更新,以起到性能优化的目的。

vdom比对

为了提升每一遍文章的独立性,这里会再次展示当前的demo代码。

// App.js
import h from './core/h.js';
export default {
    render(context) { 
        // 实际我们templete中的代码最终都会编译成render函数
        return h(
            'div',
            { id: 'appId'+context.state.count, class: 'name' },
            [h('p', { class: 'text' }, String(context.state.count)),
            h('span', null, 'span标签')]
        );
    },
    setup() {
        const state = reactive({
            count: 0,
        });
        window.state = state; // 挂载全局,方便测试
        return { state };
    }
}

// core/h.js
export default function(tag, props, children) {
    return {
        tag,
        props,
        children,
    };
}
// core/index.js

import { diff, mountElement } from "./renderer/index.js";
export function createApp(rootComponent) {
    return {
        mount(rootContainer) {
            const context = rootComponent.setup(); // 将setup返回值拿到
            let isMounted = false; // 是否首次挂载,不是则首次渲染,否则diff算法比对更新
            let prevSubTree = null; // 储存老的vdom
            effectWatch(() => {
                if (!isMounted) {
                    isMounted = true; // 置为true
                    rootContainer.innerHTML = ``;
                    // 将setup返回值渲染到容器中
                    const subTree = rootComponent.render(context); // context值发生变化,effectWatch会重新执行
                    console.log('subTree----', subTree);
                    mountElement(subTree, rootContainer);
                    prevSubTree = subTree; // 储存当前vdom
                } else {
                    const subTree = rootComponent.render(context);
                    diff(prevSubTree, subTree); // diff算法比对前后两次subTree的变化
                    prevSubTree = subTree; // 每次更新完毕后保存vdom
                }
            });
        }
    }
}
// core/render/index
export function mountElement(vnode, container) {
    const { tag, props, children} = vnode; // 由h方法返回的对象
    // 创建tag标签
    const el  = (vnode.el = document.createElement(tag));

    // 创建props属性
    if (props) {
        for(const key in props) {
            const val = props[key];
            el.setAttribute(key, val);
        }
    }

    // 创建children
    // 1. 字符
    if (typeof children === 'string') {
        const textNode = document.createTextNode(children);
        el.append(textNode);
    } else if (Array.isArray(children)) {
        // 2. 数组
        children.forEach(child => {
            mountElement(child, el);
        })
    }

    // 插入根元素
    container.append(el);
}

到这里我们已经能够渲染出我们想要的元素节点,关键地方的描述我已经写在了代码注释里结合代码更方便阅读理解。接下来我们就是要实现diff算法,使得非首次渲染时只渲染已经变化的类型,减少性能损耗。

diff算法

接下来是最重要的diff算法了,我们需要思考怎么才能找到前后两次内容变化的部分。

我们要理清所写的代码结构:首先由标签tag包裹子元素,标签有class等属性,所以我们只需要先判断标签tag,再判断标签属性,最后判断子元素是标签节点还是文本节点,是标签节点则递归回到第一步继续遍历,是文本节点则比对文本结束diff算法。

/**
 * diff比对更新对应节点
 * @param {*} n1 旧的节点
 * @param {*} n2 新的节点
 */
export function diff(n1, n2) {
    if (n1.tag !== n2.tag) {
        //  前后两次的标签不一样,则更新标签
        n1.el.replaceWith(document.createElement(n2.tag));
    } else {
        // 标签一样,则将旧的el赋给新的节点,再对节点的属性及子节点进行比对
        n2.el = n1.el;

        const { props: newProps, children: newChildren } = n2;
        const { props: oldProps, children: oldChildren } = n1;
        // 遍历比对节点属性是否变化或者新增
        if (newProps && oldProps) {
            Object.keys(newProps).forEach(key => {
                const newVal = newProps[key];
                const oldVal = oldProps[key];
                if (newVal !== oldVal) {
                    n1.el.setAttribute(key, newVal);
                }
            });
        }
        if (oldProps) {
            Object.keys(oldProps).forEach(key => {
                if (!newProps[key]) {
                    n1.el.removeAttribute(key);
                }
            });
        }
        if (typeof newChildren === 'string') {
            if (typeof oldChildren === 'string') {
                // 前后都是文本,且文本不相等
                if (newChildren !== oldChildren) {
                    n2.el.textContent = newChildren;
                }
            } else if (Array.isArray(oldChildren)) {
                // 新子节点问文本则直接替换
                n2.el.textContent = newChildren;
            }
        } else if (Array.isArray(newChildren)) {
            if (typeof oldChildren === 'string') {
                // 清空文本,渲染新的子节点
                n2.el.container = '';
                mountElement(n2, n2.el);
            } else if (Array.isArray(oldChildren)) {
                // 这里是最复杂的,前后都为标签元素
                const length = Math.min(newChildren.length, oldChildren.length);
                // 处理公共vnode,取同一级的标签元素,进行比对
                for (let index = 0;index < length;index ++) {
                    const newVnode = newChildren[index];
                    const oldVnode = oldChildren[index];
                    diff(oldVnode, newVnode);
                }
                if (newChildren.length > length) {
                    // 如果新的子节点大于旧的子节点,创建新节点中多余的
                    for (let index = length;index < newChildren.length;index ++) {
                        const newVnode = newChildren[index];
                        mountElement(newVnode);
                    }
                }
                if (oldChildren.length > length) {
                    // 如果旧的子节点大于新的子节点,删除旧节点中多余的
                    for (let index = length;index < oldChildren.length;index ++) {
                        const oldVnode = oldChildren[index];
                       oldVnode.el.parent.removeChild(oldVnode.el);
                    }
                }
            }
        }
    }
}

这里的难点主要在于子节点的比对,这里我考虑到了四种变化,第一种:子节点前后仍为文本节点;第二种:子节点由标签节点更新为文本节点;第三种和第四种就是前后都是标签节点,但是内容有删改,这两种也是实现起来比前两者更为复杂的两种变化。在代码中都已经进行了注释,结合代码更容易理解思路的实现哦。

如下图,我们删除子节点,实际上只是div#appId0发生了变化,子元素span是没有发生变化的。 image.png

image.png