@brmtech/vue-dialog-queue
v0.1.5
Published
Vue 2 and Vue 3 dialog queue and scheduling system with scoped containers, priority, frequency control, and server push support.
Maintainers
Readme
@brmtech/vue-dialog-queue
适用于 Vue 2 和 Vue 3 的弹窗队列调度包。
A dialog queue and scheduling package for both Vue 2 and Vue 3.
Repositories
- GitHub: https://github.com/brmxyy/-brmtech-vue-dialog-queue.git
- Gitee: https://gitee.com/brm-zero-x/vue-dialog-queue.git
Language
- 默认展开
中文文档 - 默认折叠
English Documentation
介绍
适用于 Vue 2 和 Vue 3 的弹窗队列调度包。它把本地弹窗、服务端推送弹窗、页面作用域、优先级、去重、频控、重复展示控制统一到同一套 API 里,同时把 Vue 2 / Vue 3 的运行时代码拆成独立入口,用户按项目版本选择导入,不存在默认 Vue 3 入口。
特性
- 同一时间只展示一个弹窗,关闭当前弹窗后自动消费下一个队列项。
- 同时支持 Vue 2 和 Vue 3,运行时代码分别位于
/vue2和/vue3子入口。 - 根入口只做显式汇总,提供
vue2/vue3命名空间和defineVue2DialogSystem/defineVue3DialogSystem别名。 - 类型定义独立在
types.ts,可从根入口或/types子入口导入。 - 支持全局弹窗、页面局部弹窗和多个自定义
scope容器。 - 支持
allow/deny/guard展示守卫,可按页面或业务上下文控制展示、延后或丢弃。 - 支持服务端单条、批量、异步来源推送弹窗。
- 默认按
type + key去重,避免重复推送造成连续弹窗。 - 支持
append,允许同一个type + key多次排队。 - 支持
weight、order排序。 - 支持高优先级弹窗打断低优先级弹窗。
- 支持每日限制、最大展示次数、重复展示间隔和用户维度频控隔离。
vue是 peer dependency,不会被打进包里。
安装
npm install @brmtech/vue-dialog-queuepnpm add @brmtech/vue-dialog-queue项目需要自己安装对应版本的 vue。包的 peer dependency 支持 ^2.6.0 || ^3.3.0 || ^3.4.0 || ^3.5.0。
入口选择
Vue 3 项目
import {
defineDialogSystem,
createDialogSystem,
} from "@brmtech/vue-dialog-queue/vue3";Vue 2 项目
import {
defineDialogSystem,
createDialogSystem,
} from "@brmtech/vue-dialog-queue/vue2";根入口
根入口不会导出裸的 defineDialogSystem,避免暗示某个默认 Vue 版本。根入口只提供显式版本别名和命名空间,适合跨版本封装或迁移工具使用。
import {
defineVue2DialogSystem,
defineVue3DialogSystem,
createVue2DialogSystem,
createVue3DialogSystem,
vue2,
vue3,
} from "@brmtech/vue-dialog-queue";
const vue2System = defineVue2DialogSystem([]);
const vue3System = defineVue3DialogSystem([]);
const sameVue2System = vue2.defineDialogSystem([]);
const sameVue3System = vue3.defineDialogSystem([]);类型入口
import type {
DialogDefinition,
DialogMeta,
DialogPushItem,
DialogSystemApi,
DialogSystemInstance,
} from "@brmtech/vue-dialog-queue/types";你也可以从版本入口导入类型:
import type { DialogDefinition } from "@brmtech/vue-dialog-queue/vue3";包结构
源码按职责拆分:
src/
index.ts # 根入口,只做 Vue2/Vue3 显式汇总和类型转出
vue2.ts # Vue 2 运行时适配:Vue.extend、$refs、Vue.nextTick
vue3.ts # Vue 3 运行时适配:defineComponent、defineAsyncComponent、shallowRef
core.ts # 与 Vue 版本无关的队列、排序、scope、频控、API 逻辑
types.ts # 所有公开类型构建后发布以下入口:
dist/index.js dist/index.cjs dist/index.d.ts
dist/vue2.js dist/vue2.cjs dist/vue2.d.ts
dist/vue3.js dist/vue3.cjs dist/vue3.d.ts
dist/types.js dist/types.cjs dist/types.d.ts
dist/core.js dist/core.cjs dist/core.d.ts快速开始:Vue 3
创建弹窗系统
// dialog.ts
import { defineDialogSystem } from "@brmtech/vue-dialog-queue/vue3";
export type DialogType = "WELCOME" | "RECHARGE";
export interface DialogData {
title?: string;
amount?: number;
}
export const dialogSystem = defineDialogSystem<DialogType, DialogData>([
{
type: "WELCOME",
component: () => import("./dialogs/WelcomeDialog.vue"),
weight: 10,
control: ["daily"],
},
{
type: "RECHARGE",
component: () => import("./dialogs/RechargeDialog.vue"),
weight: 1,
control: ["high"],
},
]);
export const DialogContainer = dialogSystem.Container;
export const dialogApi = dialogSystem.api;
export const dialogBridge = dialogSystem.bridge;挂载容器
<!-- App.vue -->
<template>
<DialogContainer />
<RouterView />
</template>
<script setup lang="ts">
import { DialogContainer } from "./dialog";
</script>编写弹窗组件
Vue 3 弹窗组件需要通过 defineExpose 暴露 openDialog(data?),建议同时暴露 closeDialog()。组件内部用户点击关闭时,应调用 dialogApi.closeCurrent() 通知调度器继续消费队列。
<!-- dialogs/WelcomeDialog.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { dialogApi, type DialogData } from "../dialog";
const visible = ref(false);
const payload = ref<DialogData | undefined>();
function openDialog(data?: DialogData) {
payload.value = data;
visible.value = true;
}
function closeDialog() {
visible.value = false;
}
function handleClose() {
closeDialog();
dialogApi.closeCurrent();
}
defineExpose({
openDialog,
closeDialog,
});
</script>
<template>
<div v-if="visible" class="dialog">
<h3>{{ payload?.title ?? "欢迎" }}</h3>
<button type="button" @click="handleClose">关闭</button>
</div>
</template>触发弹窗
dialogApi.show("WELCOME", { title: "欢迎回来" });快速开始:Vue 2
创建弹窗系统
// dialog.ts
import { defineDialogSystem } from "@brmtech/vue-dialog-queue/vue2";
export const dialogSystem = defineDialogSystem([
{
type: "WELCOME",
component: () => import("./dialogs/WelcomeDialog.vue"),
control: ["daily"],
},
]);
export const DialogContainer = dialogSystem.Container;
export const dialogApi = dialogSystem.api;挂载容器
<!-- App.vue -->
<template>
<div>
<DialogContainer />
<router-view />
</div>
</template>
<script>
import { DialogContainer } from "./dialog";
export default {
components: {
DialogContainer,
},
};
</script>编写弹窗组件
Vue 2 弹窗组件需要在 methods 中提供 openDialog(data?),建议同时提供 closeDialog()。
<!-- dialogs/WelcomeDialog.vue -->
<template>
<div v-if="visible" class="dialog">
<h3>{{ payload && payload.title ? payload.title : "欢迎" }}</h3>
<button type="button" @click="handleClose">关闭</button>
</div>
</template>
<script>
import { dialogApi } from "../dialog";
export default {
data() {
return {
visible: false,
payload: undefined,
};
},
methods: {
openDialog(data) {
this.payload = data;
this.visible = true;
},
closeDialog() {
this.visible = false;
},
handleClose() {
this.closeDialog();
dialogApi.closeCurrent();
},
},
};
</script>核心概念
type
type 是弹窗唯一类型,也是触发弹窗时传入的名字。每个系统实例内的 type 必须唯一,重复注册会抛出错误。
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
}component
component 是异步组件加载函数,返回 { default: Component }。组件需要提供 openDialog(data?) 方法,调度器会在弹窗真正展示时调用它。
data
data 是唯一会原样传给弹窗组件的业务参数。调度器不会读取、修改或解析 data,除非你配置了 getDialogId、getDialogCount、getDialogOrder 这类项目适配函数。
dialogApi.show("WELCOME", {
title: "欢迎回来",
source: "home",
});meta
meta 只影响调度行为,不会传给弹窗组件。它可以控制去重 key、排序、展示次数、动态权重。
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
order: 1,
weight: 5,
maxShowCount: 1,
},
});DialogMetaInput 支持三种形式:
dialogApi.show("WELCOME", data, "welcome-key");
dialogApi.show("WELCOME", data, 1001);
dialogApi.show("WELCOME", data, { key: "welcome-key", order: 1 });scope
scope 是容器作用域,只是一个字符串,不会自动读取路由。弹窗注册时的 control.scope 必须和容器的 scope 一致,弹窗才会在该容器中渲染。未配置时使用默认作用域 default。
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<DialogContainer scope="home" />defineDialogSystem
普通项目优先使用 defineDialogSystem。它接收弹窗定义数组,也可以接收完整 options。
const system = defineDialogSystem([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
]);const system = defineDialogSystem({
dialogs: [
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
control: ["daily"],
},
],
source: async () => [
{
type: "WELCOME",
data: { title: "接口返回" },
key: "welcome-from-api",
order: 1,
},
],
defaultScope: "default",
storagePrefix: "my-app:dialog:",
getUserScope: () => userStore.userId,
});返回值包含:
const { Container, api, bridge, controller } = system;Container:Vue 容器组件,负责渲染当前需要挂载的弹窗组件。api:业务侧最常用的弹窗操作 API。bridge:给外部系统或旧代码接入的轻量桥接 API。controller:更底层的控制器,通常用于高级封装、调试和迁移。
DialogDefinition
interface DialogDefinition<TType extends string = string> {
type: TType;
component: () => Promise<{ default: Component }>;
weight?: number;
control?: DialogDefinitionControl;
}weight
静态默认权重,数值越小越先展示。运行时可以通过 meta.weight 或 meta.priority 覆盖。
control
control 可以使用字符串、数组或对象。
control: "daily"
control: ["daily", "high"]
control: {
daily: true,
high: true,
append: true,
repeatMs: 60_000,
scope: "home",
allowOutside: true,
retainOutside: true,
deny: ["route:checkout", "route:payment"],
blocked: "defer",
}可用字段:
| 字段 | 说明 |
| --------------- | ------------------------------------------------------------------------------------ |
| enabled | 设为 false 时保留注册,但所有触发都会被忽略。 |
| daily | 开启每日限制;默认同一个 type + key 当天最多展示一次。 |
| high | 标记为高优先级弹窗,可打断低优先级弹窗。 |
| append | 允许同一个 type + key 多次进入队列。 |
| repeatMs | 同一个 type + key 再次展示前需要等待的最小间隔。 |
| scope | 指定弹窗渲染在哪个容器作用域。 |
| allowOutside | scope 不活跃时仍允许展示。 |
| retainOutside | scope 不活跃后保留队列和 pending 项。 |
| allow | 配置后,只有当前 context 命中这些标签时才允许展示。 |
| deny | 配置后,当前 context 命中这些标签时禁止展示;优先级高于 allow。 |
| blocked | 默认 "defer"。当 allow / deny / guard 不通过时可选择 "defer" 或 "drop"。 |
| guard | 自定义展示判断函数。 |
字符串标记和对象字段映射:
| 字符串 | 对象字段 |
| --------- | --------------------- |
| daily | daily: true |
| high | high: true |
| append | append: true |
| outside | allowOutside: true |
| retain | retainOutside: true |
展示守卫
allow / deny 配置的是业务 context 标签,不是容器名,也不是自动读取的路由名。context 必须由业务主动通过 api.setContext()、api.addContext()、api.removeContext() 设置。
推荐给 context 加前缀,避免和 scope 名称混在一起:
dialogApi.setContext("route:home");
dialogApi.addContext("state:vip");
dialogApi.addContext("flow:payment");context 可以是字符串、数字或数组;空值、空字符串、纯空格字符串会被忽略。
和 scope 的关系
scope 和 allow / deny 是叠加条件:
能展示 = scope 对应容器 active 或 allowOutside
&& 当前 contexts 通过 allow / deny / guard也就是说,scope 决定弹窗挂载在哪个容器,allow / deny 决定当前业务环境能不能展示。allow 不会让弹窗跨 scope 挂载。
| 需求 | 推荐配置 |
| ---------------------------- | ---------------------------- |
| 只属于某个页面的弹窗 | 只配 scope |
| 全局弹窗,某些页面不能弹 | scope: "default" + deny |
| 全局弹窗,只在某些页面弹 | scope: "default" + allow |
| 页面弹窗,还要等业务状态满足 | scope + allow |
| 临时流程中禁弹 | 维护 flow:* 标签 + deny |
| 需要读取 data 或复杂条件 | guard |
展示守卫判断顺序:
- 命中
deny:不展示,按blocked处理。 - 配置了
allow但当前 context 没命中:不展示,按blocked处理。 - 执行
guard:根据返回值决定展示、延后或丢弃。 - 展示守卫通过后,再判断
scope对应容器是否 active。
deny 优先级高于 allow。allow 不配置或为空数组时,表示不启用白名单限制。
业务标签写在哪里
这个包不会读取路由、store 或页面状态。业务标签一般写在项目自己的弹窗上下文同步文件里,例如 dialog-context.ts:
// dialog-context.ts
import { dialogApi } from "./dialog";
import { router } from "./router";
import { userStore } from "./stores/user";
let pageContexts: string[] = [];
function getDialogContexts() {
const route = router.currentRoute.value;
const contexts = [
`route:${String(route.name ?? route.path)}`,
...pageContexts,
];
if (userStore.isVip) contexts.push("state:vip");
if (userStore.isLoggedIn) contexts.push("state:logged-in");
return contexts;
}
export function syncDialogContexts() {
dialogApi.setContext(getDialogContexts());
}
export function setDialogPageContexts(contexts: string[]) {
pageContexts = contexts;
syncDialogContexts();
}然后在路由切换、登录态变化、页面状态变化时同步它:
router.afterEach(() => {
syncDialogContexts();
});
userStore.$subscribe(() => {
syncDialogContexts();
});页面内业务状态可以通过项目自己的 helper 写入:
// PaymentPage.vue
import { setDialogPageContexts } from "./dialog-context";
onMounted(() => {
setDialogPageContexts(["flow:payment"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});setContext() 会替换当前全部 context,适合做一次完整同步。addContext() / removeContext() 适合临时标签。如果项目里路由或 store 会频繁调用 setContext(),更推荐像上面一样集中汇总所有标签,避免临时标签被覆盖。
如果一次有大量服务端弹窗,优先使用 api.push([...]) 批量推入。调度器会批量排序和清理队列项;同时要保持 guard 轻量,因为大队列扫描时会对候选项执行它。
场景 1:页面专属弹窗
页面专属弹窗只配置 scope,不要再用 allow 表达页面。
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<!-- HomePage.vue -->
<template>
<DialogContainer scope="home" />
</template>dialogApi.show("HOME_POPUP");结果:只有 HomePage 里的 scope="home" 容器 active 时才会展示。
场景 2:全局弹窗,部分页面禁弹
适合活动弹窗、公告弹窗、营销弹窗。容器挂全局,黑名单页面用 deny。
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
scope: "default",
deny: ["route:checkout", "route:payment"],
blocked: "defer",
},
}结果:在结算页和支付页不弹,离开这些页面后继续尝试展示。
场景 3:全局弹窗,只在部分页面弹
适合只在首页、活动页展示的优惠券或引导弹窗。
{
type: "COUPON",
component: () => import("./CouponDialog.vue"),
control: {
scope: "default",
allow: ["route:home", "route:activity"],
blocked: "drop",
},
}结果:只有当前 context 是 route:home 或 route:activity 时才弹,其它页面直接丢弃本次触发。
场景 4:局部弹窗,叠加业务状态
适合“首页弹窗,但必须等首页数据准备好才弹”。
<!-- HomePage.vue -->
<template>
<DialogContainer scope="home" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from "vue";
import { DialogContainer } from "./dialog";
import { setDialogPageContexts } from "./dialog-context";
onMounted(async () => {
await loadHomeData();
setDialogPageContexts(["state:home-ready"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});
</script>{
type: "HOME_COUPON",
component: () => import("./HomeCouponDialog.vue"),
control: {
scope: "home",
allow: ["state:home-ready"],
blocked: "defer",
},
}结果:只有 home 容器 active,且存在 state:home-ready 标签时才弹。
场景 5:流程中临时禁弹
适合支付流程、实名认证流程、关键表单编辑中,临时挡住营销类弹窗。
import { setDialogPageContexts } from "./dialog-context";
onMounted(() => {
setDialogPageContexts(["flow:payment"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});{
type: "MARKETING",
component: () => import("./MarketingDialog.vue"),
control: {
scope: "default",
deny: ["flow:payment"],
blocked: "defer",
},
}结果:支付流程中不弹,离开流程后继续尝试。
场景 6:defer 和 drop 的选择
blocked 只在 allow / deny / guard 不通过时生效,例如命中 deny、没有命中 allow,或者 guard 返回 false。scope 不活跃时仍按 scope 队列规则延后,不会读取 blocked。
defer 适合“现在不能弹,但之后还应该弹”的场景:
control: {
scope: "default",
deny: ["route:payment"],
blocked: "defer",
}drop 适合“错过当前场景就不需要再弹”的场景:
control: {
scope: "default",
allow: ["route:home"],
blocked: "drop",
}场景 7:自定义 guard
更复杂的业务可以使用 guard。不配置 guard 时,不会额外增加自定义限制。guard 必须是同步函数,不支持返回 Promise;它可能在队列检查时多次执行,因此应保持轻量、无副作用,不要在里面请求接口、修改 store 或直接打开/关闭弹窗。
guard 返回值:
| 返回值 | 含义 |
| -------------------- | ----------------------------------------------------- |
| true | 允许继续展示。 |
| undefined / null | 允许继续展示,适合只处理少数特殊场景。 |
| false | 不允许展示,并按 blocked 配置处理。 |
| "defer" | 不允许现在展示,先留在队列里,等 context 变化后再试。 |
| "drop" | 不允许展示,并直接丢弃本次触发。 |
guard 参数:
| 参数 | 说明 |
| -------------- | ----------------------------------------------------------------- |
| type | 当前弹窗 type。 |
| data | 本次触发传给弹窗组件的数据。 |
| meta | 本次触发的调度信息,例如 dialogId、order、count、weight。 |
| scope | 当前弹窗注册的容器作用域。 |
| activeScopes | 当前 active 的作用域集合。 |
| contexts | 当前业务 context 标签集合。 |
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
blocked: "defer",
guard: ({ contexts, data }) => {
if (contexts.has("flow:payment")) return "defer"
if ((data as { expired?: boolean } | undefined)?.expired) return "drop"
return true
},
},
}结果:支付流程中延后,活动已过期时直接丢弃,其它情况正常展示。
不推荐写法
不要用 allow 表达弹窗要挂在哪个页面。下面的配置不会让弹窗在活动页挂载:
control: {
scope: "home",
allow: ["route:home", "route:activity"],
}如果真实需求是首页和活动页都能弹,应该挂到共享容器,再用 context 过滤:
control: {
scope: "default",
allow: ["route:home", "route:activity"],
}不要只配置 allow 却忘记调用 setContext()。当 allow 非空而当前没有任何匹配 context 时,本次弹窗会按 blocked 处理,默认是延后。
本地触发
api.show(type, data?, meta?, order?, count?)
强制尝试展示弹窗。如果当前 scope 可展示、组件 ref 已就绪且没有更高优先级冲突,会立即调用组件的 openDialog(data);否则会进入队列或 pending。
dialogApi.show("WELCOME", { title: "欢迎回来" });api.open(...) / api.forceShow(...)
show 的别名。
dialogApi.open("WELCOME");
dialogApi.forceShow("WELCOME");api.enqueue(type, data?, meta?, order?, count?)
只入队,不强制立即打断当前弹窗。
dialogApi.enqueue("WELCOME", data, { key: "welcome" });api.add(...)
enqueue 的别名。
api.showWhenReady(type, data?, options?)
等待弹窗组件 ref 可用后再展示,返回 Promise<boolean>。默认超时是 10000ms。
const shown = await dialogApi.showWhenReady("WELCOME", data, {
timeout: 5000,
meta: { key: "welcome" },
});options 也可以直接带 key、order、count、weight:
await dialogApi.showWhenReady("WELCOME", data, {
timeout: 5000,
key: "welcome",
order: 1,
});服务端推送
单条推送
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
order: activity.sort,
maxShowCount: 1,
},
});批量推送
dialogApi.push([
{
type: "WELCOME",
data: welcomeData,
meta: { key: "welcome" },
},
{
type: "ACTIVITY",
data: activity,
meta: { key: activity.id, order: 1 },
},
]);批量 push 会在一个批处理中排序,避免每插入一条都重新排序。
服务端字段直映射
DialogSourceItem 支持把服务端常见字段直接放在顶层,内部会合并为调度 meta。
dialogApi.push({
type: "ACTIVITY",
data: activity,
id: activity.id,
order: activity.sort,
count: 1,
weight: 5,
});字段别名:
| 顶层字段 | 对应 meta 字段 |
| ------------------------- | -------------- |
| id / key / dialogId | dialogId |
| order | order |
| count / maxShowCount | count |
| weight / priority | weight |
异步来源 source
source 用于初始化拉取、轮询接口或处理服务端弹窗列表。调用 api.fetch() 或 api.handleExternalSignal() 时会执行它。
export const dialogSystem = defineDialogSystem({
dialogs,
source: async () => {
const list = await requestDialogList();
return list.map((item) => ({
type: item.type,
data: item.payload,
id: item.id,
order: item.order,
count: item.maxShowCount,
weight: item.weight,
}));
},
});
await dialogSystem.api.fetch();source 可以返回数组,也可以返回 void。没有返回项时不会改变队列。
排序规则
队列排序按以下规则处理:
- 高优先级弹窗优先于普通弹窗。
- 同为高优先级时,比较
weight,数值越小越先展示。 - 普通弹窗也比较
weight,数值越小越先展示。 weight相同再比较order,数值越小越先展示。order未设置时视为最后。
dialogApi.push([
{ type: "A", meta: { weight: 10, order: 2 } },
{ type: "B", meta: { weight: 1, order: 9 } },
{ type: "C", meta: { weight: 1, order: 1 } },
]);上面会优先展示 C,再展示 B,最后展示 A。
高优先级弹窗
高优先级弹窗通过 control.high 或 control: "high" 开启。高优先级弹窗可以打断当前低优先级弹窗,被打断的弹窗会重新回到队列中。
{
type: "URGENT_NOTICE",
component: () => import("./UrgentNotice.vue"),
weight: 0,
control: {
high: true,
},
}如果当前展示的也是高优先级弹窗,则继续比较权重。新的权重更高,也就是数值更小时,才会打断当前弹窗。
去重、频控和重复展示
去重 key
默认按 type + dialogId 去重。dialogId 来自 meta.key / meta.id / meta.dialogId,未传时使用内部默认 key。
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: { key: activity.id },
});相同 type + key 重复入队时,默认更新已有队列项的数据和排序信息,而不是新增一条。
允许重复排队
{
type: "TOAST_LIKE_DIALOG",
component: () => import("./ToastLikeDialog.vue"),
control: {
append: true,
},
}开启 append 后,相同 type + key 可以多次进入队列。
每日限制
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
control: ["daily"],
}开启后,同一个 type + key 默认每天最多展示一次。
单次最大展示次数
可以在单次触发时通过 meta.count 或 meta.maxShowCount 指定当天最大展示次数。
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
maxShowCount: 3,
},
});重复展示间隔
{
type: "COUPON",
component: () => import("./CouponDialog.vue"),
control: {
repeatMs: 30 * 60 * 1000,
},
}repeatMs 开启后,同一个 type + key 在间隔内不会再次展示。内部还有 minRepeatGuardMs,默认 5000ms,用于避免过小间隔造成异常频繁弹窗。
用户隔离
频控存储 key 会带上用户作用域。你可以通过 getUserScope 按用户、租户或会话隔离记录。
const dialogSystem = defineDialogSystem(dialogs, {
getUserScope: () => userStore.userId,
storagePrefix: "my-app:dialog:",
});如果 getUserScope 返回空值,会使用默认用户作用域 guest。
scope 作用域
全局弹窗
不配置 control.scope 的弹窗属于默认 scope,即 default。
{
type: "GLOBAL_NOTICE",
component: () => import("./GlobalNotice.vue"),
}<DialogContainer />局部弹窗
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<DialogContainer scope="home" />只有 scope="home" 的容器 active 时,HOME_POPUP 才会展示。
autoActive
容器默认 autoActive=true,挂载时会把自己的 scope 标记为 active,卸载或 deactivated 时标记为不活跃。
<DialogContainer scope="home" :auto-active="false" />关闭自动激活后,需要手动控制:
dialogApi.setScopeActive(true, "home");
dialogApi.setScopeActive(false, "home");allowOutside 和 retainOutside
allowOutside:scope 不活跃时也允许展示该弹窗。retainOutside:scope 变为不活跃后保留队列和 pending 项,但不一定立刻展示。
{
type: "CROSS_PAGE_NOTICE",
component: () => import("./NoticeDialog.vue"),
control: {
scope: "home",
allowOutside: true,
retainOutside: true,
},
}关闭流程
弹窗组件的 openDialog(data?) 是必须的,closeDialog() 是可选但强烈建议实现的。
openDialog(data?):调度器展示弹窗时调用。closeDialog():调度器需要从外部关闭弹窗时调用,例如高优先级打断、api.closeAll()、scope 失活、api.closeCurrent()。- 用户在弹窗内部点击关闭后,需要调用
api.closeCurrent()通知调度器当前弹窗已结束。
推荐写法:
function handleClose() {
closeDialog();
dialogApi.closeCurrent();
}如果组件只把自己的 visible 设为 false,但不调用 api.closeCurrent(),调度器不会知道当前弹窗已经结束,也不会继续展示下一个队列项。
API
api.show(type, data?, meta?, order?, count?)
尝试立即展示弹窗。不能展示时会按规则入队或进入 pending。
dialogApi.show("WELCOME", data, { key: "welcome" });api.open(...) / api.forceShow(...)
show 的别名。
api.enqueue(type, data?, meta?, order?, count?)
加入等待队列,不主动打断当前弹窗。
api.add(...)
enqueue 的别名。
api.push(input)
推入一个或多个服务端风格队列项。
dialogApi.push({ type: "WELCOME", data, key: "welcome" });
dialogApi.push([{ type: "A" }, { type: "B" }]);api.fetch()
执行创建系统时配置的 source,并把返回项推入队列。
api.handleExternalSignal()
外部信号入口,内部等同于调用 fetch()。适合 WebSocket、SSE、轮询事件触发。
api.showNext()
手动消费队列中下一个可展示弹窗。通常业务不需要直接调用,closeCurrent() 会在关闭延迟后自动继续消费。
api.showWhenReady(type, data?, options?)
等待组件 ref 可用后展示,返回 Promise<boolean>。
api.closeCurrent()
关闭当前弹窗,清理当前展示状态,并在 closeDelayMs 后尝试展示下一个队列项。
api.closeAll()
关闭所有已知弹窗,清理当前展示状态,并恢复队列消费状态。
api.pause() / api.resume()
暂停或恢复队列消费。暂停不会关闭当前弹窗。
dialogApi.pause();
dialogApi.resume();api.markShown(type, dialogId?)
手动标记某个 type + key 已展示,并从队列中移除同 identity 项。
dialogApi.markShown("WELCOME", "welcome-key");api.reset()
关闭弹窗、清空队列、清空 pending 回调、清理 ref,并执行 onReset 钩子。
api.setActive(value, scope?) / api.setScopeActive(value, scope?)
设置 scope 是否 active。setActive 和 setScopeActive 是同一个能力。
dialogApi.setScopeActive(true, "home");api.setContext(context?) / api.addContext(context) / api.removeContext(context)
设置展示守卫使用的页面或业务上下文。context 可以是字符串、数字或数组。
dialogApi.setContext("route:checkout");
dialogApi.addContext("state:form-dirty");
dialogApi.removeContext("state:form-dirty");api.getDialogId(data?, meta?)
按系统配置和传入 meta 解析调度 key。
api.getDialogCount(data?, meta?)
按系统配置和传入 meta 解析最大展示次数。
api.getDialogOrder(data?, meta?)
按系统配置和传入 meta 解析排序值。
api.state
响应式状态对象。Vue 3 使用 shallowRef,Vue 2 使用 Vue.observable({ value }) 模拟 ref 结构。
const {
queue,
currentDialog,
isProcessing,
activeScopes,
contexts,
pendingDialogs,
refs,
} = dialogApi.state;字段:
| 字段 | 说明 |
| ------------------- | ----------------------------------------------------------- |
| queue | 当前等待队列。 |
| currentDialog | 当前展示的弹窗 type。 |
| currentDialogData | 当前弹窗 data。 |
| currentDialogMeta | 当前弹窗调度 meta。 |
| isProcessing | 是否正在展示弹窗。 |
| activeScopes | 当前 active scope 集合。 |
| contexts | 当前通过 setContext / addContext 设置的展示守卫上下文。 |
| pendingDialogs | 组件尚未挂载或 ref 未就绪的等待项。 |
| refs | 已注册的弹窗组件 ref。 |
Bridge
bridge 是给外部系统或旧代码使用的轻量入口。
const { bridge } = dialogSystem;
bridge.closeActiveDialog();
bridge.markDialogShown("WELCOME", "welcome");
bridge.pauseDialogQueue();
bridge.resumeDialogQueue();Controller
controller 暴露更底层的能力,适合封装适配层、调试工具或迁移旧系统。普通业务优先使用 api。
常用字段:
controller.dialogQueue;
controller.currentDialog;
controller.currentDialogData;
controller.currentDialogMeta;
controller.isProcessing;
controller.activeScopes;
controller.contexts;
controller.dialogRefs;
controller.pendingDialogs;常用方法:
controller.setDialogRef(type, dialogRef);
controller.addDialog(type, data, meta);
controller.pushDialogs(input);
controller.showDialog(type, data, meta);
controller.closeCurrentDialog();
controller.closeAllDialogs();
controller.pauseQueue();
controller.resumeQueue();
controller.fetchAndProcessDialogs();
controller.setScopeActive(true, "home");
controller.setContext("route:checkout");
controller.addContext("state:form-dirty");
controller.removeContext("state:form-dirty");
controller.markDialogAsShown(type, dialogId);
controller.resetAll();
controller.handleExternalDialogSignal();辅助分组:
controller.limit.hasShown(type, dialogId, countLimit);
controller.limit.markShown(type, dialogId, countLimit);
controller.limit.clear(type, dialogId);
controller.limit.getShownCount(type, dialogId);
controller.config.getDialogConfig(type);
controller.config.getDialogPriority(type);
controller.config.isDialogEnabled(type);
controller.config.isHighPriorityDialog(type);
controller.config.hasDialogDailyLimit(type);
controller.config.shouldAppendDialogQueue(type);
controller.config.canDialogShowOutsideContext(type);
controller.config.shouldRetainDialogWhenOutsideContext(type);
controller.config.getDialogRepeatControl(type);高级入口 createDialogSystem
createDialogSystem 接收底层配置 DialogSystemOptions。当你已经有一套历史配置、需要自己做转换,或正在封装更上层 DSL 时使用它。
import { createDialogSystem } from "@brmtech/vue-dialog-queue/vue3";
const dialogSystem = createDialogSystem({
configs: [
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
priority: 10,
mountScope: "default",
hasDailyLimit: true,
queueStrategy: "dedupe",
isHighPriority: false,
enabled: true,
deny: ["route:checkout", "route:payment"],
blocked: "defer",
repeatControl: {
enabled: false,
intervalMs: 0,
},
},
],
defaultMountScope: "default",
storagePrefix: "my-app:dialog:",
getUserScope: () => userStore.userId,
});DialogSystemConfig 常用字段:
| 字段 | 说明 |
| ---------------------------------------------------- | --------------------------------------------- |
| type | 弹窗唯一类型。 |
| component | 异步组件加载函数。 |
| priority | 默认权重,数值越小越先展示。 |
| hasDailyLimit / dailyLimit | 是否开启每日限制。 |
| mountScope | 容器作用域。 |
| allowOutsideContext / allowOutsideHome | scope 不活跃时仍允许展示。 |
| retainWhenOutsideContext / retainWhenOutsideHome | scope 不活跃后保留队列和 pending 项。 |
| queueStrategy | dedupe 或 append。 |
| isHighPriority / highPriority | 是否高优先级。 |
| enabled | 是否启用。 |
| allow | 配置后,只允许在指定业务 context 标签中展示。 |
| deny | 配置后,在指定业务 context 标签中禁止展示。 |
| blocked | 守卫失败时选择延后或丢弃。 |
| guard | 自定义展示判断。 |
| repeatControl | 重复展示间隔配置。 |
自定义 data 解析
默认调度器不读取 data。如果服务端字段都在 data 里,可以配置解析函数,后续触发时少写 meta。
export const dialogSystem = defineDialogSystem(dialogs, {
getDialogId: (data) => {
return typeof data === "object" && data && "id" in data
? String((data as { id: string | number }).id)
: undefined;
},
getDialogCount: (data) => {
return typeof data === "object" && data && "maxShowCount" in data
? Number((data as { maxShowCount: number }).maxShowCount)
: undefined;
},
getDialogOrder: (data) => {
return typeof data === "object" && data && "order" in data
? Number((data as { order: number }).order)
: undefined;
},
});之后可以这样触发:
dialogApi.push({
type: "ACTIVITY",
data: {
id: 1001,
order: 1,
maxShowCount: 1,
},
});自定义存储
默认频控使用 window.localStorage,不可用时降级到内存存储。你也可以传入自定义存储。
const memory = new Map<string, string>();
const dialogSystem = defineDialogSystem(dialogs, {
storage: {
get: (key) => memory.get(key) ?? null,
set: (key, value) => {
memory.set(key, value);
},
remove: (key) => {
memory.delete(key);
},
},
});存储值使用 JSON,包含:
interface DialogLimitData {
date: string;
shownCount: number;
lastShownTime: number;
}TypeScript 用法
限制弹窗 type
import { defineDialogSystem } from "@brmtech/vue-dialog-queue/vue3";
type DialogType = "WELCOME" | "RECHARGE" | "ACTIVITY";
export const dialogSystem = defineDialogSystem<DialogType>([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
]);
dialogSystem.api.show("WELCOME");统一 data 类型
interface DialogData {
title?: string;
id?: string | number;
}
type DialogType = "WELCOME" | "ACTIVITY";
export const dialogSystem = defineDialogSystem<DialogType, DialogData>([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
]);
dialogSystem.api.show("WELCOME", { title: "欢迎" });常用导出类型
import type {
DefineDialogSystemOptions,
DialogComponentRef,
DialogContextName,
DialogControlFlag,
DialogControlOptions,
DialogDefinition,
DialogDefinitionControl,
DialogGuardBlockedAction,
DialogGuardContext,
DialogGuardContextInput,
DialogGuardDecision,
DialogGuardFn,
DialogMeta,
DialogMetaInput,
DialogPushItem,
DialogQueueItem,
DialogQueueStrategy,
DialogRepeatControlConfig,
DialogResolvedMeta,
DialogSourceItem,
DialogSystem,
DialogSystemApi,
DialogSystemBridge,
DialogSystemConfig,
DialogSystemController,
DialogSystemInstance,
DialogSystemOptions,
DialogSystemState,
DialogSystemStorage,
DialogWaitOptions,
} from "@brmtech/vue-dialog-queue/types";Vue 2 / Vue 3 差异
| 项目 | Vue 2 | Vue 3 |
| ------------ | ---------------------------------------------- | ------------------------------------------- |
| 推荐入口 | @brmtech/vue-dialog-queue/vue2 | @brmtech/vue-dialog-queue/vue3 |
| 容器实现 | Vue.extend + render 函数 | defineComponent + render 函数 |
| 异步组件 | Vue 2 async component 工厂 | defineAsyncComponent |
| 响应式状态 | Vue.observable({ value }) 或普通 { value } | shallowRef |
| ref 同步 | $refs + mounted/updated/activated | function ref + lifecycle hooks |
| 组件暴露方法 | methods.openDialog / methods.closeDialog | defineExpose({ openDialog, closeDialog }) |
如果导错版本入口,会在创建系统或使用运行时能力时抛出明确错误:
@brmtech/vue-dialog-queue/vue2 requires Vue 2. Use @brmtech/vue-dialog-queue/vue3 in Vue 3 projects.
@brmtech/vue-dialog-queue/vue3 requires Vue 3. Use @brmtech/vue-dialog-queue/vue2 in Vue 2 projects.构建和发布检查
pnpm type-check
pnpm build
pnpm pack:check构建脚本会生成 ESM、CJS 和 .d.ts 文件,并把 CJS 内部引用从 .js 修正为 .cjs。
常见问题
根入口为什么没有 defineDialogSystem?
因为这个包同时支持 Vue 2 和 Vue 3。根入口如果导出裸的 defineDialogSystem,用户会误以为存在默认版本,也可能在迁移时导错运行时。请按项目版本导入 /vue2 或 /vue3,或者使用根入口的显式别名。
Vue 2 项目能不能从根入口导入?
可以,但更推荐直接导入 /vue2。根入口用于显式汇总,会同时暴露两个版本的命名空间;版本子入口更清晰,也更利于打包器只处理当前版本代码。
设置了 scope: "home",为什么其它页面也弹?
通常是因为 <DialogContainer scope="home" /> 被放在 App.vue 或常驻 layout 中,导致 home scope 一直处于 active。把容器放到对应页面内,或设置 :auto-active="false" 并手动调用 setScopeActive。
全局容器和局部容器会互相影响吗?
不会。默认全局容器是 scope="default",局部容器例如 scope="home"。注册到 home 的弹窗只会渲染到 home 容器。
服务端推了多个相同 type,只弹了一个?
默认按 type + key 去重。如果没有传 key,同 type 会被视为同一个默认 key。服务端多条业务弹窗请传 key / id / dialogId。
想让同一个弹窗重复排队怎么办?
开启 append。
control: {
append: true;
}弹窗组件必须实现 closeDialog 吗?
openDialog 必须实现。closeDialog 可选,但强烈建议实现。否则 api.closeCurrent()、高优先级打断、scope 失活、api.closeAll() 只能清理调度器状态,无法可靠关闭组件 UI。
能不能在弹窗组件里关闭后自动展示下一个?
可以,关闭按钮里调用 dialogApi.closeCurrent()。如果只把组件自己的 visible 设为 false,调度器不会继续消费队列。
unknown type 为什么没有反应?
未注册的 type 会被忽略,避免拼错的弹窗永久进入 pending 队列。可以通过 controller.config.getDialogConfig(type) 调试注册状态。
Introduction
@brmtech/vue-dialog-queue is a dialog queue and scheduling package for both Vue 2 and Vue 3. It brings local dialogs, server-driven dialogs, page scopes, priorities, deduplication, rate limits, and repeat-display control into one API, while splitting the Vue 2 and Vue 3 runtimes into independent entries. Consumers import the runtime that matches their project version, and there is no implicit default Vue 3 entry.
Features
- Shows only one dialog at a time and automatically consumes the next queued item after the current one closes.
- Supports both Vue 2 and Vue 3, with runtime entries exposed through
/vue2and/vue3. - Keeps the root entry explicit and provides
vue2/vue3namespaces plusdefineVue2DialogSystem/defineVue3DialogSystemaliases. - Publishes type definitions from
types.ts, which can be imported from the root entry or the/typessub-entry. - Supports global dialogs, page-local dialogs, and multiple custom
scopecontainers. - Supports
allow,deny, andguardgates to control whether a dialog should display, defer, or drop based on business context. - Supports single-item push, batch push, and async-source driven dialogs from the server.
- Deduplicates by
type + keyby default to avoid repeated pushes creating back-to-back dialogs. - Supports
appendto allow the sametype + keyto enter the queue multiple times. - Supports
weightandordersorting. - Supports high-priority dialogs preempting lower-priority dialogs.
- Supports daily limits, max show counts, repeat intervals, and user-scoped rate-limit isolation.
- Uses
vueas a peer dependency and does not bundle it into the package.
Install
npm install @brmtech/vue-dialog-queuepnpm add @brmtech/vue-dialog-queueInstall the matching version of vue in your own project. The peer dependency range is ^2.6.0 || ^3.3.0 || ^3.4.0 || ^3.5.0.
Entry Selection
Vue 3 Projects
import {
defineDialogSystem,
createDialogSystem,
} from "@brmtech/vue-dialog-queue/vue3";Vue 2 Projects
import {
defineDialogSystem,
createDialogSystem,
} from "@brmtech/vue-dialog-queue/vue2";Root Entry
The root entry does not export a bare defineDialogSystem, so it never implies a default Vue runtime. It only provides explicit version aliases and namespaces, which is useful for cross-version wrappers and migration tooling.
import {
defineVue2DialogSystem,
defineVue3DialogSystem,
createVue2DialogSystem,
createVue3DialogSystem,
vue2,
vue3,
} from "@brmtech/vue-dialog-queue";
const vue2System = defineVue2DialogSystem([]);
const vue3System = defineVue3DialogSystem([]);
const sameVue2System = vue2.defineDialogSystem([]);
const sameVue3System = vue3.defineDialogSystem([]);Types Entry
import type {
DialogDefinition,
DialogMeta,
DialogPushItem,
DialogSystemApi,
DialogSystemInstance,
} from "@brmtech/vue-dialog-queue/types";You can also import types from a version-specific entry:
import type { DialogDefinition } from "@brmtech/vue-dialog-queue/vue3";Package Layout
The source code is split by responsibility:
src/
index.ts # explicit Vue2/Vue3 aggregation and type re-export
vue2.ts # Vue 2 runtime adapter: Vue.extend, $refs, Vue.nextTick
vue3.ts # Vue 3 runtime adapter: defineComponent, defineAsyncComponent, shallowRef
core.ts # framework-agnostic queue, sorting, scope, rate-limit, and API logic
types.ts # all public typesThe published build exposes:
dist/index.js dist/index.cjs dist/index.d.ts
dist/vue2.js dist/vue2.cjs dist/vue2.d.ts
dist/vue3.js dist/vue3.cjs dist/vue3.d.ts
dist/types.js dist/types.cjs dist/types.d.ts
dist/core.js dist/core.cjs dist/core.d.tsQuick Start: Vue 3
Create the Dialog System
// dialog.ts
import { defineDialogSystem } from "@brmtech/vue-dialog-queue/vue3";
export type DialogType = "WELCOME" | "RECHARGE";
export interface DialogData {
title?: string;
amount?: number;
}
export const dialogSystem = defineDialogSystem<DialogType, DialogData>([
{
type: "WELCOME",
component: () => import("./dialogs/WelcomeDialog.vue"),
weight: 10,
control: ["daily"],
},
{
type: "RECHARGE",
component: () => import("./dialogs/RechargeDialog.vue"),
weight: 1,
control: ["high"],
},
]);
export const DialogContainer = dialogSystem.Container;
export const dialogApi = dialogSystem.api;
export const dialogBridge = dialogSystem.bridge;Mount the Container
<!-- App.vue -->
<template>
<DialogContainer />
<RouterView />
</template>
<script setup lang="ts">
import { DialogContainer } from "./dialog";
</script>Write a Dialog Component
Vue 3 dialog components must expose openDialog(data?) through defineExpose, and closeDialog() is strongly recommended. When the user closes the dialog from inside the component, call dialogApi.closeCurrent() so the scheduler can continue consuming the queue.
<!-- dialogs/WelcomeDialog.vue -->
<script setup lang="ts">
import { ref } from "vue";
import { dialogApi, type DialogData } from "../dialog";
const visible = ref(false);
const payload = ref<DialogData | undefined>();
function openDialog(data?: DialogData) {
payload.value = data;
visible.value = true;
}
function closeDialog() {
visible.value = false;
}
function handleClose() {
closeDialog();
dialogApi.closeCurrent();
}
defineExpose({
openDialog,
closeDialog,
});
</script>
<template>
<div v-if="visible" class="dialog">
<h3>{{ payload?.title ?? "Welcome" }}</h3>
<button type="button" @click="handleClose">Close</button>
</div>
</template>Trigger a Dialog
dialogApi.show("WELCOME", { title: "Welcome back" });Quick Start: Vue 2
Create the Dialog System
// dialog.ts
import { defineDialogSystem } from "@brmtech/vue-dialog-queue/vue2";
export const dialogSystem = defineDialogSystem([
{
type: "WELCOME",
component: () => import("./dialogs/WelcomeDialog.vue"),
control: ["daily"],
},
]);
export const DialogContainer = dialogSystem.Container;
export const dialogApi = dialogSystem.api;Mount the Container
<!-- App.vue -->
<template>
<div>
<DialogContainer />
<router-view />
</div>
</template>
<script>
import { DialogContainer } from "./dialog";
export default {
components: {
DialogContainer,
},
};
</script>Write a Dialog Component
Vue 2 dialog components must provide openDialog(data?) inside methods, and closeDialog() is strongly recommended as well.
<!-- dialogs/WelcomeDialog.vue -->
<template>
<div v-if="visible" class="dialog">
<h3>{{ payload && payload.title ? payload.title : "Welcome" }}</h3>
<button type="button" @click="handleClose">Close</button>
</div>
</template>
<script>
import { dialogApi } from "../dialog";
export default {
data() {
return {
visible: false,
payload: undefined,
};
},
methods: {
openDialog(data) {
this.payload = data;
this.visible = true;
},
closeDialog() {
this.visible = false;
},
handleClose() {
this.closeDialog();
dialogApi.closeCurrent();
},
},
};
</script>Core Concepts
type
type is the unique dialog type and also the name used when triggering dialogs. Every type inside a system instance must be unique. Duplicate registration throws an error.
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
}component
component is an async component loader that returns { default: Component }. The component must provide an openDialog(data?) method, and the scheduler calls it when the dialog is actually displayed.
data
data is the only business payload passed to the dialog component as-is. The scheduler does not read, modify, or parse data unless you configure project-specific adapters such as getDialogId, getDialogCount, or getDialogOrder.
dialogApi.show("WELCOME", {
title: "Welcome back",
source: "home",
});meta
meta only affects scheduling behavior and is never passed into the dialog component. It controls dedupe keys, ordering, show counts, and dynamic priority.
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
order: 1,
weight: 5,
maxShowCount: 1,
},
});DialogMetaInput supports three forms:
dialogApi.show("WELCOME", data, "welcome-key");
dialogApi.show("WELCOME", data, 1001);
dialogApi.show("WELCOME", data, { key: "welcome-key", order: 1 });scope
scope is a container scope string. It does not read route information automatically. A dialog renders into a container only when the dialog's control.scope matches the container's scope. If not configured, the default scope is default.
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<DialogContainer scope="home" />defineDialogSystem
Use defineDialogSystem for normal projects first. It accepts either a dialog definition array or a full options object.
const system = defineDialogSystem([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
]);const system = defineDialogSystem({
dialogs: [
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
control: ["daily"],
},
],
source: async () => [
{
type: "WELCOME",
data: { title: "Returned by API" },
key: "welcome-from-api",
order: 1,
},
],
defaultScope: "default",
storagePrefix: "my-app:dialog:",
getUserScope: () => userStore.userId,
});It returns:
const { Container, api, bridge, controller } = system;Container: Vue container component that renders the currently mounted dialog component.api: the main business-facing dialog API.bridge: a lightweight integration API for external systems or legacy code.controller: a lower-level controller used for advanced wrappers, debugging, and migration.
DialogDefinition
interface DialogDefinition<TType extends string = string> {
type: TType;
component: () => Promise<{ default: Component }>;
weight?: number;
control?: DialogDefinitionControl;
}weight
Static default weight. Smaller values are shown earlier. At runtime it can be overridden by meta.weight or meta.priority.
control
control can be written as a string, an array, or an object.
control: "daily"
control: ["daily", "high"]
control: {
daily: true,
high: true,
append: true,
repeatMs: 60_000,
scope: "home",
allowOutside: true,
retainOutside: true,
deny: ["route:checkout", "route:payment"],
blocked: "defer",
}Available fields:
| Field | Description |
| --------------- | ------------------------------------------------------------------------------------------------------------ |
| enabled | Keeps the dialog registered but ignores every trigger when set to false. |
| daily | Enables a daily limit; by default the same type + key shows at most once per day. |
| high | Marks the dialog as high priority and allows it to interrupt lower-priority dialogs. |
| append | Allows the same type + key to enter the queue multiple times. |
| repeatMs | Minimum interval before the same type + key can show again. |
| scope | Chooses which container scope renders the dialog. |
| allowOutside | Allows display even when the scope is not active. |
| retainOutside | Retains queue and pending items after the scope becomes inactive. |
| allow | Only allows display when the current context hits one of the given labels. |
| deny | Blocks display when the current context hits one of the given labels; takes precedence over allow. |
| blocked | Defaults to "defer". Chooses between "defer" and "drop" when allow / deny / guard does not pass. |
| guard | Custom display decision function. |
String flags map to object fields as follows:
| String | Object field |
| --------- | --------------------- |
| daily | daily: true |
| high | high: true |
| append | append: true |
| outside | allowOutside: true |
| retain | retainOutside: true |
Display Guards
allow / deny configure business context labels, not container names and not automatically derived route names. Context must be maintained explicitly through api.setContext(), api.addContext(), and api.removeContext().
Prefixing context labels is recommended so they do not get confused with scope values:
dialogApi.setContext("route:home");
dialogApi.addContext("state:vip");
dialogApi.addContext("flow:payment");Context can be a string, a number, or an array. Empty values, blank strings, and whitespace-only strings are ignored.
Relationship With scope
scope and allow / deny are combined conditions:
can show = scope container is active or allowOutside
&& current contexts pass allow / deny / guardIn other words, scope decides where the dialog mounts, while allow / deny decides whether the current business environment permits showing it. allow never moves a dialog across scopes.
| Requirement | Recommended config |
| --------------------------------------- | --------------------------------- |
| Dialog belongs only to a single page | scope only |
| Global dialog, blocked on some pages | scope: "default" + deny |
| Global dialog, shown only on some pages | scope: "default" + allow |
| Page dialog gated by business state | scope + allow |
| Temporarily block dialogs in a flow | maintain flow:* labels + deny |
| Read data or apply complex logic | guard |
Guard evaluation order:
- If
denymatches, do not show and followblocked. - If
allowis configured but no current context matches, do not show and followblocked. - Execute
guardand use its return value to allow, defer, or drop. - After the guard passes, check whether the
scopecontainer is active.
deny has higher priority than allow. When allow is not configured or is an empty array, whitelist restriction is considered disabled.
Where Business Labels Should Live
The package does not read routes, stores, or page state by itself. Business labels are usually maintained in a dedicated project file such as dialog-context.ts:
// dialog-context.ts
import { dialogApi } from "./dialog";
import { router } from "./router";
import { userStore } from "./stores/user";
let pageContexts: string[] = [];
function getDialogContexts() {
const route = router.currentRoute.value;
const contexts = [
`route:${String(route.name ?? route.path)}`,
...pageContexts,
];
if (userStore.isVip) contexts.push("state:vip");
if (userStore.isLoggedIn) contexts.push("state:logged-in");
return contexts;
}
export function syncDialogContexts() {
dialogApi.setContext(getDialogContexts());
}
export function setDialogPageContexts(contexts: string[]) {
pageContexts = contexts;
syncDialogContexts();
}Then synchronize it when routes, login state, or page state changes:
router.afterEach(() => {
syncDialogContexts();
});
userStore.$subscribe(() => {
syncDialogContexts();
});Page-local business states can be written through project helpers:
// PaymentPage.vue
import { setDialogPageContexts } from "./dialog-context";
onMounted(() => {
setDialogPageContexts(["flow:payment"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});setContext() replaces the full current context set and is suitable for full sync. addContext() / removeContext() are better for temporary labels. If routes or stores call setContext() frequently, a centralized aggregator like the example above is safer because temporary labels will not be overwritten by accident.
When you receive many server-driven dialogs at once, prefer api.push([...]) so the scheduler can sort and clean the queue in batch. Also keep guard functions lightweight because they may run repeatedly during queue scans.
Scenario 1: Page-Specific Dialog
For a dialog that only belongs to one page, configure only scope and do not use allow to represent the page.
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<!-- HomePage.vue -->
<template>
<DialogContainer scope="home" />
</template>dialogApi.show("HOME_POPUP");Result: it only shows while the scope="home" container in HomePage is active.
Scenario 2: Global Dialog Blocked on Some Pages
This fits announcement, campaign, or marketing dialogs. Keep a global container and blacklist pages with deny.
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
scope: "default",
deny: ["route:checkout", "route:payment"],
blocked: "defer",
},
}Result: the dialog does not show on checkout or payment pages, and resumes trying after the user leaves those pages.
Scenario 3: Global Dialog Shown Only on Some Pages
This fits coupons or guidance dialogs that should appear only on the home page or activity pages.
{
type: "COUPON",
component: () => import("./CouponDialog.vue"),
control: {
scope: "default",
allow: ["route:home", "route:activity"],
blocked: "drop",
},
}Result: it shows only when the current context is route:home or route:activity; otherwise the trigger is dropped immediately.
Scenario 4: Local Dialog Combined With Business State
This fits cases like “home page dialog, but only after the home data is ready.”
<!-- HomePage.vue -->
<template>
<DialogContainer scope="home" />
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted } from "vue";
import { DialogContainer } from "./dialog";
import { setDialogPageContexts } from "./dialog-context";
onMounted(async () => {
await loadHomeData();
setDialogPageContexts(["state:home-ready"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});
</script>{
type: "HOME_COUPON",
component: () => import("./HomeCouponDialog.vue"),
control: {
scope: "home",
allow: ["state:home-ready"],
blocked: "defer",
},
}Result: it shows only when the home container is active and the state:home-ready context exists.
Scenario 5: Temporarily Block Dialogs During a Flow
This fits payment flows, real-name verification flows, or critical form editing, where marketing dialogs should be blocked temporarily.
import { setDialogPageContexts } from "./dialog-context";
onMounted(() => {
setDialogPageContexts(["flow:payment"]);
});
onBeforeUnmount(() => {
setDialogPageContexts([]);
});{
type: "MARKETING",
component: () => import("./MarketingDialog.vue"),
control: {
scope: "default",
deny: ["flow:payment"],
blocked: "defer",
},
}Result: the dialog does not show during the payment flow and keeps retrying after the flow ends.
Scenario 6: Choosing Between defer and drop
blocked only applies when allow / deny / guard fails, such as a deny hit, an allow miss, or a guard returning false. When a scope is inactive, the dialog still follows normal scope queue behavior and does not use blocked.
Use defer when the dialog should still show later:
control: {
scope: "default",
deny: ["route:payment"],
blocked: "defer",
}Use drop when missing the current scenario means the dialog is no longer needed:
control: {
scope: "default",
allow: ["route:home"],
blocked: "drop",
}Scenario 7: Custom guard
Use guard for more complex business rules. If guard is not configured, no extra custom restriction is added. A guard must be synchronous and cannot return a Promise. It may run many times during queue checks, so it should remain lightweight and side-effect free. Do not request APIs, mutate stores, or directly open/close dialogs inside it.
guard return values:
| Return value | Meaning |
| -------------------- | ------------------------------------------------------------------------------ |
| true | Allow display. |
| undefined / null | Also allow display; useful when the function only handles a few special cases. |
| false | Block display and use the configured blocked behavior. |
| "defer" | Do not show now; keep the dialog in queue and retry when context changes. |
| "drop" | Do not show and discard the current trigger. |
guard parameters:
| Parameter | Description |
| -------------- | ------------------------------------------------------------------------------------------- |
| type | Current dialog type. |
| data | Data passed to the dialog component for this trigger. |
| meta | Scheduling information for this trigger, such as dialogId, order, count, or weight. |
| scope | The container scope registered for the dialog. |
| activeScopes | Set of currently active scopes. |
| contexts | Set of current business context labels. |
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
blocked: "defer",
guard: ({ contexts, data }) => {
if (contexts.has("flow:payment")) return "defer"
if ((data as { expired?: boolean } | undefined)?.expired) return "drop"
return true
},
},
}Result: defer during the payment flow, drop expired activities, and show normally in every other case.
Anti-Patterns
Do not use allow to represent where the dialog should mount. The following configuration will not mount the dialog on the activity page:
control: {
scope: "home",
allow: ["route:home", "route:activity"],
}If the real requirement is “show on both home and activity pages,” mount the dialog in a shared scope and filter with context:
control: {
scope: "default",
allow: ["route:home", "route:activity"],
}Also avoid configuring allow without ever calling setContext(). If allow is non-empty and no current context matches, the trigger is handled with blocked, which defaults to defer.
Local Triggering
api.show(type, data?, meta?, order?, count?)
Force an immediate display attempt. If the current scope is displayable, the component ref is ready, and there is no higher-priority conflict, the scheduler calls the component's openDialog(data) immediately. Otherwise, the dialog enters the queue or pending state.
dialogApi.show("WELCOME", { title: "Welcome back" });api.open(...) / api.forceShow(...)
Aliases of show.
dialogApi.open("WELCOME");
dialogApi.forceShow("WELCOME");api.enqueue(type, data?, meta?, order?, count?)
Queue the dialog without forcing an immediate interruption of the current dialog.
dialogApi.enqueue("WELCOME", data, { key: "welcome" });api.add(...)
Alias of enqueue.
api.showWhenReady(type, data?, options?)
Wait until the component ref becomes available and then try to show the dialog. Returns Promise<boolean>. The default timeout is 10000ms.
const shown = await dialogApi.showWhenReady("WELCOME", data, {
timeout: 5000,
meta: { key: "welcome" },
});options can also directly contain key, order, count, and weight:
await dialogApi.showWhenReady("WELCOME", data, {
timeout: 5000,
key: "welcome",
order: 1,
});Server Push
Single Push
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
order: activity.sort,
maxShowCount: 1,
},
});Batch Push
dialogApi.push([
{
type: "WELCOME",
data: welcomeData,
meta: { key: "welcome" },
},
{
type: "ACTIVITY",
data: activity,
meta: { key: activity.id, order: 1 },
},
]);Batch push sorts items in one batch so the queue is not re-sorted after every insertion.
Direct Mapping of Server Fields
DialogSourceItem allows common server fields to be placed directly on the top level. The scheduler merges them into scheduling meta internally.
dialogApi.push({
type: "ACTIVITY",
data: activity,
id: activity.id,
order: activity.sort,
count: 1,
weight: 5,
});Field aliases:
| Top-level field | Target meta field |
| ------------------------- | ----------------- |
| id / key / dialogId | dialogId |
| order | order |
| count / maxShowCount | count |
| weight / priority | weight |
Async Source source
source is useful for initial fetching, polling, or processing server-side dialog lists. It runs when you call api.fetch() or api.handleExternalSignal().
export const dialogSystem = defineDialogSystem({
dialogs,
source: async () => {
const list = await requestDialogList();
return list.map((item) => ({
type: item.type,
data: item.payload,
id: item.id,
order: item.order,
count: item.maxShowCount,
weight: item.weight,
}));
},
});
await dialogSystem.api.fetch();source may return an array or void. When nothing is returned, the queue remains unchanged.
Ordering Rules
Queue ordering follows these rules:
- High-priority dialogs come before normal dialogs.
- If both dialogs are high priority, compare
weight; smaller values come first. - Normal dialogs also compare
weight; smaller values come first. - When
weightis equal, compareorder; smaller values come first. - When
orderis missing, it is treated as the last position.
dialogApi.push([
{ type: "A", meta: { weight: 10, order: 2 } },
{ type: "B", meta: { weight: 1, order: 9 } },
{ type: "C", meta: { weight: 1, order: 1 } },
]);The resulting display order is C, then B, then A.
High-Priority Dialogs
Enable a high-priority dialog through control.high or control: "high". High-priority dialogs can interrupt the currently visible lower-priority dialog, and the interrupted dialog is pushed back into the queue.
{
type: "URGENT_NOTICE",
component: () => import("./UrgentNotice.vue"),
weight: 0,
control: {
high: true,
},
}If the currently visible dialog is also high priority, the scheduler keeps comparing weights. The new dialog interrupts only when its weight is higher, meaning the number is smaller.
Deduplication, Rate Limits, and Repeat Display
Dedupe Key
By default, deduplication uses type + dialogId. dialogId comes from meta.key, meta.id, or meta.dialogId. If none is provided, the internal default key is used.
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: { key: activity.id },
});When the same type + key is enqueued again, the existing queue item is updated with the new data and ordering information instead of creating a new entry.
Allow Repeated Queueing
{
type: "TOAST_LIKE_DIALOG",
component: () => import("./ToastLikeDialog.vue"),
control: {
append: true,
},
}After enabling append, the same type + key can enter the queue multiple times.
Daily Limit
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
control: ["daily"],
}When enabled, the same type + key is shown at most once per day by default.
Max Show Count Per Trigger
You can set the max show count for the current day through meta.count or meta.maxShowCount.
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
maxShowCount: 3,
},
});Repeat Interval
{
type: "COUPON",
component: () => import("./CouponDialog.vue"),
control: {
repeatMs: 30 * 60 * 1000,
},
}When repeatMs is enabled, the same type + key cannot be shown again within the configured interval. Internally, minRepeatGuardMs also exists with a default value of 5000ms to avoid extremely small intervals causing overly frequent dialog display.
User Isolation
Rate-limit storage keys include a user scope. You can isolate records by user, tenant, or session through getUserScope.
const dialog