从 VNode 到 DOM

之前我们已经生成了 VNode,将 VNode 渲染成真实 DOM 的工作在 __patch__() 中进行:

// src/core/instance/render.js

Vue.prototype._update = function (vnode, hydrating) {
    const vm = this;
    if (vm._isMounted) {
        callHook(vm, 'beforeUpdate');
    }

    const prevEl = vm.$el;
    const prevVnode = vm._vnode;
    const prevActiveInstance = activeInstance;
    activeInstance = vm;
    vm._vnode = vnode;

    if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(
            vm.$el, vnode, hydrating,
            false,
            // removeOnly,
            vm.$options._parentElm,
            vm.$options._refElm
        );
        // no need for the ref nodes after initial patch
        // this prevents keeping a detached DOM tree in memory (#5851)
        vm.$options._parentElm = vm.$options._refElm = null;
    }
    else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode);
    }
};

平台化 patch

patch 方法和平台相关,比如在浏览器环境中需要生成 DOM,所以在 /platform 文件夹下:

Vue.prototype.__patch__ = inBrowser ? patch : noop;

其中需要定义一些和平台相关的操作方法,比如使用了 DOM API 的一系列操作:

// src/platforms/web/runtime/node-ops.js

export function createElement(tagName, vnode) {
    const elm = document.createElement(tagName);
    return elm;
}

export function createTextNode(text) {
    return document.createTextNode(text);
}

export function createComment(text) {
    return document.createComment(text);
}

patch 方法肩负了初始创建和 Diff 新旧节点两个任务。 另外,定义了一系列生命周期方法,便于平台方提供自定义处理逻辑。

const hooks = ['create', 'activate', 'update', 'remove', 'destroy'];

创建新节点

我们先来看创建新节点的部分:

// src/core/vdom/patch.js

if (isUndef(oldVnode)) {
    // empty mount (likely as component), create new root element
    isInitialPatch = true;
    createElm(vnode, insertedVnodeQueue, parentElm, refElm);
}

创建 DOM 元素的方法使用之前封装的 DOM API,DOM 节点存放在 vnode.elm

function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested) {
    vnode.isRootInsert = !nested; // for transition enter check
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return;
    }

    const data = vnode.data;
    const children = vnode.children;
    const tag = vnode.tag;
    if (isDef(tag)) {
        // 检查 tag 是否是已知元素

        // 创建 DOM 元素
        vnode.elm = vnode.ns
            ? nodeOps.createElementNS(vnode.ns, tag)
            : nodeOps.createElement(tag, vnode);

        // Scoped CSS
        setScope(vnode);

        // 处理子节点
        createChildren(vnode, children, insertedVnodeQueue);
        if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue);
        }

        // 插入新创建的节点到 DOM 中
        insert(parentElm, vnode.elm, refElm);
    }
    else if (isTrue(vnode.isComment)) {
        // 创建并插入注释节点
        vnode.elm = nodeOps.createComment(vnode.text);
        insert(parentElm, vnode.elm, refElm);
    }
    else {
        // 创建并插入文本节点
        vnode.elm = nodeOps.createTextNode(vnode.text);
        insert(parentElm, vnode.elm, refElm);
    }
}

Scoped CSS

在 DOM 节点上添加 data-xxx 属性:

function setScope(vnode) {
    let i;
    if (isDef(i = vnode.functionalScopeId)) {
        nodeOps.setAttribute(vnode.elm, i, '');
    }
    else {
        let ancestor = vnode;
        while (ancestor) {
            if (isDef(i = ancestor.context) && isDef(i = i.$options._scopeId)) {
                nodeOps.setAttribute(vnode.elm, i, '');
            }

            ancestor = ancestor.parent;
        }
    }
    // for slot content they should also get the scopeId from the host instance.
    if (isDef(i = activeInstance)
        && i !== vnode.context
        && i !== vnode.functionalContext
        && isDef(i = i.$options._scopeId)
    ) {
        nodeOps.setAttribute(vnode.elm, i, '');
    }
}

Diff 算法

除了创建新节点,patch 方法也需要比较新旧 VNode 节点,执行更新操作。

// src/core/vdom/patch.js

else {
    const isRealElement = isDef(oldVnode.nodeType);
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // patch existing root node
        patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly);
    }

除了执行最开始提到的平台钩子,data.hook 是前一篇文章中介绍过的,在创建元素型 VNode 时定义的。 一些简单的情况比较好理解,比如如果只是文本改变,只需要调用 setTextContent() 设置文本内容。

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

    const elm = vnode.elm = oldVnode.elm;

    let i;
    const data = vnode.data;
    // 执行 data.hook.prepatch 钩子
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
        i(oldVnode, vnode);
    }

    const oldCh = oldVnode.children;
    const ch = vnode.children;
    if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) {
            // 执行平台提供的 update 方法
            cbs.update[i](oldVnode, vnode);
        }
        // 执行 data.hook.update 钩子
        if (isDef(i = data.hook) && isDef(i = i.update)) {
            i(oldVnode, vnode);
        }
    }

    if (isUndef(vnode.text)) {
        if (isDef(oldCh) && isDef(ch)) {
            if (oldCh !== ch) {
                updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
            }
        }
        else if (isDef(ch)) {
            if (isDef(oldVnode.text)) {
                nodeOps.setTextContent(elm, '');
            }

            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        }
        else if (isDef(oldCh)) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1);
        }
        else if (isDef(oldVnode.text)) {
            nodeOps.setTextContent(elm, '');
        }
    }
    else if (oldVnode.text !== vnode.text) {
        nodeOps.setTextContent(elm, vnode.text);
    }

    if (isDef(data)) {
        if (isDef(i = data.hook) && isDef(i = i.postpatch)) {
            i(oldVnode, vnode);
        }
    }
}

