太长不看
直接看源码。
快速实现
首先创建一个用来管理弹窗列表的 Store:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
import { defineStore } from "pinia";
import { ref, shallowReactive, type VNode } from "vue";

export interface ModalContext {
 vnode: VNode;
 zIndex: number;
 duration: number;
 open: Ref<boolean>;
 close: () => any;
}

export const useModalStore = defineStore("modal", () => {
 const modals = shallowReactive<ModalContext[]>([]);

 return {
 modals,
 };
});
之后,向该 Store 中添加一个可组合项:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
export interface UseModalOptions {
 duration?: number;
 immediate?: boolean;
 unique?: boolean;
}

function use(render: () => VNode, options: UseModalOptions = {}) {
 const {
 duration = 400,
 immediate = false,
 unique = false,
 } = options;

 let ctx: ModalContext;
}
这里逐个解释配置项的作用:
-
duration:弹窗的渐入与渐出动画的持续时长; -
immediate:是否在调用该可组合项后立即打开弹窗; -
unique:该弹窗是否唯一。
当然,还可以根据实际的业务逻辑添加其他的配置项以提供更加丰富的功能。
基于此,我们继续编写该可组合项的逻辑,它主要返回 open 和 close 两个方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
import { promiseTimeout } from "@vueuse/core";

const status = ref("closed");

function open() {
 if (unique && modals.includes(ctx)) {
 return;
 }

 const vnode = render();
 const last = dialog.value.at(-1);
 const zIndex = (last?.zIndex ?? 510) + 2;

 ctx = {
 vnode,
 zIndex,
 duration,
 open: computed(() => status.value === "open"),
 close: (vnode.props ??= {}).onClose ??= close,
 };

 dialog.value.push(ctx);
 vnode.props.onVnodeMounted = () => {
 status.value = "open";
 };
}

async function close() {
 status.value = "closing";
 await promiseTimeout(duration);
 status.value = "closed";

 const i = modals.indexOf(ctx);
 if (i !== -1) {
 modals.splice(i, 1);
 }
}
我们先看 status 变量,它为弹窗提供了利用 Transition 组件实现过渡动画的能力:首先在 open 方法的末尾通过 onVNodeMounted 事件设置能够使得 ctx.open 被计算为 true 的值,然后在 close 方法的开头关闭,等待一段时间后才将弹窗上下文从 Store 中移除。
之后是 zIndex 的计算,它有助于使弹窗按照打开的顺序正确叠加。之所以间隔 2 个单位,是因为中间还需要一层遮罩。
再看 ctx.close 相关的部分,会发现这里采用了一连串稍微复杂的赋值逻辑,简单来讲就是将组件发出的关闭事件默认绑定到弹窗上下文的 close 方法上。而将该方法存储到上下文中,是为了便于让遮罩层获得关闭弹窗的能力。
最后,再将 Store 与视图绑定。创建一个列表渲染的组件并放置到应用布局上即可:
1 2 3 4 5 6 7 8 9 10 11 12 13
<script lang="ts" setup>
 import { storeToRefs } from "pinia";
 import { useModalStore } from "~/stores/modal";

 const modalStore = useModalStore();
 const { modals } = storeToRefs(modalStore);
</script>

<template>
 <template v-for="{ vnode, zIndex, open } in modals" :key="zIndex">
 <component :is="vnode" :open="open.value" :style="{ zIndex }"/>
 </template>
</template>
至于遮罩层,则按照类似的方式继续创建一个统一管理的组件挂载上去就行了,当然也可以直接放在上面这个组件里。
使用方式
全局弹窗
依然采用 Store 管理,不同的是我们不再需要提前将该弹窗挂载到 DOM 树上:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import { defineStore } from "pinia";
import { h } from "vue";
import Setting from "~/components/setting.vue";
import { useModalStore } from "~/stores/modal";

export const useSettingStore = defineStore("setting", () => {
 const modalStore = useModalStore();

 const { open, close } = modalStore.use(() => h(Setting), {
 unique: true,
 });

 return {
 open,
 close,
 };
});
临时弹窗
创建一个实用函数,在调用时立即召唤弹窗,剩下的就是具体的业务逻辑了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
import { h } from "vue";
import Confirm from "~/components/confirm.vue";
import { useModalStore } from "~/stores/modal";

export function confirm(message: string) {
 return new Promise((resolve) => {
 const modalStore = useModalStore();

 const { close } = modalStore.use(() => h(Confirm, {
 message,
 onClose(val) {
 resolve(val);
 close();
 },
 }), {
 immediate: true,
 });
 });
}
![[cover]](/api/image/cover/a4e3e63.webp)
评论0