前言#
昨天我们分析了vnode的创建
坏蛋Dan:vue runtime源码分析学习——day5:生成vnode
今天我们来分析很重要的patch阶段
考虑到要手动去写h函数测试,我决定从这里开始,分开测试和代码分析,测试在另一个跑起来的demo里,分析还是在core这里。
分析之前#
位置#
先找到代码所在位置,之前我们分析createApp流程的render逻辑的时候遇到过patch,它在runtime-core\src\renderer.ts文件中的baseCreateRenderer方法中。

调用位置#

之前createApp中mount就是调用这里的render,然后执行patch打补丁的。
参数#

n1:旧的vnode,保存在container._vnode中,这个container是真实的dom,也就是vnode挂载的目标n2:新的vnodeparentComponent:看名字应该是父组件的意思parentSuspense:应该是被包裹在suspense内置组件里面的vnode的意思slotScopeIds:slot作用域的idoptimized:应该是用于优化流程的标志位isHmrUpdating:和热更新有关,先mark下。
分析场景#

其中重点是switch阶段,它会更细化的diff。
选择测试用例#
由于我们希望看到diff的所有过程,所以这里直接选择一组测试用例packages\runtime-core\__tests__\rendererElement.spec.ts 。当然,大多数时候我们还是直接去跑demo省事。
import {
h,
render,
nodeOps,
TestElement,
serializeInner as inner
} from '../../runtime-test/src/index'
describe('renderer: element', () => {
let root: TestElement
beforeEach(() => {
root = nodeOps.createElement('div')
})
it('should create an element', () => {
render(h('div'), root)
expect(inner(root)).toBe('<div></div>')
})
it('should create an element with props', () => {
render(h('div', { id: 'foo', class: 'bar' }), root)
expect(inner(root)).toBe('<div id="foo" class="bar"></div>')
})
it('should create an element with direct text children', () => {
render(h('div', ['foo', ' ', 'bar']), root)
expect(inner(root)).toBe('<div>foo bar</div>')
})
it('should create an element with direct text children and props', () => {
render(h('div', { id: 'foo' }, ['bar']), root)
expect(inner(root)).toBe('<div id="foo">bar</div>')
})
it('should update an element tag which is already mounted', () => {
render(h('div', ['foo']), root)
expect(inner(root)).toBe('<div>foo</div>')
render(h('span', ['foo']), root)
expect(inner(root)).toBe('<span>foo</span>')
})
it('should update element props which is already mounted', () => {
render(h('div', { id: 'bar' }, ['foo']), root)
expect(inner(root)).toBe('<div id="bar">foo</div>')
render(h('div', { id: 'baz', class: 'bar' }, ['foo']), root)
expect(inner(root)).toBe('<div id="baz" class="bar">foo</div>')
})
})另外别忘了替换@runtime-test的引入路径以及它自己里面引用其它包的路径

这样才能触发到我们在patch中打的断点。
然后我们测试下

正常进入。
patch#
代码分析咱这次采用分块分析,就不直接贴代码了。
需要注意的一点是,在patch阶段,所有的vnodecall或者需要runtime编译的代码都已经变成vnode了,换句话说就是已经确定下来了具体是什么数据什么属性等。
我们先来看分析场景的第一处
相同的引用#
if (n1 === n2) {
return
}如果旧(n1)新(n2)俩vnode的引用是指向同一个的,那么完全就没必要再继续判断了,为什么呢?
还记得我们前面看render这个方法吗?它最后一行就是直接container._vnode = vnode,直接覆盖掉原来的vnode的引用,所以直接返回。
类型比对#
// patching & not same type, unmount old tree
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}如果n1存在并且新旧俩vnode的类型是不一样的,那么这个时候卸载当前旧的vnode对应的dom,包括它的所有子节点。同时移除n1,此时n1引用的vnode在失去所有引用之后将被回收处理。
我们来看下isSameVNodeType
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (
__DEV__ &&
n2.shapeFlag & ShapeFlags.COMPONENT &&
hmrDirtyComponents.has(n2.type as ConcreteComponent)
) {
// HMR only: if the component has been hot-updated, force a reload.
return false
}
return n1.type === n2.type && n1.key === n2.key
} 就是在比较type和key,看到这个key相信大家心里都明白了为什么vfor要搭配key了吧,如果没有这个key,所有tag都相同,那么如果后面进一步diff时没法发现任何不同,这颗tree将被认定为没有发生改变。
这里还有些hmr相关的代码,它只作用于component。
如果我们此时的vnode是一个component且此时处于开发阶段且此时hmrDirtyComponents中已经有n2了,此时需要重新挂载。
hmrDirtyComponents是一个集合, 用于存放热更新过程中需要更新的组件,将它们mark到这里面强制父组件patch这个子组件来达到更新的效果。
然后我们再来看下getNextHostNode这个方法
const getNextHostNode: NextFn = vnode => {
if (vnode.shapeFlag & ShapeFlags.COMPONENT) {
return getNextHostNode(vnode.component!.subTree)
}
if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
return vnode.suspense!.next()
}
return hostNextSibling((vnode.anchor || vnode.el)!)
}
function nextSibling(node: TestNode): TestNode | null {
const parent = node.parentNode
if (!parent) {
return null
}
const i = parent.children.indexOf(node)
return parent.children[i + 1] || null
} 这里的hostNextSibling是nextSibling方法的local名字。
如果当前虚拟节点是component,那么就递归调用getNextHostNode方法,传入component的subTree也就是子节点树。


