即上一篇文章中研究了watchEffect中传入异步函数的问题,我又在群里看了到另一个类似的问题,故写一篇文章记录一下。
发现问题
我简单写一个demo,子组件在异步函数内注册了一个watch,监听了一个外部的变量,当子组件卸载掉时候,这个watch依旧存在。demo里监听次数在子组件卸载之后,watch仍然在执行,这不就相当于内存泄露了嘛!
搜索问题
在我搜索的过程中,我发现了vue官网上这样一条eslint
eslint.vuejs.org/rules/no-wa…
它上面展示的错误代码简直和我这个一模一样,也就是说vue官方是知道这个问题的。
但是并没有彻底解决,只给出一个eslint提示规则,但是还有一个条件,如果主动停止监听也不会触发这条规则。
因此,我可以这样理解vue官方给出两个解决办法:1、不要这样写,2、主动结束监听
我们把被监听的变量打印一下看看。
可以看见,我们watch就是从dep中新增了一个依赖,同时也会产生一个cleanup函数,用于清理dep,在我的代码里,子组件卸载之后,dep没有被清理,所以导致watch一直存在。
从源码中找原因
在我的固有概念里,当组件卸载之后,组件内部的数据,副作用都应该清空。那么我们就现在看看组件卸载时候的代码
我们打开vue3源码中的packages/runtime-core/src/renderer.ts
大概在2095行,unmount
函数。
unmount
函数
const unmount: UnmountFn = (
vnode,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false,
) => {
const {
type,
props,
ref,
children,
dynamicChildren,
shapeFlag,
patchFlag,
dirs,
} = vnode
// 重置template里的ref
if (ref != null) {
setRef(ref, null, parentSuspense, vnode, true)
}
// 如果存在keepAlive
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
}
// 调起自定义指令的beforeUnmount
if (shouldInvokeDirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
}
if (shapeFlag & ShapeFlags.TELEPORT) {
//处理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 (
// 处理v-for的子节点
(type === Fragment &&
patchFlag &
(PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
(!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
) {
unmountChildren(children as VNode[], parentComponent, parentSuspense)
}
// 应该是删除dom?
if (doRemove) {
remove(vnode)
}
}
// 卸载完成,调用生命周期勾子
if (
(shouldInvokeVnodeHook &&
(vnodeHook = props && props.onVnodeUnmounted)) ||
shouldInvokeDirs
) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
shouldInvokeDirs &&
invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
}, parentSuspense)
}
}
unmountComponent
函数
咱们继续深入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)
}
// 这里应该是兼容vue2的写法
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeDestroy')
}
// stop effects in component scope
// 核心代码:这里有一条提交记录 feat(reactivity): new effectScope API ([#2195](https://github.com/vuejs/core/pull/2195))
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 勾子
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()
}
}
// devtools里删除这个组件
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentRemoved(instance)
}
}
从上面的代码可以看出,基本都是这触发生命周期勾子,真正有关的代码也就那句scope.stop()
,同时旁边还有一个commit,发现这里使用了一个新的API:effect Scope,文档在这,我就不赘述了。github.com/vuejs/rfcs/…
effect Scope
这个API其实属于Reactivity,因此你可以在packages/reactivity/src/effectScope.ts
找到。我们主要关注run这个函数。
let activeEffectScope: EffectScope | undefined
export class EffectScope {
private _active = true
effects: ReactiveEffect[] = []
cleanups: (() => void)[] = []
parent: EffectScope | undefined
scopes: EffectScope[] | undefined
private index: number | undefined
constructor(public detached = false) {
this.parent = activeEffectScope
if (!detached && activeEffectScope) {
this.index =
(activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
this,
) - 1
}
}
// 执行当前scope内的回调
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
try {
// 这里依旧是同步的保存依赖,所以如果你在微任务里,就有可能丢失依赖
activeEffectScope = this
return fn()
} finally {
activeEffectScope = currentEffectScope
}
} else if (__DEV__) {
warn(`cannot run an inactive effect scope.`)
}
}
}
很明显,run里面收集activeEffectScope
和activeEffect
几乎一模一样,都是同步的保存。因此,当你执行的时候,前面有微任务阻塞,或者本身就放在微任务里,就又可能出现对应不上的问题。
解决办法
如果你在异步函数中声明了watch,应当手动取消监听。
(async ()=> {
await Promise.resolve()
const stop = watch(subCount, (newVal) => {
watchCount.value = newVal
console.log('watch in sub run one time')
})
onUnmount(stop)
})()
原文链接:https://juejin.cn/post/7338645701659017253 作者:水煮鱼写前端