携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
本文主要内容摘抄自黄轶老师的慕课网课程Vue.js 源码全方位深入解析 全面深入理解Vue实现原理,主要用于个人学习和复习,不用作其他用途。
在组件化那篇文章中,介绍了 Vue 的组件化实现过程,不过只讲了 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__(prevVnode, vnode),它仍然会调用 patch 函数,在 src/core/vdom/patch.js 中定义:
return function patch (oldVnode, vnode, hydrating, removeOnly) {
...
let isInitialPatch = false
const insertedVnodeQueue = []
// 组件第一次渲染oldVnode为空
if (isUndef(oldVnode)) {
isInitialPatch = true
createElm(vnode, insertedVnodeQueue)
} else {
const isRealElement = isDef(oldVnode.nodeType)
// 如果vnode的tag key等一样,说明是同样的vnode,那么就进行子节点的比较
if (!isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
// 如果vnode 的tag key等不同,说明是不同的vnode,直接根据新的vnode创建真实dom即可,子节点都不用继续比较,因为父节点都不一样了
if (isRealElement) {
// ...
}
// replacing existing element
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
// vnode.parent表示该组件的占位符vnode,现在因为组件修改了,所以也要更改它的占位符vnode
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
....
ancestor = ancestor.parent
}
}
// 删除旧的真实dom
if (isDef(parentElm)) {
removeVnodes(parentElm, [oldVnode], 0, 0)
} else if (isDef(oldVnode.tag)) {
invokeDestroyHook(oldVnode)
}
}
}
invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
return vnode.elm
}
这里执行 patch 的逻辑和首次渲染是不一样的,因为 oldVnode 不为空,并且它和 vnode 都是 VNode 类型,接下来会通过 sameVNode(oldVnode, vnode) 判断它们是否是相同的 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)
)
)
)
}
sameVnode 的逻辑非常简单,如果两个 vnode 的 tag 和 key 不相等,则是不同的;否则继续判断对于同步组件,则判断 isComment、data、input 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。
所以根据新旧 vnode 是否为 sameVnode,会走到不同的更新逻辑,先来说一下不同的情况。
新旧节点不同
如果新旧 vnode 不同,那么更新的逻辑非常简单,它本质上是要替换已存在的节点,大致分为 3 步:
- 创建新节点
此时会把新的节点插入到原来的节点中,那么在界面上就会同时显示新旧两个节点内容。
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
vnode,
insertedVnodeQueue,
oldElm._leaveCb ? null : parentElm,
nodeOps.nextSibling(oldElm)
)
- 更新组件的占位符节点
找到当前 vnode 的父的占位符节点,先执行各个 module 的 destroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 module 的 create 钩子函数。对于这些钩子函数的作用,在之后的章节会详细介绍。
- 删除旧节点
前面在节点中添加了新的内容,那么需要把就的节点内容删除掉。
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)) {
...
for (i = 0; i < cbs.remove.length; ++i) {
cbs.remove[i](vnode, rm)
}
...
// 如果删除的这个节点是组件占位符节点,就会递归删除组件节点
if (isDef(i = vnode.componentInstance) && isDef(i = i._vnode) && isDef(i.data)) {
removeAndInvokeRemoveHook(i, 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 函数;这种情况出现在如下代码中:
<div id="app">
<MyTest v-if="flag" />
<div v-else>pengchangjun</div>
<button @click="change">change</button>
</div>
change() {
this.flag = !this.flag
}
当改变flag的时候,就会比对children,发现节点的key不相等,一个tag是div,一个tag是vue-component-2-mytest,所以会走到removeAndInvokeRemoveHook逻辑,在里面发现是一个组件的占位符节点,那么递归调用removeAndInvokeRemoveHook删除子组件节点。
invokeDestroyHook 是执行 module 的 destory 钩子函数以及 vnode 的 destory 钩子函数,并对它的子 vnode 递归调用 invokeDestroyHook 函数;removeNode 就是调用平台的 DOM API 去把真正的 DOM 节点移除。
在之前介绍组件生命周期的时候提到 beforeDestroy & destroyed 这两个生命周期钩子函数,它们就是在执行 invokeDestroyHook 过程中,执行了 vnode 的 destory 钩子函数,它的定义在 src/core/vdom/create-component.js 中:
const componentVNodeHooks = {
destroy (vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy()
} else {
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
}
当组件并不是 keepAlive 的时候,会执行 componentInstance.$destroy() 方法,然后就会执行 beforeDestroy & destroyed 两个钩子函数。
下面看一个例子,当我在父组件改变flag的值后,MyTest会经历销毁到重建的过程:
// app.vue
<template>
<div id="app" :key="flag">
<MyTest />
<button @click="change">change</button>
</div>
</template>
<script>
change() {
this.flag = !this.flag
}
</script>
// MyTest.vue
<template>
<ul>
<li>1</li>
<li>1</li>
<li>1</li>
<li>1</li>
</ul>
</template>
export default {
name: 'MyTest',
...
mounted() {
console.log('mounted')
},
destroyed() {
console.log('destroyed')
}
}
当在父组件改变flag的值后,MyTest组件会先执行destroyed钩子,然后再执行mounted钩子,也就说明当父节点不一样的话,即使子节点一样也会全部销毁后重建。
新旧节点相同
对于新旧节点不同的情况,这种创建新节点 -> 更新占位符节点 -> 删除旧节点的逻辑是很容易理解的。还有一种组件 vnode 的更新情况是新旧节点相同,它会调用 patchVNode 方法,它的定义在 src/core/vdom/patch.js 中:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
if (oldVnode === vnode) {
return
}
const elm = vnode.elm = oldVnode.elm
...
let i
// 这段逻辑主要是针对组件的占位符vnode,因为占位符vnode有data.hook
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// 新旧节点的children
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)) {
// 如果都存在chilren,则调用updateChildren,其实这个函数也是递归调用patchVnode
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)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
patchVnode 的作用就是把新的 vnode patch 到旧的 vnode 上,这里我们只关注关键的核心逻辑,我把它拆成四步骤:
- 执行
prepatch钩子函数
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法(因为children除了普通的vnode,还有组件vnode),它的定义在 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
)
}
}
vnode.componentOptions有如下的值,里面有新vnode的最新props值,这个值会导致子组件会重新渲染。
prepatch 方法就是拿到新的 vnode 的组件配置以及组件实例,去执行 updateChildComponent 方法,它的定义在 src/core/instance/lifecycle.js 中:
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
...
// 把新的组件占位符节点赋值给vm.$options._parentVnode和 vm.$vnode
vm.$options._parentVnode = parentVnode
vm.$vnode = parentVnode // update vm's placeholder node without re-render
if (vm._vnode) {
vm._vnode.parent = parentVnode
}
vm.$options._renderChildren = renderChildren
// 更新$attrs and $listeners
vm.$attrs = parentVnode.data.attrs || emptyObject
vm.$listeners = listeners || emptyObject
// 更新props,在更新props的时候因为props里面的数据是响应式的,所以会触发set,然后引起子组件的重新渲染
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?
// 这个时候会执行setter钩子,触发子组件的重新渲染
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
// 更新 listeners
listeners = listeners || emptyObject
const oldListeners = vm.$options._parentListeners
vm.$options._parentListeners = listeners
updateComponentListeners(vm, listeners, oldListeners)
...
}
updateChildComponent 的逻辑也非常简单,由于更新了 vnode,那么 vnode 对应的实例 vm 的一系列属性也会发生变化,包括占位符 vm.$vnode 的更新、slot 的更新,listeners 的更新,props 的更新等等。
特别是如果props更新了,那么就会触发子组件的重新渲染。
- 执行
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)
}
回到 patchVNode 函数,在执行完新的 vnode 的 prepatch 钩子函数,会执行所有 module 的 update 钩子函数以及用户自定义 update 钩子函数,对于 module 的钩子函数,之后我们会有具体的章节针对一些具体的 case 分析。
- 完成
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钩子函数
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
再执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。
那么在整个 pathVnode 过程中,最复杂的就是 updateChildren 方法了,下面我们来单独介绍它。
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
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 {
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 部分的列表数据,我们通过图的方式来描述一下它的更新过程:
- 第一步:
- 第二步:
- 第三步:
-
第四步:
-
第五步:
- 第六步:
组件更新的过程核心就是新旧 vnode diff,对新旧节点相同以及不同的情况分别做不同的处理。新旧节点不同的更新流程是创建新节点->更新父占位符节点->删除旧节点;而新旧节点相同的更新流程是去获取它们的 children,根据不同情况做不同的更新逻辑。最复杂的情况是新旧节点相同且它们都存在子节点,那么会执行 updateChildren 逻辑,这块儿可以借助画图的方式配合理解。
原理图:
下面以一个例子来完整感受下组件更新的过程:
const CompB = {
template: `
<div>
<p>age: {{age}}</p>
<p>sex: {{sex}}</p>
<p>ball: {{hobby.ball}}</p>
<p>game: {{hobby.game}}</p>
</div>
`,
props: {
age: Number,
sex: {
type: String,
default: 'female',
validator(value) {
return value === 'male' || value === 'female'
}
},
hobby: {
type: Object,
default() {
return {
ball: 'basketball',
game: 'dota'
}
}
}
}
}
new Vue({
el: '#app',
components: { CompB },
data() {
return {
age: 18,
hobby: {
ball: 'football',
game: 'car'
}
}
},
methods: {
change() {
this.age = 19
}
},
template: `
<div>
<comp-b :age="age" sex="male" :hobby="hobby"></comp-b>
<button @click="change">change</button>
</div>
`
})
- 当渲染组件
app.vue的时候会访问age和hobby,此时这个两个dep会收集app.vue组件的渲染watcher - 当点击
change事件后,改变了age,会执行dep.notify();
-
接着执行
queue里面的渲染watcher,此时queue只有一个app.vue的watcher -
执行到
patchVnode(oldVnode, vnode)函数,此时oldVnode, vnode如下:
两者的区别在于oldVnode有一个elm,它是虚拟dom对应的真实DOM节点,后面对dom的修改就是对elm的修改。
- 首先比较第一个子节点,第一个子节点是组件节点
vue-component-1-com-b,然后就走到下面的逻辑
然后执行updateChildComponent
在重新的赋值的过程中,就触发了子组件某个属性的setter函数,然后就执行子组件的dep.notify(),此时父组件正在渲染,所以flushing为true,所以子组件的渲染watcher会插入到queue队列中,当父组件比对完其他的子节点,就会去执行queue队列里面的组件watcher的更新。
- 组件渲染
watcher执行update操作,这样当queue里面所有的watcher执行完毕后,说明整个组件树都更新完毕。