【组件封装】使用React框架手搓一个Message
1. 思路
2. 实现过程
1. 先想好可以传递给message的参数,定义一个interface
一般来说message组件都有type、duration、content、closeable等等参数,以下这些参数基本可以满足日常使用
export interface MessageOptions {
/**
* 内容
*
* @default ''
*/
content?: ReactNode;
/**
* 类型
*
* @default success
*/
type?: MessageType;
/**
* 唯一标识
*/
key?: MessageKey;
/**
* 可关闭
*
* @default false
*/
closable?: boolean;
/**
* 关闭时间,0不关闭
*
* @default 3000
*/
duration?: number;
/**
* 隐藏icon
*
* @default false
*/
hideIcon?: boolean;
/**
* 是否单例
*
* @description
* 适合频繁更新message的场景,保持在一个message实例中去更新内容
* 如果更新失败,会自动去执行新增message的逻辑
* 配合key一起使用
*
* @default false
*/
isSingleton?: boolean;
/**
* hover是否停止定时器
*
* @default true
*/
isHoverStop?: boolean;
}
2. Message组件,用于每个Message的UI
这个组件是渲染单个Message的UI,在这个组件里,需要处理:UI布局、Hover事件的监听、点击close按钮的监听
实现如下:
interface MessageProps {
options: MessageOptions;
closeCallback?: (key: MessageKey) => void;
hoverCallback?: (key: MessageKey, type: 'enter' | 'leave') => void;
}
const Message = memo((props: MessageProps) => {
const { content, type, key, closable, hideIcon, isHoverStop } = props.options;
const { closeCallback, hoverCallback } = props;
const container = useRef<HTMLDivElement>(null);
useHover(container, {
onEnter: () => {
isHoverStop && hoverCallback?.(key, 'enter');
},
onLeave: () => {
isHoverStop && hoverCallback?.(key, 'leave');
},
});
const renderIcon = (type: MessageType) => {
const icons = {
info: 'icon-info-fill text-blue-500',
success: 'icon-success-fill text-green-500',
error: 'icon-error-fill text-red-500',
warning: 'icon-warning text-yellow-500',
loading: 'icon-loading text-blue-500',
};
const iconCls = icons[type];
return <i className={`iconfont ${iconCls} text-24px`} />;
};
return (
<div
ref={container}
className="border-default box-border inline-block rounded-[8px] border border-solid bg-[#fff] px-4 py-3 shadow-lg"
>
<div className="flex items-center">
{!hideIcon && (
<div
className={classNames(['mr-2 h-6', { 'animate-spin': type === 'loading' }])}
>
{renderIcon(type as MessageType)}
</div>
)}
<div className="text-secondary text-16 whitespace-nowrap leading-[24px]">
{content}
</div>
{closable && (
<span
className="ml-2 flex cursor-pointer"
onClick={() => {
closeCallback?.(key);
}}
>
<i className="iconfont icon-close leading-[24px] text-stone-500" />
</span>
)}
</div>
</div>
);
});
export default Message;
closeCallback和hoverCallback这两个事件是MessageContainer传进来的,后续再处理这两个事件,先写好放着,同时用memo包裹了这个组件,避免不必要的渲染
3. MessageContainer组件,用来渲染所有的Message
在这个组件中,要使用useState去定义一个messageList,要为MessageStore添加更新UI的方法(即把setMessageList传进MessageStore中),MessageStore中的messages数组作为messageList的初始值,同时 要写一下hoverCallback和closeCallback。
message的动画方面,我使用的是framer-motion这个库,这是个用于React框架的动画库,功能强大。
代码如下:
const MessageContainer = () => {
const [messageList, setMessageList] = useState(messageStore.getMessages());
// 关闭按钮的回调
const closeCallback = useMemoizedFn((key: MessageKey) => {
messageStore.removeMessage(key);
});
// hover的回调
const hoverCallback = useMemoizedFn((key: MessageKey, type: 'enter' | 'leave') => {
const message = messageStore.getMessage(key);
if (message?.duration === 0) return;
type === 'enter' ? messageStore.removeTimeout(key) : messageStore.addTimeout(key);
});
// 为MessageStore添加更新Ui的方法
useEffect(() => {
const unSubscribe = messageStore.subscribe((newMessages) => {
setMessageList([...newMessages]);
});
return unSubscribe;
}, []);
return (
<ul className="components-message-container absolute left-[50%] top-0 flex -translate-x-1/2 transform flex-col items-center gap-y-6">
<AnimatePresence>
{messageList.map((message) => (
<motion.li
key={message.key}
animate={{ opacity: 1, y: 24 }}
exit={{ opacity: 0, y: 0 }}
transition={{ ease: 'linear', duration: 0.2 }}
className="list-none"
>
<Message
options={message}
closeCallback={closeCallback}
hoverCallback={hoverCallback}
/>
</motion.li>
))}
</AnimatePresence>
</ul>
);
};
代码里面的messageStore是从store文件里导入的MessageStore的单例
4. 定义MessageStore,实现各种操作,如add、remove、update等
class MessageStore {
private messages: MessageOptions[] = [];
private cb: (messages: MessageOptions[]) => void = () => {
throw new Error('subscribe function is not defined');
};
private timeouts: MessageTimeout[] = [];
static instance = new MessageStore();
constructor() {
if (!MessageStore.instance) {
MessageStore.instance = this;
}
return MessageStore.instance;
}
addMessage(baseMessage: MessageOptions) {
const defaultMessage: MessageOptions = {
type: 'success',
key: uniqueId(),
closable: false,
duration: 3000,
hideIcon: false,
isSingleton: false,
isHoverStop: true,
};
const message = mergeProps<MessageOptions>(
baseMessage,
defaultMessage
) as Required<MessageOptions>;
if (message.duration < 0) {
message.duration = defaultMessage.duration as number;
}
if (message.duration > 0) {
const timerId = setTimeout(() => {
this.removeMessage(message.key);
}, message.duration);
this.timeouts.push({ key: message.key, timerId, ms: message.duration });
}
this.messages.push(message);
this.cb(this.messages);
}
removeMessage(key: MessageKey) {
const message = this.getMessage(key);
if (message) {
this.messages = this.messages.filter((mes) => mes.key !== key);
this.removeTimeout(key);
this.cb(this.messages);
}
}
updateMessage(baseMessage: MessageOptions) {
const message = this.getMessage(baseMessage.key);
if (message) {
const newMessage = mergeProps(baseMessage, message);
this.messages = this.messages.map((mes) =>
mes.key === baseMessage.key ? newMessage : mes
);
// delete old timeout
this.removeTimeout(baseMessage.key);
// add new timeout
this.addTimeout(baseMessage.key);
this.cb(this.messages);
}
}
clear() {
!!this.timeouts.length &&
this.timeouts.forEach((timeout) => clearTimeout(timeout.timerId));
this.timeouts = [];
this.messages = [];
}
destory() {
this.clear();
this.cb(this.messages);
}
subscribe(callback: (messages: MessageOptions[]) => void) {
this.cb = callback;
return this.clear;
}
getMessage(key: MessageKey) {
return this.messages.find((mes) => mes.key === key);
}
getMessages() {
return this.messages;
}
getTimeout(key: MessageKey) {
return this.timeouts.find((timeout) => timeout.key === key);
}
removeTimeout(key: MessageKey) {
const timeout = this.getTimeout(key);
if (timeout) {
clearTimeout(timeout.timerId);
this.timeouts = this.timeouts.filter((timeout) => timeout.key !== key);
}
}
addTimeout(key: MessageKey) {
const message = this.getMessage(key);
if (message?.duration === 0) return;
const timerId = setTimeout(() => {
this.removeMessage(key);
}, message?.duration);
this.timeouts.push({ key, timerId, ms: message?.duration as number });
}
}
export const messageStore = new MessageStore();
我在这个类里处理了props的默认值,所以不用在Message里处理,传到Message里的props一定是有值的
这个类里维护了四个变量,解释一下这几个变量:
- messages: 存放message实例
- cb: UI层的更新函数
- timeouts: 存放每个message实例对应的定时器,因为message被创建之后,会在n秒后进行销毁,类型定义如下:
export interface MessageTimeout {
/**
* key
*/
key: MessageKey;
/**
* 定时器id
*/
timerId: number;
/**
* 关闭时间
*/
ms: number;
}
- instance:MessageStore实例
总之,流程就是:调用MessageStore里的方法进行数据操作 => 调用cb => UI更新
- 暴露给外部使用的API 单独开一个文件message.ts, 暴露了几个api,分别是open api 和 别名api, 如下:
const message: MessageApi = {
success: (content) => {
messageStore.addMessage({ content, type: 'success' });
},
info: (content) => {
messageStore.addMessage({ content, type: 'info' });
},
warning: (content) => {
messageStore.addMessage({ content, type: 'warning' });
},
error: (content) => {
messageStore.addMessage({ content, type: 'error' });
},
loading: (content) => {
messageStore.addMessage({ content, type: 'loading' });
},
open: (option) => {
if (option?.isSingleton) {
const message = messageStore.getMessage(option?.key);
if (message) {
messageStore.updateMessage(option);
return;
}
}
messageStore.addMessage(option);
},
destoryAll: () => {
messageStore.destory();
},
};
export default message;
类型MessageApi定义如下:
export interface MessageApi {
success: (content: ReactNode) => void;
info: (content: ReactNode) => void;
error: (content: ReactNode) => void;
warning: (content: ReactNode) => void;
loading: (content: ReactNode) => void;
open: (option: MessageOptions) => void;
destoryAll: () => void;
}
这个messageStore跟MessageContainer里面的是同一个store,都是从store文件里导入的,
别名api暴露的parameters是比较简洁的,只有一个content,调用示例:
message.success('This is a message!')
open api 就暴露了所有的props,调用示例:
message.open({
content: `这是m1, date: ${Date.now()}`,
isSingleton: true,
key: 'm1',
});
5. render
现在暴露给外面使用的api也写完了,如果直接调用message.success('xxx),理论上来说看不到任何效果, 因为MessageContainer还没有渲染到Render Tree中,只要把MessageContainer放在任何一个地方,例如App.tsx中,如:
function App() {
return <div>
{/* ohter components... */}
<MessageContainer />
</div>
}
这样就能看到效果了
但是这样做不是很好,因为有点麻烦。
搞一个root,来render, 写个render方法,如下:
const render = () => {
const containerEle = document.createElement('div');
containerEle.id = 'components-message-wrapper';
document.body.appendChild(containerEle);
const root = createRoot(containerEle);
root.render(<MessageContainer />);
};
render();
这段代码我放在了MessageContainer组件里,因为没有地方需要导入MessageContainer.tsx这个组件,这段代码也不会执行,所以在message.ts(定义暴露给外部使用的api的文件)手动引入一下,如下:
import './ui/MessageContainer';
6. 整理一下目录结构,如下:
转载自:https://juejin.cn/post/7352245958593970214