From 483ced4182f979df6b1bd8dfcf60ea121ff81056 Mon Sep 17 00:00:00 2001 From: panda <919401990@qq.com> Date: Tue, 1 Oct 2024 14:33:54 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=AE=8C=E5=96=84readme+?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E7=BB=86=E8=8A=82=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 95 ++++++++++++++- index.html | 23 ++-- src/App.vue | 11 +- src/Layout.vue | 42 ++++--- src/components/Core.tsx | 16 ++- src/components/ElementPlusDialog.tsx | 28 +++-- src/components/VantUiPopup.tsx | 1 - src/components/index.ts | 1 + src/components/utils.ts | 34 ++++-- src/main.ts | 2 +- src/pages/example/advance/communication.vue | 111 ++++++++++++++++++ src/pages/example/advance/stack.vue | 52 -------- .../base/compatible-native-attributes.vue | 45 ++++--- src/pages/example/base/confirm.vue | 77 +++++++----- src/pages/example/base/index.vue | 22 ++-- src/router/index.ts | 4 +- typed-router.d.ts | 2 +- 17 files changed, 396 insertions(+), 170 deletions(-) create mode 100644 src/pages/example/advance/communication.vue delete mode 100644 src/pages/example/advance/stack.vue diff --git a/README.md b/README.md index 77f64da..a517dcb 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,96 @@ # Vue 3 通用的命令式弹窗方案 -TODO +天下苦弹窗开发久矣, 作为一个前端开发, 弹窗的开发体验一直很糟糕, 尤其是嵌套弹窗, 状态管理, 销毁重建等问题, 让人不胜其烦. 所以, 我决定实现一个通用的命令式弹窗解决方案来解决这些痛点.这是一个专为 Vue 3 设计的通用命令式弹窗解决方案。它提供了一种灵活且可扩展的方式来管理和控制应用程序中的弹窗。虽然唤作弹窗,但是它不仅仅局限于弹窗,理论上任何组件都可以进行适配. + +这里就不比较声明式弹窗和命令弹窗的优缺点了,如果你已经尝试探索命令弹窗而看到这里,那么你或许已经切实的体会到了声明式弹窗开发的繁琐和痛苦,那么不妨试试这个库,或许能给你带来一些不一样的体验. + +## 特性 + +- 命令式 API,弹窗开发变更为编程式,解放弹窗生产力! +- 支持弹窗嵌套,链式管理,并提供完整上下文支持(状态管理,路由,国际化等). +- 灵活的配置, 支持自定义属性,插槽,事件处理器等. +- 开箱即用,已实现与 Element Plus 的 Dialog 组件以及 vant 的 Popup 组件的适配,也可以自行拓展以便更贴切你的实际业务. +- 命令式组件核心逻辑解耦,可自行适配不同的 UI 库目标组件 + +## 在线示例 + +您可以通过以下链接查看在线示例(以 element-plus Dialog 为例): + +[Example](https://pandavips.github.io/Vue3-Command-Dialog/#/Vue3-Command-Dialog/base) + +## 你如何适配自己 UI 库组件 + +除了已经适配的 Element Plus 的 Dialog 组件以及 vant 的 Popup 组件, 您也可以自行适配您自己的 UI 库组件, 具体可以参考以下步骤: + +具体可以借鉴示例代码中对 element-plus 以及 vantui 的实现,这里只说一下核心逻辑; + +1.我们需要 CommandDialogProvider 函数来对我们的目标组件进行包装, 它的最主要的作用是对被包裹的组件注入`Consumer`对象,那么我们的弹窗内部组件就可以接收到这个对象,它是我们对弹窗进行控制的主要手段.这个对象上有下列属性和方法: + +```ts +/** 弹窗消费者对象,或者也可理解为弹窗实例实例~ */ +export interface IConsumer { + /** 弹窗promise */ + promise: Promise; + /** 弹窗promise执行器参数resolve */ + resolve: (val?: any) => void; + /** 弹窗promise执行器参数reject */ + reject: (reason?: any) => void; + /** 弹窗销毁,并解决promise */ + destroyWithResolve: (val?: any) => void; + /** 弹窗销毁,并拒绝promise */ + destroyWithReject: (reason?: any) => void; + /** 弹窗销毁,但是不继续推进promise的状态改变 */ + destroy: (external?: boolean) => void; + /** 弹窗是否可见响应式变量,虽然已经提供了hide以及show方法不需要通过该属性来控制弹窗的显示与隐藏,但是为了方便一些特殊场景,还是提供了该属性,比如你需要watch这个属性来做一些事情 */ + visible: Ref; + /** 隐藏 */ + hide: () => void; + /** 显示 */ + show: () => void; + /** 订阅取消 */ + off: (name: string | symbol, callback: Function) => void; + /** 订阅 */ + on: (name: string | symbol, callback: Function) => void; + /** 单次订阅 */ + once: (name: string | symbol, callback: Function) => void; + /** 发布 */ + emit: (name: string | symbol, ...args: any) => void; + /** 一般建议赋值为UI库的弹窗实例实例Ref */ + componentRef?: Ref | undefined; + /** 弹窗挂载的html元素 */ + container: HTMLDivElement; + /** 弹窗嵌套堆栈 */ + stack: IConsumer[]; + /** 当前在弹窗嵌套堆栈中的索引 */ + stackIndex: number; +} +``` + +你不用关心这个对象的创建销毁等逻辑,只需要知道有这么一个对象,以及它身上有哪些属性和方法即可.你还可以注意到,这个对象上还有`on` `once` `emit` `off`等方法,通过这些 api 注册的事件函数都会严格限制在 `consumer` 对象下,所以不同的`consumer`对象的事件注册发布均不互相影响;同时你也不用关心事件的解绑等逻辑,这些内部已经帮你处理好了. + +CommandDialogProvider 同时也会返回一个`consumer`对象,以供弹窗外部使用,弹窗内部和外部拿到的 consumer 是同一个对象,所以他们是全等(===)的. + +弹窗内部组件获取 `consumer` 对象的方式为调用`getCommandDialogConsumer`, 该函数会返回一个 consumer 对象,它一样只能在 setup 顶部直接调用,不可条件调用或者异步调用. + +2.剩余的就是传递参数的介绍了, + +```ts + // 你大可直接使用provide注入,内部一样能接收到,但是你想实现更私有的作用域,可以将需要注入的数据放置在这个对象下 + provideProps?: Record; + // 挂载点,默认body + appendTo?: string | HTMLElement; + // 内部维护的响应式变量,你需要完整的将其传递进去,不要将响应式变量解包 + visible: Ref; +``` + +其余并不复杂,更多查看 element-plus 适配代码:/src/components/ElementPlusDialog.tsx + +## 一些建议 + +- 强烈建议你的项目配置 jsx!如果你能忍受一味的使用`h`函数,那么你可以忽略这个建议. + +- 尽管 consumer 对象实现了一个订阅模式,但是你应该避免通过它来进行内部和外部的通信,它的出现是为了实现对命令弹窗的组件的增强,不建议用于业务开发.所以,情非得已之下,请尽量使用`destroyWithReject`和`destroyWithResolve`来借助 promise 的特性进行数据交互.当然,也可以使用很常规的`props`和`emit`等手段进行通信. + +## TODO + +- 适配 vantui 的 popup 组件 diff --git a/index.html b/index.html index 8fe2f02..83ae0a0 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,15 @@ - - - - - DEMO - - -
- - + + + + + + DEMO + + + +
+ + + diff --git a/src/App.vue b/src/App.vue index 2d155ed..2bf8a60 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,19 @@ diff --git a/src/Layout.vue b/src/Layout.vue index 24dbc9a..65db66a 100644 --- a/src/Layout.vue +++ b/src/Layout.vue @@ -1,9 +1,8 @@ diff --git a/src/components/Core.tsx b/src/components/Core.tsx index deb4977..8d45ca5 100644 --- a/src/components/Core.tsx +++ b/src/components/Core.tsx @@ -1,6 +1,6 @@ import type { Component, ComponentInternalInstance, InjectionKey, Ref } from "vue"; import { defineComponent, inject, nextTick, render, provide } from "vue"; -import { ConsumerEventBus, PromiseWithResolvers } from "./utils"; +import { ConsumerEventBus, PromiseWithResolvers, type IOnConfig } from "./utils"; import { EVENT_NAME } from "./type"; export interface ICommandDialogArrtsProviderConfig { @@ -24,8 +24,10 @@ export interface IConsumer { destroyWithResolve: (val?: any) => void; /** 弹窗销毁,并拒绝promise */ destroyWithReject: (reason?: any) => void; - /** 弹窗销毁,不处理promise */ + /** 弹窗销毁,但是不继续推进promise的状态改变 */ destroy: (external?: boolean) => void; + /** 弹窗是否可见响应式变量,虽然已经提供了hide以及show方法不需要通过该属性来控制弹窗的显示与隐藏,但是为了方便一些特殊场景,还是提供了该属性,比如你需要watch这个属性来做一些事情 */ + visible: Ref, /** 隐藏 */ hide: () => void; /** 显示 */ @@ -33,7 +35,7 @@ export interface IConsumer { /** 订阅取消 */ off: (name: string | symbol, callback: Function) => void; /** 订阅 */ - on: (name: string | symbol, callback: Function) => void; + on: (name: string | symbol, callback: Function, config?: IOnConfig) => void; /** 单次订阅 */ once: (name: string | symbol, callback: Function) => void; /** 发布 */ @@ -60,7 +62,7 @@ const getProvidesChain = (ins: ComponentInternalInstance): any => ({ ...(ins as any).provides }); -export function CommandDialogProvider(parentInstance: ComponentInternalInstance | null, uiComponentVnode: Component, config: ICommandDialogProviderConfig) { +export function CommandDialogProvider(parentInstance: ComponentInternalInstance | null, uiComponentVnode: Component, config: ICommandDialogProviderConfig): IConsumer { const appendToElement = (typeof config.appendTo === "string" ? document.querySelector(config.appendTo) : config.appendTo) || document.body; const container = document.createElement("div"); appendToElement.appendChild(container); @@ -79,7 +81,8 @@ export function CommandDialogProvider(parentInstance: ComponentInternalInstance } const destroy = (external = false) => { if (external) { - consumer.once(EVENT_NAME.destory, unmount); + // 这里的事件是为了完整的关闭动画展示,如果关闭时没有触发该事件,那么将永远不会执行卸载操作,所以加入延时立即调用,保证最终一定会执行卸载操作 + consumer.on(EVENT_NAME.destory, unmount, { once: true, callAfterDelay: 3000 }); hide(); } else { consumer.stack.splice(consumer.stackIndex).forEach((c) => c.destroy(true)); @@ -98,7 +101,8 @@ export function CommandDialogProvider(parentInstance: ComponentInternalInstance const consumer: IConsumer = { promise, resolve, reject, destroyWithResolve, destroyWithReject, hide, show, destroy, container, - on: (name: string | symbol, callback: Function) => eventBus.on(consumer, name, callback), + visible: config.visible, + on: (name: string | symbol, callback: Function, config: IOnConfig = {}) => eventBus.on(consumer, name, callback, config), once: (name: string | symbol, callback: Function) => eventBus.once(consumer, name, callback), emit: (name: string | symbol, ...args: any) => eventBus.emit(consumer, name, ...args), off: (name: string | symbol, callback: Function) => eventBus.off(consumer, name, callback), diff --git a/src/components/ElementPlusDialog.tsx b/src/components/ElementPlusDialog.tsx index d2589e5..9bcca2c 100644 --- a/src/components/ElementPlusDialog.tsx +++ b/src/components/ElementPlusDialog.tsx @@ -7,31 +7,42 @@ import { busName2EventName, eventName2BusName } from "./utils"; import { EVENT_NAME } from "./type"; export type IElementPlusConfig = { + // 目标ui库目标组件的插槽 slots?: { [key: string]: () => VNode | VNode[]; }; + // 目标ui库目标组件的属性 attrs?: Partial>; + + // 其实title和width都是目标组件的属性,所以通过attrs属性也能实现,但是这两个属性实在太常见了,可以单独拎出来,少些一些代码 title?: string; width?: string; + + // 快捷的确认/取消按钮,可以传入函数,也可以传入boolean;如果传入函数,点击事会调用该函数;如果传入boolean,则表示是否显示该按钮,会通过consumer来触发固定的`EVENT_NAME.confirm`和`EVENT_NAME.cancel`事件,你可以在弹窗组件内部或者外部提供的consumer对象来注册这两个事件的回调函数来实现你的逻辑; onCancel?: (() => void) | boolean; - onCancelBtnText?: string; + cancelBtnText?: string; onConfirm?: (() => void) | boolean; - onConfirmBtnText?: string; + confirmBtnText?: string; + } & ICommandDialogArrtsProviderConfig & Record; -export const createElementPlusDialog = () => { +export const createElementPlusDialog = (immediately = true) => { + // 我们需要捕获使用命令式组件的的组件实例,我们会用它来获取上下文 const parentInstance = getCurrentInstance(); + // 可忽略,只是为了获取语言包 const { locale: { t } } = useGlobalComponentSettings('message-box') + // 返回一个函数,这个函数接收一个组件节点,以及配置项,返回一个consumer对象 const commandDialog = (ContentVNode: VNode, config: IElementPlusConfig = {}) => { - const visible = ref(true); - + // 我们不再依赖外部的visible变量来控制弹窗的显示与隐藏,这免去了外部手动控制弹窗显示与隐藏的麻烦,而是通过consumer对象来进行控制 + const visible = ref(immediately); + // 这里的consumer和弹窗内部通过`inject`接收到的`consumer`是同一个对象 const consumer = CommandDialogProvider( parentInstance, h(defineComponent({ setup() { + // 这里一般建议你在后续赋值为UI库的弹窗组件的ref,以便将来使用它暴露的属性和方法 const componentRef = ref() - const handleClose = (done: () => void) => { done(); consumer.destroy(); @@ -66,12 +77,12 @@ export const createElementPlusDialog = () => {
{config[busName2EventName(EVENT_NAME.cancel)] && ( consumer.emit(EVENT_NAME.cancel)}> - {config.onCancelBtnText || t('el.messagebox.cancel')} + {config.cancelBtnText || t('el.messagebox.cancel')} )} {config[busName2EventName(EVENT_NAME.confirm)] && ( consumer.emit(EVENT_NAME.confirm)}> - {config.onConfirmBtnText || t('el.messagebox.confirm')} + {config.confirmBtnText || t('el.messagebox.confirm')} )}
@@ -89,6 +100,7 @@ export const createElementPlusDialog = () => { } ); + // --------------便利性功能---------------- // 处理事件绑定 Object.entries(config) .filter(([key, fn]) => key.startsWith('on') && typeof fn === 'function') diff --git a/src/components/VantUiPopup.tsx b/src/components/VantUiPopup.tsx index 9afdfc8..cbf5b8f 100644 --- a/src/components/VantUiPopup.tsx +++ b/src/components/VantUiPopup.tsx @@ -1,2 +1 @@ // TODO: 适配vant ui 的popup组件 - diff --git a/src/components/index.ts b/src/components/index.ts index 0af7b9c..16e262d 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,3 +1,4 @@ export * from "./Core"; export * from "./ElementPlusDialog"; +export * from "./type"; // export * from "./VantUiPopup"; diff --git a/src/components/utils.ts b/src/components/utils.ts index d2afd05..53e5bb6 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -1,5 +1,11 @@ import type { IConsumer } from "./Core"; +export interface IOnConfig { + once?: boolean; + // 延迟执行时间后,即使事件没有触发也立即调用 + callAfterDelay?: number; +} + // 基于命令弹窗消费对象的事件注册中心 export class ConsumerEventBus { private map = new WeakMap>>(); @@ -23,23 +29,31 @@ export class ConsumerEventBus { return events; } - on(consumer: IConsumer, name: string | symbol, callback: Function): void { - this.getEventsByConsumer(consumer, name).add(callback); + on(consumer: IConsumer, name: string | symbol, callback: Function, config: IOnConfig = {}): void { + const events = this.getEventsByConsumer(consumer, name); + let finalCallback = callback; + if (config.once) { + finalCallback = (...args: any[]) => { + callback(...args); + this.off(consumer, name, finalCallback); + }; + } + events.add(finalCallback); + config.callAfterDelay !== void 0 && + setTimeout(() => { + finalCallback(); + }, config.callAfterDelay || 0); } once(consumer: IConsumer, name: string | symbol, callback: Function): void { - const onceCallback = (...args: any[]) => { - callback(...args); - this.off(consumer, name, onceCallback); - }; - this.on(consumer, name, onceCallback); + this.on(consumer, name, callback, { once: true }); } emit(consumer: IConsumer, name: string | symbol, ...args: any[]): void { const events = this.getEventsByConsumer(consumer, name); - if (events.size === 0) { - console.warn(`${consumer}未注册${String(name)}事件`); - } + // if (events.size === 0) { + // console.warn(`${consumer}未注册${String(name)}事件`); + // } events.forEach((callback) => callback(...args)); } diff --git a/src/main.ts b/src/main.ts index 0d88267..29e6f32 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import App from "./App.vue"; import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; - +import "element-plus/theme-chalk/dark/css-vars.css"; // import Vant from "vant"; // import "vant/lib/index.css"; diff --git a/src/pages/example/advance/communication.vue b/src/pages/example/advance/communication.vue new file mode 100644 index 0000000..f269af4 --- /dev/null +++ b/src/pages/example/advance/communication.vue @@ -0,0 +1,111 @@ + + + diff --git a/src/pages/example/advance/stack.vue b/src/pages/example/advance/stack.vue deleted file mode 100644 index 827f730..0000000 --- a/src/pages/example/advance/stack.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/pages/example/base/compatible-native-attributes.vue b/src/pages/example/base/compatible-native-attributes.vue index 75b820e..83e2559 100644 --- a/src/pages/example/base/compatible-native-attributes.vue +++ b/src/pages/example/base/compatible-native-attributes.vue @@ -1,6 +1,6 @@ diff --git a/src/pages/example/base/confirm.vue b/src/pages/example/base/confirm.vue index 2810c2c..bc6224b 100644 --- a/src/pages/example/base/confirm.vue +++ b/src/pages/example/base/confirm.vue @@ -1,44 +1,48 @@ diff --git a/src/router/index.ts b/src/router/index.ts index c35cf09..9cc0de5 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,8 +1,8 @@ -import { createRouter, createWebHistory } from "vue-router"; +import { createRouter, createWebHashHistory } from "vue-router"; import { routes, handleHotUpdate } from "vue-router/auto-routes"; export const router = createRouter({ - history: createWebHistory(), + history: createWebHashHistory(), routes, }); diff --git a/typed-router.d.ts b/typed-router.d.ts index 507e556..332c646 100644 --- a/typed-router.d.ts +++ b/typed-router.d.ts @@ -18,8 +18,8 @@ declare module 'vue-router/auto-routes' { * Route name map generated by unplugin-vue-router */ export interface RouteNamedMap { + '/example/advance/communication': RouteRecordInfo<'/example/advance/communication', '/example/advance/communication', Record, Record>, '/example/advance/promise': RouteRecordInfo<'/example/advance/promise', '/example/advance/promise', Record, Record>, - '/example/advance/stack': RouteRecordInfo<'/example/advance/stack', '/example/advance/stack', Record, Record>, '/example/base/': RouteRecordInfo<'/example/base/', '/example/base', Record, Record>, '/example/base/compatible-native-attributes': RouteRecordInfo<'/example/base/compatible-native-attributes', '/example/base/compatible-native-attributes', Record, Record>, '/example/base/components/Content': RouteRecordInfo<'/example/base/components/Content', '/example/base/components/Content', Record, Record>,