这是在真实场景模拟的,实际上我们也可以通过测试用例的方式来实现,但是要自己调用h函数来写,有些麻烦。
如果是Suspense组件的子节点,那么就调用suspense的next方法,我们来看下next方法代码
next() {
return suspense.activeBranch && next(suspense.activeBranch)
},至于这个next方法里的next方法就是getNextHostNode方法。
这里需要补充下Suspense的触发场景。
还记得我们在分析compiler-sfc时script setup语法糖时遇到的await可以直接写的场景嘛?因为语法糖的原因,我们并不需要也没地方可以用async包裹,所以此时compiler-sfc会帮我们包裹一层async的写法。
根据官方文档的说明

这个时候的组件也会自动被判定为async component ,所以此时咱用suspense包裹再合适不过。
所以回到我们的代码中,如果我们想要测试这一块代码,那么我们就需要改成异步的组件。
我们修改下我们的demo代码

父组件中

然后我们来看下效果

然后看下这里触发的时候数据

可以看到activeBranch就是我们的Test组件。
扯得有些远,回到我们的hostNextSibling方法中。
如果不是上面两个场景,就调用hostNextSibling方法
hostNextSibling方法中直接拿这个节点的parentNode,拿到之后通过parent.children找到它所在的位置,然后那它的下一个节点。
所以getNextHostNode这个方法实际上就是在找这个非组件/Suspense的节点的下一个节点。
注意这里component等都是vnode.el,而这个Suspense则是vnode.anchor。
然后跳出getNextHostNode之后把找到的下一个节点赋值给anchor。
接着执行unmount,unmount顾名思义就是移除vnode,放到下面分析。
unmount#
const unmount: UnmountFn = (
vnode,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false
) => {
const {
type,
props,
ref,
children,
dynamicChildren,
shapeFlag,
patchFlag,
dirs
} = vnode
// unset ref
if (ref != null) {
setRef(ref, null, parentSuspense, vnode, true)
}
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs
const shouldInvokeVnodeHook = !isAsyncWrapper(vnode)
let vnodeHook: VNodeHook | undefined | null
if (
shouldInvokeVnodeHook &&
(vnodeHook = props && props.onVnodeBeforeUnmount)
) {
invokeVNodeHook(vnodeHook, parentComponent, vnode)
}
if (shapeFlag & ShapeFlags.COMPONENT) {
unmountComponent(vnode.component!, parentSuspense, doRemove)
} else {
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
vnode.suspense!.unmount(parentSuspense, doRemove)
return
}
if (shouldInvokeDirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
}
if (shapeFlag & ShapeFlags.TELEPORT) {
;(vnode.type as typeof TeleportImpl).remove(
vnode,
parentComponent,
parentSuspense,
optimized,
internals,
doRemove
)
} else if (
dynamicChildren &&
// #1153: fast path should not be taken for non-stable (v-for) fragments
(type !== Fragment ||
(patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
) {
// fast path for block nodes: only need to unmount dynamic children.
unmountChildren(
dynamicChildren,
parentComponent,
parentSuspense,
false,
true
)
} else if (
(type === Fragment &&
patchFlag &
(PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
) {
unmountChildren(children as VNode[], parentComponent, parentSuspense)
}
if (doRemove) {
remove(vnode)
}
}
if (
(shouldInvokeVnodeHook &&
(vnodeHook = props && props.onVnodeUnmounted)) ||
shouldInvokeDirs
) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
shouldInvokeDirs &&
invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
}, parentSuspense)
}
} 稍微有点长,我们调用的时候是unmount(n1, parentComponent, parentSuspense, true)
传入了四个数据分别是:旧的vnode,父组件节点,父Suspense节点,true。
第四个数据对应doRemove这个参数
一上来就遇到这个setRef的方法,这个方法放到后面分析,这里简单的说下就是在注销原来放在ref里的当前节点。毕竟这个旧的vnode就要被注销了。
然后是判断这个节点所在的组件是否是keep-alive的,如果是直接return,咱不注销了,交给keep-alive自家的人deactivate处理。
这个deactivate暂时不分析,因为涉及到keep-alive相关的知识,但是咱连keep-alive的入口都没见过。不过大家应该也不陌生这个生命周期以及它的兄弟activate。
注意3.x改名为onDeactivated以及onActivated。这俩只有在KeepAlive包裹的组件里才可以监听。
扯远了,回到我们的代码中。
isAsyncWrapper应该是用来判断是否是一个异步懒加载组件,即通过defineAsyncComponent创建的组件。和defineAsyncComponent相关我们也暂时不说,以后再单独分析。invokeVNodeHook:看名字就知道是用来触发生命周期函数的,unmount阶段自然是触发onBeforeUnmount以及onUnmounted这两个生命周期。不过你可能会疑惑为什么触发的生命周期来自于props里,实际上这是2.x中就有的一种监听子dom生命周期的方法,注意只有3.x才支持监听非组件dom,2.x仅支持监听组件,具体可看:VNode Lifecycle Events | Vue 3 Migration Guide (vuejs.org)。扯远了,简单的说就是在执行对应实例的生命周期,需要注意的一点则是注册到hooks里的回调不止有一个。这里就不多说了。unmountComponent: 如果当前节点是组件,那么这个时候执行unmountComponent方法将当前节点移除,注意此时不是Keep-alive的,keep-alive的已经由它自己deactivated了。unmountComponent方法放下面分析,这里简单的说就是在卸载这个组件,各个方面的,以及执行一些注册了生命周期onBeforeUnmount以及onUnmounted的回调。
如果是suspense组件,那么执行它自己的unmount。不过它自己的unmount实际上也是当前的unmount方法。。。不过它自己有它自己的特殊逻辑,所以需要跑到它自己的范围里处理。这里就不分析了,我们后面分析Suspense组件的时候再说。
invokeDirectiveHook:这个方法一看就是用来触发某些指令的。还是放在下面分析吧,这里就是在执行onBeforeUnmount方法,前面也有说过,3.x开始支持对于非组件的节点的生命周期监听。
然后轮到Teleport了,这个组件也是自有它的国庆,它执行它自己的remove方法,这里也不分析,以后分析Teleport的时候再单独分析。
dynamicChildren:这个动态子节点,我们在createVNode那边遇到过,在createElementBlock的时候,它会包裹一层setupBlock的方法

