vue-dialog-queue
v0.1.3
Published
Vue 3 dialog queue and scheduling system with scoped containers, priority, frequency control, and server push support.
Maintainers
Readme
vue-dialog-queue
Vue 3 弹窗队列调度器。它把本地弹窗、服务端推送弹窗、页面作用域、优先级、去重、频控和重复展示控制统一到一套 API 里。
特性
- 支持 Vue 3 异步弹窗组件。
- 同一时间只展示一个弹窗,关闭后自动消费下一个。
- 支持全局弹窗和页面局部弹窗。
- 支持
scope页面作用域,弹窗只在匹配容器中渲染。 - 支持服务端单条/批量推送弹窗。
- 默认按
type + key去重,避免重复推送造成连续弹窗。 - 支持
append,允许同一个type + key多次排队。 - 支持
weight、order排序。 - 支持高优先级弹窗打断低优先级弹窗。
- 支持每日展示限制、最大展示次数、重复展示间隔。
- 支持按用户/租户/会话隔离频控记录。
- 支持 TypeScript 类型推导。
vue是 peer dependency,不会被打进包里。
安装
npm install vue-dialog-queuepnpm add vue-dialog-queue核心概念
type
type 是弹窗类型,也是你触发弹窗时使用的名字。每个注册项的 type 必须唯一。
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
}data
data 是唯一会传给弹窗组件的参数。调度器不会读取、修改或解析它。
dialogApi.show("WELCOME", {
title: "欢迎回来",
source: "home",
})弹窗组件中通过 openDialog(data) 接收。
meta
meta 只控制调度行为,不会传给弹窗组件。
dialogApi.push({
type: "ACTIVITY",
data: activity,
meta: {
key: activity.id,
order: 1,
weight: 5,
maxShowCount: 1,
},
})scope
scope 是容器作用域。它不是自动路由识别字段,只是一个字符串。
弹窗注册时的 control.scope 必须和 DialogContainer 的 scope 一致,弹窗才会在这个容器中渲染。
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}<DialogContainer scope="home" />没有设置 scope 的弹窗会使用默认作用域 default,由 <DialogContainer /> 承载。
快速开始
创建弹窗系统:
// dialog.ts
import { defineDialogSystem } from "vue-dialog-queue"
export const dialogSystem = defineDialogSystem([
{
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挂载全局容器:
<!-- App.vue -->
<template>
<DialogContainer />
<RouterView />
</template>
<script setup lang="ts">
import { DialogContainer } from "./dialog"
</script>编写弹窗组件:
<!-- WelcomeDialog.vue -->
<script setup lang="ts">
import { ref } from "vue"
interface WelcomeData {
title?: string
}
const visible = ref(false)
const payload = ref<WelcomeData | undefined>()
function openDialog(data?: WelcomeData) {
payload.value = data
visible.value = true
}
function closeDialog() {
visible.value = false
}
defineExpose({
openDialog,
closeDialog,
})
</script>
<template>
<div v-if="visible">
<h3>{{ payload?.title ?? "欢迎" }}</h3>
<button @click="closeDialog">关闭</button>
</div>
</template>触发弹窗:
dialogApi.show("WELCOME", { title: "欢迎回来" })全局弹窗和局部弹窗
全局弹窗
不写 control.scope 的弹窗默认属于 default 作用域。
{
type: "GLOBAL_NOTICE",
component: () => import("./GlobalNotice.vue"),
}它会渲染到默认容器:
<DialogContainer />局部页面弹窗
注册时设置 scope:
{
type: "HOME_POPUP",
component: () => import("./HomePopup.vue"),
control: {
scope: "home",
},
}在对应页面挂同名容器:
<!-- Home.vue -->
<template>
<DialogContainer scope="home" />
</template>这样 HOME_POPUP 只会在 scope="home" 的容器里渲染,不会出现在全局 <DialogContainer /> 里。
App.vue 或 Layout 中常驻局部容器
如果你把局部容器放在 App.vue 或布局组件里,它会一直挂载。默认 autoActive=true,这个 scope 会一直 active,页面限制会失效。
这种场景要关闭自动激活,并根据路由手动同步:
<DialogContainer scope="home" :auto-active="false" />import { watch } from "vue"
import { useRoute } from "vue-router"
import { dialogSystem } from "./dialog"
const route = useRoute()
watch(
() => route.name,
name => {
dialogSystem.api.setScopeActive(name === "Home", "home")
},
{ immediate: true }
)也可以用 path:
watch(
() => route.path,
path => {
dialogSystem.api.setScopeActive(path === "/home", "home")
},
{ immediate: true }
)scope 可以是 path 或 name 吗?
可以,但库不会自动读取路由。scope 只是字符串。
control: { scope: "home" }
control: { scope: "Home" }
control: { scope: "/home" }只要它和容器的 scope 完全一致即可。
outside 和 allowOutside
如果你设置了:
control: ["outside"]或:
control: {
scope: "home",
allowOutside: true,
}这个弹窗即使当前 scope 不活跃,也允许展示。需要严格限制页面时不要开启它。
本地触发
立即尝试展示。如果当前不能展示,会根据 scope、优先级和队列状态进入队列或 pending。
dialogApi.show("WELCOME")传入组件参数:
dialogApi.show("RECHARGE", {
amount: 100,
currency: "CNY",
})传入调度参数:
dialogApi.show(
"RECHARGE",
{ amount: 100 },
{
key: "activity-1001",
weight: 1,
maxShowCount: 1,
}
)show 返回 boolean:
true:本次已经展示。false:本次未立即展示,可能被忽略、进入队列或等待容器 ready。
服务端推送
单条推送
dialogApi.push({
type: "ACTIVITY",
data: {
id: 1001,
title: "活动弹窗",
},
meta: {
key: 1001,
},
})批量推送
dialogApi.push([
{
type: "ACTIVITY",
data: { id: 1001, title: "活动 1" },
meta: { key: 1001, order: 1 },
},
{
type: "COUPON",
data: { id: 2001, title: "优惠券" },
meta: { key: 2001, order: 2 },
},
])批量推送会批处理排序,避免每条都重新排序。
同时推送多个相同弹窗
默认按 type + key 去重。
dialogApi.push([
{ type: "ACTIVITY", data: { id: 1, title: "旧数据" }, meta: { key: 1 } },
{ type: "ACTIVITY", data: { id: 1, title: "新数据" }, meta: { key: 1 } },
])最终只保留一个 ACTIVITY + key=1,后来的数据会更新前面的队列项。
不同 key 会作为不同队列项:
dialogApi.push([
{ type: "ACTIVITY", data: { id: 1 }, meta: { key: 1 } },
{ type: "ACTIVITY", data: { id: 2 }, meta: { key: 2 } },
])这两条会分别排队。
如果没有传 key,同 type 会使用默认 key,所以会被视为同一个弹窗:
dialogApi.push([
{ type: "ACTIVITY", data: { id: 1 } },
{ type: "ACTIVITY", data: { id: 2 } },
])这种场景只会保留一个。服务端推送多个业务弹窗时,建议一定传 meta.key。
允许相同弹窗重复排队
开启 append 后,同 type + key 也可以重复进入队列。
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
append: true,
},
}dialogApi.push([
{ type: "ACTIVITY", data: { id: 1 }, meta: { key: 1 } },
{ type: "ACTIVITY", data: { id: 1 }, meta: { key: 1 } },
])这两条都会排队展示。
异步来源 source
如果弹窗来自接口拉取,可以在创建系统时配置 source。
export const dialogSystem = defineDialogSystem({
dialogs,
source: async () => {
const list = await apiGetPopupList()
return list.map((item, index) => ({
type: item.type,
data: item.payload,
meta: {
key: item.id,
order: index,
maxShowCount: item.maxShowCount,
weight: item.weight,
},
}))
},
})触发拉取:
await dialogApi.fetch()WebSocket 收到通知后也可以复用:
socket.on("popup-change", () => {
dialogApi.fetch()
})排序规则
排序主要由三个因素决定:
- 高优先级弹窗优先于普通弹窗。
weight越小越先展示。weight相同时,order越小越先展示。
注册默认权重:
{
type: "RECHARGE",
component: () => import("./RechargeDialog.vue"),
weight: 1,
}单次触发覆盖权重:
dialogApi.push({
type: "RECHARGE",
data,
meta: {
weight: 0,
order: 1,
},
})高优先级弹窗
高优先级弹窗可以打断低优先级弹窗。
{
type: "URGENT_NOTICE",
component: () => import("./UrgentNotice.vue"),
weight: 0,
control: {
high: true,
},
}如果当前正在展示普通弹窗,优先级更高的高优先级弹窗会关闭当前弹窗,当前弹窗会重新回到队列中,之后继续展示。
去重、频控和重复展示
去重 key
key、id、dialogId 是别名。
meta: { key: 1001 }
meta: { id: 1001 }
meta: { dialogId: 1001 }它们都会解析为同一个调度 key。
每日限制
注册时开启每日限制:
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
control: ["daily"],
}同一个 type + key + userScope 当天最多展示一次。
单次最大展示次数
服务端可以通过 maxShowCount 或 count 控制当天最大展示次数。
dialogApi.push({
type: "ACTIVITY",
data,
meta: {
key: data.id,
maxShowCount: 3,
},
})重复展示间隔
{
type: "ACTIVITY",
component: () => import("./ActivityDialog.vue"),
control: {
repeatMs: 6 * 60 * 60 * 1000,
},
}同一个 type + key 两次展示之间至少间隔 6 小时。
库内部有最小保护值,默认 5 秒,可以通过 minRepeatGuardMs 调整。
用户隔离
默认频控按 guest 存储。如果你的应用有用户 id,建议配置 getUserScope。
export const dialogSystem = defineDialogSystem(dialogs, {
getUserScope: () => userStore.userId,
})这样不同用户之间不会共用展示记录。
control 配置
简单场景可以使用字符串标记:
control: ["daily", "high", "append", "outside", "retain"]对象写法更清晰,也支持更多配置:
control: {
enabled: true,
daily: true,
high: true,
append: false,
repeatMs: 6 * 60 * 60 * 1000,
scope: "home",
allowOutside: false,
retainOutside: true,
}| 字段 | 说明 |
| --- | --- |
| enabled | 设为 false 时保留注册,但所有触发都会被忽略。 |
| daily | 当天同 type + key 最多展示一次。 |
| high | 是否为高优先级弹窗。 |
| append | 是否允许同 type + key 重复排队。 |
| repeatMs | 同 type + key 再次展示前的最小间隔。 |
| scope | 渲染该弹窗的容器作用域。 |
| allowOutside | scope 不活跃时是否仍允许展示。 |
| retainOutside | scope 不活跃时是否保留队列和 pending 项。 |
API
api.show(type, data?, meta?, order?, count?)
立即尝试展示弹窗。
const shown = dialogApi.show("WELCOME", data, { key: "welcome" })返回 true 表示已经展示,返回 false 表示没有立即展示。
api.open(...) / api.forceShow(...)
show 的别名。
dialogApi.open("WELCOME")
dialogApi.forceShow("WELCOME")api.push(input)
推入一个或多个服务端弹窗项。
dialogApi.push({ type: "WELCOME", data, meta: { key: "welcome" } })
dialogApi.push([{ type: "A" }, { type: "B" }])api.enqueue(type, data?, meta?, order?, count?)
加入队列,不强调立即展示。
dialogApi.enqueue("WELCOME", data, { key: "welcome" })api.add(...)
enqueue 的别名。
api.fetch()
调用创建系统时配置的 source,并把返回结果推入队列。
await dialogApi.fetch()api.showNext()
手动消费队列中的下一个可展示弹窗。
一般不需要业务手动调用。
api.showWhenReady(type, data?, options?)
等待弹窗组件 ref 可用并展示成功。
const shown = await dialogApi.showWhenReady("WELCOME", data, {
timeout: 5000,
meta: { key: "welcome" },
})返回 true 表示在超时前展示成功,false 表示超时。
等待粒度是 type + key,同 type 不同 key 不会互相 resolve。
api.closeCurrent()
关闭当前弹窗,并在关闭延迟后继续展示下一个。
dialogApi.closeCurrent()api.closeAll()
关闭所有已注册弹窗组件,并清理当前展示状态。
dialogApi.closeAll()api.pause() / api.resume()
暂停或恢复队列消费。
dialogApi.pause()
dialogApi.resume()暂停不会关闭当前弹窗,只是不继续展示下一个。
api.markShown(type, key?)
手动标记某个 type + key 已展示,用于频控。
dialogApi.markShown("WELCOME", "welcome")api.reset()
关闭弹窗、清空队列、清空 pending、清空等待回调,并执行 onReset。
dialogApi.reset()api.setScopeActive(value, scope?)
手动设置某个 scope 是否 active。
dialogApi.setScopeActive(true, "home")
dialogApi.setScopeActive(false, "home")setActive 是它的别名。
api.handleExternalSignal()
外部信号入口,内部会调用 fetch()。
socket.on("popup-change", () => {
dialogApi.handleExternalSignal()
})api.getDialogId(data?, meta?)
解析调度 key,调试或适配时使用。
api.getDialogCount(data?, meta?)
解析最大展示次数。
api.getDialogOrder(data?, meta?)
解析队列顺序。
api.state
响应式状态,适合调试或做自定义状态面板。
dialogApi.state.queue
dialogApi.state.currentDialog
dialogApi.state.currentDialogData
dialogApi.state.currentDialogMeta
dialogApi.state.isProcessing
dialogApi.state.activeScopes
dialogApi.state.pendingDialogs
dialogApi.state.refsTypeScript 用法
限制弹窗 type
type DialogType = "WELCOME" | "RECHARGE" | "ACTIVITY"
export const dialogSystem = defineDialogSystem<DialogType>([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
{
type: "RECHARGE",
component: () => import("./RechargeDialog.vue"),
},
])
dialogSystem.api.show("WELCOME")
// dialogSystem.api.show("UNKNOWN") // TS 会报错统一 data 类型
type DialogType = "WELCOME" | "RECHARGE"
interface DialogData {
id?: string | number
title?: string
payload?: unknown
}
export const dialogSystem = defineDialogSystem<DialogType, DialogData>([
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
},
])
dialogSystem.api.show("WELCOME", {
title: "欢迎",
})常用导出类型
import type {
DialogDefinition,
DialogMeta,
DialogPushItem,
DialogQueueItem,
DialogSystemApi,
DialogSystemInstance,
DialogSystemState,
DialogControlOptions,
DialogWaitOptions,
} from "vue-dialog-queue"高级入口 createDialogSystem
普通项目优先使用 defineDialogSystem。如果你在做适配层、迁移旧系统或需要底层配置,可以使用 createDialogSystem。
import { createDialogSystem } from "vue-dialog-queue"
const dialogSystem = createDialogSystem({
configs: [
{
type: "WELCOME",
component: () => import("./WelcomeDialog.vue"),
priority: 10,
mountScope: "default",
hasDailyLimit: true,
queueStrategy: "dedupe",
},
],
defaultMountScope: "default",
storagePrefix: "my-app:dialog:",
getUserScope: () => userStore.userId,
})自定义 data 解析
默认情况下,调度器不会读取 data。如果你希望从业务数据里自动解析 key、次数或顺序,可以配置适配函数。
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
},
})之后可以少写 meta:
dialogApi.push({
type: "ACTIVITY",
data: {
id: 1001,
order: 1,
maxShowCount: 1,
},
})自定义存储
频控默认使用 localStorage,失败时降级到内存存储。也可以传入自定义存储。
const memory = new Map<string, string>()
defineDialogSystem(dialogs, {
storage: {
get: key => memory.get(key) ?? null,
set: (key, value) => {
memory.set(key, value)
},
remove: key => {
memory.delete(key)
},
},
})常见问题
设置了 scope: "home",为什么其它页面也弹?
检查 DialogContainer scope="home" 是否放在 App.vue 或常驻布局里。只要这个容器一直挂载,home 就一直 active。
解决方式:
- 把
<DialogContainer scope="home" />放到 Home 页面组件里。 - 或者设置
:auto-active="false",自己根据路由调用setScopeActive。
全局容器和局部容器会互相影响吗?
不会。默认全局容器是 scope="default",局部容器比如 scope="home"。注册了 control.scope="home" 的弹窗只会渲染到 home 容器。
服务端推了多个相同 type,只弹了一个?
默认按 type + key 去重。如果没有传 key,同 type 会被视为同一个默认 key。服务端多条业务弹窗请传 meta.key。
想让同一个弹窗重复排队怎么办?
开启 append。
control: { append: true }弹窗组件必须实现 closeDialog 吗?
openDialog 必须实现。closeDialog 可选,但强烈建议实现。这样 api.closeCurrent()、高优先级打断、scope 失活、closeAll() 都能真正关闭组件。
能不能在弹窗组件里关闭后自动展示下一个?
可以在组件关闭按钮里调用:
dialogApi.closeCurrent()如果组件只把自己的 visible 设为 false,调度器不知道它已经关闭,不会自动展示下一个。
为什么 unknown type 没反应?
未注册的 type 会被忽略,避免拼错的弹窗永久进入 pending 队列。
发布包体积
当前包不发布 sourcemap。
- tarball 约 24 KB。
- 解压约 116 KB。
- ESM gzip 约 9 KB。
License
MIT
