找到 patch 出处
首先在 Vue.prototype._update 中会调用 vm._ patch_ 方法:
<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
vm._ patch_ 对应就是 Vue.prototype._ patch_ 方法:
<code>Vue.prototype.__patch__ = inBrowser ? patch : noop
export const patch: Function = createPatchFunction({ nodeOps, modules })/<code>
最后在 createPatchFunction 方法中找到 patch 方法:
<code>//source-code\\vue\\src\\core\\vdom\\patch.js
export function createPatchFunction (backend) {
const { modules, nodeOps } = backend
//...
return function patch (oldVnode, vnode, hydrating, removeOnly) {
//...
return vnode.elm
}
}/<code>
oldVnode 和 vnode 在哪里定义?
oldVnode 的定义
在最初的 init 方法中,会向 $mount 方法传入 $options.el 属性:
<code>Vue.prototype._init = function (options?: Object) {
\t//...
vm.$mount(vm.$options.el)
}/<code>
通过 query (querySelector) 选择器方法,获取对应的 dom 内容:
<code>Vue.prototype.$mount = function (
el?: string | Element,
hydrating?: boolean
): Component {
el = el && query(el)
//...
return mount.call(this, el, hydrating)
}/<code>
将 el 赋值给 vm.$el:
<code>export function mountComponent (
vm: Component,
el: ?Element,
hydrating?: boolean
): Component {
vm.$el = el
//...
}/<code>
再回到最初的 update 方法,看到 oldVnode 就是 vm.$el:
<code>if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}/<code>
当然首次 prevVnode 变量的值为 null,则会走到第一个 _ patch_ 方法进行首次渲染。
vnode 的定义
再回到 _update 方法,我们能看到它的入参列表中就有
<code>//source-code\\vue\\src\\core\\instance\\lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
const prevVnode = vm._vnode
//...
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
这个 vnode 对象 就是由 vm._render() 方法执行后得到:
<code>Vue.prototype._render = function (): VNode {
//...
\tvnode = render.call(vm._renderProxy, vm.$createElement)
//...
return vnode
}/<code>
<code>updateComponent = () => {
vm._update(vm._render(), hydrating)
}/<code>
初始 elm 的挂载
emptyNodeAt 生成 oldVnode 节点
现在我们正式进入
<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\t//...
return vnode.elm
}/<code>
我们的 vnode 目前是有定义的,之前已经看过了 render 方法的解析过程,并且 oldVnode 也是存在的(通过 query 选择器取得),所以将进入 else 判断:
<code>return function patch (oldVnode, vnode, hydrating, removeOnly) {
\tif (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
//...
if (isUndef(oldVnode)) {
}else{
\t// patchNode
}
return vnode.elm
}/<code>
这里是 else 中的逻辑:
根据 oldVnode.nodeType 来判断是否是个真实元素 isRealElement,因为我们 AST 解析出来的虚拟节点是不包括 nodeType 属性的,所以
另外,hydrating 为 undefined,所以将直接通过 emptyNodeAt 添加一个空节点,vnode 的 tag 属性为当前 dom 的 tagName。
<code>const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
//...
} else {
if (isRealElement) {
if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
//...
}
if (isTrue(hydrating)) {
//...
}
// either not server-rendered, or hydration failed.
// create an empty node and replace it
oldVnode = emptyNodeAt(oldVnode)
}
//...
}/<code>
createElm 创建节点
之后会将原来 oldVnode 的 dom 数据保存到 oldVnode.elm 中,并获取父级元素:
<code>const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)/<code>
之后将使用 vnode
<code>// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)/<code>
当然 createElm 也略复杂,但我们能看到其中的 createChildren 和 insert 方法,也能猜到在做子节点的循环遍历,以及子节点的插入操作:
<code>function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
\t//...
const data = vnode.data
const children = vnode.children
const tag = vnode.tag
//...
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
insert(parentElm, vnode.elm, refElm)
//...
}/<code>
最后将 oldVnode 上的一些旧的事件监听、各种 hook 钩子一并移除掉,再销毁当前元素:
<code>// destroy old node
if (isDef(parentElm)) {
removeVnodes([oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}/<code>
如此,将新生成的 vnode.elm 树渲染到 html 页面上了。
新 vnode 的更新
patch 中的 updateChildren
在 _update 方法里,第一个 if 判断中 patch 已经在首次渲染的时候走过了,对 vm._vnode 初始化了值(即,prevVnode 非 false),所以当后续数据触发更新,再次执行 _update,则会进入 else 中的 patch 方法:
<code>Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
//...
\tconst prevVnode = vm._vnode
vm._vnode = vnode
if (!prevVnode) {
// initial render
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
// updates
vm.$el = vm.__patch__(prevVnode, vnode)
}
//...
}/<code>
顺着之前的 patch 逻辑,会发现 isRealElement 不再是 false 了,因为此时的 oldVnode 是虚拟节点,就不包含标准的 nodeType 属性,则会跳过 emptyNodeAt 方法,从而进入对应的 if 逻辑条件,进入到 patchVnode 方法:
<code>if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}/<code>
在 patchVnode 方法中,我们跳过多数目前不太关心的条件,直接进到解析子节点 updateChildren 的判断中,它会解析我们 oldVnode 和 vnode 中子节点,并逐层解析:
<code>function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
//...
const elm = vnode.elm = oldVnode.elm
//...
const oldCh = oldVnode.children
const ch = vnode.children
//...
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
}
//...
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
//...
}/<code>
updateChildren 的逻辑相当复杂,可以说是整个 vnode 和 oldVnode 之间 diff 对比的核心:
<code>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
const canMove = !removeOnly
//...
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, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
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, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//...
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(oldCh, oldStartIdx, oldEndIdx)
}
}/<code>
所以下面我会根据一个简单的 demo,来示例新老节点更新的 diff 对比过程。
详解 updateChildren 中的 diff 过程
准备新老 node
通过 v-if 来切换的两个不同标签模板内容,来模拟新老节点(oldVnode 和 vnode):
通过 updateChildren 剥离出子节点
注意 text 第二级的子元素本篇不做判断。
4种解析条件判断过程
在 updateChildren
<code>while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
\t//...
}else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
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, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}else {
//...
}
}/<code>
首先,会用 oldStartIdx,oldEndIdx 和 newStartIdx,oldEndIdx 来分别表示 oldCh 和 newCh 子节点队列的起始和结束位置。
然后,依次对比
当然 sameVnode 方法是有特殊条件的,必须节点上 key 属性一致并且 tag、isComment、data、inputType 一致,或者和其他一些属性一致才算相同元素:
<code>function sameVnode (a, b) {
return (
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)
)
)
)
}/<code>
下面展示就目前的 demo 节点是如何在这几种判断中"游走的":
首先 oldCh 和 ch 的 start 坐标都在第一个元素。第一次:先对比新老节点的首个元素(1 vs 3):
目前,我们这两个元素分别是 span 标签和注释标签,肯定不符合 sameVnode 的判断。
然后第二次:判断两个末尾元素是否相同(2 vs 5):
很不凑巧,这两者也是不一致。
第三次,判断老节点的首个元素和新节点的末元素(1 vs 5):
这两个 tag 都是 span 标签,虽然他们的内容 text 不一致,当然这会交给内部调用的 patchVnode 继续往下一级比较(这里第二级的 children 元素不做展开,最终他们是替换 text 内容):
<code>else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}/<code>
这里运气比较好,第三次就匹配到了,如果这次没有匹配中,将拿老节点的末个元素和新节点的首个元素对比(2 vs 3,这里不做展开)
该判断内会对 oldStartIdx+1,newEndIdx-1 做偏移操作,并且挪到新位置:
这一轮 while 结束了,注意 oldStatrIdx 和 newEndIdx 已经指向定位置(如果以上被其他条件匹配中,是会有不同偏移方式的)。
之后,将通过 while 再一次进入这四种条件的判断,还是从首个元素开始判断,因为 1 和 5 已在上次判断中匹配过了,对应索引已经变更,这里将从 2 和 3 两个新的首个元素开始:
由于这两个元素都是注释元素,所以 sameVnode 匹配一致。
之后,再次偏移 oldStartIdx 和 newStartIdx 位置,最后 oldStartIdx 溢出到 oldCh 之外,之后进入 while 循环将不再符合条件:
但是我们 节点4 还未被处理过,这将走到 while 外的那段逻辑:
<code>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(oldCh, oldStartIdx, oldEndIdx)
}/<code>
目前会进入 addVnodes 方法,将 节点4 作为 refElm 挂载到 parentElm 中(oldCh 解析完毕);
相反,如果 newStartIdx > newEndIdx ,则会通过 removeVnodes 方法,移除 oldCh 中的部分元素。(ch 解析完毕。不全面的理解:四种判断中,如果多数匹配到 oldEndVnode vs newStartVnode 条件,将使得 newStartIdx 增大,导致大于 newEndIdx,或者 oldCh 数量大于 endCh 等),
如此,整个 oldVnode.elm 将更新至最新结果,return 给 vm.$el 渲染到页面上。
有关 key 属性的判断
有注意到,上面将 4种条件判断时,没有讲 while 中 else 的逻辑,这块内容将涉及 key 属性的逻辑:
<code>while(){
\telse {
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, newCh, newStartIdx)
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]
}
}/<code>
平时我们编写业务代码时,一定会遇到不同 input 标签在做 v-if/else 之类的切换时,导致 value 还停留在上面,vue 官方也给了示例:使用 key 来解决;同时 key 也建议在 v-for 中使用,以便提升性能。
现在我们逐步看下 vnode 上设置 key 属性是如何工作的?
在 oldCh 中解析 key,组成老节点的 key 集合
在 oldCh 子节点集合中,从 oldStartIdx 开始,到 oldEndIdx 结束,取存在 key 的元素,对应 value 为索引值:
<code>oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)/<code>
<code>function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}/<code>
判断老节点 key 集合中是否存在新节点 key
如果当前新节点 newStartVnode 存在 key 属性,则会判断该 key 是否是老节点 key 集合之一?如果不是,则会通过 sameVnode
<code>idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)/<code>
<code>function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}/<code>
idxInOld 不存在
如果 idxInOld 不存在,则会把 newStartVnode 节点作为新节点,通过 createElm 加入到 parentElm 中:
<code>createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)/<code>
idxInOld 存在
通过 oldCh[idxInOld] 取得将要移动的元素节点 vnodeToMove:
<code>vnodeToMove = oldCh[idxInOld]/<code>
把 vnodeToMove 和 newStartVnode 做 sameVnode 比较:
<code>if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
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)
}/<code>
如果是"相同元素",则会通过 patchVnode 来做节点间的交换操作。之后为 oldCh[idxInOld] 做置空,以免后续 findIdxInOld 方法还会匹配到结果。
接着把 vnodeToMove.elm 节点加入到 oldStartVnode.elm 之前,结束。
相反 else,通过 createElm 把 newStartVnode 加入到 parentElm 中。
demo 示意
对此过程也做了一个 demo 用于示意:
首先是我们模板中,新老节点树的说明:
然后在 while 判断中,命中于:oldEndVnode, 和 newStartVnode 的判断:
并且偏移 oldStartIdx 和 newEndIdx 的索引位置,再次进入 while 循环,这次将直接进入 else 的逻辑:
分别提取出每个 oldCh 子节点集合的 key 属性,整合到 oldKeyToIdx 中:
然后判断当前 newStartIdx.key 是否属于 oldKeyToIdx
这里的 节点5 和 节点2 的 key 相同(key=a)。
最后比较这两个节点,是否是 sameVnode,是的话:通过
反之,根据 newStartIVnode 创建新节点,挂载到 parentElm 中。
可能会问 节点1 怎么办?因为每次 else 执行后,会对 newStartIdx 做+1操作,所以以上步骤结束后,newStartIdx 将溢出于 ch 队列;之后会匹配到 newStartIdx > newEndIdx 的判断,从而通过 removeVnodes 溢出 oldCh 中相关元素。
补充:有关 document 的 node 操作
insertBefore 和 nextSibling
基于如下 vue 涉及的源码:
<code>nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))/<code>
通过一个 demo 来熟悉下 insertBefore 和 nextSibling 有什么用?(一段简单的 html,ul 下依次显示三个 li 标签)
<code>
1
2
3
/<code>
接下来我在 id=app 父节点下,提取出 id=one 的 dom 节点,并把它插入到最后个 li 标签之前:
<code>let parentElm = document.getElementById('app');
let startNode = document.getElementById('one');
let restNode = document.getElementById('two').nextSibling;
parentElm.insertBefore(startNode, restNode);/<code>
一顿操作后,页面就会如下显示: