复制代码<script setup lang="ts">
import { IPreviewBaseProps } from "@/components/FilePreview/interface";
interface IProps extends IPreviewBaseProps {
abortController: AbortController;
}const props = withDefaults(defineProps<IProps>(), {
size: 0
});/**
* blob转xml
*/
const blobToXml = (blob: Blob) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
// 拿到完整xml文本
const xmlStr = e.target.result;
resolve(xmlStr);
};
reader.onerror = error => {
reject(error);
};
reader.readAsText(blob, "utf-8");
});
};const loading = ref(false);
const parsedXml = ref(null);
const previewXmlRef = ref(null);
const initData = async () => {
loading.value = true;
try {
await nextTick();
const start = 0;
const end = props.size;
const res = await props.request({ id: props.params, start, end, signal: props.abortController.signal });
parsedXml.value = await blobToXml(res.data);
} catch (err) {
console.error("err", err);
} finally {
loading.value = false;
}
};onMounted(() => {
initData();
});onBeforeUnmount(async () => {
// ***关键成因***
if (props.abortController) {
props.abortController.abort();
}
});
</script><template>
<div class="preview-text" v-loading="loading" element-loading-text="loading..."></div>
</template><style scoped lang="scss">
</style>

复制代码onBeforeUnmount(async () => {
// loading.value增加条件,在需要调用时候在调用
if (loading.value && props.abortController) {
await nextTick();
props.abortController.abort();
}
});
可以把它想成:关抽屉时,你非要再撕一张“取消条”贴在已经办完的材料上;这张纸条上还带着“当时办公室谁在场”的名单,结果整间办公室的人都被这张纸条勾住、走不了。
XML 预览弹框关掉、点强制回收垃圾之后,内存快照里还能看到:
Detached 的 el-dialog / preview-text)AbortController → AbortSignal → DOMException → ErrorStackData → Vue 的 scope看起来像“弹框关了东西还在”,所以叫内存泄漏(至少是关不干净、回收不掉)。
abort() 造出来的“错误对象”AbortController.abort() 不只是“告诉请求停一下”,浏览器还会:
DOMException(AbortError)signal.reason 上ErrorStackData)调用栈可以粗暴理解成:“是谁、在哪段代码路径上喊停的”的现场记录。
在 Chrome 里,这段记录有时会顺手抓住当时还活着的 Vue 组件“内部工作区”(effect scope / 闭包),而组件又抓着自己的 DOM。
所以:
很多人直觉会想:
因为:
catch,只说明“有没有一次失败的 Promise”abort(),是另一件事请求成功后你再关弹框,如果卸载时仍无脑:
复制代码props.abortController.abort()
此时:
abort() 照样会新建 AbortError 并挂到 signal 上一句话:
简化关系:
复制代码AbortController(取消器)
↓
AbortError(abort 时创建,带调用栈)
↓
调用栈勾住 Vue 组件内部状态
↓
组件还能指回 AbortController(props)
↓
组件的 DOM(弹框内容)——页面上看不见了,但 JS 还以为有人在用
只要外面还有人握着这个 AbortController(哪怕只是一时、或工具/原生侧还挂着 signal),整圈都丢不掉 → 快照里就一直是 Detached 弹框。
await nextTick() 曾经“看起来能修好”?Vue 不会等异步的 onBeforeUnmount 跑完再拆组件。
nextTick 再 abort:组件先拆完,再喊停 → 调用栈不怎么沾那片 Vue 现场 → 看起来“好了”所以 nextTick 是运气型的时间差,不是清晰的所有权设计。真正干净的是:没必要时不要 abort。
onErrorCaptured 抓不到?那是两套系统:
| AbortError 挂在 signal 上 | Vue 的 onErrorCaptured | |
|---|---|---|
| 像什么 | 取消器上贴了张纸条 | 组件树报警器 |
| 要不要“抛给 Vue” | 不要 | 要 |
abort() 主要走 Signal / 请求 Promise,不是 Vue 组件异常冒泡。
请求失败进了你自己的 try/catch 被吞掉,Vue 更是无事可捕。
所以:堆里有 ErrorStackData,不等于组件报错了。
复制代码if (loading.value && props.abortController) {
props.abortController.abort();
}
含义很直白:
abort()这就同时满足了:
造成这次问题的原因:
关 XML 弹框时(尤其在请求已成功之后)仍然调用 abort(),浏览器创建了带调用栈的 AbortError,并挂在 AbortController 上;调用栈通过 Vue 组件内部状态勾住了本应销毁的实例和 DOM,形成回收不掉的 Detached 节点。
它和“有没有进 catch”无关,本质是关抽屉时多余撕了一张会勾人的取消条。
有效做法: 只在仍在 loading、确实需要取消请求时 abort;请求已成功则直接拆弹框即可。