而这个setupblock方法中,正是定义这个dynamicChildren的地方。
function setupBlock(vnode: VNode) {
// save current block children on the block vnode
vnode.dynamicChildren =
isBlockTreeEnabled > 0 ? currentBlock || (EMPTY_ARR as any) : null
// close block
closeBlock()
// a block is always going to be patched, so track it as a child of its
// parent block
if (isBlockTreeEnabled > 0 && currentBlock) {
currentBlock.push(vnode)
}
return vnode
}我们来修改下我们的代码,让它可以被开启跟踪

然后我们重新运行下代码

可以看到这里捕捉到了三个动态子节点,那么是哪几个呢?

只要自身带有指令或者动态属性的节点就需要被捕捉,组件则不必多说。
unmountChildren,这个unmountChilren就是处理子节点。可以看到此时我们只是需要处理dynamicChildren,其它的static节点已经被skip等了。
const unmountChildren: UnmountChildrenFn = (
children,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false,
start = 0
) => {
for (let i = start; i < children.length; i++) {
unmount(children[i], parentComponent, parentSuspense, doRemove, optimized)
}
}递归调用unmount方法调用子节点。
而fragment则是需要传入所有的子节点。
什么情况会产生一个fragment呢?比如v-for的时候。

而fragment则是需要传入所有的子节点。
什么情况会产生一个fragment呢?比如v-for的时候。
那为什么遇到fragment的时候就需要传入所有children呢?因为它non-stable。
而fragment则是需要传入所有的子节点。
什么情况会产生一个fragment呢?比如v-for的时候。
那为什么遇到fragment的时候就需要传入所有children呢?因为它non-stable。它的children个数是不定的,所以自然需要传入全部。
remove:看名字就是用来移除节点的。
const remove: RemoveFn = vnode => {
const { type, el, anchor, transition } = vnode
if (type === Fragment) {
if (
__DEV__ &&
vnode.patchFlag > 0 &&
vnode.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT &&
transition &&
!transition.persisted
) {
;(vnode.children as VNode[]).forEach(child => {
if (child.type === Comment) {
hostRemove(child.el!)
} else {
remove(child)
}
})
} else {
removeFragment(el!, anchor!)
}
return
}
if (type === Static) {
removeStaticNode(vnode)
return
}
const performRemove = () => {
hostRemove(el!)
if (transition && !transition.persisted && transition.afterLeave) {
transition.afterLeave()
}
}
if (
vnode.shapeFlag & ShapeFlags.ELEMENT &&
transition &&
!transition.persisted
) {
const { leave, delayLeave } = transition
const performLeave = () => leave(el!, performRemove)
if (delayLeave) {
delayLeave(vnode.el!, performRemove, performLeave)
} else {
performLeave()
}
} else {
performRemove()
}
}hostRemove:这个方法就不看代码了,简单的说就是在解绑,先是获取它的父节点,让它的父节点移除它,然后将这个子节点的parentNode赋值为null。removeFragment:这个也不看代码了,这个方法就是在移除整个fragment,先移除它的子节点,然后再通过anchor也就是挂载的目标移除fragment自己。removeStaticNode:同上afterLeave:这个是Transition相关的,这里就先不说了,以后我们分析Transition会说到。
这个方法简单没啥好说的其实,就是从parentNode移除自己。
最后就是执行通过@vue:unmounted监听的onUnmounted生命周期方法,下面unmountComponent里有说,这里就不多说了。
setRef#
/**
* Function for handling a template ref
*/
export function setRef(
rawRef: VNodeNormalizedRef,
oldRawRef: VNodeNormalizedRef | null,
parentSuspense: SuspenseBoundary | null,
vnode: VNode,
isUnmount = false
) {
if (isArray(rawRef)) {
rawRef.forEach((r, i) =>
setRef(
r,
oldRawRef && (isArray(oldRawRef) ? oldRawRef[i] : oldRawRef),
parentSuspense,
vnode,
isUnmount
)
)
return
}
if (isAsyncWrapper(vnode) && !isUnmount) {
// when mounting async components, nothing needs to be done,
// because the template ref is forwarded to inner component
return
}
const refValue =
vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
? getExposeProxy(vnode.component!) || vnode.component!.proxy
: vnode.el
const value = isUnmount ? null : refValue
const { i: owner, r: ref } = rawRef
if (__DEV__ && !owner) {
warn(
`Missing ref owner context. ref cannot be used on hoisted vnodes. ` +
`A vnode with ref must be created inside the render function.`
)
return
}
const oldRef = oldRawRef && (oldRawRef as VNodeNormalizedRefAtom).r
const refs = owner.refs === EMPTY_OBJ ? (owner.refs = {}) : owner.refs
const setupState = owner.setupState
// dynamic ref changed. unset old ref
if (oldRef != null && oldRef !== ref) {
if (isString(oldRef)) {
refs[oldRef] = null
if (hasOwn(setupState, oldRef)) {
setupState[oldRef] = null
}
} else if (isRef(oldRef)) {
oldRef.value = null
}
}
if (isFunction(ref)) {
callWithErrorHandling(ref, owner, ErrorCodes.FUNCTION_REF, [value, refs])
} else {
const _isString = isString(ref)
const _isRef = isRef(ref)
if (_isString || _isRef) {
const doSet = () => {
if (rawRef.f) {
const existing = _isString
? hasOwn(setupState, ref)
? setupState[ref]
: refs[ref]
: ref.value
if (isUnmount) {
isArray(existing) && remove(existing, refValue)
} else {
if (!isArray(existing)) {
if (_isString) {
refs[ref] = [refValue]
if (hasOwn(setupState, ref)) {
setupState[ref] = refs[ref]
}
} else {
ref.value = [refValue]
if (rawRef.k) refs[rawRef.k] = ref.value
}
} else if (!existing.includes(refValue)) {
existing.push(refValue)
}
}
} else if (_isString) {
refs[ref] = value
if (hasOwn(setupState, ref)) {
setupState[ref] = value
}
} else if (_isRef) {
ref.value = value
if (rawRef.k) refs[rawRef.k] = value
} else if (__DEV__) {
warn('Invalid template ref type:', ref, `(${typeof ref})`)
}
}
if (value) {
// #1789: for non-null values, set them after render
// null values means this is unmount and it should not overwrite another
// ref with the same key
;(doSet as SchedulerJob).id = -1
queuePostRenderEffect(doSet, parentSuspense)
} else {
doSet()
}
} else if (__DEV__) {
warn('Invalid template ref type:', ref, `(${typeof ref})`)
}
}
}如果ref是一个数组,那就递归调用setRef。
如果这个vnode是一个async components并且是mount阶段,这个时候不需要做任何事情,因为ref将转发到异步组件包裹的内部组件。
然后获取组件expose出来的东西, 这个getExposeProxy方法我们就不看代码了,简单的说就是获取这个组件的expose,然后再new Proxy代理一次。
i就是owner,指向调用这个ref的组件,也就是当前组件。r就是ref表示绑定的名字,比如``,那么这个时候的ref就是test。

