# 数据更新视图

之前讲到,在对 model 进行操作对时候,会触发对应 Dep 中的 Watcher 对象。Watcher 对象会调用对应的 update 来修改视图。最终是将新产生的 VNode 节点与老 VNode 进行一个 patch 的过程,比对得出「差异」,最终将这些「差异」更新到视图上。...

# 跨平台

因为使用了 Virtual DOM 的原因,Vue.js具有了跨平台的能力,Virtual DOM 终归只是一些 JavaScript 对象罢了,那么最终是如何调用不同平台的 API 的呢?

这就需要依赖一层适配层了,将不同平台的 API 封装在内,以同样的接口对外提供。...

const nodeOps = {
    setTextContent (text) {
        if (platform === 'weex') {
            node.parentNode.setAttr('value', text);
        } else if (platform === 'web') {
            node.textContent = text;
        }
    },
    parentNode () {
        //......
    },
    removeChild () {
        //......
    },
    nextSibling () {
        //......
    },
    insertBefore () {
        //......
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

举个例子,现在我们有上述一个 nodeOps 对象做适配,根据 platform 区分不同平台来执行当前平台对应的API,而对外则是提供了一致的接口,供 Virtual DOM 来调用。

# 一些API

接下来我们来介绍其他的一些 API,这些API在下面 patch 的过程中会被用到,他们最终都会调用 nodeOps 中的相应函数来操作平台。

insert 用来在 parent 这个父节点下插入一个子节点,如果指定了 ref 则插入到 ref 这个子节点前面。...

function insert (parent, elm, ref) {
    if (parent) {
        if (ref) {
            if (ref.parentNode === parent) {
                nodeOps.insertBefore(parent, elm, ref);
            }
        } else {
            nodeOps.appendChild(parent, elm)
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11

createElm 用来新建一个节点, tag 存在创建一个标签节点,否则创建一个文本节点。

function createElm (vnode, parentElm, refElm) {
    if (vnode.tag) {
        insert(parentElm, nodeOps.createElement(vnode.tag), refElm);
    } else {
        insert(parentElm, nodeOps.createTextNode(vnode.text), refElm);
    }
}
1
2
3
4
5
6
7

addVnodes 用来批量调用 createElm 新建节点。

function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx) {
    for (; startIdx <= endIdx; ++startIdx) {
        createElm(vnodes[startIdx], parentElm, refElm);
    }
}
1
2
3
4
5

removeNode 用来移除一个节点。

function removeNode (el) {
    const parent = nodeOps.parentNode(el);
    if (parent) {
        nodeOps.removeChild(parent, el);
    }
}
1
2
3
4
5
6

removeVnodes 会批量调用 removeNode 移除节点。

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
    for (; startIdx <= endIdx; ++startIdx) {
        const ch = vnodes[startIdx]
        if (ch) {
            removeNode(ch.elm);
        }
    }
}
1
2
3
4
5
6
7
8

# patch

首先说一下 patch 的核心 diff 算法,我们用 diff 算法可以比对出两颗树的「差异」,我们来看一下,假设我们现在有如下两颗树,它们分别是新老 VNode 节点,这时候到了 patch 的过程,我们需要将他们进行比对。

An image

diff 算法是通过同层的树节点进行比较而非对树进行逐层搜索遍历的方式,所以时间复杂度只有 O(n),是一种相当高效的算法,如下图。

An image

这张图中的相同颜色的方块中的节点会进行比对,比对得到「差异」后将这些「差异」更新到视图上。因为只进行同层级的比对,所以十分高效。

patch 的过程相当复杂,我们先用简单的代码来看一下。

function patch (oldVnode, vnode, parentElm) {
    if (!oldVnode) {
        addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
    } else if (!vnode) {
        removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
    } else {
        if (sameVnode(oldVNode, vnode)) {
            patchVnode(oldVNode, vnode);
        } else {
            removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
            addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

因为 patch 的主要功能是比对两个 VNode 节点,将「差异」更新到视图上,所以入参有新老两个 VNode 以及父节点的 element 。我们来逐步捋一下逻辑, addVnodes 、 removeVnodes 等函数后面会讲。

首先在 oldVnode(老 VNode 节点)不存在的时候,相当于新的 VNode 替代原本没有的节点,所以直接用 addVnodes 将这些节点批量添加到 parentElm 上。

if (!oldVnode) {
    addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
1
2
3

然后同理,在 vnode(新 VNode 节点)不存在的时候,相当于要把老的节点删除,所以直接使用 removeVnodes 进行批量的节点删除即可。

else if (!vnode) {
    removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
}
1
2
3

最后一种情况,当 oldVNode 与 vnode 都存在的时候,需要判断它们是否属于 sameVnode(相同的节点)。如果是则进行patchVnode(比对 VNode )操作,否则删除老节点,增加新节点。

if (sameVnode(oldVNode, vnode)) {
    patchVnode(oldVNode, vnode);
} else {
    removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
    addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
}
1
2
3
4
5
6

# sameVnode

上面这些比较好理解,下面我们来看看什么情况下两个 VNode 会属于 sameVnode (相同的节点)呢?

function sameVnode () {
    return (
        a.key === b.key &&
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        (!!a.data) === (!!b.data) &&
        sameInputType(a, b)
    )
}

function sameInputType (a, b) {
    if (a.tag !== 'input') return true
    let i
    const typeA = (i = a.data) && (i = i.attrs) && i.type
    const typeB = (i = b.data) && (i = i.attrs) && i.type
    return typeA === typeB
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

sameVnode 其实很简单,只有当 key、 tag、 isComment(是否为注释节点)、 data同时定义(或不定义),同时满足当标签类型为 input 的时候 type 相同(某些浏览器不支持动态修改类型,所以他们被视为不同类型)即可。

# patchVnode

之前patch的过程还剩下 patchVnode 这个函数没有讲,这也是最复杂的一个,我们现在来看一下。因为这个函数是在符合 sameVnode 的条件下触发的,所以会进行「比对」。

function patchVnode (oldVnode, vnode) {
    if (oldVnode === vnode) {
        return;
    }

    if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
        vnode.elm = oldVnode.elm;
        vnode.componentInstance = oldVnode.componentInstance;
        return;
    }

    const elm = vnode.elm = oldVnode.elm;
    const oldCh = oldVnode.children;
    const ch = vnode.children;

    if (vnode.text) {
        nodeOps.setTextContent(elm, vnode.text);
    } else {
        if (oldCh && ch && (oldCh !== ch)) {
            updateChildren(elm, oldCh, ch);
        } else if (ch) {
            if (oldVnode.text) nodeOps.setTextContent(elm, '');
            addVnodes(elm, null, ch, 0, ch.length - 1);
        } else if (oldCh) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (oldVnode.text) {
            nodeOps.setTextContent(elm, '')
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

首先在新老 VNode 节点相同的情况下,就不需要做任何改变了,直接 return 掉。

if (oldVnode === vnode) {
    return;
}
1
2
3

下面的这种情况也比较简单,在当新老 VNode 节点都是 isStatic(静态的),并且 key 相同时,只要将 componentInstance 与 elm 从老 VNode 节点“拿过来”即可。这里的 isStatic 也就是前面提到过的「编译」的时候会将静态节点标记出来,这样就可以跳过比对的过程。

if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
    vnode.elm = oldVnode.elm;
    vnode.componentInstance = oldVnode.componentInstance;
    return;
}
1
2
3
4
5

接下来,当新 VNode 节点是文本节点的时候,直接用 setTextContent 来设置 text,这里的 nodeOps 是一个适配层,根据不同平台提供不同的操作平台 DOM 的方法,实现跨平台。

if (vnode.text) {
    nodeOps.setTextContent(elm, vnode.text);
}
1
2
3

当新 VNode 节点是非文本节点当时候,需要分几种情况。

  • oldCh 与 ch 都存在且不想同时,使用 updateChildren 函数来更新子节点,这个后面重点讲。
  • 如果只有 ch 存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将 ch 批量插入插入到节点elm下。
  • 同理当只有 oldch 存在时,说明需要将老节点通过 removeVnodes 全部清除。
  • 最后一种情况是当只有老节点是文本节点的时候,清除其节点文本内容。
if (oldCh && ch && (oldCh !== ch)) {
    updateChildren(elm, oldCh, ch);
} else if (ch) {
    if (oldVnode.text) nodeOps.setTextContent(elm, '');
    addVnodes(elm, null, ch, 0, ch.length - 1);
} else if (oldCh) {
    removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (oldVnode.text) {
    nodeOps.setTextContent(elm, '')
}
1
2
3
4
5
6
7
8
9
10

# updateChildren

接下来就要讲一下 updateChildren 函数了。

function updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0;
    let newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    let oldKeyToIdx, idxInOld, elmToMove, refElm;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (!oldStartVnode) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (!oldEndVnode) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
            }
            }
        }
    }

    if (oldStartIdx > oldEndIdx) {
        refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
    } else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

看到代码那么多先不要着急,我们还是一点一点地讲解。

首先我们定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引,同时 oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode 分别指向这几个索引对应的 VNode 节点。