Virtual Dom - Diff 之 patchVnode 方法

大白兔粘牙

该方法用来真正对新旧节点进行对比,得到最小应该变化的DOM,然后直接更新DOM。下面是需要patch的几种情况,这几种情况都会有对应的真实DOM测试用例来验证。

function patchVnode(oldVnode, vnode) {
    const elm = vnode.elm = oldVnode.elm;
    const { children: oldCh } = oldVnode;
    const { children: ch } = vnode;

    if (!vnode.text) {
        if (oldCh && ch) {  // 新旧节点都有子节点【子节点就是vnode对象中的 children】

        } else if (oldCh) { // 旧节点有子节点,而新节点没有子节点

        } else if (ch) {    // 新节点有子节点,而旧节点没有子节点

        } else if (oldVnode.text) {    // 旧节点是一个文本节点,但是新节点的文本为空

        }
    } else if (oldVnode.text !== vnode.text) {  // 新旧节点都是文本节点,并且文本不一样

    }
}

1. const elm = vnode.elm = oldVnode.elm;
vnode表示新节点,此时是没有elm属性的。而在经过createElm方法后,vnode.children中的子节点都有了elm属性,此时只有vnode没有elm属性,而能进到 patchVnode 方法来的新旧节点,一定经过了sameVnode方法的判断,说明他们节点本身几乎一样,所以新节点可以用旧节点的elm

if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
}

elm.png
2. !vnode.text
能进入到这个条件的,有两种可能:

  1. vnode是个文本节点,但是文本节点的text为假值
    const vnode = { text: 0/false/'' }
  2. vnode有children子节点
    const vnode = { tag: 'div', children: [{...}] }

    注意: Vnode对象有很多属性,没有列出来的属性,默认值都是undefined, 所以 !vnode.text === !undefined 会进入到这个逻辑来

vnode.png
也就是说,文本节点和有children子节点是互斥的。

3. oldCh && ch
新旧节点都有子节点,能进入到 patchVnode 方法,说明新旧节点本身是几乎一样的,需要做的就是比较他们的children子节点哪里不同,从而更新DOM

if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
}
if (oldCh && ch) {
    if (oldCh !== ch) updateChildren(elm, oldCh, ch);   // updateChildren 方法有点复杂,是Diff的核心方法
}
最终的页面效果对应的DOM结构

oldCh_ch_diff_before.png
oldCh_ch_diff_after.png

Diff前后对应DOM的Vnode对象
const app = document.getElementById('app');
const span = document.querySelector('span');
const span_text = span.childNodes[0];
const comment = [...app.childNodes].filter(el => el.nodeType === 8)[0]
const ul = document.getElementsByTagName('ul')[0];
const lis = ul.children;

const oldVnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
    elm: app,     // 旧节点的Vnode对象上都会有一个 elm 属性, 表示该Vnode对应的真实DOM元素
    children: [
        {
            tag: 'span',
            elm: span,
            children: [{ text: '一去二三里', elm: span_text }]
        },
        {
            text: '我是一个注释',
            isComment: true,
            elm: comment
        },
        {
            tag: 'ul',
            elm: ul,
            children: [
                {
                    tag: 'li',
                    elm: lis[0],
                    children: [{ text: 'item1', elm: lis[0].childNodes[0] }]
                },
                {
                    tag: 'li',
                    elm: lis[1],
                    children: [{ text: 'item2', elm: lis[1].childNodes[0] }]
                },
                {
                    tag: 'li',
                    elm: lis[2],
                    children: [{ text: 'item3', elm: lis[2].childNodes[0] }]
                },
            ]
        }
    ]
}
// 新节点是没有 elm 属性的
const vnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
    children: [
        {
            tag: 'span',
            children: [{ text: '烟村四五家' }]
        },
    ]
}

从图例和新旧vnode中可以看出,他们都有chidlren子节点,所以这种情况,就会进入到 patchVnode 方法的 oldCh && ch 逻辑中来,下面举例说一下 updateChildren 方法的逻辑,先放上该方法的一个逻辑框架代码:

function updateChildren(parentElm, oldCh, newCh) {
    let oldStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];

    let newStartIdx = 0;
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (sameVnode(oldStartVnode, newStartVnode)) {          // 头头相同  本身位置不动,只用patch子节点,更新子节点DOM即可

        } else if (sameVnode(oldEndVnode, newEndVnode)) {       // 尾尾相同  本身位置不动,只用patch子节点,更新子节点DOM即可

        } else if (sameVnode(oldStartVnode, newEndVnode)) {     // 旧头 == 新尾  DOM位置需要移动, 从第一个移动到末尾 使用 insertBefore API 

        } else if (sameVnode(oldEndVnode, newStartVnode)) {     // 旧尾 == 新头  DOM位置需要移动,从最后一个移动到第一个

        } else {    // 上面四种都不符合,单个查找

        }
    }

    if (oldStartIdx > oldEndIdx) {

    } else if (newStartIdx > newEndIdx) {

    }
}