这里还有一个旧的ref,也就是oldRawRef,这个应该是动态ref的问题。后面会接触到。
我们来调整下我们index.vue里的代码

这个时候的ref已经是动态的了,现在我们重新debug下

确实使用动态ref后就会出现。
这个setupState是指向这个组件的setup上下文,我们定义在setup里的方法、属性变量等都会被收集到这里,实际上这个就是我们编译过后的组件的setup函数执行之后的结果。

接着,如果当前旧的ref和新的ref不相同,如果旧的ref是一个字符串,直接赋值为null。
如果这个旧的ref还存在于setupState里,那么这个时候需要同步把setupState里的也赋值为null。而如果是用ref包裹了的,那么这个时候就需要把它的value赋值为null。
这么做是为了解除引用,避免无法被垃圾回收。
然后判断这个ref是否是一个函数,什么情况下会是个函数呢?其实我们绑定ref的时候动态不一定是一个ref,也可以是一个函数。
我们来改下我们的代码。


可以看到确实是一个函数,并且用的是$setup,这个我们在编译阶段遇到过。
这个$setup会在render function里作为参数传入,指向这个组件的setup。
如果是函数ref,就帮他执行。
而如果是字符串或者是用ref包裹后的数据。那么就执行doSet方法,如果value也就是组件expose出来的不为空,那么就需要通过调度器(scheduler)来处理,避免执行的顺序不对
我们接着看doSet方法,如果是unmount,那么就移除这个ref。如果不是,那就是更新等。
这个时候需要处理没有初始值的场景。如果是字符串,那么需要同时赋值给setupState以及refs。
为什么呢?因为它有可能不是在setup里定义的,所以setup函数里可能拿不到。而如果是被ref的,那就简单了,它一定是在setup里定义的,编译阶段就已经被收集了。
unmountComponent#
const unmountComponent = (
instance: ComponentInternalInstance,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean
) => {
if (__DEV__ && instance.type.__hmrId) {
unregisterHMR(instance)
}
const { bum, scope, update, subTree, um } = instance
// beforeUnmount hook
if (bum) {
invokeArrayFns(bum)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeDestroy')
}
// stop effects in component scope
scope.stop()
// update may be null if a component is unmounted before its async
// setup has resolved.
if (update) {
// so that scheduler will no longer invoke it
update.active = false
unmount(subTree, instance, parentSuspense, doRemove)
}
// unmounted hook
if (um) {
queuePostRenderEffect(um, parentSuspense)
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
queuePostRenderEffect(
() => instance.emit('hook:destroyed'),
parentSuspense
)
}
queuePostRenderEffect(() => {
instance.isUnmounted = true
}, parentSuspense)
// A component with async dep inside a pending suspense is unmounted before
// its async dep resolves. This should remove the dep from the suspense, and
// cause the suspense to resolve immediately if that was the last dep.
if (
__FEATURE_SUSPENSE__ &&
parentSuspense &&
parentSuspense.pendingBranch &&
!parentSuspense.isUnmounted &&
instance.asyncDep &&
!instance.asyncResolved &&
instance.suspenseId === parentSuspense.pendingId
) {
parentSuspense.deps--
if (parentSuspense.deps === 0) {
parentSuspense.resolve()
}
}
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentRemoved(instance)
}
}通过参数我们知道了instance也就是组件的实例是vnode.component。
unregisterHMR: 又有和hmr相关的代码了,看名字就知道是用来注销组件热更新的。
export function unregisterHMR(instance: ComponentInternalInstance) {
map.get(instance.type.__hmrId!)!.instances.delete(instance)
}这个type自然就是组件自身,我们编译阶段最终组装成的东西就是它,而__hmrId则是vue-loader组装的时候加上的,至于为什么是instances,咱也不清楚,暂时只知道它是一个map。

