从 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 客户端混合逻辑。