震惊,这是 Vue3 的 Bug 么?

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

image.png

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

updates.png

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

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 内部针对热更新做了特殊处理,代码如下

isSameVnodeType.png

2. 问题排查

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

issues.png

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

3. 源码定位

3.1 onScopeDispose 的原理

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

onScopeDispose.png

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

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

activeEffectScope.png

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

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

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

componentInstance.png

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

setCurrentInstance.png

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

callSetup.png

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

  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 中,代码如下:

unmountComponent.png

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

effectScopeStop.png

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

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

scheduler.png

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

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

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

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

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

onUnmounted.png

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

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

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

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

5. 利用 cleanups 实现自动清理

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

useEventBus.png

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

useEventListener.png

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

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYV661iU' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片