本文详解如何完整创造出功能较为齐全的 Toast 轻提示组件、Message 全局提示组件
组件开发采用 React 框架,组件功能参考 Toast 轻提示 - Ant Design Mobile、全局提示 Message - Ant Design
Toast 轻提示组件
目标
通过指令式调用快速生成 Toast 提示
Toast.show('Toast 轻提示');
Toast.show({
content: <div className={css['custom-text']}>自定义文本</div>
})
Toast.show({
content: '成功',
icon: 'success',
});
...
1. 规定参数及其类型、含义
type ToastOptionsT = {
/** Toast 文本内容 */
content: string | React.ReactNode;
/** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
duration?: number;
/** Toast 图标 */
icon?: 'success' | 'fail' | 'loading' | React.ReactNode,
/** 图标和文字布局,仅在参数 icon 合法时有效 */
direction?: 'row' | 'column',
/** 是否允许背景点击,默认为 true */
maskClickable?: boolean,
/** Toast 消失时触发 */
onClose?: () => void;
};
2. 内部组件 InternalToast 及其样式
最外层一张蒙层 + 灰色背景的盒子 + 盒子里面的内容
import React, { useEffect, useMemo } from 'react';
/** 内部 InternalToast 组件 */
const InternalToast: React.FC<ToastOptionsT> = (props) => {
useEffect(() => {
if (props.duration !== 0) {
setTimeout(() => { // 定时关闭 Toast
props.onClose?.(); // 关闭回调
}, props.duration);
}
}, []);
// 处理图标显示逻辑
const ShowIcon = useMemo(() => {
if (!props.icon || ['number', 'boolean'].includes(typeof props.icon)) {
return null;
}
switch (props.icon) {
case 'success':
return <img className={css['toast-icon']} src={successIcon} />;
case 'fail':
return <img className={css['toast-icon']} src={failIcon} />;
case 'loading':
return <LoadingIcon className={css['toast-icon']} />;
default:
return typeof props.icon === 'string' ? null : <>{props.icon}</>;
}
}, [props]);
return (
<div
className={css.mask}
style={{ pointerEvents: props.maskClickable ? 'none' : 'auto' }}
>
<div
className={classNames(css.toast, ShowIcon ? css['toastWithIcon'] : '')}
style={{ flexDirection: props.direction }}
>
{/* 图标 */}
{ShowIcon && (
<div className={css['toast-icon-box']}>{ShowIcon}</div>
)}
{/* 文本 */}
{typeof props.content === 'string' ? (
<div className={css['toast-text']}>{props.content}</div>
) : (
props.content
)}
</div>
</div>
);
};
Toast.less
.mask {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
.toast {
color: #fff; // 自定义 content 时也要默认字体颜色为白色
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.7);
padding: 20px 30px;
min-width: 200px; // 设置最小宽度
max-width: 60vw; // 设置最大宽度
display: flex;
justify-content: center;
align-items: center;
&-text {
font-size: 24px;
}
&-icon-box {
margin: 10px 20px;
display: flex;
justify-content: center;
align-items: center;
}
&-icon {
width: 50px;
height: 50px;
}
}
.toastWithIcon {
padding: 30px;
}
}
如何通过调用 Toast.show 方法将 InternalToast 组件渲染出来,这是重点步骤
3. 通过 show 方法渲染 InternalToast 组件
show 函数代码
const show = (options: string | ToastOptionsT) => {
let ops = options as ToastOptionsT;
if (typeof options === 'string') {
ops = {
content: options, // 直接传入字符串时,将其转换为 `content`
}
}
const close = renderToastInBody(
<InternalToast
{...{
...ops,
onClose: () => {
close(); // 通过 renderToastInBody 函数返回的 close 方法关闭 Toast
ops.onClose?.(); // 关闭回调
},
}}
/>
);
};
renderToastInBody 函数代码
import { createRoot } from 'react-dom/client';
const animationTime = 300; // 动画时间,单位(ms)
const appearAnimation = [ { opacity: 0 }, { opacity: 1 } ]; // 淡入动画效果
const disappearAnimation = [ { opacity: 1 }, { opacity: 0 } ]; // 淡出动画效果
const renderToastInBody = function (component: JSX.Element) {
// 创建一个 div
const nextToastDiv = document.createElement('div');
nextToastDiv.id = 'toast-box';
// 如果之前存在已渲染的 Toast 组件,则使用 replaceChild 进行替换
const presentToastDiv = document.getElementById('toast-box');
if (presentToastDiv && document.body.contains(presentToastDiv)) {
document.body.replaceChild(nextToastDiv, presentToastDiv);
}
// 否则使用 appendChild 添加在 body 里面
else {
document.body.appendChild(nextToastDiv);
nextToastDiv.animate(appearAnimation , animationTime);
}
// 通过 createRoot 将 React 组件渲染到浏览器 DOM 节点里面
const root = createRoot(nextToastDiv);
root.render(component);
return () => {
nextToastDiv.animate(disappearAnimation , animationTime).onfinish = function () {
root.unmount();
// 容错处理
if (document.body.contains(nextToastDiv)) document.body.removeChild(nextToastDiv);
};
};
}
renderToastInBody 函数是如何将 InternalToast 组件渲染出来的?
是采用官方提供的 API createRoot 方法传入一个浏览器 DOM 节点 nextToastDiv 后得到一个 root 对象,通过这个对象的 render 方法传入一个 React 组件即可将其渲染到这个 DOM 节点里面(官方文档:createRoot – React)
值得注意的是:在 React18 中使用 createRoot 代替 render
// React18 以前的写法
import { render } from 'react-dom';
import App from 'App';
const container = document.getElementById('app');
render(<App />, container);
// 新的写法
import { createRoot } from 'react-dom/client';
import App from 'App';
const container = document.getElementById('app');
const root = createRoot(container);
root.render(<App />);
renderToastInBody 函数返回一个函数,用来关闭 Toast,其中调用 root.unmount 销毁 DOM 节点 nextToastDiv 里的 React 组件,然后再调用 removeChild 方法将该 DOM 节点移除
4. 通过 clear 方法关闭当前 Toast
clear 函数代码
/** 关闭当前显示中的 Toast */
const clear = () => {
removeToastInBody();
}
/** 移除当前的 Toast 组件 */
const removeToastInBody = function () {
const presentToastDiv = document.getElementById('toast-box');
if (presentToastDiv && document.body.contains(presentToastDiv)) {
presentToastDiv.animate(disappearAnimation, animationTime).onfinish = function () {
document.body.removeChild(presentToastDiv);
};
}
}
5. 通过 config 方法进行全局配置
config 函数代码
/** 全局配置 */
const configOptions = {};
/** 全局配置 */
const config = (options: Pick<ToastOptionsT, 'duration' | 'icon' | 'maskClickable' | 'onClose'>) => {
Object.assign(configOptions, options);
}
这样在使用 Toast.show 之前调用 Toast.config 方法进行全局配置,可以减少重复配置的代码
6. 默认配置
/** 默认配置 */
const defaultOptions = {
duration: 2000,
maskClickable: true,
direction: 'column',
}
需要在 show 函数里添加逻辑:将默认配置和全局配置合并到传入的 option 当中
ops = mergeOptions(defaultOptions, configOptions, ops);
/** 合并配置参数 */
export function mergeOptions(...options: any) {
let res: any = {};
options.forEach((option: any) => {
res = Object.assign(res, option);
})
return res;
}
值得注意的是:Object.assign 传入对象的顺序,参考 MDN 官方文档 Object.assign() - JavaScript | MDN:
如果目标对象与源对象具有相同的 key,则目标对象中的属性将被源对象中的属性覆盖,后面的源对象的属性将类似地覆盖前面的源对象的属性
所以是外部传入的 option 覆盖 configOptions 覆盖 defaultOptions
7. 导出 Toast 对象
Toast.tsx 最终导出一个对象,里面包含 show、clear、config 函数
/** Toast 轻提示,只支持指令式调用 */
const Toast: {
show: typeof show;
clear: typeof clear;
config: typeof config;
} = { show, clear, config };
export default Toast;
这样在外部使用 Toast 组件时就很直观了:
使用
自此,一个功能较为齐全的 Toast 组件已经完成,使用示例:
import React, { useEffect } from 'react';
import Toast from '@/components/toast/toast';
const Demo: React.FC<{}> = (props) => {
useEffect(() => {
Toast.config({
duration: 3000,
maskClickable: false,
})
}, [])
return (
<div className={css.demo}>
<div className={css.demoBlock}>
<div className={css.title}>Toast 轻提示</div>
<div className={css.main}>
<button onClick={() => {
Toast.show('Toast 轻提示')
}}>轻提示</button>
<button onClick={() => {
Toast.show({
content: <div className={css['custom-text']}>自定义文本</div>
})
}}>自定义文本</button>
<br />
<button onClick={() => {
Toast.show({
content: '成功',
icon: 'success',
})
}}>成功</button>
<button onClick={() => {
Toast.show({
content: '加载中',
icon: 'loading',
duration: 0,
})
}}>加载中</button>
<button onClick={() => {
Toast.show({
content: '上传中',
icon: <img src={uploadIcon} className={css['upload-icon']} />,
})
}}>自定义图标</button>
<br />
<button onClick={() => {
Toast.show({
content: '请耐心等待',
maskClickable: false,
})
}}>阻止背景点击</button>
<br />
<button onClick={() => {
Toast.clear();
}}>清除当前 Toast</button>
<br />
<button onClick={() => {
Toast.show({
content: '加载中',
icon: 'loading',
duration: 0,
})
setTimeout(() => {
const rdm = Math.random();
if (rdm >= 0.2) {
Toast.show({
content: '成功',
icon: 'success',
})
} else {
Toast.show({
content: '失败',
icon: 'fail',
})
}
}, 1000);
}}>模拟网络请求</button>
<br />
<button onClick={() => {
Toast.show({
content: '可打开控制台查看',
onClose: () => { console.log('Toast 关闭'); }
})
}}>关闭时触发回调函数</button>
</div>
</div>
</div>
);
};
Message 全局提示组件
Message 组件的实现与 Toast 组件有一定的相似之处,最大差别在于:需要维护一个 messageList 消息队列来管理消息
目标
Message.show('Message 全局提示');
Message.show({
content: <div className={css['custom-text']}>自定义文本</div>
})
Message.show({
content: '成功',
icon: 'success',
});
...
1. 规定参数及其类型、含义
type MessageOptionsT = {
/** Message 文本内容 */
content: string | React.ReactNode;
/** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
duration?: number;
/** Message 图标 */
icon?: 'success' | 'fail' | 'loading' | React.ReactNode;
/** 当前提示的唯一标志 */
key?: string | number;
/** Message 消失时触发 */
onClose?: () => void;
};
2. 内部组件 InternalMessage 及其样式
import React, { useEffect, useMemo, useSyncExternalStore } from 'react';
/** 内部 InternalMessage 组件 */
const InternalMessage: React.FC<{}> = () => {
// 订阅 messageList 的变化,具体实现后面会补充
const messageList = useSyncExternalStore(store.subscribe, store.getSnapshot);
return (
<div
id='message-box-mask'
className={css.mask}
>
{messageList.map((item, index) => (
<MessageNotice key={index} id={item.id} options={item.options} />
))}
</div>
);
};
/** Message 列表中每条消息的类型 */
type MessageItemT = {
id: string; // 每条的唯一标识
options: MessageOptionsT; // 每条消息的配置
}
/** 单条消息组件 */
const MessageNotice: React.FC<MessageItemT> = ({ id, options }) => {
useEffect(() => {
if (options.duration !== 0) {
setTimeout(() => { // 定时关闭
removeDomByID(id); // 根据指定 id 移除指定消息
}, options.duration);
}
}, []);
const ShowIcon = useMemo(() => {
if (!options.icon || ['number', 'boolean'].includes(typeof options.icon)) {
return null;
}
switch (options.icon) {
case 'success':
return <img className={css['message-icon']} src={successIcon} />;
case 'fail':
return <img className={css['message-icon']} src={failIcon} />;
case 'loading':
return <LoadingIcon className={css['message-icon']} />;
default:
return typeof options.icon === 'string' ? null : <>{options.icon}</>;
}
}, [options]);
return (
<div
id={`message-${id}`}
className={css.message}
>
{ShowIcon && (
<div className={css['message-icon-box']}>{ShowIcon}</div>
)}
{typeof options.content === 'string' ? (
<div className={css['message-text']}>{options.content}</div>
) : (
options.content
)}
</div>
);
};
Message.less
.mask {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 0; // 顶部和底部留空
pointer-events: none; // 允许穿透点击蒙层
.message {
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.7);
padding: 20px 30px;
min-width: 200px; // 设置最小宽度
max-width: 60vw; // 设置最大宽度
color: #fff; // 自定义 content 时也要默认字体颜色为白色
display: flex;
justify-content: center;
align-items: center;
animation: move 300ms; // 消息关闭时的动画
margin-bottom: 20px;
&-text {
font-size: 24px;
}
&-icon-box {
margin-right: 20px;
display: flex;
justify-content: center;
align-items: center;
}
&-icon {
width: 40px;
height: 40px;
}
}
}
@keyframes move {
from {
opacity: 0.5;
transform: translate(0, -100%);
}
to {
opacity: 1;
transform: translate(0, 0);
}
}
如何通过调用 Message.show 方法去更新消息队列,这是重点也是难点
3. 通过 show 方法更新消息队列
难点: 由于我们是通过 show 函数去新增消息的,要想从视图层面上新增消息,就需要通知函数组件 InternalMessage 说 messageList 队列发生了变化
思路: 可以在函数组件外部维护一个 store、一个数组 messageList,通过 store 去管理 messageList,而在函数组件里面订阅这个 store。当 messageList 发生变化时由 store 去通知函数组件
针对这个思路选择采用官方提供的 useSyncExternalStore – React 钩子
useSyncExternalStore 是一个可以订阅外部 store 的 react hook,需要传入两个参数:
- subscribe 函数:接受一个回调函数作为参数并将其订阅到这个 store(放到 store 里面)。当 store 发生变化时,这个回调函数会执行。另外 subscribe 函数需要返回一个可以取消订阅的函数;
- getSnapshot 函数:返回函数组件(此例子为
InternalMessage)所订阅的 store 里的数据(此例子为messageList)的一个快照。当这个 store 没有改变的时候,重复调用 getSnapshot 必须返回同样的值。如果 store 发生变化并且返回值不同(判断依据:用 Object.is() 做比较),React 就会重新渲染这个函数组件
先自定义一个外部的 store
/** Message 列表 */
let messageList: MessageItemT[] = [];
/** 监听器列表 */
let listeners: (() => void)[] = [];
/** Message 列表的状态管理 */
const store = {
// 新增一条消息
addMessage(item: MessageItemT) {
messageList = [...messageList, item]; // 注意不能直接 push,需要重新赋值才能被监听到变化
emitChange();
},
// 关闭一条消息
deleteMessage(id: string) {
messageList.find((m) => m.id === id)?.options.onClose?.(); // 消息关闭回调
messageList = messageList.filter((m) => m.id !== id); // 根据 id 删除指定消息
emitChange();
},
// 订阅函数
subscribe(listener: () => void) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener); // 取消订阅函数
};
},
// 直接返回 messageList
getSnapshot() {
return messageList;
}
};
// 通知变化
function emitChange() {
for (let listener of listeners) {
listener();
}
}
show 函数代码
const show = (options: string | MessageOptionsT) => {
let ops = options as MessageOptionsT;
if (typeof options === 'string') {
ops = {
content: options,
}
}
// 判断 body 下是否含有放置 Message 列表的容器
const dom = document.getElementById('message-box');
if (!dom) {
createMessageBox(ops);
}
// 消息队列新增一条消息
store.addMessage({
id: String(ops.key ?? '') || getUuiD(4), // id 的值为传入的 key 值或随机生成的字符串
options: ops,
});
};
/** 创建一个放置 Message 列表的容器 */
const createMessageBox = function (option: MessageOptionsT) {
const $div = document.createElement('div');
$div.id = 'message-box';
document.body.appendChild($div);
const root = ReactDOMClient.createRoot($div);
root.render(
<InternalMessage />
);
};
/**
* 生成一个用不重复的ID
* @param { Number } randomLength
*/
export function getUuiD(randomLength: number) {
return Number(Math.random().toString().slice(2) + Date.now())
.toString(36)
.slice(0, randomLength);
}
4. 通过 clear 方法关闭消息
clear 函数代码
/**
* 关闭消息
* - 不传 key 关闭所有消息
* - 传 key 关闭指定一条消息
*/
const clear = (key?: string) => {
if (key) {
removeDomByID(key);
return;
}
messageList.forEach((message) => {
removeDomByID(message.id);
})
}
/** 根据 id 移除指定 message 的 DOM 节点 */
const removeDomByID = function (id: string) {
const msgDom = document.getElementById(`message-${id}`);
if (!msgDom) return;
const msgDomHeight = msgDom.offsetHeight;
// 关闭消息动画
const disappearAnimation = [
{ opacity: 1, },
{ opacity: 0, marginTop: `${-msgDomHeight}px`, marginBottom: 0 }
];
msgDom.animate(disappearAnimation, 300).onfinish = function () {
store.deleteMessage(id);
}
};
5. 通过 config 方法进行全局配置
config 函数代码
/** 消息的全局配置项类型 */
type MessageGlobalOptionsT = {
/** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
duration?: number;
/** 最大显示数, 超过限制时,最早的消息会被自动关闭 */
maxCount?: number;
/** Message 消失时触发 */
onClose?: () => void;
};
/** 全局配置 */
const globalOptions: MessageGlobalOptionsT = {};
/**
* 全局配置
* | 属性 | 类型 | 说明 | 默认值 |
* | :----: | :----: | :----: | :----: |
* | duration | number | 提示持续时间(ms),为 0 则不会自动关闭 | 2000 |
* | maxCount | number | 最大显示数, 超过限制时,最早的消息会被自动关闭 | - |
* | onClose | () => void | Message 消失时触发 | - |
*/
const config = (options: MessageGlobalOptionsT) => {
Object.assign(globalOptions, options);
}
为实现【超过限制时,最早的消息会被自动关闭】功能需要在 show 函数里添加:
// 超过最大显示数限制时,队头消息会被自动关闭。
if (globalOptions.maxCount && globalOptions.maxCount > 0) {
if (messageList.length + 1 > globalOptions.maxCount) {
removeDomByID(messageList[0].id);
}
}
这样在使用 Message.show 之前调用 Message.config 方法进行全局配置,可以减少重复配置的代码
6. 默认配置
/** 默认配置 */
const defaultOptions: Pick<MessageOptionsT, 'duration' | 'onClose'> = {
duration: 2000,
onClose: undefined,
}
所以在 config 函数里,需要加这样的逻辑:全局配置会替换默认配置
if (options.duration !== undefined) {
defaultOptions.duration = options.duration;
}
if (options.onClose !== undefined) {
defaultOptions.onClose = options.onClose;
}
而在 show 函数里,需要将默认配置合并到传入的 ops 当中
ops = mergeOptions(defaultOptions, ops);
7. 导出 Message 对象
Message.tsx 最终导出一个对象,里面包含 show、clear、config 函数
/** Message 全局提示,只支持指令式调用 */
const Message: {
show: typeof show;
clear: typeof clear;
config: typeof config;
} = { show, clear, config };
export default Message;
使用
自此,一个功能较为齐全的 Message 组件已经完成,使用示例:
import Message from '@/components/message/message';
const Demo: React.FC<{}> = (props) => {
useEffect(() => {
Message.config({
duration: 3000,
maxCount: 5,
})
}, [])
return (
<div className={css.demo}>
<div className={css.demoBlock}>
<div className={css.title}>Message 全局提示</div>
<div className={css.main}>
<button onClick={() => {
Message.show('Message 全局提示')
}}>全局提示</button>
<button onClick={() => {
Message.show({
content: <div className={css['custom-text']}>自定义文本</div>,
})
}}>自定义文本</button>
<br />
<button onClick={() => {
Message.show({
content: '成功',
icon: 'success',
})
}}>成功</button>
<button onClick={() => {
Message.show({
content: '失败',
icon: 'fail',
})
}}>失败</button>
<button onClick={() => {
Message.show({
content: '加载中',
icon: 'loading',
duration: 0
})
}}>加载中</button>
<button onClick={() => {
Message.show({
content: '上传中',
icon: <img src={uploadIcon} className={css['upload-icon-small']} />,
})
}}>自定义图标</button>
<br />
<button onClick={() => {
Message.show({
content: '不会消失的 Message',
duration: 0
});
}}>不会消失的 Message</button>
<button onClick={() => {
Message.clear();
}}>清除所有 Message</button>
<br />
<button onClick={() => {
Message.show({
content: '可打开控制台查看',
onClose: () => { console.log('Message 关闭'); }
})
}}>关闭时触发回调函数</button>
</div>
</div>
</div>
);
};
小 Tips
写组件注释
采用 Markdown 语法写组件注释,可以让组件使用者更加清晰地了解参数及其含义
/**
* options 配置项说明
* | 属性 | 类型 | 说明 | 默认值 |
* | :----: | :----: | :----: | :----: |
* | content | string、React.ReactNode | Toast 文本内容 | - |
* | duration | number | 提示持续时间(ms),为 0 则不会自动关闭 | 2000 |
* | icon | `success`、`fail`、`loading`、React.ReactNode | Toast 图标 | - |
* | direction | `row`、`column` | 图标和文字布局,仅在参数 `icon` 合法时有效 | `column` |
* | maskClickable | boolean | 是否允许背景点击 | true |
* | onClose | () => void | Toast 消失时触发 | - |
*/
鼠标 hover 在 show 方法上面时就可以看到完整的注释:
写类型注释
这样写类型注释,可以让组件使用者快速地了解参数的含义
type ToastOptionsT = {
/** Toast 文本内容 */
content: string | React.ReactNode;
/** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
duration?: number;
/** Toast 图标 */
icon?: 'success' | 'fail' | 'loading' | React.ReactNode,
/** 图标和文字布局,仅在参数 icon 合法时有效 */
direction?: 'row' | 'column',
/** 是否允许背景点击,默认为 true */
maskClickable?: boolean,
/** Toast 消失时触发 */
onClose?: () => void;
};
鼠标 hover 在 duration 参数上面时就可以看到完整的注释: