继续深入vue3源码中的watch的异步问题

即上一篇文章中研究了watchEffect中传入异步函数的问题,我又在群里看了到另一个类似的问题,故写一篇文章记录一下。

发现问题

我简单写一个demo,子组件在异步函数内注册了一个watch,监听了一个外部的变量,当子组件卸载掉时候,这个watch依旧存在。demo里监听次数在子组件卸载之后,watch仍然在执行,这不就相当于内存泄露了嘛!

搜索问题

在我搜索的过程中,我发现了vue官网上这样一条eslint
eslint.vuejs.org/rules/no-wa…
它上面展示的错误代码简直和我这个一模一样,也就是说vue官方是知道这个问题的。

但是并没有彻底解决,只给出一个eslint提示规则,但是还有一个条件,如果主动停止监听也不会触发这条规则。
因此,我可以这样理解vue官方给出两个解决办法:1、不要这样写,2、主动结束监听

我们把被监听的变量打印一下看看。

继续深入vue3源码中的watch的异步问题

可以看见,我们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里面收集activeEffectScopeactiveEffect几乎一模一样,都是同步的保存。因此,当你执行的时候,前面有微任务阻塞,或者本身就放在微任务里,就又可能出现对应不上的问题。

解决办法

如果你在异步函数中声明了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 作者:水煮鱼写前端

(0)
上一篇 2024年2月23日 上午11:13
下一篇 2024年2月24日 上午10:06

相关推荐

发表评论

登录后才能评论