响应式原理-组件更新
在之前组件化章节我们介绍了 Vue
的组件化创建过程,并没有涉及到组件的数据变化时更新组件的
逻辑。在本章节我们了解了数据响应式的原理,当数据变化时会触发 watcher
的回调函数,进而执行组件更新,接下来我们详细分析此过程。
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted) {
callHook(vm, "beforeUpdate");
}
},
},
true /* isRenderWatcher */
);
组件更新还是调用 vm._update
方法,它定义在 src/core/instance/lifecycle.js
中:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this;
// ...
const prevVnode = vm._vnode;
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode);
}
// ...
};
在组件更新的过程中,会执行 vm.$el = vm.__patch__(preVnode, vnode)
,他仍然会调用 patch
函数,定义在 src/core/vdom/patch.js
中定义:
// hydrating 表示是否是服务端渲染
return function patch(oldVnode, vnode, hydrating, removeOnly) {
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode);
return;
}
let isInitialPatch = false;
const insertedVnodeQueue = [];
// 组件首次渲染时 oldVnode 为 undefined
if (isUndef(oldVnode)) {
// empty mount (likely as component), create new root element
isInitialPatch = true;
createElm(vnode, insertedVnodeQueue);
} else {
const isRealElement = isDef(oldVnode.nodeType);
// 组价更新时 判断是否为相同的 VNode
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// ----------- 新旧节点相同,修补现有根节点
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly);
} else {
// ----------- 新旧节点不同
if (isRealElement) {
// ...
oldVnode = emptyNodeAt(oldVnode);
}
// 1. 以当前旧节点为参考,创建新节点
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);
// 2. 生成真实的 `浏览器DOM` 并插入到对应的节点
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
// 3. 更新父占位符节点
// 找到当前 vnode 的父的占位符节点,先执行各个 module 的 destroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 module 的 create 钩子函数
if (isDef(vnode.parent)) {
let ancestor = vnode.parent;
const patchable = isPatchable(vnode);
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor);
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert;
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}
// 4.删除旧节点
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
return vnode.elm;
};
这里执行 patch
的逻辑和首次渲染时是不同的,因为 oldVnode
不为空,且 oldVnode
和 vnode
都是 VNode
类型,所以会通过 sameVNode(oldVnode, vnode)
来判断是否是相同的 VNode
来决定走不同的更新逻辑:
function sameVnode(a, b) {
return (
// 判断 key
a.key === b.key && // 同步组件
((a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)) ||
// 异步组件
(isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)))
);
}
新旧节点不同
如果新旧 vnode
不同,逻辑相对来说比较简单,本质是替换已存在的节点,分为三步
创建新节点
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
);
以当前旧节点为参考节点,创建新的节点,并插入到 DOM
中,createElm 之前分析过。
更新父的占位符节点
// update parent placeholder node element, recursively
if (isDef(vnode.parent)) {
let ancestor = vnode.parent;
const patchable = isPatchable(vnode);
while (ancestor) {
for (let i = 0; i < cbs.destroy.length; ++i) {
cbs.destroy[i](ancestor);
}
ancestor.elm = vnode.elm;
if (patchable) {
for (let i = 0; i < cbs.create.length; ++i) {
cbs.create[i](emptyNode, ancestor);
}
// #6513
// invoke insert hooks that may have been merged by create hooks.
// e.g. for directives that uses the "inserted" hook.
const insert = ancestor.data.hook.insert;
if (insert.merged) {
// start at index 1 to avoid re-invoking component mounted hook
for (let i = 1; i < insert.fns.length; i++) {
insert.fns[i]();
}
}
} else {
registerRef(ancestor);
}
ancestor = ancestor.parent;
}
}
在这里首先找到 vnode
的父占位符节点,执行各个 module
的 destory
的钩子函数,如果当前占位符是可挂载节点,则执行 module
的 create
钩子函数。
删除旧节点
// destroy old node
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0);
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode);
}
把 oldVnode
从当前 DOM 树
中删除,如果父节点存在,则执行 removeVnodes
方法:
function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (isDef(ch)) {
if (isDef(ch.tag)) {
removeAndInvokeRemoveHook(ch);
invokeDestroyHook(ch);
} else {
// Text node
removeNode(ch.elm);
}
}
}
}
function removeAndInvokeRemoveHook(vnode, rm) {
if (isDef(rm) || isDef(vnode.data)) {
let i;
const listeners = cbs.remove.length + 1;
if (isDef(rm)) {
// we have a recursively passed down rm callback
// increase the listeners count
rm.listeners += listeners;
} else {
// directly removing
rm = createRmCb(vnode.elm, listeners);
}
// recursively invoke hooks on child component root node
if (
isDef((i = vnode.componentInstance)) &&
isDef((i = i._vnode)) &&
isDef(i.data)
) {
removeAndInvokeRemoveHook(i, rm);
}
for (i = 0; i < cbs.remove.length; ++i) {
cbs.remove[i](vnode, rm);
}
if (isDef((i = vnode.data.hook)) && isDef((i = i.remove))) {
i(vnode, rm);
} else {
rm();
}
} else {
removeNode(vnode.elm);
}
}
function invokeDestroyHook(vnode) {
let i, j;
const data = vnode.data;
if (isDef(data)) {
if (isDef((i = data.hook)) && isDef((i = i.destroy))) i(vnode);
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
}
if (isDef((i = vnode.children))) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j]);
}
}
}
删除节点的逻辑主要是遍历待删除的 vnodes
做删除,其中:
removeAndInvokeRemoveHook
的作用是从 DOM
中移除节点并执行 module
的 remove
钩子函数,并对其子节点递归调用 removeAndInvokeRemoveHook
。
invokeDestroyHook
是执行 module
的 destory
钩子函数以及 vnode
的 destory
钩子函数,并对它的子 vnode
递归调用 invokeDestroyHook
函数。
removeNode
就是调用平台的 DOM API
去把真正的 DOM
节点移除。
新旧节点相同
会调用 patchVNode
方法,定义在 src/core/vdom/patch.js
中:
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
return;
}
const elm = (vnode.elm = oldVnode.elm);
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue);
} else {
vnode.isAsyncPlaceholder = true;
}
return;
}
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (
isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance;
return;
}
// 1. 执行 prepatch 钩子函数
// 当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法
let i;
const data = vnode.data;
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
i(oldVnode, vnode);
}
const oldCh = oldVnode.children;
const ch = vnode.children;
// 2. 执行 update 钩子函数
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
}
// 3. 完成 patch 过程
if (isUndef(vnode.text)) {
// 不是文本内容
if (isDef(oldCh) && isDef(ch)) {
// oldCh 与 ch 都存在且不相同,使用 updateChildren 更新子节点
if (oldCh !== ch)
updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
} else if (isDef(ch)) {
// 只有 ch,表示旧节点不需要。如果旧节点是文本节点则先清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm 下
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 只有 oldCh,表示更新的是空节点,需要将旧节点通过 removeVnodes 全部清除
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);
}
}
patchVnode
的作用就是把新的 vnode
patch
到旧的 vnode
上,我们可以把核心逻辑拆分成四个步骤:
执行 prepatch 钩子函数
// 1. 执行 prepatch 钩子函数
// 当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法
let i;
const data = vnode.data;
if (isDef(data) && isDef((i = data.hook)) && isDef((i = i.prepatch))) {
i(oldVnode, vnode);
}
prepatch
定义在 src/core/vdom/create-component.js
中:
const componentVNodeHooks = {
prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions;
const child = (vnode.componentInstance = oldVnode.componentInstance);
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
);
},
};
prepatch
方法就是拿到新的 vnode
的组件配置以及组件实例,去执行 updateChildComponent
方法,它的定义在 src/core/instance/lifecycle.js
中:
export function updateChildComponent(
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
if (process.env.NODE_ENV !== "production") {
isUpdatingChildComponent = true;
}
// determine whether component has slot children
// we need to do this before overwriting $options._renderChildren
const hasChildren = !!(
(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
parentVnode.data.scopedSlots || // has new scoped slots
vm.$scopedSlots !== emptyObject
) // has old scoped slots
);
vm.$options._parentVnode = parentVnode;
vm.$vnode = parentVnode; // update vm's placeholder node without re-render
if (vm._vnode) {
// update child tree's parent
vm._vnode.parent = parentVnode;
}
vm.$options._renderChildren = renderChildren;
// update $attrs and $listeners hash
// these are also reactive so they may trigger child update if the child
// used them during render
vm.$attrs = parentVnode.data.attrs || emptyObject;
vm.$listeners = listeners || emptyObject;
// update props
if (propsData && vm.$options.props) {
toggleObserving(false);
const props = vm._props;
const propKeys = vm.$options._propKeys || [];
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i];
const propOptions: any = vm.$options.props; // wtf flow?
props[key] = validateProp(key, propOptions, propsData, vm);
}
toggleObserving(true);
// keep a copy of raw propsData
vm.$options.propsData = propsData;
}
// update listeners
listeners = listeners || emptyObject;
const oldListeners = vm.$options._parentListeners;
vm.$options._parentListeners = listeners;
updateComponentListeners(vm, listeners, oldListeners);
// resolve slots + force update if has children
if (hasChildren) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context);
vm.$forceUpdate();
}
if (process.env.NODE_ENV !== "production") {
isUpdatingChildComponent = false;
}
}
updateChildComponent
主要目的就是更新 vnode
对应实例 vm
的一些属性,比如 vm.$vnode
、slot
、listeners
、props
等。
执行 update 钩子函数
// 2. 执行 update 钩子函数
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
}
在执行完 prepatch
钩子函数后,会执行所有 module
的 update
钩子函数,以及用户自定义的 update
钩子函数。
完成 patch 过程
const oldCh = oldVnode.children;
const ch = vnode.children;
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
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);
}
如果 vnode
是个文本节点且新旧文本不同,则直接替换文本内容。
如果不是,则判断子节点并分几种情况处理:
oldCh
与ch
都存在且不相同时,使用updateChildren
函数来更新子节点,这个后面重点讲- 如果只有
ch
存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过addVnodes
将ch
批量插入到新节点 elm 下 - 如果只有
oldCh
存在,表示更新的是空节点,则需要将旧的节点通过removeVnodes
全部清除 - 当只有旧节点是文本节点的时候,则清除其节点文本内容
执行 postpatch 钩子函数
// 4. 执行 postpatch 钩子函数
if (isDef(data)) {
if (isDef((i = data.hook)) && isDef((i = i.postpatch))) i(oldVnode, vnode);
}
执行完 patch
后,如果定义则执行 postpatch
钩子函数。
在整个 patchVnode
过程中,最复杂的就是 updateChildren
方法也就是我们常见的 diff
算法。
updateChildren
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];
let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly;
if (process.env.NODE_ENV !== "production") {
checkDuplicateKeys(newCh);
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx];
} 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];
} 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,
false,
newCh,
newStartIdx
);
} 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,
false,
newCh,
newStartIdx
);
}
}
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);
}
}
由于 updateChildren
逻辑复杂,我们通过具体实例来分析:
<template>
<div id="app">
<div>
<ul>
<li v-for="item in items" :key="item.id">{{ item.val }}</li>
</ul>
</div>
<button @click="change">change</button>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
items: [
{ id: 0, val: "A" },
{ id: 1, val: "B" },
{ id: 2, val: "C" },
{ id: 3, val: "D" },
],
};
},
methods: {
change() {
this.items.reverse().push({ id: 4, val: "E" });
},
},
};
</script>
当我们点击 change
按钮去改变数据,最终会执行到 updateChildren
去更新 li
部分的列表数据,我们通过图的方式来描述一下它的更新过程:
更多 diff 算法相关文章:
总结
在组件更新的过程中,核心就是新旧 vnode diff。如果新旧节点不同,则 创建新节点 -> 更新父占位符节点 -> 删除旧节点。
如果新旧节点类型相同,则在获取它们的 children,根据不同情况执行不同的更新逻辑。最复杂的情况是新旧节点类型相同且都存在子节点,那么会执行 updateChildren 方法。
对于 diff 算法,还是推荐大家打断点进行走几遍,会记得更深。
参考:Vue.js 技术揭秘