需求
全局展示操作反馈信息。
- 可提供成功、警告和错误等反馈信息。
- 顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。
我们希望通过如下方式调用组件:
import Message from '@/components/Message';
// or
import { Message } from '@/components'
message.open({ type: 'info', content: 'Hello!' })
设计
总体设计
向外暴露 open 方法,使用者在第一次调用 open 方法时初始化容器,open 方法接收消息类型(type),根据是否已完成初始化:
未完成初始化:
- 创建容器
- 根据消息类型派发延时任务(必须先完成初始化,任务才可以执行,所以要等到下一次 eventLoop)
已完成初始化
- 根据消息类型派发即时任务
const message = {
open: ({ type = 'info', content }) => {
if (初始化完成) {
// 派发即时任务
} else {
// 未完成初始化
// 创建容器
// 派发延时任务
}
},
info: null, // 具体的实现会在初始化的过程中填充
error: null,
};
export default message;
容器设计(React.createRoot 的使用)
使用 React.createRoot()
来创建容器,此方法需要一个真实的DOM节点,在这个节点下创建一个 React root ,这个 React root 提供一个 render 方法,用于渲染 React Component。
const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;
// 创建真实 DOM
function createContainer() {}
// 创建 React root
function renderMessageRoot() {
// 获取到真实 DOM
const container = createContainer();
if (!containerRoot) {
// 创建 React root
containerRoot = ReactDOM.createRoot(container);
// 渲染消息组件
containerRoot.render(<消息组件 />);
}
}
消息组件设计(forwardRef 与 useImperativeHandle 的使用)
使用React.forwardRef
、React.useImperativeHandle
将消息管理与增删消息的实现相分离。
最外层的消息管理组件被渲染在 React root 下,所以进行初始化的过程中,会填充向外暴露的 message 对象上的info、error等方法的具体实现。而这些方法又是下层容器组件通过 useImperativeHandle
向管理器组件提供的。具体见伪代码:
const MessageContainer = React.forwardRef((props, ref) => {
const { messageList, setMessageList } = props;
useImperativeHandle(ref, () => {
return {
info: text => {
// 插入一条消息
},
error: text => {
// 插入一条消息
},
};
});
return (
<>
// 遍历消息列表,渲染
</>
);
});
function MessageManager() {
const [messageList, setMessageList] = useState([]);
const msgRef = useRef();
useEffect(() => {
message.current = msgRef.current;
message.info = msgRef.current.info;
message.error = msgRef.current.error;
}, []);
return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}
架构设计
// 创建真实 DOM 容器
function createContainer() {}
// 创建 React root 容器
function renderMessageRoot() {}
// 单条消息组件
const MessageItem = () => {};
// 消息容器组件
const MessageContainer = React.forwardRef((props, ref) => {
const { messageList, setMessageList } = props;
useImperativeHandle(ref, () => {
return {
info: text => {},
error: text => {},
};
});
return // 遍历消息列表,渲染;
});
// 消息管理组件
function MessageManager() {
const [messageList, setMessageList] = useState([]);
const msgRef = useRef();
useEffect(() => {
message.current = msgRef.current;
message.info = msgRef.current.info;
message.error = msgRef.current.error;
}, []);
return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}
const message = {
open: ({ type = 'info', content }) => {}, // 向外暴露 open 方法
info: null,
error: null
};
export default message;
实现
创建容器
const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;
// 创建真实 DOM
function createContainer() {
// 尝试根据 ID 获取真实DOM节点
let container = document.getElementById(MESSAGE_CONTAINER_ID);
if (!container) {
// 如果没有获取到,就创建一个,并插入 body 中
container = document.createElement('div');
container.setAttribute('id', MESSAGE_CONTAINER_ID);
document.body.appendChild(container);
}
return container;
}
// 创建 React root
function renderMessageRoot() {
const container = createContainer();
if (!containerRoot) {
containerRoot = ReactDOM.createRoot(container);
containerRoot.render(<消息组件 />);
}
}
const message = {
open: ({ type = 'info', content }) => {
if (containerRoot) {
message[type](content);
} else {
renderMessageRoot();
nextTick(() => {
message[type](content);
});
}
},
};
export default message;
消息组件
const MessageItem = ({ children }) => {
return (
<div className="messageItem">
{children}
</div>
);
};
const MessageContainer = React.forwardRef((props, ref) => {
const { messageList, setMessageList } = props;
useImperativeHandle(ref, () => {
return {
info: text => {
setMessageList(list => [...list, text]);
},
error: text => {
setMessageList(list => [...list, text]);
},
};
});
return (
<>
{messageList.map((msg, index) => (
<MessageItem key={index}>
<span>{msg.text}</span>
</MessageItem>
))}
</>
);
});
function MessageManager() {
const [messageList, setMessageList] = useState([]);
const msgRef = useRef();
useEffect(() => {
message.current = msgRef.current;
message.info = msgRef.current.info;
message.error = msgRef.current.error;
}, []);
return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}
自动移除消息
const MessageItem = ({ children, onRemove }) => {
useEffect(() => {
let timer = null;
timer = setTimeout(() => {
onRemove()
}, 2000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<div className="messageItem">
{children}
</div>
);
};
const MessageContainer = React.forwardRef((props, ref) => {
// ...
return (
<>
{messageList.map((msg, index) => (
<MessageItem key={index} onRemove={() => setMessageList(list => list.filter((item, i) => i !== index))}>
<span>{msg.text}</span>
</MessageItem>
))}
</>
);
});
单条消息唯一ID
在循环渲染和移除消息的时候,最好为单条消息加上唯一ID,此时我们使用Web Crypto API生成符合密码学要求的安全的随机 ID。
function uuid() {
const uuid = window.crypto.getRandomValues(new Uint8Array(8));
return uuid.toString().split(',').join('');
}
改造组件:
const MessageContainer = React.forwardRef((props, ref) => {
const { messageList, setMessageList } = props;
useImperativeHandle(ref, () => {
return {
info: text => {
const id = uuid();
setMessageList(list => [...list, { id, text }]);
},
error: text => {
const id = uuid();
setMessageList(list => [...list, { id, text }]);
},
};
});
return (
<>
{messageList.map(msg => (
<MessageItem key={msg.id} onRemove={() => setMessageList(list => list.filter(item => item.id !== msg.id))}>
<span>{msg.text}</span>
</MessageItem>
))}
</>
);
});
动画
为消息弹出、消息加上动画:
.messageItem {
width: max-content;
background-color: white;
margin: 0.5em auto;
text-align: center;
padding: 9px 12px;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%);
position: relative;
opacity: 0;
display: flex;
align-items: center;
}
.messageItem-appear {
opacity: 0;
animation-name: messageItemMoveIn;
animation-duration: 0.3s;
animation-play-state: 'paused';
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
animation-fill-mode: forwards;
}
.messageItem-disappear {
opacity: 1;
animation-name: messageItemMoveOut;
animation-duration: 0.3s;
animation-play-state: 'paused';
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
animation-fill-mode: forwards;
}
@keyframes messageItemMoveIn {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes messageItemMoveOut {
from {
opacity: 1;
}
to {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
opacity: 0;
}
}
改造单条消息组件:
const MessageItem = ({ children, onRemove }) => {
const messageItemRef = useRef();
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
let timer = null;
timer = setTimeout(() => {
if (messageItemRef.current) {
messageItemRef.current.addEventListener('animationend', onRemove, {
once: true,
});
}
setIsVisible(false);
}, 2000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<div ref={messageItemRef} className={`messageItem ${isVisible ? 'messageItem-appear' : 'messageItem-disappear'}`}>
{children}
</div>
);
};
完整代码
React Component:
// index.jsx
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { nextTick, uuid } from './utils';
import './index.css';
const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;
function createContainer() {
let container = document.getElementById(MESSAGE_CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.setAttribute('id', MESSAGE_CONTAINER_ID);
document.body.appendChild(container);
}
return container;
}
function renderMessageRoot() {
const container = createContainer();
if (!containerRoot) {
containerRoot = ReactDOM.createRoot(container);
containerRoot.render(<MessageManager />);
}
}
const MessageItem = ({ children, onRemove }) => {
const messageItemRef = useRef();
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
let timer = null;
timer = setTimeout(() => {
if (messageItemRef.current) {
messageItemRef.current.addEventListener('animationend', onRemove, {
once: true,
});
}
setIsVisible(false);
}, 2000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<div ref={messageItemRef} className={`messageItem ${isVisible ? 'messageItem-appear' : 'messageItem-disappear'}`}>
{children}
</div>
);
};
const MessageContainer = React.forwardRef((props, ref) => {
const { messageList, setMessageList } = props;
useImperativeHandle(ref, () => {
return {
info: text => {
const id = uuid();
setMessageList(list => [...list, { id, text }]);
},
error: text => {
const id = uuid();
setMessageList(list => [...list, { id, text }]);
},
};
});
return (
<>
{messageList.map(msg => (
<MessageItem key={msg.id} onRemove={() => setMessageList(list => list.filter(item => item.id !== msg.id))}>
<span className="text-blue messageItem-icon">
<svg
viewBox="64 64 896 896"
focusable="false"
data-icon="info-circle"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"></path>
</svg>
</span>
<span>{msg.text}</span>
</MessageItem>
))}
</>
);
});
function MessageManager() {
const [messageList, setMessageList] = useState([]);
const msgRef = useRef();
useEffect(() => {
message.current = msgRef.current;
message.info = msgRef.current.info;
message.error = msgRef.current.error;
}, []);
return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}
const message = {
current: null,
open: ({ type = 'info', content }) => {
if (containerRoot) {
message[type](content);
} else {
renderMessageRoot();
nextTick(() => {
message[type](content);
});
}
},
};
export default message;
CSS:
#message_container {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999999;
margin: 0;
padding: 0;
font-size: 14px;
min-height: 1px;
background-color: transparent;
display: flex;
justify-content: center;
flex-direction: column;
pointer-events: none;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
}
.messageItem {
width: max-content;
background-color: white;
margin: 0.5em auto;
text-align: center;
padding: 9px 12px;
border-radius: 8px;
box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%);
position: relative;
opacity: 0;
display: flex;
align-items: center;
}
.messageItem-icon {
margin-inline-end: 8px;
}
.messageItem-appear {
opacity: 0;
animation-name: messageItemMoveIn;
animation-duration: 0.3s;
animation-play-state: 'paused';
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
animation-fill-mode: forwards;
}
.messageItem-disappear {
opacity: 1;
animation-name: messageItemMoveOut;
animation-duration: 0.3s;
animation-play-state: 'paused';
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
animation-fill-mode: forwards;
}
@keyframes messageItemMoveIn {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes messageItemMoveOut {
from {
opacity: 1;
}
to {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 0;
opacity: 0;
}
}
用到的工具方法:
export function nextTick(callback) {
let timerFunc;
if (typeof Promise !== 'undefined') {
const p = Promise.resolve();
timerFunc = () => {
p.then(callback);
};
} else if (
typeof MutationObserver !== 'undefined' &&
MutationObserver.toString() === '[object MutationObserverConstructor]'
) {
let counter = 1;
const observer = new MutationObserver(callback);
const textNode = document.createTextNode(String(counter));
observer.observe(textNode, { characterData: true });
timerFunc = () => {
counter = (counter + 1) % 2;
textNode.data = String(counter); // 数据更新
};
} else if (typeof setImmediate !== 'undefined') {
timerFunc = () => {
setImmediate(callback);
};
} else {
timerFunc = () => {
setTimeout(callback, 0);
};
}
timerFunc();
}
export function uuid() {
const uuid = window.crypto.getRandomValues(new Uint8Array(8));
return uuid.toString().split(',').join('');
}
总结
知识点总结:
ReactDOM.createRoot
React.forwardRef
React.useImperativeHandle
- Web Crypto API
- CSS animate
快动手试试把组件跑起来吧~