震惊,这是 Vue3 的 Bug 么?
最近在用Vue3
开发项目时,封装了一些hook
,在某些hook
中会执行一些清除的操作,比如: 清除定时器、解绑事件等,这些清除的操作我一般会放在onScopeDispose
中去执行,不与组件中的onUnmounted
耦合,这也是官网推荐我们这么做的
但是如果在onScopeDispose
中再次操作响应式更新,Vue 会提示超出最大更新次数
接下来,我们使用最小化代码复现这个问题
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
内部针对热更新做了特殊处理,代码如下
2. 问题排查
出现了此问题后,去查了下 Vue
的相关 issues
,发现有其他人提出过这个问题
并且有大佬还提了 PR
去修复这个问题,这个 PR
提的时间有将近半年了,不过还没有被合进去,在看某个复现链接时,发现可以用 onUnmounted
去代替 onScopeDispose
,于是我试了一下,发现确实可以暂时解决这个问题,带着好奇心去拜读了一下 Vue
的源码
3. 源码定位
3.1 onScopeDispose 的原理
先是看了 onScopeDispose
这个 Api
的实现原理,文件路径在 packages/reactivity/src/effectScope.ts
,代码如下:
我们看完此代码,知道的是 onScopeDispose
中的回调函数会被放在 activeEffectScope
这个变量的 cleanups
数组中
接着我们在去找 activeEffectScope
的定义,还是在相同的文件路径,代码如下:
我们从中能读取到2
个有效信息
activeEffectScope
的类型是EffectScope
这个类的实例activeEffectScope
是在某处调用EffectScope.on
方法进行的赋值
知道了这些信息,我们先看 EffectScope
是在哪里被实例化的,发现是在创建组件实例时被实例化的,文件路径是在 packages/runtime-core/src/component.ts
,代码如下:
接着看哪里调用了 instance.scope.on
方法,发现在 setCurrentInstance
方法中,文件路径同上,代码如下:
那我们就继续看 setCurrentInstance
方法在哪里被调用,发现是在调用组件的 setup
函数前调用的,文件路径同上,代码如下:
大功告成了一半,此时我们来小总结一下:
onScopeDispose
函数的作用是将我们的回调函数存放在activeEffectScope
这个变量的cleanups
数组中activeEffectScope
这个变量是EffectScope
的实例,我们暂且叫它scope
- 每个
vue
组件都有一个实例,这个实例上有一个scope
属性,这个属性就是EffectScope
的实例 - 在组件初始化时,会创建一个组件实例,然后调用
setCurrentInstance
方法将activeEffectScope
这个变量赋值为scope
我们现在已经分析完了 onScopeDispose
的原理,知道其是将回调函数存放到一个数组中,那么我们就要看这个数组是什么时候被执行,既然官网说了能作为 onUnmounted
的代替品,我们直接就将接下来的重点放在组件被卸载时做了什么
3.2 组件被卸载时
组件被卸载是通过 unmountComponent
这个函数实现,文件路径在 packages/runtime-core/src/renderer.ts
中,代码如下:
这个 scope
就是 EffectScope
的实例,会调用一个 stop
方法,我们看看 stop
方法做了什么,代码如下:
这个方法中执行了我们在 onScopeDispose
中存放的回调函数,那么问题来了,为什么会超出最大更新呢?
我们知道 Vue
的响应式原理是通过 发布订阅
来实现的,利用了 proxy
,读取属性收集依赖(trackEffect),属性变更时通知更新(triggerEffect),而 Vue
的内部是异步更新的,更新时会调用自定义的 scheduler
,然后将更新的函数通过微任务去执行,达到异步更新的作用
更新时会调用我圈出来的函数,而该函数是将组件的更新函数
放入到一个队列中,利用promise.then
去执行,这里不做过多展开,有兴趣的小伙伴可以自行去查看源码
3.3 onScopeDispose + 热更新导致无限更新
- 首先热更新时
Vue
内部会进行强制刷新,也就是说会将原来的组件卸载掉,然后重新挂载 - 在执行组件卸载时,会调用
onScopeDispose
中的回调函数,如果我们在回调函数中操作了响应式状态,会通知组件去更新(triggerEffect),在更新时会调用组件自定义的scheduler
函数,将更新事件推入到一个队列中,异步的去执行 - 卸载执行完毕后,会重新挂载组件,此时会重新执行我们的
setup
函数,又将onScopeDispose
中的回调函数存入到activeEffectScope.cleanups
数组中 - 组件挂载完毕后,此时我们推入到队列中的异步任务被执行,所以就再次调用了
patch
函数,又进行卸载流程了,所以陷入了无限更新
3.4 为什么 onUnmounted + 热更新不会导致无限更新
实际上是因为onUnmounted
中的回调函数是异步去执行,而我们的onScopeDispose
中的回调函数是同步执行的,这里我贴上代码
那为什么异步的去执行回调函数不会导致无限更新呢?
那是因为等到onUnmounted
中的回调函数执行时,响应式数据依赖的effects
都已经被清空掉了,所以在执行triggerEffect
时找不到响应式数据的依赖,自然就不会去执行effect
的scheduler
函数了
4. 解决 onScopeDispose + 热更新导致无限更新的手段
- 简单粗暴,可以使用
onUnmounted
去代替onScopeDispose
- 将
onScopeDispose
中的回调函数包裹一层nextTick
即可,代码如下 - 在
onScopeDispose
源码层面利用高阶函数去包裹一层,在执行回调函数前停止依赖收集,可以参考提PR
的大佬代码如何去修复的
5. 利用 cleanups 实现自动清理
可以参考vueuse/core/useEventBus
的代码来实现事件的自动清理
当然vueuse/core/useEventListener
的代码也是类似实现
如有分析错误之处,请指正,谢谢!
转载自:https://juejin.cn/post/7264078722758967351