From 4a15da21083a30d2590a03fe68c488c71ec44eb2 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 16 Jul 2026 19:35:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(editor):=20=E4=BF=AE=E5=A4=8D=20Monaco=20?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=8D=B8=E8=BD=BD=E6=97=B6=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E5=A4=B1=E6=95=88=E4=B8=8E=20Canceled=20=E6=9C=AA?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor/src/layouts/CodeEditor.vue | 40 +++++++++++++++---- packages/editor/src/utils/monaco-editor.ts | 27 +++++++++++++ .../tests/unit/layouts/CodeEditor.spec.ts | 36 +++++++++++++++++ 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/layouts/CodeEditor.vue b/packages/editor/src/layouts/CodeEditor.vue index 7a954dc9..64e01bbd 100644 --- a/packages/editor/src/layouts/CodeEditor.vue +++ b/packages/editor/src/layouts/CodeEditor.vue @@ -194,6 +194,9 @@ const values = ref(''); const loading = ref(false); const codeEditorEl = useTemplateRef('codeEditor'); +// 组件是否已卸载,避免异步初始化过程中组件被销毁后继续创建编辑器 +let destroyed = false; + const resizeObserver = new globalThis.ResizeObserver( throttle((): void => { vsEditor?.layout(); @@ -254,11 +257,18 @@ const handleKeyDown = (e: KeyboardEvent) => { } }; -const init = async () => { - if (!codeEditorEl.value) return; +// 判断容器是否仍然有效:组件未卸载、ref 存在且已挂载到文档树中 +// Monaco 在创建编辑器时会向上遍历 parentNode 判断是否处于 shadow DOM, +// 若容器为 null 或已从文档树脱离,将抛出 "Cannot read properties of null (reading 'parentNode')" +const isEditorElValid = () => !destroyed && !!codeEditorEl.value && codeEditorEl.value.isConnected; - if (codeEditorEl.value.clientHeight === 0) { +const init = async () => { + if (!isEditorElValid()) return; + + if (codeEditorEl.value!.clientHeight === 0) { await nextTick(); + // await 期间组件可能已被卸载或容器被移除 + if (!isEditorElValid()) return; } // 重置缓存的额外高度,因为编辑器重新初始化 @@ -266,6 +276,11 @@ const init = async () => { monaco = await loadMonaco(); + // 加载 monaco 是异步过程,期间组件可能已被卸载或容器被移除 + if (!isEditorElValid()) return; + + const editorEl = codeEditorEl.value!; + const options = { value: values.value, language: props.language, @@ -275,7 +290,7 @@ const init = async () => { }; if (props.type === 'diff') { - vsDiffEditor = await getEditorConfig('customCreateMonacoDiffEditor')(monaco!, codeEditorEl.value, options); + vsDiffEditor = await getEditorConfig('customCreateMonacoDiffEditor')(monaco!, editorEl, options); // 监听diff编辑器内容变化 vsDiffEditor.getModifiedEditor().onDidChangeModelContent(() => { @@ -285,7 +300,7 @@ const init = async () => { } }); } else { - vsEditor = await getEditorConfig('customCreateMonacoEditor')(monaco!, codeEditorEl.value, options); + vsEditor = await getEditorConfig('customCreateMonacoEditor')(monaco!, editorEl, options); // 监听编辑器内容变化 vsEditor.onDidChangeModelContent(() => { @@ -296,10 +311,19 @@ const init = async () => { }); } + // 编辑器创建可能是异步的,创建完成后若组件已卸载则立即销毁,避免内存泄漏 + if (destroyed) { + vsEditor?.dispose(); + vsDiffEditor?.dispose(); + vsEditor = null; + vsDiffEditor = null; + return; + } + setEditorValue(props.initValues, props.modifiedValues); emit('initd', vsEditor); - codeEditorEl.value.addEventListener('keydown', handleKeyDown); + editorEl.addEventListener('keydown', handleKeyDown); if (props.type !== 'diff' && props.autoSave) { vsEditor?.onDidBlurEditorWidget(() => { @@ -311,7 +335,7 @@ const init = async () => { }); } - resizeObserver.observe(codeEditorEl.value); + resizeObserver.observe(editorEl); }; watch( @@ -362,6 +386,8 @@ onMounted(async () => { }); onBeforeUnmount(() => { + destroyed = true; + resizeObserver.disconnect(); vsEditor?.dispose(); diff --git a/packages/editor/src/utils/monaco-editor.ts b/packages/editor/src/utils/monaco-editor.ts index d2748044..78e71c1a 100644 --- a/packages/editor/src/utils/monaco-editor.ts +++ b/packages/editor/src/utils/monaco-editor.ts @@ -1,7 +1,34 @@ let cached: Promise | undefined; +// 是否为 Monaco 内部抛出的取消错误(Canceled) +// 销毁编辑器时,WordHighlighter 内部挂起的 Delayer 会被取消, +// 取消时 reject 出的 Canceled 错误没有 catch,会变成 "Uncaught (in promise) Canceled", +// 该错误本身无害,这里做一次性、全局的兜底处理,仅吞掉这类取消错误。 +const isMonacoCanceledError = (reason: any): boolean => { + if (!reason) return false; + if (reason instanceof Error) { + return reason.name === 'Canceled' || reason.message === 'Canceled'; + } + return reason === 'Canceled'; +}; + +let canceledRejectionHandlerInstalled = false; +const installCanceledRejectionHandler = () => { + if (canceledRejectionHandlerInstalled || typeof globalThis.addEventListener !== 'function') return; + canceledRejectionHandlerInstalled = true; + + globalThis.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + if (isMonacoCanceledError(event.reason)) { + // 阻止其冒泡到控制台,避免无意义的报错噪音 + event.preventDefault(); + } + }); +}; + export default () => { if (!cached) { + installCanceledRejectionHandler(); + cached = Promise.all([import('emmet-monaco-es'), import('monaco-editor')]).then(([emmet, monaco]) => { const { emmetHTML, emmetCSS } = emmet; emmetHTML(monaco); diff --git a/packages/editor/tests/unit/layouts/CodeEditor.spec.ts b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts index 3399c6ee..c488bcf6 100644 --- a/packages/editor/tests/unit/layouts/CodeEditor.spec.ts +++ b/packages/editor/tests/unit/layouts/CodeEditor.spec.ts @@ -8,6 +8,7 @@ import { defineComponent, h, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import CodeEditor from '@editor/layouts/CodeEditor.vue'; +import { getEditorConfig } from '@editor/utils/config'; const { vsEditorInstance, @@ -345,6 +346,41 @@ describe('CodeEditor', () => { wrapper.unmount(); }); + test('异步初始化期间组件卸载则不创建编辑器', async () => { + const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body }); + // loadMonaco 为异步,此时尚未 resolve,立即卸载 + wrapper.unmount(); + await flush(); + // 组件已卸载,不应创建编辑器(不注册内容变化监听、不派发 initd) + expect(vsEditorInstance.onDidChangeModelContent).not.toHaveBeenCalled(); + expect(wrapper.emitted('initd')).toBeFalsy(); + }); + + test('编辑器创建期间组件卸载则销毁编辑器', async () => { + let resolveCreate!: (v: any) => void; + const createPromise = new Promise((resolve) => { + resolveCreate = resolve; + }); + // 让 customCreateMonacoEditor 变为异步,模拟创建过程中组件被卸载 + (getEditorConfig as any).mockImplementationOnce(() => async () => { + await createPromise; + return vsEditorInstance; + }); + + const wrapper = mount(CodeEditor, { props: { initValues: 'abc' } as any, attachTo: document.body }); + // 等待进入创建阶段(loadMonaco 已 resolve),但 create 尚未完成 + await nextTick(); + await new Promise((r) => setTimeout(r, 10)); + + wrapper.unmount(); + // 创建在卸载之后才完成 + resolveCreate(vsEditorInstance); + await flush(); + + expect(vsEditorInstance.dispose).toHaveBeenCalled(); + expect(wrapper.emitted('initd')).toBeFalsy(); + }); + test('toString 处理 json 对象', async () => { const wrapper = mount(CodeEditor, { props: { initValues: { a: 1 }, language: 'json' } as any,