likes
comments
collection
share

震惊,这是 Vue3 的 Bug 么?

作者站长头像
站长
· 阅读数 55

最近在用Vue3开发项目时,封装了一些hook,在某些hook中会执行一些清除的操作,比如: 清除定时器、解绑事件等,这些清除的操作我一般会放在onScopeDispose中去执行,不与组件中的onUnmounted耦合,这也是官网推荐我们这么做的

震惊,这是 Vue3 的 Bug 么?

但是如果在onScopeDispose中再次操作响应式更新,Vue 会提示超出最大更新次数

震惊,这是 Vue3 的 Bug 么?

接下来,我们使用最小化代码复现这个问题

1. 问题复现

<script setup lang="ts">
import { ref, onScopeDispose } from 'vue'
const count = ref(0)

console.log('Write some code and wait for the hot update')
onScopeDispose(() => {
  count.value++
})
</script>

<template>
  <button type="button" @click="count++">count is {{ count }}</button>
</template>

我们写了一个组件,暂且将这个组件命名为 Demo 组件吧,因为组件还没有被卸载,所以不会触发 onScopeDispose 中的回调函数执行

我们可以随便在 Demo 组件中写一些代码,然后等待热更新,热更新时会拉取一份新的Demo 组件,然后交由 Vue 去更新,更新前会卸载原来的 Demo 组件,此时会执行 onScopeDispose 中的回调函数,然后就会提示超出最大更新次数。Vue 内部针对热更新做了特殊处理,代码如下

震惊,这是 Vue3 的 Bug 么?

2. 问题排查

出现了此问题后,去查了下 Vue 的相关 issues,发现有其他人提出过这个问题

震惊,这是 Vue3 的 Bug 么?

并且有大佬还提了 PR 去修复这个问题,这个 PR 提的时间有将近半年了,不过还没有被合进去,在看某个复现链接时,发现可以用 onUnmounted 去代替 onScopeDispose,于是我试了一下,发现确实可以暂时解决这个问题,带着好奇心去拜读了一下 Vue 的源码

3. 源码定位

3.1 onScopeDispose 的原理

先是看了 onScopeDispose 这个 Api 的实现原理,文件路径在 packages/reactivity/src/effectScope.ts,代码如下:

震惊,这是 Vue3 的 Bug 么?

我们看完此代码,知道的是 onScopeDispose 中的回调函数会被放在 activeEffectScope 这个变量的 cleanups 数组中

接着我们在去找 activeEffectScope 的定义,还是在相同的文件路径,代码如下:

震惊,这是 Vue3 的 Bug 么?

我们从中能读取到2个有效信息

  1. activeEffectScope 的类型是 EffectScope 这个类的实例
  2. activeEffectScope 是在某处调用 EffectScope.on 方法进行的赋值

知道了这些信息,我们先看 EffectScope 是在哪里被实例化的,发现是在创建组件实例时被实例化的,文件路径是在 packages/runtime-core/src/component.ts,代码如下:

震惊,这是 Vue3 的 Bug 么?

接着看哪里调用了 instance.scope.on 方法,发现在 setCurrentInstance 方法中,文件路径同上,代码如下:

震惊,这是 Vue3 的 Bug 么?

那我们就继续看 setCurrentInstance 方法在哪里被调用,发现是在调用组件的 setup 函数前调用的,文件路径同上,代码如下:

震惊,这是 Vue3 的 Bug 么?

大功告成了一半,此时我们来小总结一下:

  1. onScopeDispose 函数的作用是将我们的回调函数存放在 activeEffectScope 这个变量的 cleanups 数组中
  2. activeEffectScope 这个变量是 EffectScope 的实例,我们暂且叫它 scope
  3. 每个 vue 组件都有一个实例,这个实例上有一个 scope 属性,这个属性就是 EffectScope 的实例
  4. 在组件初始化时,会创建一个组件实例,然后调用 setCurrentInstance 方法将 activeEffectScope 这个变量赋值为 scope

我们现在已经分析完了 onScopeDispose 的原理,知道其是将回调函数存放到一个数组中,那么我们就要看这个数组是什么时候被执行,既然官网说了能作为 onUnmounted 的代替品,我们直接就将接下来的重点放在组件被卸载时做了什么

3.2 组件被卸载时

组件被卸载是通过 unmountComponent 这个函数实现,文件路径在 packages/runtime-core/src/renderer.ts 中,代码如下:

震惊,这是 Vue3 的 Bug 么?

这个 scope 就是 EffectScope 的实例,会调用一个 stop 方法,我们看看 stop 方法做了什么,代码如下:

震惊,这是 Vue3 的 Bug 么?

这个方法中执行了我们在 onScopeDispose 中存放的回调函数,那么问题来了,为什么会超出最大更新呢?

我们知道 Vue 的响应式原理是通过 发布订阅 来实现的,利用了 proxy,读取属性收集依赖(trackEffect),属性变更时通知更新(triggerEffect),而 Vue 的内部是异步更新的,更新时会调用自定义的 scheduler,然后将更新的函数通过微任务去执行,达到异步更新的作用

震惊,这是 Vue3 的 Bug 么?

更新时会调用我圈出来的函数,而该函数是将组件的更新函数放入到一个队列中,利用promise.then去执行,这里不做过多展开,有兴趣的小伙伴可以自行去查看源码

3.3 onScopeDispose + 热更新导致无限更新

  1. 首先热更新时Vue内部会进行强制刷新,也就是说会将原来的组件卸载掉,然后重新挂载
  2. 在执行组件卸载时,会调用onScopeDispose中的回调函数,如果我们在回调函数中操作了响应式状态,会通知组件去更新(triggerEffect),在更新时会调用组件自定义的 scheduler 函数,将更新事件推入到一个队列中,异步的去执行
  3. 卸载执行完毕后,会重新挂载组件,此时会重新执行我们的setup函数,又将onScopeDispose中的回调函数存入到activeEffectScope.cleanups 数组中
  4. 组件挂载完毕后,此时我们推入到队列中的异步任务被执行,所以就再次调用了 patch 函数,又进行卸载流程了,所以陷入了无限更新

3.4 为什么 onUnmounted + 热更新不会导致无限更新

实际上是因为onUnmounted中的回调函数是异步去执行,而我们的onScopeDispose中的回调函数是同步执行的,这里我贴上代码

震惊,这是 Vue3 的 Bug 么?

那为什么异步的去执行回调函数不会导致无限更新呢?

那是因为等到onUnmounted中的回调函数执行时,响应式数据依赖的effects都已经被清空掉了,所以在执行triggerEffect时找不到响应式数据的依赖,自然就不会去执行effectscheduler函数了

4. 解决 onScopeDispose + 热更新导致无限更新的手段

  1. 简单粗暴,可以使用 onUnmounted 去代替 onScopeDispose
  2. onScopeDispose 中的回调函数包裹一层 nextTick 即可,代码如下 震惊,这是 Vue3 的 Bug 么?
  3. onScopeDispose 源码层面利用高阶函数去包裹一层,在执行回调函数前停止依赖收集,可以参考提 PR 的大佬代码如何去修复的 震惊,这是 Vue3 的 Bug 么?

5. 利用 cleanups 实现自动清理

可以参考vueuse/core/useEventBus的代码来实现事件的自动清理

震惊,这是 Vue3 的 Bug 么?

当然vueuse/core/useEventListener的代码也是类似实现

震惊,这是 Vue3 的 Bug 么?

如有分析错误之处,请指正,谢谢!

转载自:https://juejin.cn/post/7264078722758967351
评论
请登录