所以核心是 updateChildren() 方法,这里面就涉及到 Diff 算法的核心,其实就是深度优先遍历。在比较新老两组子节点时,我们分别设置一头一尾两个指针,比较过程中两组指针都相遇了就终止:

function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    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];

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    }
}

当新老头尾节点相同时,直接应用上面提到的 patchVnode()

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

当发现新的头部节点恰好就是老的尾部节点时,只需要移动老的尾部节点到头部就能完成更新操作,反之亦然。

else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
    patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
    canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
    patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
    canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}

如果发现新节点的 key 在老数组中不存在,需要创建新元素。如果存在就只要移动节点,这也是 Vue 列表元素通常需要设置 key 属性的原因,能帮助高效地完成更新。

else {
    if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
    }

    idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
    if (isUndef(idxInOld)) { // New element
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
    }
    else {
        vnodeToMove = oldCh[idxInOld];

        if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined;
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm);
        }
        else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm);
        }
    }
    newStartVnode = newCh[++newStartIdx];
}

最后,如果老数组已经处理完,而新数组还有,直接把新数组中的多余内容添加进来:

if (oldStartIdx > oldEndIdx) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
}

同样如果老数组出现了多余元素,直接删掉就完成了更新。

else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}

Hydrate

还有第三种情况,就是 SSR 中的前端混合。在检查到 DOM 元素上确实包含服务端渲染的标记后,执行 hydrate()。 这个方法也定义在 vdom 中,似乎违背了与平台无关的原则,毕竟里面操作的是 DOM。

// src/core/vdom/patch.js

// DOM 元素上是否有 data-server-rendered 标记
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
    oldVnode.removeAttribute(SSR_ATTR);
    hydrating = true;
}

if (isTrue(hydrating)) {
    if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
        invokeInsertHook(vnode, insertedVnodeQueue, true);
        return oldVnode;
    }

终于见到了开发模式下经常出现的 mismatch 警告。

let bailed = false;

function hydrate(elm, vnode, insertedVnodeQueue) {
    // mismatch 警告,比较 DOM 和 VNode
    if (process.env.NODE_ENV !== 'production') {
        if (!assertNodeMatch(elm, vnode)) {
            return false;
        }
    }
    vnode.elm = elm;
    const {
        tag,
        data,
        children
    } = vnode;
    // 省略后续...

触发 init 钩子

触发 init 钩子

if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.init)) {
        i(
            vnode,
            true, // hydrating
        );
    }

    if (isDef(i = vnode.componentInstance)) {
        // child component. it should have hydrated its own tree.
        initComponent(vnode, insertedVnodeQueue);
        return true;
    }
}

对 DOM 元素的子节点递归调用 hydrate:

if (isDef(tag)) {
    if (isDef(children)) {
        // 空节点,直接重新渲染
        if (!elm.hasChildNodes()) {
            createChildren(vnode, children, insertedVnodeQueue);
        }
        else {
            let childrenMatch = true;
            let childNode = elm.firstChild;
            for (let i = 0; i < children.length; i++) {
                // 递归子节点
                if (!childNode || !hydrate(childNode, children[i], insertedVnodeQueue)) {
                    childrenMatch = false;
                    break;
                }
                // 下一个子节点的兄弟节点
                childNode = childNode.nextSibling;
            }
        }
    }
}

重新生成属性

在混合过程中,某些属性不需要重新创建初始化,例如一些静态的 class style 等等。 但是例如事件绑定工作是需要重新执行一遍的,这里就需要调用这些属性的 create 钩子。

const isRenderedModule = makeMap('attrs,style,class,staticClass,staticStyle,key');

if (isDef(data)) {
    for (const key in data) {
        if (!isRenderedModule(key)) {
            invokeCreateHooks(vnode, insertedVnodeQueue);
            break;
        }
    }
}

收尾工作

在 patch 和 hydrate 完成后都会调用 insert 钩子

function invokeInsertHook(vnode, queue, initial) {
    // delay insert hooks for component root nodes, invoke them after the
    // element is really inserted
    if (isTrue(initial) && isDef(vnode.parent)) {
        vnode.parent.data.pendingInsert = queue;
    }
    else {
        for (let i = 0; i < queue.length; ++i) {
            queue[i].data.hook.insert(queue[i]);
        }
    }
}

而 insert 钩子会给 Vue 实例添加完成标记,并执行 mounted() 生命周期函数。

// src/core/vdom/create-component.js

insert(vnode) {
    const {context, componentInstance} = vnode;
    if (!componentInstance._isMounted) {
        componentInstance._isMounted = true;
        callHook(componentInstance, 'mounted');
    }

    // 省略 keep-alive 相关处理
},

至此终于完成了整个 $mount() 的渲染流程。

总结

这部分包含的内容很多,包括了关于 VNode 的 Diff 算法以及 SSR 客户端混合逻辑。