vue 命令式组件

命令式组件是指组件的创建、props 的传递、emits 方法的执行都通过一个函数来完成。
常见的命令式组件有 ELMessage、ElMessageBox、ElNotification 等。

应用场景

对于单文件组件,每次用到都需要在父组件中定义所需的 props 和 emits 方法,如果还需要控制子组件的显隐,往往要调用子组件内部控制显隐的方法,这样的话还需要定义一个模板引用 ref。
由此可见,这个子组件的调用逻辑多且分散,如果这个组件需要被多次用到,那么无疑给代码结构和开发人员都带来不好的影响。
而命令式组件仅仅通过一个函数来完成,逻辑高度集中,代码结构清晰易于维护。


构建命令式组件

构建命令式组件的主要思想就是:调用函数时根据已有组件创建一个虚拟节点,将虚拟节点挂载到一个真实 DOM 上并渲染出来。
vue 中可以创建虚拟节点的 API 有两个:createAppcreateVNode/h
现在我们来构建一个命令式组件,其功能主要是通过一个弹窗显示一些内容,包括取消和确定两个按钮,点击后分别执行开发者定义的逻辑。

1. 创建单文件组件

首先我们要创建一个组件,完成其内部样式和逻辑。

1
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
<!--message.vue-->

<template>
<div class="box">
<div>{{ title }}</div>
{{ message }}
<button @click="handleAction('cancel')">{{ cancelButtonText }}</button>
<button @click="handleAction('confirm')">{{ confirmButtonText }}</button>
</div>
</template>


<script setup lang="ts">
const props = withDefaults(defineProps<MessageProps>(), {
title: "提示",
cancelButtonText: "取消",
confirmButtonText: "确定"
});

function handleAction(action: Action) {
props.onAction(action);
}

</script>

<style scoped>
.box {
width: 200px;
height: 150px;
background-color: red;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 999;
}
</style>

这里我们需要传入一些 props,类型可以写入 message.d.ts 文件中:

1
2
3
4
5
6
7
8
9
10
interface MessageOptions {
title?: string;
message: string;
confirmButtonText?: string;
cancelButtonText?: string;
}
interface MessageProps extends MessageOptions {
onAction: (action: Action) => void;
}
type Action = "confirm" | "cancel";

onAction 的作用是在用户点击按钮后,判断操作类型并执行相应逻辑。


2. 创建命令函数
基于 createApp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// message.ts

import MessageComponent from "./message.vue";
import { createApp } from "vue";


export function CustomMessage (options: MessageOptions): Promise<void> {
return new Promise((resolve, reject) => {
const messageApp = createApp(MessageComponent, {
...options,
onAction: action => {
if (action === "confirm") resolve();
else reject();
app.unmount();
}
});
const container = document.createElement("div");
document.body.appendChild(container);
messageApp.mount(container);
});
}

注意:

  1. 这里 messageApp 不能直接挂载到 body,因为 body 下的 #app 也是通过 createApp 创建后挂载的,两者会产生冲突。
  2. 组件内自定义组件生效,第三方组件库不生效,可通过以下方式解决:
    1
    2
    3
    import ElementPlus from "element-plus";

    messageApp.use(ElementPlus);
基于 createVNode/h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// message.ts

import MessageComponent from "./message.vue";
import { h, render } from "vue";

export function CustomMessage (options: MessageOptions): Promise<void> {
return new Promise((resolve, reject) => {
const vnode = h(MessageComponent, {
...options,
onAction: action => {
if (action === "confirm") resolve();
else reject();
// 相当于执行 Element.remove()
render(null, document.body);
}
});
render(vnode, document.body);
});
}

这里需要注意的问题与上面相同,不同的是它无法使用第三方组件库。


3. 使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<button @click="click"></button>
</template>

<script setup lang="ts">
import { CustomMessage } from "./message";


function click () {
CustomMessage({
title: "提示",
message: "这是一个命令式组件",
confirmButtonText: "确定",
cancelButtonText: "取消"
}).then(() => {
console.log("点击确定");
}).catch(() => {
console.log("点击取消");
});
}
</script>

4. 拓展

如果想要遵循高内聚原则,即把组件和命令函数都放在一个文件中,我们可以使用 defineComponent 方法来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineComponent, h } from "vue";


const MessageComponent = defineComponent(
(props: MessageProps) => {
return () => {
return h("div", { class: "box" }, [h(...), h(...), h(...)])
}
},
{
props: ["title", "message", "cancelButtonText", "confirmButtonText"]
}
)

defineComponent 方法的第一个参数要返回整个组件的虚拟 DOM,用 h() 方法的太过繁琐,我们可以用 tsx 来书写:

1
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
import { defineComponent } from "vue";
import { styled } from "@styils/vue";


const MessageComponent = defineComponent(
(props: MessageProps) => {
const Box = styled("div", {
width: "200px",
height: "150px",
backgroundColor: "red",
position: "fixed",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
zIndex: 999
});

return () => {
return (
<Box>
<div>{ props.title }</div>
{ props.message }
<button onClick={ () => props.onAction('cancel') }>{ props.cancelButtonText }</button>
<button onClick={ () => props.onAction('confirm') }>{ props.confirmButtonText }</button>
</Box>
)
}
},
{
props: ["title", "message", "cancelButtonText", "confirmButtonText", "onAction"]
}
)