fix(editor): 修复 Monaco 编辑器卸载时容器失效与 Canceled 未处理异常

This commit is contained in:
roymondchen 2026-07-16 19:35:43 +08:00
parent ce43fface8
commit 4a15da2108
3 changed files with 96 additions and 7 deletions

View File

@ -194,6 +194,9 @@ const values = ref('');
const loading = ref(false);
const codeEditorEl = useTemplateRef<HTMLDivElement>('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();

View File

@ -1,7 +1,34 @@
let cached: Promise<typeof import('monaco-editor')> | 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);

View File

@ -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,