破解Vue自定义弹窗销毁的"nextSibling"之谜
问题场景描述
在动态创建的Dialog组件内部再次使用Dialog时,触发Cannot read properties of null (reading 'nextSibling')错误。
最小复现代码
1.弹窗前端组件
html
<!-- ParentDialog.vue -->
<template>
<!-- 父弹窗直接使用el-dialog -->
<el-dialog v-model="parentVisible" title="父弹窗">
<button @click="openChild">打开子弹窗</button>
<!-- 子弹窗组件 -->
<el-dialog v-model="childVisible" title="子弹窗" width="60%" append-to-body>
<p>我是嵌套的子弹窗内容</p>
</el-dialog>
</el-dialog>
</template>
<script setup>
import { ref } from 'vue'
const parentVisible = ref(true)
const childVisible = ref(false)
const openChild = () => { childVisible.value = true }
</script>
2.调用代码
typescript
import ParentDialog from "@component/ParentDialog.vue"
function showDialog(){
createDialog(ParentDialog);
}
3.createDialog函数的代码
typescript
import ElementUi from 'element-plus';
export const createDialog = (
Node: Component<any, any, any, ComputedOptions, MethodOptions, {}, any>
) => {
const div = document.createElement('div');
div.id = 'test-dialog';
document.body.appendChild(div);
const app = createApp(Node, {
onClose() {
console.log('弹窗关闭了');
app.unmount();
div.remove();
}
});
app.use(ElementUi);
app.mount('#test-dialog');
};
问题出在了哪呢?
问题主要来源于 Vue 组件生命周期与 DOM 操作的冲突。在嵌套使用 el-dialog 时:
- 动态创建和销毁组件 :父弹窗和子弹窗分别以动态方式挂载到 DOM 上,子弹窗由于
append-to-body被移到body下,这使得 Vue 的虚拟 DOM 与真实 DOM 不完全同步。 nextSibling属性访问 :Element-Plus在处理某些 DOM 操作时,依赖父子元素的 DOM 顺序,但动态挂载会导致 DOM 层级被打乱,从而触发错误。
问题如图所示
sequenceDiagram
participant App as Vue App
participant Parent as Parent Dialog
participant Child as Child Dialog
participant DOM as Real DOM
App->>+Parent: 1. 创建父弹窗组件
Parent->>+DOM: 2. 挂载到 DOM
Parent->>+Child: 3. 动态创建子弹窗
Child->>+DOM: 4. 使用 append-to-body 挂载到 body
Note over DOM: 子弹窗被移到 body 下
Parent->>Child: 5. 操作子弹窗状态
Child->>DOM: 6. 更新挂载位置
DOM->>DOM: 7. 尝试 nextSibling 获取
DOM-->>-App: 抛出错误
"Cannot read properties of null" deactivate DOM deactivate Child deactivate Parent
"Cannot read properties of null" deactivate DOM deactivate Child deactivate Parent
解决办法详解
- 使用
teleport明确挂载位置
在弹窗嵌套场景下,通过teleport将子弹窗直接挂载到body下,这样可以避免 Vue 在嵌套管理 DOM 时的复杂性,保持组件逻辑清晰。 - 调整
append-to-body和挂载点
在子弹窗的el-dialog上启用append-to-body,但需确保它的生命周期与父组件一致。 - 事件冒泡和通信优化
使用 Vue 的emit或者自定义事件来控制子弹窗的打开和关闭状态,而不是直接操作 DOM。这样可以减少对nextSibling的依赖。
解决方案如图所示
sequenceDiagram
participant App as Vue App
participant Parent as Parent Dialog
participant Child as Child Dialog
participant DOM as Real DOM
App->>+Parent: 1. 创建父弹窗组件
Parent->>+DOM: 2. 挂载到 DOM
Parent->>+Child: 3. 使用 Teleport 明确挂载位置
Child->>+DOM: 4. 挂载到 body
Parent->>Child: 5. 操作子弹窗状态
Child->>DOM: 6. Teleport 管理挂载
DOM-->>-App: 7. 正常完成所有操作
deactivate Child
deactivate Parent
修改后的代码
html
<template>
<!-- 父弹窗 -->
<el-dialog v-model="dialogState.parentVisible" title="父弹窗" @close="emitClose">
<button @click="openChild">打开子弹窗</button>
<!-- 子弹窗通过 teleport 挂载到 body -->
<teleport to="body">
<el-dialog
v-model="dialogState.childVisible"
title="子弹窗"
width="60%"
@close="handleChildClose"
>
<p>我是嵌套的子弹窗内容</p>
</el-dialog>
</teleport>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, reactive, defineExpose, defineProps, defineEmits } from 'vue';
// 定义 props 和 emits
const props = defineProps({
onClose: {
type: Function,
default: null,
},
});
const emit = defineEmits(['close']);
// 控制弹窗状态
const dialogState = reactive({
parentVisible: true,
childVisible: false,
});
// 定义 expose 方法供父组件调用
defineExpose({
close: () => {
dialogState.parentVisible = false;
},
});
// 打开子弹窗
const openChild = () => {
dialogState.childVisible = true;
};
// 子弹窗关闭逻辑
const handleChildClose = () => {
dialogState.childVisible = false;
};
// 父弹窗关闭时触发回调
const emitClose = () => {
if (typeof props.onClose === 'function') {
props.onClose();
}
emit('close');
};
</script>
描述 :仅仅改动了 子弹窗的挂载方式,将其通过 teleport 明确指定到 body ,从而避免 DOM 层级错乱的问题,而 createDialog 函数的代码不需要修改哦~
总结
其实只需要在组件中使用 teleport 明确指定子弹窗的挂载位置,同时让父子弹窗的状态管理逻辑清晰即可啦~