持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情
一、前情回顾 & 背景
上一篇小作文相当于复习,是 DOM diff
的前奏部分——触发渲染函数更新:
-
通过修改响应式数据(UI操作、代码修改均可)触发响应式数据的
setter
函数; -
setter
函数接收新的值,然后通过被修改的响应式数据的dep
实例派发依赖该数据的Watcher
更新; -
dep.notify
会把这些watcher
添加到队列中,然后下个事件循环
再集中更新这些watcher
; -
这些
watcher
中包含了渲染 watcher
,对渲染 watcher
求值就会执行创建渲染 watcher
时传入的updateComponent
方法,这个方法就会执行vm._update()
进而执行vm.__patch__
方法,进入patch
阶段;
本篇小作文将会进入 patch
方法的细节部分,详细讨论 DOM-diff
过程:
二、patch 方法结构
patch
方法是 createPatchFunction
工厂函数的返回值
方法位置:src/core/vdom/patch.js -> function createPatchFunction -> return function patch
方法参数:
-
oldVnode
,旧虚拟 DOM
节点 -
vnode
,新虚拟 DOM
节点 -
hydraing
,是否合成,忽略它 -
removeonly
,是否只移除
方法细节:
-
如果新节点不存在而旧节点存在,此时要销毁节点;
-
如果新节点存在旧节点不存在,说明此时是初次渲染,这个是前面我们研究的重点;
-
上面的新、旧节点都存在,此时都是要进入
patch
阶段了
export function createPatchFunction () {
return function patch (oldVnode, vnode, hydrating, remoeonly) {
if (isUndef(vnode)) {
// 新的没有,销毁节点
}
if(isUndef(oldVnode)){
// 旧节点不存在,说明是自定义组件的初次渲染,
// 注意是自定义组件不是根实例,有别与
} else {
// 旧节点存在
// 判断旧节点类型,如果是真实元素说明是根实例的初次渲染
// 旧节点不是真实元素,即旧节点也是虚拟DOM时就是执行 patch
}
}
}
接下来的重点将是上面 else
中的代码块中关于旧节点不是真实元素的情形;
2.1 判断新节点是否存在
新节点是否存将作为当前节点在视图上是否要删除的判断依据。啥意思嘞?
就是说某个节点,在上一次视图对应的虚拟DOM树中时存在的,但是经历一番响应式数据变更重新执行 render 函数
后得到的最新的虚拟 DOM
树中没有这个节点了,这就说明这个节点已经不需要了,可以销毁掉了。
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新节点不存在,老节点存在,调用 destroy 销毁老节点
if (isUndef(vnode)) {
if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
return
}
// ....
}
2.1.1 invokeDestroyHook
- 执行组件的
destroy
钩子,即执行$destroy
方法 - 执行组件各个模块(
style
,class
,directive
) 的destroy
方法 - 如果
vnode
还存在子节点,则递归调用invokeDestroyHook
function invokeDestroyHook (vnode) {
let i, j
const data = vnode.data
if (isDef(data)) {
// 执行 destroy 钩子
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
}
// 递归调用 invokeDestroyHook
if (isDef(i = vnode.children)) {
for (j = 0; j < vnode.children.length; ++j) {
invokeDestroyHook(vnode.children[j])
}
}
}
2.1.1 vnode.data.hook.destroy
vnode.data.hook
对象时在组件创建时被合并到 vnode.data
对象上的,其中包含四个钩子:init
、insert
、prepatch
、destroy
销毁组件:
- 如果组件被
keep-alive
组件包裹,则组件失活,并不销毁组件实例,达到缓存组件状态的目的 - 如果组件未被
keep-alive
组件包裹,则调用实例的$destroy
方法销毁组件
const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
},
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
},
insert (vnode: MountedComponentVNode) {
},
destroy (vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
// 未被 keep-alive 包裹
componentInstance.$destroy()
} else {
// 被 keep-alive 包裹
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
}
2.2 判断旧节点是否存在
如果旧节点不存在说明这个是一个自定义组件的初次渲染,这里先不展开,稍后看到判断旧节点类型分辨是初次渲染还是 patch
,这个问题也就清楚了;
2.3 else 判断旧节点类型
能够走道这里说明新旧节点都存在,因为这里是个 else
了,说明旧节点存在的,新节点已经在前面判断过了;
接着就是判断旧节点类型,如果旧节点类型是元素,即 oldVnode.nodeType
属性存在,nodeType 属性(戳这里看详情)是一个 DOM
属性,用以标识当前元素类型的,例如 1
表示元素,3
表示文本,8
表示注释等,如果这个值不为 undefined
则说明 oldVnode
是个真实的 HTML DOM
对象;
return function patch (oldVnode, vnode, hydrating, removeOnly) {
// 如果新节点不存在,老节点存在,调用 destroy 销毁老节点
if (isUndef(vnode)) {}
let isInitialPatch = false
const insertedVnodeQueue = []
if (isUndef(oldVnode)) {
// 新 VNode 存在,旧 VNode 不存在,
// 说明这种情况下是一个组件初次渲染时出现,比如:
// <div id='app'> <comp></comp> </div>
// 这里的 comp 组件初次渲染时就会走这里
} else {
// 判断 oldVnode 是否是真实元素
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// 不是真实元素,但是老节点和新节点是同一个节点,
// 则是更新阶段,执行 patch 更新节点
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
} else {
// 是真实节点,则表示初次渲染
}
}
2.3.1 旧节点元素类型初次渲染?
这里有个比较奇怪的问题,这个问题其实也困扰我很久?为啥旧节点元素类型就是初次渲染?
为了解开这个问题,我走了不少弯路,按照我的理解,如果有 nodeType
这个属性,难不成在 VNode
实例属性有这个属性?我把 VNode
的属性翻了个底朝天,然鹅也没发现。。。此时我发现路走偏了。
想知道为啥,只需要看看 patch
函数被调用时,传递的参数都是什么不就可以了?
patch
函数也就是 Vue.prototype.__patch__
方法,它被 Vue.prototype._update
方法调用,如下:
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
const vm: Component = this
if (!prevVnode) {
// 旧 VNode 不存在,标识首次渲染,即初始化页面时走这里
// 首次渲染,即初始化页面时走这里
vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false )
} else {
// 响应式数据更新时,即更新页面时走这里
vm.$el = vm.__patch__(prevVnode, vnode)
}
从上面的代码可以清晰看出,当初次渲染时 vm.__patch__
函数收到的第一个参数是 vm.$el
,也就传给 oldVnode
参数的实参。
在这个场景下冷不丁的一看 vm.el你觉得熟悉吗?对的,就是表示真实挂载点的真实元素对象,他就是
它还有一个你常见的出场方式,这个你肯定认识:
new Vue({
el: '#app' // 声明挂载点,这个 #app 就对应了上面的 vm.$el 属性
})
2.3.2 sameVnode 验证同一节点
为啥要验证同一节点?
先说节点是啥?可以粗暴的理解成页面上的 DOM
元素,这里的验证,是要求两段 VNode
描述的是同一段响应式数据,或者说对应的同一视图。
这是因为如果本次 diff
的不是同一节点,那就不需要再 diff
了直接走下面的 else
将 vnode
变成新的元素进行渲染就行了;
值得一提的是判定是否为同一节点的标准并不是 vnode
和 oldVnode
是同一个对象,这也没法做到,因为两次得到的是不同的 Vnode
实例,而真正的判断是一些描述 Vnode
对象的特征属性,如标签名、key
等;
function sameVnode (a, b) {
return (
// key 必须相同或
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
// 标签名相同
a.tag === b.tag &&
// 都是注释节点
a.isComment === b.isComment &&
// 都有 data 属性
isDef(a.data) === isDef(b.data) &&
// input 标签的情况
sameInputType(a, b)
) || (
// 或者异步占位符节点
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
2.4 执行 patchVnode 方法
有了前面一系列的判断,终于到了执行 patchVnode
这个方法了。我们接着捋一下执行到这里的条件:
- 新旧节点都存在;
- 旧节点类型不是元素;
- 新旧节点是同一节点;
以上三者同时满足才会执行重点方法 patchVnode
,这里我们不再展开,我们下一篇专门讨论它;
三、总结
不得不吐槽下,用这个在线编辑器编辑好了,我看到的是已经存好的,结果等我再次从草稿箱进来时发现丢失了大半内容。。。。。
这给我提了一个醒我还是乖乖的做好备份吧,求诸人不如求己。。。。
本篇小作文讨论了一下 patch
方法的大致结构,真正 diff
两棵树的 patchVnode
方法还没开始,这里主要讨论的是如何进入到 patchVnode
的执行条件:
-
如果新节点不存在,就要销毁掉旧节点,因为视图不再需要它了;
-
判断旧节点是否存在,如果不存在说明是自定义组件的初次渲染,这个是坑,这里填一下,你会发现非自定义组件,甚至根实例的初次渲染是
oldVnode
传递真实DOM
元素也不会使得oldVnode
不存在。而自定义组件就不是了,它只有经历过初次渲染才会有oldVnode
,所以当oldVnode
不存在时就是初次渲染了。 -
新旧节点都有,判断不是元素说明就是两颗虚拟
DOM
树了,此时调用patchVnode
进行比对并patch