vue2模板渲染更新详细流程
此文章基于vue2.6.10
版本进行解析,在看文章最好结合源码一起看能帮助更快的理解。
在vue
中会将.vue
文件或者template
属性解析成一个render
函数,在渲染(调用$mount
方法)的时候通过执行这个render
函数生成真实节点,再通过_update()
方法将真实节点挂载到页面上实现最终的渲染。下面来看看具体的流程。
在初次渲染时,调用mountComponent
方法组成一个updateComponent
方法,并把这个方法传递到当前实例(vm
)的watcher
上,同时根据是否是懒加载来判断要不是直接先渲染一次。当当前实例中依赖的数据发生改变后就会通知到watcher
并触发updateComponent
方法实现重渲染。
相关代码:
// src/core/instance/lifecycle mountComponent
updateComponent = () => {
vm._update(vm._render(), hydrating);
};
// 将updateComponent传递到Watcher实例中
new Watcher(
vm,
updateComponent,
noop,
{
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook(vm, "beforeUpdate");
}
},
},
true
);
_render
函数是通过.vue
文件里的模板解析生成的一个函数,我们只需要知道render()
的返回值是一个虚拟节点(Vnode
),我们现在暂时不关注render
是怎么解析生成vnode
的
我们把重点放在_update
方法上。在源码的src/core/instance/lifecycle.js
中的lifecycleMixin
方法中定义了一个Vue.prototype._update
方法。
在这个方法中,调用了__patch__
方法将vnode
生成真实节点并挂载到页面上(或者在旧节点上进行更新)
// _update 逻辑
if (!prevVnode) {
// 第一次初始化,没有旧虚拟节点
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
} else {
// 更新节点
vm.$el = vm.__patch__(prevVnode, vnode);
}
这个
__patch__
方法是createPatchFunction
方法的返回值,这是因为vue
兼容了web
和weex
两个平台(传入不同的渲染相关的方法,暴露一个相同的调用方法),需要使用不同的方法来处理不同平台的渲染(比如说创建一个文本节点,web
是通过document.createTextNode
,而weex
的则是new TextNode
)。
我们这里只看web
端的:// src/plaforms/web/runtime/patch.js const patch = createPatchFunction({ nodeOps, modules })
在
createPatchFunction
中传入了node
的操作方法、标签中一些属性处理以及事件监听器的处理方法,通过闭包的方式将这些操作方法保存下来给返回值调用。function createPatchFunction(backend) { // 通过闭包方法保存提供给其他方法使用。 const { modules, nodeOps } = backend; ... return function patch (oldVnode, vnode, hydrating, removeOnly) { ... } }
我们先把钩子相关的代码先忽略,先重点看看是怎么生成的真实节点并挂载到页面上以及是页面怎么进行更新的。
我们分以下几种情况来逐步剖析patch
方法:
1. 首次渲染
// 在前面的_update方法里提到这是第一次初始化时的传参,
vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
可以看到传入的是vm。$el
,也就是我们在初始化vue
需要挂载的那个元素。
为了之后的方法可以统一传参类型,因此特地为这个真实节点也创建了一个vnode
if(isRealElement) {
oldVnode = emptyNodeAt(oldVnode);
}
在给真实节点创建了一个vnode
后,可以也看成是下面更新渲染中非同一种vnode
的类型,vue
内部的处理逻辑也是这样的,所以首次渲染这个部分在单独为真实节点创建了一个vnode
后,后续的讲解可以跳转到更新渲染中非同个vnode
继续。
2. 更新渲染
这种情况下的更新也分两种,更新前后是否是同一种vnode
。
在src/core/vdom/patch.js
中的sameVnode
方法判断是否是同一种vnode
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)
)
)
)
}
从上面的函数可以看出:
key
是前提,key
不相同肯定就不算同一个vnode
了- 相同标签名,
isComment
属性相同,标签中必须同时存在属性或者同时不存在属性(style
、class
这些),如果标签是input
类型,input type
可以有不同,不过必须都是文本类型的输入(text
、password
、email
等)。 - 对于异步占位符的
vnode
,就需要判断异步的工厂函数是否相同。
同一种vnode
如果是同一种vnode
,直接调用patchVnode
进行新旧节点的比对更新(不需要处理当前节点,直接处理子节点即可)。
patchVnode
中的关于DOM
更新的算法是基于Snabbdom
的。
来看看具体是怎么处理的:
// patchVnode
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 (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, '')
}
// 如果要比对的新节点是文本节点,直接通过setTextContent替换文本内容即可(文本节点不存在子节点)。
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
如果新节点是非文本节点,则可能具有以下几种可能:
- 新旧节点都是存在子节点。
- 只有新节点存在子节点。使用
createElm
创建了对应的DOM
后挂载到父元素上。 - 只有旧节点存在子节点。使用
removeNode
方法移除旧节点的子节点。 - 新节点是空节点,旧节点是文本节点。使用
setTextContent
把旧节点的值改为空值 - 新旧节点都是空节点。这个不需要处理,因为没有什么影响
重点需要看新旧节点都存在子节点的情况(也就是updateChildren
方法):
从patchVnode
到现在要讲解的updateChildren
方法都是在同一层级进行比较的,不会跨层级比较。这样只比较同层级的方式时间复杂度可以降低到O(n)。
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
...
} else if (isUndef(oldEndVnode)) {
...
} else if (sameVnode(oldStartVnode, newStartVnode)) {
...
} else if (sameVnode(oldEndVnode, newEndVnode)) {
...
} else if (sameVnode(oldStartVnode, newEndVnode)) {
...
} else if (sameVnode(oldEndVnode, newStartVnode)) {
} else {
...
}
}
}
上面只把核心的条件代码展示出来,我们通过下面例子来更好的理解以上条件处理逻辑:
假设图中的每个字母代表一个节点(没有子节点),相同的字母代表是相同节点。
当第一次循环时,前面的条件都不满足,但是都存在节点B
,执行else
里的逻辑
// else 里的逻辑
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
先看看这几行代码:
将旧子节点按照节点内的key
值与索引建立映射并把这个map
保存下来,如果当前循环中新子节点的存在key
值,那么就可以直接映射到旧子节点列表中的索引值,直接找到旧的节点。
如果没有key
值,那么每次循环都需要遍历旧子节点(oldStartIdx
- oldEndIdx
)去与当前循环中的新子节点进行比对判断是否是相同节点(非常影响性能,这也就是为什么v-for
指令都需要带key
进行标识)
那么根据上面的对比结果,我们可以非常容易猜到下面肯定就是对有无索引值两种情况(旧子节点列表中存不存在与当前循环中的新子节点相同的节点)进行处理:
// else 里的逻辑
// 没有索引值
if (isUndef(idxInOld)) {
// 说明这个节点是新的,需要调用createElm生成一个DOM
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
// 有
vnodeToMove = oldCh[idxInOld];
// 存在相同key不代表一定是相同节点,同样需要判断一下
if (sameVnode(vnodeToMove, newStartVnode)) {
// 继续对比这俩子节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 移除索引值对应的节点(只是移除vnode中的,真实DOM仍存在)
oldCh[idxInOld] = undefined
// 把比对结束后的子节点插入到旧子节点的起始指针对应节点的前一位(位置要按照新子节点列表的顺序排列)
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 生成一个DOM
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
// 移动指针
newStartVnode = newCh[++newStartIdx]
}
使用
insertBefore
方法会将节点从一个位置移动插入到新位置,原位置上节点相关的关系会重新建立
第二次循环,满足sameVnode(oldStartVnode, newStartVnode)
条件,都是A
// sameVnode(oldStartVnode, newStartVnode) 条件逻辑
// 继续对比节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// 新旧起始指针右移
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
这是只需要比对这两个节点,然后移动新旧节点的起始指针:
第三次循环,满足isUndef(oldStartVnode)
的条件:
// isUndef(oldStartVnode) 条件逻辑:
oldStartVnode = oldCh[++oldStartIdx]
就只有一个移动起始指针的逻辑:
第四次循环,满足sameVnode(oldStartVnode, newEndVnode)
的条件,都是C
// sameVnode(oldStartVnode, newEndVnode) 条件逻辑
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 移动指针
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
同样对比节点后插入到DOM
中,然后移动指针:
这里把节点C
插入到E
后
第五次循环,满足sameVnode(oldEndVnode, newStartVnode)
条件,都是E
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
同样对比节点后插入到DOM
中,然后移动指针:
在这里把节点E
插到G
前面
第六次循环,满足sameVnode(oldEndVnode, newEndVnode)
的条件,都是D
// sameVnode(oldEndVnode, newEndVnode) 条件逻辑
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 移动指针
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
同样对比节点后插入到DOM
中,然后移动指针:
当前渲染的节点为BAEGDC
,比我们的新子节点要多一个节点。
此时newEndIdx < newStartIdx
,中止循环,执行updateChildren
方法最后一段代码:
// 新子节点比旧子节点多
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)
}
移除G
节点,所以最终生成的节点为 BAEDC
其实updateChildren
方法看似复杂,不过是if
判断多了几个,核心都是比较两个节点,然后在遍历对比子节点进行更新。
非同一种vnode
不是同一种的话就要通过createElm
生成真实节点,在新节点挂载后把旧节点移除掉。
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// 生成新节点
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// 移除旧节点
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
}
在createElm
方法中可以看到就是通过document.createElement
来创建新元素,而后通过xxx.appendChild
或者xxx.insertBefore
方法把节点挂载到页面上。
function createElm(
vnode,
insertedVnodeQueue,
parentElm,
// 旧节点的下一个兄弟节点
refElm,
nested,
ownerArray,
index
) {
// 当是组件的情况下,createComponent就会在根据传入的参数生成组件实例,经过$mount方法选渲染成组件后返回true
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
...
// 生成真实节点
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
// 创建子元素
createChildren(vnode, children, insertedVnodeQueue)
// 挂载
insert(parentElm, vnode.elm, refElm)
}
对于普通标签而言就是通过createElement
创建,但是组件则是需要使用createComponent
方法解析后才能插入文档。
createComponent
方法通过调用组件钩子init
方法创建一个组件实例并通过$mount
方法创建了真实节点后通过xxx.appendChild
或者xxx.insertBefore
方法插入到文档中。
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
...
// 组件实例相关的生成看下面渲染相关钩子部分
if (isDef(vnode.componentInstance)) {
// 把组件实例上的真实节点赋值到vnode.elm中
initComponent(vnode, insertedVnodeQueue)
// 挂载
insert(parentElm, vnode.elm, refElm)
if (isTrue(isReactivated)) {
reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
}
return true
}
}
至此,渲染相关的部分其实已经执行完了,也就是页面上已经渲染好节点了。