perf: improve `destroyOnClose` for `VbenModal` (#5935)

* perf: 优化Vben Modal destroyOnClose,解决destroyOnClose=false,Modal依旧会被销毁的问题

影响范围(重要):destroyOnClose默认为true,这会导致所有的modal都会默认渲染到body
radix-vue Dialog组件默认会销毁挂载的组件,所以即使destroyOnClose=false,Modal依旧会被销毁的问题
对于一些大表单重复渲染导致卡顿,ApiComponent也会频繁的加载数据

* fix: modal closing animation

---------

Co-authored-by: Netfan <netfan@foxmail.com>
pull/73/head
ming4762 2025-04-13 23:02:07 +08:00 committed by YunaiV
parent 697f4119f3
commit 9f51ddce97
8 changed files with 39 additions and 39 deletions

View File

@ -60,7 +60,6 @@ Modal 内的内容一般业务中,会比较复杂,所以我们可以将 moda
- `VbenModal` 组件对与参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenModal参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenModal`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
- 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
- 如果弹窗的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultModalProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。
:::
@ -84,7 +83,7 @@ const [Modal, modalApi] = useVbenModal({
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Modal组件 | `Component` | - |
| destroyOnClose | 关闭时销毁`connectedComponent` | `boolean` | `false` |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
| description | 描述信息 | `string\|slot` | - |

View File

@ -44,6 +44,7 @@ export class ModalApi {
confirmDisabled: false,
confirmLoading: false,
contentClass: '',
destroyOnClose: true,
draggable: false,
footer: true,
footerClass: '',

View File

@ -60,6 +60,10 @@ export interface ModalProps {
*
*/
description?: string;
/**
*
*/
destroyOnClose?: boolean;
/**
*
* @default false
@ -153,10 +157,6 @@ export interface ModalApiOptions extends ModalState {
*
*/
connectedComponent?: Component;
/**
* 使 connectedComponent
*/
destroyOnClose?: boolean;
/**
* false
* @returns

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import { computed, nextTick, provide, ref, useId, watch } from 'vue';
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
import {
useIsMobile,
@ -34,6 +34,7 @@ interface Props extends ModalProps {
const props = withDefaults(defineProps<Props>(), {
appendToMain: false,
destroyOnClose: true,
modalApi: undefined,
});
@ -67,6 +68,7 @@ const {
confirmText,
contentClass,
description,
destroyOnClose,
draggable,
footer: showFooter,
footerClass,
@ -100,10 +102,15 @@ const { dragging, transform } = useModalDraggable(
shouldDraggable,
);
const firstOpened = ref(false);
const isClosed = ref(false);
watch(
() => state?.value?.isOpen,
async (v) => {
if (v) {
isClosed.value = false;
if (!firstOpened.value) firstOpened.value = true;
await nextTick();
if (!contentRef.value) return;
const innerContentRef = contentRef.value.getContentRef();
@ -113,6 +120,7 @@ watch(
dialogRef.value.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
}
},
{ immediate: true },
);
watch(
@ -176,6 +184,15 @@ const getAppendTo = computed(() => {
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const getForceMount = computed(() => {
return !unref(destroyOnClose);
});
function handleClosed() {
isClosed.value = true;
props.modalApi?.onClosed();
}
</script>
<template>
<Dialog
@ -197,9 +214,11 @@ const getAppendTo = computed(() => {
shouldFullscreen,
'top-1/2 !-translate-y-1/2': centered && !shouldFullscreen,
'duration-300': !dragging,
hidden: isClosed,
},
)
"
:force-mount="getForceMount"
:modal="modal"
:open="state?.isOpen"
:show-close="closable"
@ -207,7 +226,7 @@ const getAppendTo = computed(() => {
:overlay-blur="overlayBlur"
close-class="top-3"
@close-auto-focus="handleFocusOutside"
@closed="() => modalApi?.onClosed()"
@closed="handleClosed"
:close-disabled="submitting"
@escape-key-down="escapeKeyDown"
@focus-outside="handleFocusOutside"

View File

@ -1,14 +1,6 @@
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue';
import { useStore } from '@vben-core/shared/store';
@ -32,7 +24,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isModalReady = ref(true);
const Modal = defineComponent(
(props: TParentModalProps, { attrs, slots }) => {
provide(USER_MODAL_INJECT_KEY, {
@ -42,11 +33,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateModal() {
isModalReady.value = false;
await nextTick();
isModalReady.value = true;
},
});
checkProps(extendedApi as ExtendedModalApi, {
...props,
@ -55,7 +41,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
});
return () =>
h(
isModalReady.value ? connectedComponent : 'div',
connectedComponent,
{
...props,
...attrs,
@ -84,14 +70,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateModal?.();
}
};
const api = new ModalApi(mergedOptions);
const extendedApi: ExtendedModalApi = api as never;

View File

@ -16,15 +16,18 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen) {
if (isOpen) {
handleUpdate(10);
handleUpdate();
}
},
});
function handleUpdate(len: number) {
function handleUpdate(len?: number) {
modalApi.setState({ confirmDisabled: true, loading: true });
setTimeout(() => {
list.value = Array.from({ length: len }, (_v, k) => k + 1);
list.value = Array.from(
{ length: len ?? Math.floor(Math.random() * 10) + 1 },
(_v, k) => k + 1,
);
modalApi.setState({ confirmDisabled: false, loading: false });
}, 2000);
}
@ -40,7 +43,7 @@ function handleUpdate(len: number) {
{{ item }}
</div>
<template #prepend-footer>
<Button type="link" @click="handleUpdate(6)"></Button>
<Button type="link" @click="handleUpdate()"></Button>
</template>
</Modal>
</template>

View File

@ -24,7 +24,7 @@ const value = ref();
title="基础弹窗示例"
title-tooltip="标题提示内容"
>
此弹窗指定在内容区域打开
<Input v-model="value" placeholder="KeepAlive测试" />
此弹窗指定在内容区域打开并且在关闭之后弹窗内容不会被销毁
<Input v-model:value="value" placeholder="KeepAlive测试" />
</Modal>
</template>

View File

@ -198,7 +198,7 @@ async function openPrompt() {
</template>
</Card>
<Card class="w-[300px]" title="指定容器">
<Card class="w-[300px]" title="指定容器+关闭后不销毁">
<p>在内容区域打开弹窗的示例</p>
<template #actions>
<Button type="primary" @click="openInContentModal"></Button>