将自己从全局的hmr map中移除之后,回到我们的unmounteComponent方法中。
从instance中解构出来5个东西
bum:就是beforeUnmount的hook,它里面存放的是回调。

scope:这货应该是和更新有关的,它自身有effects以及deps等字眼,先mark下。

update:这个应该是用来触发更新事件的方法,先mark下。

subTree自然不用多说,组件自身的节点内容um:自然就是注册到onUnmounted里的回调们。
首先触发的是beforeUnmount里的回调,需要注意,之前父组件里监听子组件的beforeUnmount已经执行了,在组件自身注册的之前。
(兼容性代码跳过)
然后执行scope.stop方法,这里更加深了我们对scope的作用的猜想,其实我们可以稍微看下stop方法的代码
stop(fromParent?: boolean) {
if (this.active) {
let i, l
for (i = 0, l = this.effects.length; i < l; i++) {
this.effects[i].stop()
}
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]()
}
if (this.scopes) {
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true)
}
}
// nested scope, dereference from parent to avoid memory leaks
if (!this.detached && this.parent && !fromParent) {
// optimized O(1) removal
const last = this.parent.scopes!.pop()
if (last && last !== this) {
this.parent.scopes![this.index!] = last
last.index = this.index!
}
}
this.parent = undefined
this.active = false
}
}就是在stop所有effect。
接着停止update, 让它不再触发effect,停下调度(schedule)。然后就开始递归调用unmount卸载组件的子节点。
然后触发onUnmounted生命周期,此时已经是所有东西都卸载完了。
这个queuePostRenderEffect方法我们放到下面分析,量有些大,还涉及到vue任务调度相关。
如果Suspense包裹的组件自身有一个异步的依赖,那么它unmount的时候是会早于它异步依赖处理完毕的。那么这个时候就需要把这个异步依赖给处理掉。由于Suspense自身也对这个异步组件有依赖,所以如果仅仅只是处理组件自身那是没有用的,所以也需要把Suspense里的依赖去掉。如果此时Suspense自身已经没有依赖了,那可以直接切换状态了
具体可以看:Suspense | Vue.js (vuejs.org)
然后是开发环境将该组件从vue-devTools中移除。
devTools相关的我们就不分析了,以后有需要再回来分析。
queuePostRenderEffect#
queuePostRenderEffect:看名字就知道是一个render队列。
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
? queueEffectWithSuspense
: queuePostFlushCb
export function queueEffectWithSuspense(
fn: Function | Function[],
suspense: SuspenseBoundary | null
): void {
if (suspense && suspense.pendingBranch) {
if (isArray(fn)) {
suspense.effects.push(...fn)
} else {
suspense.effects.push(fn)
}
} else {
queuePostFlushCb(fn)
}
}如果是在Suspense组件包裹下的组件,注意,如果suspense不为null,那就说明注销不包括suspense自己。pendingBranch也就是我们传入插槽里的代码,当我们的异步组件还没准备好渲染的时候就是这个pendingBranch代替占位,当我们的异步组件准备完了之后,这个pendingBranch就会被替换成一个comment节点占位。
扯远了,回到我们的代码中,这里如果suspense以及它的pendingBranch存在的话,就将回调存放到suspense的effects里调度。
如果没有,就是老样子执行queuePostFlushCb。
queuePostFlushCb:也是一个队列
export function queuePostFlushCb(cb: SchedulerJobs) {
if (!isArray(cb)) {
if (
!activePostFlushCbs ||
!activePostFlushCbs.includes(
cb,
cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
)
) {
pendingPostFlushCbs.push(cb)
}
} else {
// if cb is an array, it is a component lifecycle hook which can only be
// triggered by a job, which is already deduped in the main queue, so
// we can skip duplicate check here to improve perf
pendingPostFlushCbs.push(...cb)
}
queueFlush()
}这里也是将回调注册到pendingPostFlushCbs里,这是一个调度队列。
queueFlush:
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}isFlushing和isFlushPending是两个状态,注意这俩状态是整个模块中共用的,前面分析的时候其实也遇到很多,比如hmrDirtyComponent,都是共用的。
前者表示正在处理调度中,后者表示正在等待进入处理调度。
resolvedPromise.then是用来异步处理的,需要保证执行的时间顺序上是正确的。
flushJobs:处理调度任务
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
if (__DEV__) {
seen = seen || new Map()
}
// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
queue.sort(comparator)
// conditional usage of checkRecursiveUpdate must be determined out of
// try ... catch block since Rollup by default de-optimizes treeshaking
// inside try-catch. This can leave all warning code unshaked. Although
// they would get eventually shaken by a minifier like terser, some minifiers
// would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610)
const check = __DEV__
? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job)
: NOOP
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
if (__DEV__ && check(job)) {
continue
}
// console.log(`running:`, job.id)
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0
flushPostFlushCbs(seen)
isFlushing = false
currentFlushPromise = null
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
const getId = (job: SchedulerJob): number =>
job.id == null ? Infinity : job.id
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}在开始清空调度任务之前,需要给这些任务进行排序处理。
queue是一个调度任务队列,那么这个队列和pendingPostFlushCbs又有什么不同呢?
实际上queue存放的是一些通过watch或者组件自身的update等传入的回调,而pendingPostFlushCbs则是在queue执行完毕之后才能执行的cb,所以它的名字带个cbs。
扯远了,queue的排序是通过id来排序的,但是如果任务自身pre为true并且另一个任务的pre不为true,那么它会更前一些。
那么为啥要排序呢?
有两点:1. 由于父组件的创建必然是早于子组件的,所以它的update一定要早于子组件的。2. 如果子组件正在注销,而此时父组件却在更新,那么这个时候就可以绕过子组件不用去更新子组件了。
然后是对递归调度任务的check,如果你这个任务是递归的,也就是会一直重复触发自己,那么当这个任务递归到100的时候直接给出warn,这个warn相信大家有遇到过,当你在使用computed的时候,如果你在监听数据的同时改动某个数据,并且这个数据和这个监听的数据有关联,那么就会一直重复触发这个computed。
这个时候就直接不再处理这个任务了。
callWithErrorHandling方法咱就不看代码了,相信大家也知道这个方法是干啥的,就是执行调度任务,然后将数据返回出来,如果这个过程中出了错,被catch到了,那么就传递出错误。
接着try ... catch执行完毕之后轮到finally了,这里已经清空了队列。
然后执行flushPostFlushCbs。
export function flushPostFlushCbs(seen?: CountMap) {
if (pendingPostFlushCbs.length) {
const deduped = [...new Set(pendingPostFlushCbs)]
pendingPostFlushCbs.length = 0
// #1947 already has active queue, nested flushPostFlushCbs call
if (activePostFlushCbs) {
activePostFlushCbs.push(...deduped)
return
}
activePostFlushCbs = deduped
if (__DEV__) {
seen = seen || new Map()
}
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
if (
__DEV__ &&
checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
) {
continue
}
activePostFlushCbs[postFlushIndex]()
}
activePostFlushCbs = null
postFlushIndex = 0
}
}先是合并重复的任务,然后如果此时activePostFlushCbs还有任务没处理完,那么直接就给加到activePostFlushCbs里,完事。如果已经处理完了,那么此时的activePostFlushCbs就会被替换成当前这个deduped队列。
同样是需要根据id来排序。因为也有先后顺序问题。
创建的时候是按顺序的从父到子组件创建,而注销的时候就应该是反过来的,从子到父组件。
为了验证这个问题,我们回到我们的demo里,创建一个t.vue文件,让它被test.vue引用,那么这个时候我们index.vue对test.vue的v-if也会作用于t.vue文件,然后我们给t.vue文件注册onUnmounted生命周期
<template>
<div>
</div>
</template>
<script setup>
import { onUnmounted } from "@vue/runtime-core";
onUnmounted(() => {
console.log('i am t.vue');
})
</script>
<style lang="scss" scoped>
</style>然后我们回到页面中触发

