前言
<Message> 组件是一个用于展示消息的组件,通常用于在应用程序中传递和显示消息。该组件应该具有可自定义的样式和布局,并能够支持多种类型的消息,如成功、警告、错误等。
本文介绍如何实现一个 Message 组件,涉及到的技术:
Vue3.0JSXTypeScript
首先,我们需要什么?
需要一个 message 函数!
从组件包里面导入 message 函数
import { message } from '@/components';
调用 message 函数
我们可以调用 message 这个方法来创建提示信息。
- 直接调用
message({
type: 'info',
message: 'this is a message',
duration: 1500,
});
message('this is a message');
- 调用静态方法调用
message.success('Success Message', 3000);
关闭 message
关闭单个 message 实例
const messageInstance = message({
type: 'info',
message: 'this is a message',
duration: 1500,
});
// 直接关闭
messageInstance.close();
全量关闭 message
调用 message.closeAll() 关闭所有的 Message 组件
message.closeAll();
参数?配置?
message方法
| 参数名称 | 参数类型 | 参数说明 | 是否必传? |
|---|---|---|---|
type | 字符串 | 展示 Message 的类型 | 否 |
duration | 数字 | 在没有鼠标操作下,Message 的停留时长(单位:毫秒) | 否 |
closable | 布尔 | Message 是否可以直接关闭? | 否 |
message | 字符串或虚拟节点 | Message的内容 | 是(不传递默认显示组件内部配置的内容) |
position | 字符串 | Message出现的位置,默认为 top。(可选值:top / bottom) | 否 |
onClose | 函数 | Message关闭时的回调函数 | 否 |
程序设计
1. 组织组件架构,编码前最重要的一步:
|- Message
|- docs # 组件文档
|- README.md # 组件使用说明 (相当于组件的摘要)
|- CHANGELOG.md # 组件更新日志 (用于项目迭代)
|- modules # 组件用到的模块
|- config.ts # 组件的静态配置
|- hooks.ts # 组件用到的 hooks
|- queue.ts # 组件的队列模块
|- utils.ts # 组件用到的工具函数
|- src # 组件核心源码
|- Message.tsx # 组件视图
|- method.ts # message 方法
|- index.ts # 组件出口
|- types.ts # 组件类型声明
2. 设计出口, 静态配置和类型,根据上面的设计来编写即可。
- 出口
index.ts
export { default as message } from './src/method';
export type * from './types';
- 组件类型
types.ts
import type {
ComputedRef,
ExtractPropTypes,
VNode,
ComponentPublicInstance,
App,
} from 'vue';
import { messageProps } from './modules/config';
export type MessageType = (
| 'info'
| 'success'
| 'warning'
| 'error'
);
export type MessagePosition = (
| 'top'
| 'bottom'
);
export interface BasicMessageMethodOptions {
type: MessageType;
duration: number;
closable: boolean;
message: string | VNode;
position: MessagePosition;
onClose: () => void;
}
export type MessageMethodOptions = (
| string
| Partial<BasicMessageMethodOptions>
);
export interface MessageInstance {
close: () => void;
}
export interface MessageMethod {
(options: MessageMethodOptions): MessageInstance;
closeAll: () => void;
info: (message: string, duration?: number) => MessageInstance;
success: (message: string, duration?: number) => MessageInstance;
warning: (message: string, duration?: number) => MessageInstance;
error: (message: string, duration?: number) => MessageInstance;
}
export type MessageComponentProps = ExtractPropTypes<typeof messageProps>;
export interface MessageComponentExpose {
id: string;
messageShow: ComputedRef<boolean>;
setMessageShow(setMessageShow: boolean): void;
setOffset: (newOffset: number | ((oldOffset: number) => number)) => void;
}
export interface MessageEventOptions {
props: MessageComponentProps;
setMessageShow(setMessageShow: boolean): void;
}
export interface MessageTimerOptions {
props: MessageComponentProps;
closeMessage(): void;
}
export type VmType = (
& ComponentPublicInstance
& MessageComponentExpose
);
export interface MessageQueueItem {
vm: VmType;
app: App;
}
export interface MessageQueueState {
queue: MessageQueueItem[];
}
- 静态配置
modules/config.ts
import type { PropType, VNode, RendererElement } from 'vue';
import type {
MessageType,
BasicMessageMethodOptions,
MessagePosition,
} from '../types';
export const messageProps = {
modelValue: {
type: Boolean,
required: false,
default: false,
},
message: {
type: [
String,
Object,
] as PropType<string | VNode>,
default: 'Message',
},
duration: {
type: Number,
default: 1500,
},
closable: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<MessageType>,
default: 'info',
},
offset: {
type: Number,
default: 0,
},
appendTo: {
type: [String, Object] as PropType<string | RendererElement>,
default: 'body',
},
position: {
type: String as PropType<MessagePosition>,
default: 'top',
},
};
export const messageEmits = {
'update:modelValue': (val: boolean) => true,
close: (val: boolean) => true,
}
export const defaultMessageOptions: BasicMessageMethodOptions = {
message: 'Message',
duration: 1500,
closable: false,
type: 'info',
position: 'top',
onClose: () => null,
};
export const messageTypeList: MessageType[] = [
'info',
'success',
'warning',
'error',
];
3. 组件样式编写,先把结构弄出来:
3.1. 图标设计
@font-face {
font-family: "iconfont"; /* Project id */
src: url('iconfont.ttf?t=1717913837012') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-error:before {
content: "\e839";
}
.icon-close:before {
content: "\e83a";
}
.icon-info:before {
content: "\e83d";
}
.icon-success:before {
content: "\e843";
}
.icon-warning:before {
content: "\e849";
}
3.2. 样式编写
.my-message {
z-index: 999;
position: fixed;
left: 50%;
transform: translateX(-50%);
background-color: #fff;
width: 300px;
height: 40px;
line-height: 40px;
padding: 0 8px;
box-sizing: border-box;
box-shadow: 0 0 3px #d9d9d9;
border-radius: 3px;
transition: top bottom 0.25s ease-out;
}
.my-message-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: inline-block;
line-height: 1;
width: 15px;
height: 15px;
font-size: 15px;
}
.my-message-icon-success {
color: #95d475;
}
.my-message-icon-warning {
color: #eebe77;
}
.my-message-icon-error {
color: #f89898;
}
.my-message-icon-info {
color: #79bbff;
}
.my-message-type-icon {
left: 0;
margin-left: 5px;
}
.my-message-close-icon {
right: 0;
margin-right: 5px;
cursor: pointer;
}
.my-message-content {
color: #3d3d3d;
font-size: 14px;
padding: 0 24px;
box-sizing: border-box;
}
.my-message-content-success {
color: #67C23A;
}
.my-message-content-warning {
color: #E6A23C;
}
.my-message-content-error {
color: #F56C6C;
}
.my-message-content-info {
color: #409EFF;
}
.my-message-top-enter-active {
transition: all 0.25s ease-in;
}
.my-message-top-leave-active {
transition: all 0.25s ease-out;
}
.my-message-top-enter-from, .my-message-top-leave-to {
opacity: 0;
transform: translateY(-100%) translateX(-50%);
}
.my-message-top-enter-to, .my-message-top-leave-from {
opacity: 1;
transform: translateY(0) translateX(-50%);
}
.my-message-bottom-enter-active {
transition: all 0.25s ease-in;
}
.my-message-bottom-leave-active {
transition: all 0.25s ease-out;
}
.my-message-bottom-enter-from, .my-message-bottom-leave-to {
opacity: 0;
transform: translateY(100%) translateX(-50%);
}
.my-message-bottom-enter-to, .my-message-bottom-leave-from {
opacity: 1;
transform: translateY(0) translateX(-50%);
}
4. 渲染组件视图。
在src/Message.tsx编写基本视图,不需要特别详细
import { Teleport, Transition, defineComponent } from 'vue';
export default defineComponent({
name: 'MyMessage',
setup(props, ctx) {
return (
<Teleport to={props.appendTo}>
<Transition name={`my-message-${props.position}`}>
<div
v-show={messageShow.value}
class={messageWrapCls.value}
style={messageWrapStyle.value}
onMouseenter={handleMouseEnterMessage}
onMouseleave={handleMouseLeaveMessage}
>
<i class={messageIconCls.value} />
<p class={messageContentCls.value}>
{messageContent.value} {/* {props.offset} */}
</p>
{props.closable === true && (
<i
class={messageCloseIconCls.value}
onClick={handleCloseMessage}
/>
)}
</div>
</Transition>
</Teleport>
);
}
});
5. 基于函数式编程进行逻辑模块拆分,完善组件细节。
hook 设计:
|- useId # 生成一个随机的 id (基于 Base64 编码)
|- useMessageCls # 整合 Message 组件用到的 class
|- useMessageStyle # 整合 Message 组件用到的 style
|- useMessageShow # 整合 Message 组件的显示逻辑
|- useMessageExpose # 整合 Message 对外抛出的 Api (在 method 中使用)
|- useMessageEvent # 整合 Message 组件的事件处理逻辑
|- useMessageOffset # 整合 Message 组件的偏移逻辑 (基于队列)
hook 引用:
import {
defineComponent,
computed,
Transition,
Teleport,
} from 'vue';
import type { ComputedRef, VNode } from 'vue';
import { messageProps, messageEmits } from '../modules/config';
import {
useId,
useMessageCls,
useMessageShow,
useMessageStyle,
useMessageExpose,
useMessageEvent,
useMessageOffset,
} from '../modules/hooks';
export default defineComponent({
name: 'MyMessage',
props: messageProps,
emits: messageEmits,
setup(props, { expose, emit }) {
const id = useId();
const {
messageWrapCls,
messageIconCls,
messageContentCls,
messageCloseIconCls,
} = useMessageCls(props);
const [messageShow, setMessageShow] = useMessageShow(props, emit);
const { offsetState, setOffset } = useMessageOffset(props);
useMessageExpose({
id,
messageShow,
setMessageShow,
setOffset,
}, expose);
const { messageWrapStyle } = useMessageStyle(offsetState, props);
const {
handleCloseMessage,
handleMouseEnterMessage,
handleMouseLeaveMessage,
} = useMessageEvent({ props, setMessageShow });
const messageContent: ComputedRef<string | VNode> = computed(() => {
return props.message;
});
return () => (
<Teleport to={props.appendTo}>
<Transition name={`my-message-${props.position}`}>
<div
v-show={messageShow.value}
class={messageWrapCls.value}
style={messageWrapStyle.value}
onMouseenter={handleMouseEnterMessage}
onMouseleave={handleMouseLeaveMessage}
>
<i class={messageIconCls.value} />
<p class={messageContentCls.value}>
{messageContent.value} {/* {props.offset} */}
</p>
{props.closable === true && (
<i
class={messageCloseIconCls.value}
onClick={handleCloseMessage}
/>
)}
</div>
</Transition>
</Teleport>
);
},
});
hook 实现:
import {
computed,
reactive,
watch,
shallowRef,
onMounted,
} from 'vue';
import type {
ComputedRef,
CSSProperties,
// VNode,
} from 'vue';
import type {
MessageComponentExpose,
MessageComponentProps,
MessageEventOptions,
MessageTimerOptions,
} from '../types';
export function useMessageCls(props: MessageComponentProps) {
const messageWrapCls: ComputedRef<string[]> = computed(() => {
return [
'my-message',
`my-message-${props.type}`,
];
});
const messageIconCls: ComputedRef<string[]> = computed(() => {
return [
'my-message-icon',
'my-message-type-icon',
`my-message-icon-${props.type}`,
'iconfont',
`icon-${props.type}`
];
});
const messageContentCls: ComputedRef<string[]> = computed(() => {
return [
'my-message-content',
`my-message-content-${props.type}`,
];
});
const messageCloseIconCls: ComputedRef<string[]> = computed(() => {
return [
'my-message-icon',
'my-message-close-icon',
`my-message-close-icon-${props.type}`,
'iconfont',
'icon-close',
];
})
return {
messageWrapCls,
messageIconCls,
messageContentCls,
messageCloseIconCls,
};
}
export function useMessageStyle(
state: Pick<MessageComponentProps, 'offset'>,
props: MessageComponentProps,
) {
const messageWrapStyle: ComputedRef<CSSProperties> = computed(() => {
const marginNum = Math.ceil(Number(state.offset)) + 1;
const messageNum = Math.ceil(state.offset);
return {
zIndex: 1000,
[props.position]: (marginNum * 15 + messageNum * 40) + 'px',
};
});
return {
messageWrapStyle,
};
}
export function useMessageShow(
props: MessageComponentProps,
emit: ((event: "update:modelValue", val: boolean) => void) & ((event: "close", val: boolean) => void),
): [ComputedRef<boolean>, (newIsShow: boolean) => void] {
const state = reactive({
show: props.modelValue,
});
const isShow: ComputedRef<boolean> = computed(() => state.show);
function setIsShow(newIsShow: boolean) {
state.show = newIsShow;
emit('update:modelValue', newIsShow);
}
watch(() => props.modelValue, (newValue) => {
setIsShow(newValue);
});
return [isShow, setIsShow];
}
export function useMessageExpose(
exposedApi: MessageComponentExpose,
expose: (exposed: MessageComponentExpose) => void,
) {
expose(exposedApi);
return exposedApi;
}
export function useMessageEvent({
props,
setMessageShow,
}: MessageEventOptions) {
const {
handleMouseEnterMessage,
handleMouseLeaveMessage,
} = useMessageTimerControl({
props,
closeMessage,
});
return {
handleCloseMessage: closeMessage,
handleMouseEnterMessage,
handleMouseLeaveMessage,
};
function closeMessage() {
setMessageShow(false);
}
}
export function useMessageTimerControl(options: MessageTimerOptions) {
const { props, closeMessage } = options;
const timerRef = shallowRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnterMessage = () => {
endTimer();
}
const handleMouseLeaveMessage = () => {
Promise.resolve()
.then(startTimer);
}
onMounted(() => {
startTimer();
});
return {
handleMouseEnterMessage,
handleMouseLeaveMessage,
};
function startTimer() {
timerRef.value = setTimeout(() => {
closeMessage();
endTimer();
}, props.duration);
}
function endTimer() {
if (!timerRef.value) return;
clearTimeout(timerRef.value);
timerRef.value = null;
}
}
export function useMessageOffset(props: MessageComponentProps) {
const offsetState = reactive({
offset: props.offset,
});
const setOffset = (
newOffset: number | ((oldOffset: number) => number)
) => {
if (typeof newOffset === 'function') {
offsetState.offset = newOffset(offsetState.offset);
} else {
offsetState.offset = newOffset;
}
}
return {
offsetState,
setOffset,
}
}
export function useId(): string {
return btoa(Date.now().toString());
}
6. 设计 Message 队列模块,集成模块方便维护和后续的扩展
modules/queue.ts
import { reactive, computed } from 'vue';
import {
MessageQueueItem,
MessageQueueState,
VmType,
} from '../types';
const queueState: MessageQueueState = reactive({
queue: [],
});
function getQueue() {
return computed(() => queueState.queue);
}
function addItemToQueue(queueItem: MessageQueueItem) {
if (queueState.queue.includes(queueItem)) {
return;
}
queueState.queue.push(queueItem);
}
function removeItemFromQueue(queueItem: MessageQueueItem) {
const index = getQueueItemIndex(queueItem);
if (index === -1) {
return;
}
const oldQueue: MessageQueueItem[] = [...queueState.queue];
queueState.queue.splice(index, 1);
updateQueueItemIndex(queueItem.vm, oldQueue);
}
function getQueueItemIndex(queueItem: MessageQueueItem) {
return queueState.queue.findIndex(item => item.vm === queueItem.vm);
}
function clearQueue() {
queueState.queue.forEach((item) => {
item.vm.setMessageShow(false);
setTimeout(() => {
item.app.unmount();
}, 300);
});
queueState.queue = [];
}
function getQueueLen() {
return getQueue().value.length;
}
function updateQueueItemIndex(vm: VmType, queue: MessageQueueItem[]) {
const { id } = vm;
const targetIdx = queue.findIndex(item => item.vm.id === id);
if (targetIdx === -1) return;
queue
.slice(targetIdx)
.forEach(item => {
item.vm.setOffset(offset => {
return offset === 0 ? 0 : offset - 1;
});
});
}
export {
addItemToQueue,
removeItemFromQueue,
getQueueItemIndex,
clearQueue,
getQueueLen,
};
个人思考:
- 使用面向对象的程序设计 (e,g 封装
MessageQueue类) 也能实现这样的效果。- 考虑到模块只是单例并且本文以函数式为主,所以这个模块使用函数式编程来做。
7. 整合 method 模块,大功告成!
核心逻辑实现
src/method.ts
import {
createApp,
watch,
} from 'vue';
// import type {} from 'vue';
import type { MessageMethod } from '../types';
import {
getMessageOptions,
renderMessageApp,
showMessageByVm,
destroyMessage,
} from '../modules/utils';
import Message from './Message';
import {
addItemToQueue,
removeItemFromQueue,
getQueueLen,
clearQueue,
} from '../modules/queue';
const message: MessageMethod = (opt) => {
const {
message,
duration,
closable,
type,
position,
onClose,
} = getMessageOptions(opt);
const app = createApp(Message, {
modelValue: false,
message,
duration,
closable,
type,
offset: getQueueLen(),
position,
});
const vm = renderMessageApp(app);
// 1. 挂载 vm
showMessageByVm(vm);
addItemToQueue({ vm, app });
// 2. 观察 vm.messageShow
watch(() => vm.messageShow, (newVal, oldVal) => {
console.log('messageShow 的值发生了变化:\n newVal: 【%s】 \n oldVal: 【%s】', newVal, oldVal);
!newVal && close();
});
return {
close,
};
function close() {
destroyMessage(app, duration);
removeItemFromQueue({ vm, app });
onClose?.();
}
}
追加逻辑实现
message.closeAll = () => {
clearQueue();
}
message.info = (opt, duration = 1500) => {
const options = getMessageOptions(opt);
return message({
...options,
duration,
type: 'info',
});
}
message.success = (opt, duration = 1500) => {
const options = getMessageOptions(opt);
return message({
...options,
duration,
type: 'success',
});
}
message.warning = (opt, duration = 1500) => {
const options = getMessageOptions(opt);
return message({
...options,
duration,
type: 'warning',
});
}
message.error = (opt, duration = 1500) => {
const options = getMessageOptions(opt);
return message({
...options,
duration,
type: 'error',
});
}
模块导出:
export default message;