这就说所有讲 Diff 文章中的头头相同、尾尾相同、旧头===新头....等,刚开始我看到这样的描述时是迷糊的...每种情况我都会以一个例子来说明
3.1. 新头 === 旧头
意思是: 新节点的头部vnode跟旧节点的头部vnode是近似相等的,需要做的就是比较他们的子节点有什么不同,从而更新需要更新的子节点DOM。如图:
hh_diff_before.png
hh_diff_after.png
从图例可以看出,对于头头相等的情况,相同的那个节点(span)在DOM中的位置是不用动的,将旧节点中剩余的子节点(comment、ul)删除即可。

4. oldCh
新节点没有,而旧节点有的,需要删除旧节点中的这些DOM元素

最终的页面效果对应的DOM结构

oldCh_ch_diff_before.png
oldCh_diff_after.png

Diff前后对应DOM的Vnode对象
const oldVnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
    elm: app,
    children: [
        {
            tag: 'span',
            elm: span,
            children: [{ text: '一去二三里', elm: span_text }]
        },
        {
            text: '我是一个注释',
            isComment: true,
            elm: comment
        },
        {
            tag: 'ul',
            elm: ul,
            children: [
                {
                    tag: 'li',
                    elm: lis[0],
                    children: [{ text: 'item1', elm: lis[0].childNodes[0] }]
                },
                {
                    tag: 'li',
                    elm: lis[1],
                    children: [{ text: 'item2', elm: lis[1].childNodes[0] }]
                },
                {
                    tag: 'li',
                    elm: lis[2],
                    children: [{ text: 'item3', elm: lis[2].childNodes[0] }]
                },
            ]
        }
    ]
}
const vnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
}
patchVnode逻辑
function patchVnode(oldVnode, vnode) {
    const elm = vnode.elm = oldVnode.elm;
    const { children: oldCh } = oldVnode;
    const { children: ch } = vnode;

    if (!vnode.text) {
        if (oldCh && ch) {

        } else if (oldCh) { // 旧节点有子节点,而新节点没有子节点
            for (const child of oldCh) {
                if (child) {
                    oldVnode.elm.removeChild(child.elm);
                }
            }
        } else if (ch) {

        } else if (oldVnode.text) {

        }
    } else if (oldVnode.text !== vnode.text) {

    }
}

5. ch
新节点有,而旧节点没有的,需要创建成节点插入到DOM中

最终的页面效果对应的DOM结构

ch_diff_before.png
ch_diff_after.png

Diff前后对应DOM的Vnode对象
const oldVnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
    elm: app
}
const vnode = {
    tag: 'div',
    data: {
        attrs: { id: 'app' }
    },
    children: [
        {
            tag: 'span',
            data: {
                attrs: { class: 'first' }
            },
            children: [{ text: '一去二三里' }]
        },
        {
            text: '我是一个注释',
            isComment: true,
        },
        {
            tag: 'ul',
            data: {
                attrs: { class: 'list' }
            },
            children: [
                {
                    tag: 'li',
                    children: [{ text: 'item1' }]
                },
                {
                    tag: 'li',
                    children: [{ text: 'item2' }]
                },
                {
                    tag: 'li',
                    children: [{ text: 'item3' }]
                },
            ]
        }
    ]
}
patchVnode逻辑
function patchVnode(oldVnode, vnode) {
    const elm = vnode.elm = oldVnode.elm;
    const { children: oldCh } = oldVnode;
    const { children: ch } = vnode;

    if (!vnode.text) {
        if (oldCh && ch) {

        } else if (oldCh) {

        } else if (ch) {        // 新节点有子节点,旧节点没有
            for (const child of ch) {
                createElm(child, elm, null);   // 创建并插入到父元素中
            }
        } else if (oldVnode.text) {

        }
    } else if (oldVnode.text !== vnode.text) {

    }
}
function createElm(vnode, parentNode, refNode) {
    const { text, tag, children, data, isComment } = vnode;
    if (tag) {
        vnode.elm = document.createElement(tag);

        // 生成子节点
        createChildren(vnode, children);

        // 将属性添加上去
        if (data) {
            const { attrs } = data;
            if (attrs) {
                for (const k in attrs) {
                    vnode.elm.setAttribute(k, attrs[k]);
                }
            }
        }

        // 将子节点插入到父节点
        insert(parentNode, vnode.elm, refNode);
    } else if (isComment) {
        vnode.elm = document.createComment(text);       // 新增 注释节点 并添加到其父元素中
        insert(parentNode, vnode.elm, refNode);
    } else {
        vnode.elm = document.createTextNode(text)       // 新增 文本节点 并添加到其父元素中
        insert(parentNode, vnode.elm, refNode);
    }
}
function createChildren(vnode, children) {
    if (Array.isArray(children)) {
        for (const child of children) {
            createElm(child, vnode.elm);
        }
    }
}
function insert(parent, newNode, refNode) {
    if (parent) {
        if (refNode) {
            if (refNode.parentNode === parent) {   // 看下图
                parent.insertBefore(newNode, refNode);
            }
        } else {
            parent.appendChild(newNode);
        }
    }
}

insertBefore.png

阅读 1.6k

前端工程师

7 声望
0 粉丝
0 条评论

前端工程师

7 声望
0 粉丝
文章目录
宣传栏