Skip to content

Commit

Permalink
✨ feat: 完善readme+一些细节调整
Browse files Browse the repository at this point in the history
  • Loading branch information
BBBboys committed Oct 1, 2024
1 parent 0818b34 commit 483ced4
Show file tree
Hide file tree
Showing 17 changed files with 396 additions and 170 deletions.
95 changes: 94 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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<any>;
/** 弹窗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<boolean>;
/** 隐藏 */
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<any> | 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<string, any>;
// 挂载点,默认body
appendTo?: string | HTMLElement;
// 内部维护的响应式变量,你需要完整的将其传递进去,不要将响应式变量解包
visible: Ref<boolean>;
```

其余并不复杂,更多查看 element-plus 适配代码:/src/components/ElementPlusDialog.tsx

## 一些建议

- 强烈建议你的项目配置 jsx!如果你能忍受一味的使用`h`函数,那么你可以忽略这个建议.

- 尽管 consumer 对象实现了一个订阅模式,但是你应该避免通过它来进行内部和外部的通信,它的出现是为了实现对命令弹窗的组件的增强,不建议用于业务开发.所以,情非得已之下,请尽量使用`destroyWithReject``destroyWithResolve`来借助 promise 的特性进行数据交互.当然,也可以使用很常规的`props``emit`等手段进行通信.

## TODO

- 适配 vantui 的 popup 组件
23 changes: 13 additions & 10 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DEMO</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<html lang="en" class="dark">

<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DEMO</title>
</head>

<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

</html>
11 changes: 9 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
<script lang="tsx" setup>
import { provide } from "vue";
import zhCn from "element-plus/dist/locale/zh-cn.mjs";

Check failure on line 2 in src/App.vue

View workflow job for this annotation

GitHub Actions / Release

Could not find a declaration file for module 'element-plus/dist/locale/zh-cn.mjs'. '/home/runner/work/Vue3-Command-Dialog/Vue3-Command-Dialog/node_modules/.pnpm/[email protected][email protected][email protected]_/node_modules/element-plus/dist/locale/zh-cn.mjs' implicitly has an 'any' type.
import en from "element-plus/dist/locale/en.mjs";

Check failure on line 3 in src/App.vue

View workflow job for this annotation

GitHub Actions / Release

Could not find a declaration file for module 'element-plus/dist/locale/en.mjs'. '/home/runner/work/Vue3-Command-Dialog/Vue3-Command-Dialog/node_modules/.pnpm/[email protected][email protected][email protected]_/node_modules/element-plus/dist/locale/en.mjs' implicitly has an 'any' type.
import { provide, ref, computed } from "vue";
import Layout from "./Layout.vue";
provide("App", "来自App的🩷");
const language = ref("zh-cn");
const locale = computed(() => (language.value === "zh-cn" ? zhCn : en));
</script>

<template>
<Layout />
<el-config-provider :locale="locale">
<Layout />
</el-config-provider>
</template>

<style scoped></style>
42 changes: 26 additions & 16 deletions src/Layout.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
<script lang="tsx" setup>
import { provide } from "vue";
import { RouterView } from "vue-router";
import { provide, computed } from "vue";
import { RouterView, useRoute } from "vue-router";
provide("Layout", "来自Layout的🩷");
// 基础用法
const baseExampleRoutes = [
{
path: "/example/base",
Expand All @@ -17,32 +16,43 @@ const baseExampleRoutes = [
path: "/example/base/compatible-native-attributes",
text: "完美兼容组件库属性",
},
{
path: "/example/base/confirm",
text: "确认/取消弹窗",
},
];
// 基础用法
const advanceExampleRoutes = [
{
path: "/example/advance/promise",
text: "利用promise特性",
},
{
path: "/example/advance/communication",
text: "promise方式通知外部组件",
},
];
const $route = useRoute();
const activeExampleTitle = computed(() => {
return baseExampleRoutes.find((e) => e.path === $route.fullPath)?.text || advanceExampleRoutes.find((e) => e.path === $route.fullPath)?.text;
});
</script>

<template>
<h3>基础用法</h3>
<el-row>
<el-col v-for="e in baseExampleRoutes" :span="24 / baseExampleRoutes.length">
<el-button type="primary" @click="$router.push(e.path)">{{ e.text }}</el-button>
</el-col>
</el-row>
<h3>
点击这里查看👉
<el-link href="https://github.com/pandavips/Vue3-Command-Dialog/tree/main/src/pages/example"> 示例代码 </el-link>
</h3>

<el-divider />
<h3>进阶用法</h3>
<el-row>
<el-col v-for="e in advanceExampleRoutes" :span="24 / advanceExampleRoutes.length">
<el-button type="primary" @click="$router.push(e.path)">{{ e.text }}</el-button>
</el-col>
</el-row>
<el-button v-for="e in baseExampleRoutes" type="primary" @click="$router.push(e.path)">{{ e.text }}</el-button>
<el-divider />
<el-button v-for="e in advanceExampleRoutes" type="primary" @click="$router.push(e.path)">{{ e.text }}</el-button>
<el-divider />
<h3>当前示例: {{ activeExampleTitle }}</h3>

<RouterView />
</template>

Expand Down
16 changes: 10 additions & 6 deletions src/components/Core.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,16 +24,18 @@ export interface IConsumer {
destroyWithResolve: (val?: any) => void;
/** 弹窗销毁,并拒绝promise */
destroyWithReject: (reason?: any) => void;
/** 弹窗销毁,不处理promise */
/** 弹窗销毁,但是不继续推进promise的状态改变 */
destroy: (external?: boolean) => void;
/** 弹窗是否可见响应式变量,虽然已经提供了hide以及show方法不需要通过该属性来控制弹窗的显示与隐藏,但是为了方便一些特殊场景,还是提供了该属性,比如你需要watch这个属性来做一些事情 */
visible: Ref<boolean>,
/** 隐藏 */
hide: () => void;
/** 显示 */
show: () => void;
/** 订阅取消 */
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;
/** 发布 */
Expand All @@ -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);
Expand All @@ -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));
Expand All @@ -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),
Expand Down
28 changes: 20 additions & 8 deletions src/components/ElementPlusDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DialogProps & Record<string, any>>;

// 其实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<string, any>;

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();
Expand Down Expand Up @@ -66,12 +77,12 @@ export const createElementPlusDialog = () => {
<div>
{config[busName2EventName(EVENT_NAME.cancel)] && (
<el-button onClick={() => consumer.emit(EVENT_NAME.cancel)}>
{config.onCancelBtnText || t('el.messagebox.cancel')}
{config.cancelBtnText || t('el.messagebox.cancel')}
</el-button>
)}
{config[busName2EventName(EVENT_NAME.confirm)] && (
<el-button type="primary" onClick={() => consumer.emit(EVENT_NAME.confirm)}>
{config.onConfirmBtnText || t('el.messagebox.confirm')}
{config.confirmBtnText || t('el.messagebox.confirm')}
</el-button>
)}
</div>
Expand All @@ -89,6 +100,7 @@ export const createElementPlusDialog = () => {
}
);

// --------------便利性功能----------------
// 处理事件绑定
Object.entries(config)
.filter(([key, fn]) => key.startsWith('on') && typeof fn === 'function')
Expand Down
1 change: 0 additions & 1 deletion src/components/VantUiPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
// TODO: 适配vant ui 的popup组件

1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./Core";
export * from "./ElementPlusDialog";
export * from "./type";
// export * from "./VantUiPopup";
Loading

0 comments on commit 483ced4

Please sign in to comment.