mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-22 10:43:18 +08:00
fix(editor): 修复 Monaco 编辑器卸载时容器失效与 Canceled 未处理异常
This commit is contained in:
parent
ce43fface8
commit
4a15da2108
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user