在vue中,patch过程,是以新的虚拟dom为基准,改造旧的虚拟dom。
宏观上讲,patch过程就做了3件事:
- 创建节点
- 更新节点
- 删除节点
接下来,我们逐个击破。
一. update
在执行render函数,返回虚拟dom之后,vue会执行update方法,去更新视图。其主干代码如下:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
const prevEl = vm.$el
const prevVnode = vm._vnode
const restoreActiveInstance = setActiveInstance(vm)
vm._vnode = vnode
if (!prevVnode) {
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
} else {
vm.$el = vm.__patch__(prevVnode, vnode)
}
restoreActiveInstance()
// ...
}
setActiveInstance
export let activeInstance: any = null
export function setActiveInstance(vm: Component) {
const prevActiveInstance = activeInstance
activeInstance = vm
return () => {
activeInstance = prevActiveInstance
}
}
前面章节,我们分析了组件化实践。setActiveInstance方法是 设置 当前是 哪个组件被激活。 因为 同一时间,只会有一个组件实例化。
activeInstance变量是 当前正在实例化的组件对象。 prevActiveInstance实际上是父的实例化对象。在每次子组件实例化并且patch之后,就会执行restoreActiveInstance方法,就会将当前的 activeInstance 重置为 当前的父组件,以此类推,直到最上层的Vue。
需要指出的是, 这里设置了 activeInstance, 会在 组件实例化的 时候 会使用到, 不清楚的小伙伴可以看我的上一篇 《Vue源码解析-组件化&虚拟DOM》
下面,我们继续看__patch__
二. patch
__patch__方法的定义,实际就是执行的 createPatchFunction 方法。此方法比较庞大,我们先看主入口patch方法定义:
return function patch(oldVnode, vnode, hydrating, removeOnly) {
// ...
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}else {
if(isRealElement) {
// ssr 属性... 暂时忽略
oldVnode = emptyNodeAt(oldVnode)
}
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
if (isDef(vnode.parent)) {
// ...
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
我们先来看个?:
<html>
<head>
<meta charset="utf-8"/>
</head>
<body>
<div id='root'></div>
<script src="../vue/dist/vue.js"></script>
<script>
let vm = new Vue({
el: '#root',
data() {
return {
a: "这是根节点"
}
},
template: `<div data-test='这是测试属性' @click="handleClick"> {{ a }} </div>`,
methods: {
handleClick() {
this.a = '变了'
}
},
})
</script>
</body>
</html>
页面渲染时,会执行一次update patch。
oldVnode
此时oldVnode是div#root,是实际的DOM节点。
vnode值是执行render函数得到,其结构大致如下:
vnode
{
tag: "div",
text: undefined,
key: undefined,
isStatic: false,
isRootInsert: true,
isComment: false,
elm: undefined,
componentInstance: undefined,
componentOptions: undefined,
children: [
// vnode 纯文本节点
{
// ...
}
],
context: Vue,
data: {
attrs: {...},
on: {
click: function () {...}
}
}
}
nodeType
nodeType实际上是html的原生属性,这里第一次渲染时,nodeType为节点, 值为 1。
不清楚nodeType的小伙伴,可以移步: www.w3school.com.cn/jsref/prop_…
回归到我们的demo中,isRealElement = 1, 显示是true。这个时候会调用 emptyNodeAt
emptyNodeAt
function emptyNodeAt (elm) {
return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
}
问题来了,第一次页面渲染时,oldVnode是id = "root"的真实dom节点。为什么需要调用emptyNodeAt方法,重新设置为虚拟dom节点?
其实有几点原因:
-
- removeVnodes是基于虚拟dom操作
-
- invokeDestroyHook也是基于虚拟dom操作
-
- 新旧节点diff对比,都是基于虚拟dom操作
此时,根节点root转化为虚拟dom之后(即oldVnode),其数据结构如下:
{
tag: "div",
text: undefined,
key: undefined,
isStatic: false,
isRootInsert: true,
isComment: false,
elm: undefined,
componentInstance: undefined,
componentOptions: undefined,
children: [],
context: Vue,
data: {},
// 注意此变化
elm: div#root
}
到这里,页面还没渲染时,只有一个空div,id = 'root', vue将其转化为vnode,其上面的oldVnode空节点 和 new Vue之后的vnode做对比。
需要指出的是: parentElm 在第一次update时,指的是body
三. createElm
function createElm (
vnode,
insertedVnodeQueue,
parentElm,
refElm,
nested,
ownerArray,
index
) {
// ...
// 嵌套组件处理
if(createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
// ...
const tag = vnode.tag
if(isDef(tag)) {
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode);
setScope(vnode)
if(__WEEX__) {
// ... weex相关处理
}else {
createChildren(vnode, children, insertedVnodeQueue)
if (isDef(data)) {
invokeCreateHooks(vnode, insertedVnodeQueue)
}
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)
}
}
createComponent实现多层嵌套组件,这里不再赘述。不清楚的小伙伴,可以看我之前的《vue源码解析-组件化&虚拟DOM》。
第一次渲染时,触发createElm方法,传入的vnode即新的vnode。显然,在我们的demo中,tag = "div",ns = undefined。 将会执行nodeOps.createElement方法。
nodeOps对象,实际上就是封装了对原生DOM的操作。 这里createElement方法,实际上就是调用:document.createElement方法,返回原生dom对象。
此时新的虚拟dom上elm属性,指向就是刚创建的div。此时 vnode数据结构如下:
{
tag: "div",
text: undefined,
key: undefined,
isStatic: false,
isRootInsert: true,
isComment: false,
// elm属性值变更为 刚创建的 div dom对象
elm: div,
componentInstance: undefined,
componentOptions: undefined,
children: [
// vnode 纯文本节点
{
// ...
}
],
context: Vue,
data: {
attrs: {...},
on: {
click: function () {...}
}
}
}
需要注意的是:setScope(vnode),实际上是对于elm真实dom的style对象,添加scopeId。
到这里,demo中的外层div已创建,但是这个时候还是没有文字显示。因为children中的文字,也是个虚拟dom,只不过他是普通纯文本节点而已。
下面流程,将调用 createChildren 方法。
四. createChildren
其主干代码如下:
function createChildren (vnode, children, insertedVnodeQueue) {
// ...
for (let i = 0; i < children.length; ++i) {
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
}
// ...
}
这里,我们可以看到,实际上是个递归循环操作。 无论我们的组件嵌套多少层,都将对每层vnode的childrens进行循环。然后一个个createElm,遇到childrens,继续调用createChildren。如此反复,递归一个个创建子组件。
在我们的demo中,子的childrens是一行文本,属于纯文本节点。那么createElm时,将进入最后一个else操作,创建文本节点。即原生的dom调用:
document.createTextNode(text)
同理,子的vnode对象上的elm属性,指向了刚刚创建的文本节点的真实dom 对象。
最后调用update restoreActiveInstance方法,激活当前的父组件为 当前的activeInstance实例。
嗯,这只是个最简单的patch过程,还未涉及多层嵌套和对比。
因为这是第一次渲染过程,而diff是发生已渲染页面的情况下,再次发生页面需要变更。
下面,我们将进入数据变化,视图需要变化的patch过程
五. patchVnode
reactiveSetter
前面的章节介绍了依赖收集,我们知道,当数据改变时,会触发reactiveSetter。
首先reactiveSetter 会判断,前后的value是否相同,如果相同直接return。 否则进入下面的环节。
依赖收集时,Dep类的实例对象dep下有个subs数组,里面存放了依赖这些数据的watcher对象。
所以当触发reactiveSetter时,实际上是调用了每个watcher的update方法。
watcher的update方法,并不是直接去更新。而是将watcher放入一个更新队列里。
注意: 这个更新队列的大小,最大是100个
最后调用nextTick函数,设置promise更新队列,在callback中执行Scheduler job,即每个watcher的run方法。最终将进入第二轮patch。
需要注意的是:为什么要有队列?这其实是两方面考虑:
- 性能考虑,因为同一个nextTick里,可能同一个组件,依赖了多个数据对象,而多个数据对象都变化了,没必要update多次,在队列中,vue会判断是否属于同一个watcher id。
- 多个组件,分别依赖了多个数据对象。每个组件,实际上都会有自己的nextTick。
这里实际上远不止如此,后面我将单独开一个章节,分享更新队列和nextTick。
此时,oldVnode数据结构如下:
oldVnode
{
tag: "div",
text: undefined,
key: undefined,
isStatic: false,
isRootInsert: true,
isComment: false,
elm: div,
componentInstance: undefined,
componentOptions: undefined,
children: [
{
tag: undefined,
text: "这是根节点",
key: undefined,
isStatic: false,
isRootInsert: false,
isComment: false,
elm: test,
componentInstance: undefined,
componentOptions: undefined,
children: undefined,
// ...
}
],
context: Vue,
data: {
attrs: {...},
on: {
click: function () {...}
}
},
// ...
}
vnode (新的vnode)
{
tag: "div",
text: undefined,
key: undefined,
isStatic: false,
isRootInsert: true,
isComment: false,
elm: div,
componentInstance: undefined,
componentOptions: undefined,
children: [
{
tag: undefined,
// 注意,这里变了
text: "变了",
key: undefined,
isStatic: false,
isRootInsert: false,
isComment: false,
elm: test,
componentInstance: undefined,
componentOptions: undefined,
children: undefined,
// ...
}
],
context: Vue,
data: {
attrs: {...},
on: {
click: function () {...}
}
},
// ...
}
不难看出,此次isRealElement = false,将先执行sameVnode判断。
我们先看sameVnode做了些什么
sameVnode
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的原因。
sameInputType方法,其实很简单:
-
- 如果不是input节点,直接返回true
-
- 如果是,那么判断虚拟dom上的data, attrs, type是否相等
下面终于进入了 patchVnode 方法:
主干代码如下:
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
if (oldVnode === vnode) {
return
}
// ...
// ... 省略异步占位组件
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
// ...
// 组件节点,需要先调用组件prepatch钩子,data,props,slot,listener等可能都需要更新
// ...此处省略
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
}
可以看到,这里是虚拟dom前后对比更新的情况。大致分以下几种:
-
- 先判断oldVnode和vnode,是否相等,如果相等,return
-
- 如果oldVnode是静态节点,并且vnode也是静态节点。并且,oldVnode.key 和 vnode.key相等。并且,vnode节点是克隆的或者是isOnce,那么直接返回,不需要对比了。
-
- 如果新的vnode,不是文本节点,那么:
-
3.1 如果oldVnode和vnode都存在children,那么:
- 3.1.1 如果2个children不相等,那么updateChildren (这里比较复杂,需要单独分析)
-
3.2 如果新的vnode存在children, 而老的oldVnode不存在children,那么:
-
3.2.1 如果老的oldVnode是文本节点,那么先清空真实dom中的内容,再把新的vnode的children添加到真实dom中。
-
3.2.2 如果老的oldVnode不是文本节点,那么直接添加到DOM中
-
-
3.3 如果新的vnode不存在children,而老的oldVnode中存在children,那么:直接把dom中的子节点清空
-
3.4 如果新的vnode,老的oldVnode都不存在children,但是老的oldVnode是文本节点,那么直接清空DOM内容
-
- 如果新的vnode是文本节点,老的oldVnode也是文本节点,那么:如果内容不相等,用新的内容覆盖老的内容
updateChildren
上面,3.1.1情况,如果新老vnode,都存在children,但是他们不相等,那么将调用updateChildren方法。这里单独说明。
其实,都有children的情况下,也不外乎四种处理方式,分别是:
-
- 创建子节点
-
- 删除子节点
-
- 移动子节点
-
- 更新子节点
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
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, 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 {
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 {
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(oldCh, oldStartIdx, oldEndIdx)
}
}
可以看到,updateChildren阶段,实际上是将新的vnode和老的oldVnode,进行双重循环。
如下:
开始
第二步
第三步
第四步
第五步
第六步
diff的比较,实际上,都是以新的vnode为基准,不断的调整老的vnode位置。
可以看到diff的比较策略, 左左,右右,左右,右左
六. 总结
patch第一阶段:activeInstance
-
- 同一时刻,只会有一个组件正在实例化和patch
-
- 设置当前被实例化的activeInstace对象,并且保留preActiveInstace。
-
- 当前保留的activeInstace,在patch过程中,遇到嵌套组件,需要做为其parent,进行组件的实例化。
-
- 当前子组件patch完成后,将切换当前的activeInstance为preActiveInstance,如果还有多层嵌套的话,再次重复上面的过程。
patch第二阶段:root dom patch
-
- dom根即 div#root容器,页面首页渲染时,oldVnode不是一个虚拟dom,而是一个真实dom。此时oldVnode.nodeType = 1,
-
- 将div#root本身,转化为空的虚拟dom。将其作为oldVnode与新的vnode进行对比。
-
- 进入createElm,那么:
- 3.1 如果存在组件,那么进行组件化patch (组件化不了解的小伙伴可以看我的上一篇)
-
- 完成insert,注意此时并没有进入patchVnode diff。
-
- 设置dom style scope id
patch第三阶段:reactiveSetter
-
- 页面完成了首次渲染,如果页面上有数据变化了,将触发reactiveSetter。(依赖收集不清楚的小伙伴,可以看我之前的分享:《vue源码解析-响应式原理》)
-
- 对比新老数据是否相等,如果相等,直接return
-
- 数据不相等,将根dep下的subs,循环调用watcher的update方法。
-
- watcher的update,并不是直接去通知更新。 而是放在一个队列中。更新通知将进入queueWatcher
-
- queueWatcher中优化不必要的多次渲染,比如:多个值的变化,都指向同一个watcher,没必要触发多次patch
-
- Scheduler job中,将调用watcher的run方法
-
- 执行render函数,获取新的vnode,执行update,重复前1个阶段。
-
- isRealElement 为undefined,进入 patchVnode阶段
patch第四阶段:patchVnode
-
- 比较新老节点,是否相等。即oldVnode == vnode。如果相等,直接return
-
- 是否是静态节点,是否前后key相等,或者 是否是克隆节点/isOnce。是直接return。(备注:静态标记是compiler第二阶段生成的)
-
- 如果是比较的是组件节点,那么根据vnode更新oldVnode组件props, listener, slots, parent等属性
-
- 新的vnode是文本节点,那么:
- 4.1 如果oldVnode和vnode 都存在childrens,
- 4.1.1 如果2个children相等,那么直接return
- 4.1.2 如果2个children不相等,那么只需第5阶段-updateChildren
- 4.2 如果新的节点存在children, 而老的节点不存在children,那么:
- 4.2.1 如果老的节点是文本节点,那么先清空老的子节点内容
- 4.2.1 将新的vnode的多个children,插入到老的dom流中
- 4.3 如果新的节点不存在children,而老的节点存在children,那么:
- 4.3.1 将老的childrens全部删除
- 4.4 如果老节点,新节点都不存在children,并且老的节点是文本节点,那么清空老的节点内容
-
- 新老节点都是文本节点,但是文件节点内容不同,那么直接用新的文本内容 更新 老的文本内容
patch第五阶段:updateChildren
-
- 同层比较,不同层的节点是不能复用的
-
- oldStartVnode指的是未处理的开始节点,newStartVnode新的未处理的开始节点
-
- oldEndVnode指的是未处理的最后节点,newEndVnode新的未处理的最后节点
-
- 比较策略:oldStartVnode 和 newStartVnode 先比较,那么:
- 4.1 如果相等,那么将 oldStartVnode,newStartVnode 都往后挪一个
- 4.2 如果不相等,那么进入 oldEndVnode, newEndVnode 比较
-
- oldEndVnode 和 newEndVnode 比较,那么:
- 5.1 如果相等,那么将 oldEndVnode 和 newEndVnode 都往前挪一个
- 5.2 如果不相等,那么进入 oldStartVnode 和 newEndVnode
-
- oldStartVnode 和 newEndVnode 比较,那么:
- 6.1 如果相等,那么将 oldStartVnode 向后挪一个,将 newEndVnode向前挪一个。
- 6.2 如果不相等,将进入 oldEndVnode 和 newStartVnode 比较
-
- oldEndVnode 和 newStartVnode 比较,那么:
- 7.1 如果相等,将 oldEndVnode 往前挪一个,newStartVnode 往后挪一个
- 7.2 如果不相等,那么将进入 查找节点
-
- 根据新的vnode位置,去同层的老节点中查找。
- 8.1 如果存在,那么移动到对应的位置(注意,是未处理节做参照物,而不是已处理节点)
- 8.2 如果不存在,那么根据新的节点children,创建节点,放入老的节点之中
- 8.3 如果老的节点,在新的节点中不存在,那么将老的对应的节点删除
-
- 这就是双指针算法,如此循环,就能将所有节点对比完成。总的概括,不外乎三点:
- 9.1 同层不存在,直接更新移动
- 9.2 同层不存在,那么创建
- 9.3 新的节点,同层 在 老节点中 不存在,那么删除
以上,就是patch阶段的总体流程。
码字不易,多多关注?
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!