这里index.vue先触发是因为监听方式是@vue:的方式,所以自然就早于组件自身的onUnmounted。
当然,这里排序并不一定是这个问题,因为我们在unmountComponent的时候,将这个任务传入调度队列的时候,subTree已经unmount完成了,所以它的所有子组件注册onUnmounted的任务应该是早于当前组件自身的onUnmounted任务的。
扯远了,回到我们的flushPostFlushCbs方法中,直接就调用,也不需要有返回值。
那么这一块我们就分析完成了,这一块功能是任务调度相关的。
invokeDirectiveHook#
export function invokeDirectiveHook(
vnode: VNode,
prevVNode: VNode | null,
instance: ComponentInternalInstance | null,
name: keyof ObjectDirective
) {
const bindings = vnode.dirs!
const oldBindings = prevVNode && prevVNode.dirs!
for (let i = 0; i < bindings.length; i++) {
const binding = bindings[i]
if (oldBindings) {
binding.oldValue = oldBindings[i].value
}
let hook = binding.dir[name] as DirectiveHook | DirectiveHook[] | undefined
if (__COMPAT__ && !hook) {
hook = mapCompatDirectiveHook(name, binding.dir, instance)
}
if (hook) {
// disable tracking inside all lifecycle hooks
// since they can potentially be called inside effects.
pauseTracking()
callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
vnode.el,
binding,
vnode,
prevVNode
])
resetTracking()
}
}
}这个方法并不只用于注销阶段,和setRef一样,它们都是更新/注销复用的。
如果是更新阶段,那么它会把旧的节点的dir也就是指令的值赋值给新的节点的oldValue字段。而hook自然就是通过@vue:xx注册监听的生命周期。
pauseTracking:看名字就知道是用来停止track也就是跟踪的,这个track我们在编译阶段有说过,最后编译阶段总结的时候我们也有说到过,这是一种优化性能的方法,简单的说,一棵vnode tree就是一个block,这个block中只会跟踪这些非static的vnode,比如带有指令或者动态属性等,需要注意的是v-if/v-for自带一个block。这么做有什么好处呢?自然就是没有必要再去跟踪那些static的节点了,能省下的diff时间比例随着你的static vnode增加而增加,我们一般管这叫做Tree Pattern,也就是树扁平化。具体可以看:Rendering Mechanism | Vue.js (vuejs.org)。扯远了回到我们的代码中,我们来看下parseTracking方法。
export let shouldTrack = true
const trackStack: boolean[] = []
export function pauseTracking() {
trackStack.push(shouldTrack)
shouldTrack = false
}
export function enableTracking() {
trackStack.push(shouldTrack)
shouldTrack = true
}
export function resetTracking() {
const last = trackStack.pop()
shouldTrack = last === undefined ? true : last
}它实际上就是一个栈,栈可以让我们保证在嵌套关系的扁平化这个过程中排序的正常,之前compiler-core对于template的parse也就是解析核心就是有用到这个栈,感兴趣的可以去看我之前的文章。
将当前的跟踪状态放入shouldTrack这个栈里面,然后将当前shouldTrack的状态置为false。
而resetTracking则是将stackState从栈中拿出来,重新赋值回来。
而callbackWithAsyncErrorHandling方法我们前面说过了,这里就不多说了。
所以这里在执行监听dom的生命周期的时候是需要停止trakcing的。
总结#
今天分析的内容主要就是unmount的过程,和我们patch里的diff关系比较少,但是unmount也是patch里重要的一环,所以也是需要分析的。
发布于 2023-03-02 21:16・IP 属地广东
