一次 Vue 3 组件卸载与异步 watch 导致的三维场景资源残留问题排查

Vue 3 页面返回时温度场无法清除的问题分析与解决思路

一、问题现象

在页面中开启温度场后,点击返回按钮,前几次通常可以正常清除温度场。但是当用户多次进入温度场页面、开启温度场并返回之后,后续会出现温度场无法关闭、无法清除的问题。

具体表现为:

用户点击返回离开页面后,页面本身已经正常退出,但三维场景中的温度场仍然残留在引擎中。再次进入页面或再次操作时,温度场状态可能与页面 UI 状态不一致,造成"页面认为已经关闭,但场景中仍然显示"的异常。

这个问题不是单纯的接口调用失败,而是页面状态、Vue 组件生命周期、watch 执行时机以及场景资源清理逻辑之间产生了竞态条件。

二、相关逻辑

当前温度场的显示和隐藏主要依赖 showTempField 这个响应式变量。

showTempField.valuetrue 时,向引擎发送创建温度场事件:

复制代码
TemperatureFieldXZ-Create

showTempField.valuefalse 时,向引擎发送隐藏温度场事件:

复制代码
TemperatureFieldXZ-Hide

这个逻辑通常通过 watch 监听 showTempField 的变化实现。

与此同时,页面返回时会执行页面重置逻辑,例如在 usePageBackReset 中将页面状态重置,使 showTempField.value 变成 false。理论上,这应该触发 watch,然后发送隐藏温度场事件。

但是在实际返回过程中,组件可能很快进入卸载阶段,导致 watch 回调没有稳定执行,最终隐藏事件没有发送出去。

三、问题原因

该问题的核心原因是:组件卸载时依赖了已经被外部重置过的响应式状态,导致场景资源清理判断失效。

返回页面时,执行顺序大致如下:

  1. 用户点击返回按钮。

  2. 页面返回逻辑开始执行。

  3. usePageBackReset 重置页面状态。

  4. showTempField.value 被设置为 false

  5. 理论上 watch 应该监听到变化并发送 TemperatureFieldXZ-Hide

  6. 但是组件同时进入路由切换和卸载流程。

  7. Vue 在组件卸载时会清理尚未执行或未完成的响应式副作用。

  8. watch 中的隐藏逻辑可能来不及执行。

  9. onBeforeUnmount 开始执行兜底清理。

  10. 但此时 showTempField.value 已经是 false

  11. 清理逻辑判断当前温度场不是激活状态,于是跳过隐藏事件。

  12. 最终 TemperatureFieldXZ-Hide 没有发送,温度场残留在引擎中。

这就导致一个结果:

页面状态已经变成 false,但引擎里的温度场资源仍然存在。

也就是说,UI 状态和引擎资源状态发生了不一致。

四、为什么前几次可能正常,后面才出现问题

这个问题具有一定的偶发性和路径依赖。

在某些情况下,如果用户在页面内有过交互,返回逻辑可能会先执行软重置,例如刷新当前组件、重置部分状态,然后再真正离开页面。在这种路径下,watch 有机会执行完成,因此隐藏事件能够正常发送。

但是在另一种情况下,用户直接返回离开页面,组件很快被销毁,watch 回调可能还没有真正完成,组件就已经进入卸载流程。此时如果卸载清理又依赖 showTempField.value,就会因为这个值已经提前被重置为 false,从而误判为"不需要清理"。

所以这个 Bug 并不是每次都会稳定复现,而是在多次进入、退出、状态切换之后更容易暴露出来。

五、问题本质

这个问题的本质是:不应该用页面 UI 状态来判断引擎资源是否需要清理。

showTempField 表示的是页面上的开关状态,它是一个 UI 层状态。它可能会因为页面重置、路由返回、组件刷新等原因被提前修改。

但是引擎中的温度场是否已经创建、是否需要销毁,是场景资源状态。这个状态不应该完全依赖外部传入的响应式变量。

换句话说:

复制代码
showTempField.value === false

只能说明页面当前希望温度场是关闭的,并不能说明引擎里的温度场已经真的被关闭。

因此,如果 onBeforeUnmount 仍然使用 unref(active)showTempField.value 来判断是否需要清理,就存在误判风险。

六、解决思路

更合理的方案是在封装温度场资源控制的 hook 内部维护一个独立的资源状态,例如:

复制代码
let resourceActive = false

这个变量不表示 UI 是否选中,也不表示用户是否希望显示温度场,而是表示:

当前 hook 是否已经向引擎发送过 Create,并且还没有成功发送 Hide。

推荐的状态维护逻辑如下:

当发送创建事件成功后:

复制代码
resourceActive = true

当发送隐藏事件成功后:

复制代码
resourceActive = false

组件卸载时,不再判断外部传入的 active,而是判断内部的 resourceActive

复制代码
onBeforeUnmount(() => {
  if (leaveOnUnmount && resourceActive) {
    leaveInner()
  }
})

这样即使页面返回时 showTempField.value 已经被提前重置成 false,hook 内部仍然知道自己曾经创建过温度场资源,并且还没有完成清理,于是可以在组件卸载时补发隐藏事件。

七、优化后的设计原则

优化后的设计重点是将 UI 状态和资源状态分离。

showTempField 负责表达用户界面上的状态:

复制代码
用户是否勾选了显示温度场

resourceActive 负责表达引擎资源的真实状态:

复制代码
引擎中是否仍然存在由当前 hook 创建的温度场资源

两者不能简单等价。

页面状态可以被重置,但资源状态必须由资源创建和销毁动作来维护。只有这样,才能避免组件卸载、路由跳转、异步 watch、任务队列之间产生竞态时出现资源泄漏。

八、最终结论

这个问题不是温度场接口本身的问题,而是 Vue 组件生命周期和异步 watch 机制下的资源清理竞态问题。

原来的逻辑在组件卸载时依赖 showTempField 判断是否需要清理温度场,但 showTempField 在页面返回流程中可能已经被提前重置为 false。这会导致卸载清理逻辑误以为温度场已经关闭,从而跳过隐藏事件。

解决这个问题的关键是:在资源 hook 内部维护真实的资源激活状态,不再依赖外部 UI 状态进行卸载清理判断。

也就是说,页面状态负责控制显示意图,资源状态负责保证引擎清理。只有把这两类状态分开,才能彻底解决多次进入和返回后温度场残留的问题。