🔗 Vue3弹窗交互进阶:五种优雅返回结果方案解析
在Vue的组件化世界里却需要重新理解状态流。经过多个中后台项目的迭代验证,我总结出5种方案:
- 官方事件流:最朴素的Vue式解法
- 回调注入:解耦父子组件的过渡方案
- Promise:符合异步编程直觉的改造
- usePromisify:可复用的TypeScript工程化实践
- 事件中枢:复杂场景的终极解法
接下来我将结合代码实现逐一介绍。
以下为展示代码所使用的技术栈:
{
"dependencies": {
"element-plus": "^2.9.7",
"mitt": "^3.0.1",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
展示代码内的弹窗组件都命名为 Child.vue
,以下将称为子组件。负责引用的父组件命名为 Parent.vue
。
🔗 官方事件流
这应该是我初学前端时使用最频繁的一种方法。也是vue的标准做法。
子组件内使用 emits
触发事件。
<template>
<el-dialog
v-model="visible"
title="操作窗口"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<div>...</div>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确认
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
const emits = defineEmits<{
close: [],
confirm: [string]
}>()
/**
* 关闭
*/
function handleClose() {
visible.value = false
emits('close')
}
/**
* 确认
*/
function handleConfirm() {
visible.value = false
emits('confirm', 'confirm result')
}
defineExpose({
open: () => {
visible.value = true
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
父组件调用子组件 defineExpose
出来的 open
方法打开子组件弹窗,然后通过监听 confirm
事件接收弹窗结果
<template>
<div>
<el-button type="primary" @click="handleOpenDialog">
打开操作弹窗
</el-button>
<Child ref="ChildRef" @close="handleDialogClose" @confirm="handleDialogConfirm" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const ChildRef = ref<InstanceType<typeof Child>>()
/**
* 打开弹窗
*/
function handleOpenDialog() {
ChildRef.value?.open()
}
/**
* 弹窗关闭回调事件
*/
function handleDialogClose() {
console.log('弹窗关闭回调事件')
}
/**
* 弹窗确认回调事件
*/
function handleDialogConfirm(result: string) {
console.log('弹窗确认回调事件')
console.log(result)
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
🔗 回调注入
子组件会在 defineExpose
的 open
方法内多接收两个回调函数。然后用包裹函数进行一层封装,在关闭或确认时执行对应的回调函数。
<template>
<el-dialog
v-model="visible"
title="操作窗口"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<div>...</div>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确认
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
type Callbacks = {
confirm?: (result: string) => void
close?: () => void
}
const callbacks = ref<Callbacks>({})
/**
* 关闭
*/
function handleClose() {
callbacks.value.close?.()
}
/**
* 确认
*/
function handleConfirm() {
callbacks.value.confirm?.('confirm result')
}
/**
* 包裹回调,执行完后清空回调
*/
function wrapCallback<T extends ((...args: any[]) => void) | undefined>(
callback: T
): T {
if (!callback) return undefined as T
return ((...args: Parameters<Extract<T, Function>>) => {
callback(...args)
callbacks.value = {}
visible.value = false
}) as T
}
defineExpose({
open: (
confirmCallback?: Callbacks['confirm'],
closeCallback?: Callbacks['close']
) => {
visible.value = true
callbacks.value = {
confirm: wrapCallback(confirmCallback),
close: wrapCallback(closeCallback),
}
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
父组件需要在调用 open
方法时将回调函数传入子组件,而不是用监听事件了。
<template>
<div>
<el-button type="primary" @click="handleOpenDialog">
打开操作弹窗
</el-button>
<Child ref="ChildRef" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const ChildRef = ref<InstanceType<typeof Child>>()
/**
* 打开弹窗
*/
function handleOpenDialog() {
ChildRef.value?.open(handleDialogConfirm, handleDialogClose)
}
/**
* 弹窗关闭回调事件
*/
function handleDialogClose() {
console.log('弹窗关闭回调')
}
/**
* 弹窗确认回调事件
*/
function handleDialogConfirm(result: string) {
console.log('弹窗确认回调')
console.log(result)
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
这种方案不论是父组件还是子组件维护性都略差,唯一优点就是兼容性略好,只适合需要兼容旧浏览器的项目。
🔗 Promise
子组件在 open
方法调用时返回一个 Promise
实例,并且将 resolve
和 reject
保存到 promiseController
变量,再用 controlPromise
函数控制执行。
<template>
<el-dialog
v-model="visible"
title="操作窗口"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<div>...</div>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确认
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const visible = ref(false)
type PromiseController = {
resolve?: (value?: string | PromiseLike<string>) => void,
reject?: (reason?: any) => void,
}
// promise 控制器
let promiseController: PromiseController = {}
// 控制 promise 状态
function controlPromise(key: keyof PromiseController, value?: string) {
promiseController[key]?.(value)
promiseController = {}
visible.value = false
}
/**
* 关闭
*/
function handleClose() {
controlPromise('reject')
}
/**
* 确认
*/
function handleConfirm() {
controlPromise('resolve', 'confirm result')
}
defineExpose({
open: () => {
return new Promise<string | undefined>((resolve, reject) => {
visible.value = true
promiseController = { resolve, reject }
})
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
父组件只需要在调用子组件 open
方法时进行 await
,就能在弹窗确认时拿到结果并且进行操作。如果需要在关闭时进行操作,就需要使用 try...catch
包裹进行捕获。
或者调用 .then
方法注册成功回调和失败回调。
<template>
<div>
<el-button type="primary" @click="handleOpenDialog">
打开操作弹窗
</el-button>
<Child ref="ChildRef" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const ChildRef = ref<InstanceType<typeof Child>>()
/**
* 打开弹窗
*/
async function handleOpenDialog() {
try {
const result = await ChildRef.value?.open()
console.log('弹窗确认回调事件')
console.log(result)
} catch (e) {
console.error(e)
console.log('弹窗关闭或错误回调事件')
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
可以看到这个方法使用起来最简便,只是子组件需要维护 promiseController
和 controlPromise
,代码并不雅观。
如果子组件 defineExpose
导出多个操作函数,比如add和edit有时可能在同一个弹窗内进行操作,那么需要维护的 promiseController
和 controlPromise
可能就会随之增多。
所以我们需要将这些操作进行封装。
🔗 usePromisify
usePromisify
会将业务操作函数包装成 Promise
函数,通过返回的 controlPromise
函数操作结果。
type PromiseController<R> = {
resolve?: (value: R) => void;
reject?: (reason?: any) => void;
};
type ControlPromise<R> = {
(type: "resolve", res: R): void;
(type: "reject", reason?: any): void;
}
function usePromisify<R, T extends (...args: any[]) => void>(
callback: T
): [
ControlPromise<R>,
(...args: Parameters<T>) => Promise<R>
] {
let promiseController: PromiseController<R> = {};
// 控制 Promise 状态的核心函数
const controlPromise: ControlPromise<R> = (type: 'resolve' | 'reject', res: R) => {
// 获取对应的 resolve/reject 方法
const handler = promiseController[type];
if (handler) {
handler(res); // 执行状态变更
promiseController = {}; // 清空控制器避免重复调用
}
};
// 包装后的 Promise 函数
const wrappedFunction = (...args: Parameters<T>): Promise<R> => {
return new Promise<R>((resolve, reject) => {
// 暂存 resolve/reject 方法供外部调用
promiseController.resolve = resolve;
promiseController.reject = reject;
// 触发原始回调函数
callback(...args);
});
};
return [controlPromise, wrappedFunction];
}
export default usePromisify;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
如果你的项目不用ts,那么这里也有加了jsdoc的js版。
/**
* @template R 返回值类型
* @template {Function} T 函数类型,接受任意参数且无返回值
* @param {T} callback 要包装的回调函数
* @returns {[
* (type: 'resolve' | 'reject', res: R) => void, // 控制 Promise 的操作函数
* (...args: Parameters<T>) => Promise<R> // 包装后的函数,返回 Promise
* ]} 返回操作函数和包装后的 Promise 函数
*/
function usePromisify(callback) {
/** @type {Partial<Record<'resolve' | 'reject', (value: R) => void>>} */
let promiseController = {}
/**
* 控制 Promise 的状态
* @param {'resolve' | 'reject'} type 操作类型
* @param {R} res Promise 的结果
*/
const controlPromise = (type, res) => {
if (promiseController[type] && typeof promiseController[type] === 'function') {
[type](res)
promiseController = {}
}
}
/**
* 包装后的函数
* @param {...Parameters<T>} args 回调函数的参数
* @returns {Promise<R>} 包装后的 Promise
*/
const wrappedFunction = (...args) => {
return new Promise((resolve, reject) => {
promiseController.resolve = resolve
promiseController.reject = reject
callback(...args)
})
}
return [controlPromise, wrappedFunction]
}
export default usePromisify
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
使用 usePromisify
之后子组件的代码少了很多,可以更加专注业务代码了。
<template>
<el-dialog
v-model="visible"
title="操作窗口"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<div>...</div>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确认
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import usePromisify from './usePromisify'
const visible = ref(false)
const [openControl, open] = usePromisify<string, () => void>(() => {
visible.value = true
})
/**
* 关闭
*/
function handleClose() {
// 关闭弹窗
visible.value = false
// 返回结果
openControl('reject')
}
/**
* 确认
*/
function handleConfirm() {
// 关闭弹窗
visible.value = false
// 返回结果
openControl('resolve', 'confirm result')
}
defineExpose({
open: open
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
父组件的使用没有变化,还是用 await
或者 .then
就可以了。
<template>
<div>
<el-button type="primary" @click="handleOpenDialog">
打开操作弹窗
</el-button>
<Child ref="ChildRef" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'
const ChildRef = ref<InstanceType<typeof Child>>()
/**
* 打开弹窗
*/
async function handleOpenDialog() {
try {
const result = await ChildRef.value?.open()
console.log('弹窗确认回调事件')
console.log(result)
} catch (e) {
console.error(e)
console.log('弹窗关闭或错误回调事件')
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
这个方案目前是我使用下来最好用的方法,推荐大家使用该方案。
🔗 事件中枢
还有一种特殊情况,弹窗组件和需要监听弹窗结果的组件不一定是父子关系。或者有多处地方需要监听一个弹窗的结果。
这时候就需要有一个 eventbus
事件中心,通过发布订阅模式来串联多方。
先用 mitt
创建一个弹窗的事件中心。注意 CLOSE
和 CONFIRM
是字符串常量,不是类型,用于区分组件触发的事件类型。
import mitt from 'mitt'
export const CLOSE = 'close'
export const CONFIRM = 'confirm'
export type ModalResult = typeof CLOSE | typeof CONFIRM
export type ModalEventOptions = { type: ModalResult, data?: any }
export const emitter = mitt<{
[k: string]: ModalEventOptions
}>()
2
3
4
5
6
7
8
9
10
11
12
13
弹窗组件用 defineOptions.customOptions
向外开放一个 mittKey
事件键,这样需要监听的组件可以通过导入这个组件的静态属性用于监听了。
如果担心事件键重复,可以使用 symbol
类型。
需要注意一次窗口不要 emit
多次事件。
<template>
<el-dialog
v-model="visible"
title="操作窗口"
append-to-body
:close-on-click-modal="false"
@close="handleClose"
>
<div>...</div>
<template #footer>
<el-button @click="handleClose">
取消
</el-button>
<el-button type="primary" @click="handleConfirm">
确认
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { emitter, CLOSE, CONFIRM } from '@/eventbus/modal'
const visible = ref(false)
const mittKey = 'MyMittKey'
defineOptions({
customOptions: {
mittKey: mittKey
}
})
/**
* 关闭
*/
function handleClose() {
// 避免重复调用关闭事件
if (visible.value) emitter.emit(mittKey, { type: CLOSE })
visible.value = false
}
/**
* 确认
*/
function handleConfirm() {
emitter.emit(mittKey, { type: CONFIRM, data: 'confirm result' })
visible.value = false
}
defineExpose({
open: () => {
visible.value = true
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
需要监听的内容组件如下,通过取弹窗组件的 customOptions.mittKey
来精准监听该组件的回调。
<template>
<div>
content
</div>
</template>
<script setup lang="ts">
import { onUnmounted, onMounted } from 'vue'
import OperateModal from '@/components/OperateModal.vue'
import { emitter, CLOSE, CONFIRM, type ModalEventOptions } from '@/eventbus/modal'
/**
* 弹窗关闭回调事件
*/
function handleDialogClose() {
console.log('弹窗关闭回调事件')
}
/**
* 弹窗确认回调事件
*/
function handleDialogConfirm(result: string) {
console.log('弹窗确认回调事件')
console.log(result)
}
function handleEmitter(e: ModalEventOptions) {
// 执行对应操作函数
if (e.type === CLOSE) {
handleDialogClose()
} else if (e.type === CONFIRM) {
handleDialogConfirm(e.data)
}
}
onMounted(() => {
// 监听弹窗组件的事件
emitter.on(
OperateModal.customOptions.mittKey,
handleEmitter
)
})
// 组件销毁时,移除事件监听
onUnmounted(() => {
emitter.off(
OperateModal.customOptions.mittKey,
handleEmitter
)
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
这样就可以不用关心两个组件的层级问题了,多个组件同时监听弹窗结果也没问题。
<script setup lang="ts">
import { ref } from 'vue'
import content from '@/views/content.vue'
import OperateModal from '@/components/OperateModal.vue'
const OperateModalRef = ref<InstanceType<typeof OperateModal>>()
/**
* 打开弹窗
*/
function handleOpenDialog() {
OperateModalRef.value?.open()
}
</script>
<template>
<div>
<el-button type="primary" @click="handleOpenDialog">
打开操作弹窗
</el-button>
<content />
<OperateModal ref="OperateModalRef" />
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25