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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,15 +16,18 @@ const [Modal, modalApi] = useVbenModal({
}, },
onOpenChange(isOpen) { onOpenChange(isOpen) {
if (isOpen) { if (isOpen) {
handleUpdate(10); handleUpdate();
} }
}, },
}); });
function handleUpdate(len: number) { function handleUpdate(len?: number) {
modalApi.setState({ confirmDisabled: true, loading: true }); modalApi.setState({ confirmDisabled: true, loading: true });
setTimeout(() => { 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 }); modalApi.setState({ confirmDisabled: false, loading: false });
}, 2000); }, 2000);
} }
@ -40,7 +43,7 @@ function handleUpdate(len: number) {
{{ item }} {{ item }}
</div> </div>
<template #prepend-footer> <template #prepend-footer>
<Button type="link" @click="handleUpdate(6)">点击更新数据</Button> <Button type="link" @click="handleUpdate()">点击更新数据</Button>
</template> </template>
</Modal> </Modal>
</template> </template>

View File

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

View File

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