Toast
可以提供使用者操作的反饋訊息。包含一般資訊、操作成功、操作失敗、警告訊息等。預設為在頂部置中顯示並自動消失,是一種不打斷用戶操作的輕量級提示方式。
我們來參考一下 Antd 的 message 元件,這個元件很有意思,我還蠻喜歡的。
他跟我們其他元件需要寫 JSX 在畫面上不一樣,他是直接執行一個 function 來顯示 toast,如下:
import { message, Button } from 'antd';
const info = () => {
message.info('This is a normal message');
};
ReactDOM.render(
<Button type="primary" onClick={info}>
Display normal message
</Button>,
mountNode,
);
這個 message.info()
只要被執行一次,toast 就會在畫面上跳出來一次。
另外,我也參考了 React-Toastify 這個 npm 套件,這個也是我過去在專案中有接觸過的套件,我們來看他的使用方法:
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
function App(){
const notify = () => toast("Wow so easy!");
return (
<div>
<button onClick={notify}>Notify!</button>
<ToastContainer />
</div>
);
}
雖然看起來也是還蠻不錯的 toast,但是我們觀察看看,他要使用的時候會需要比 Antd message 多幾個步驟,像是他需要放一個 <ToastContainer />
在畫面上,然後需要引入他的 CSS 樣式,另外也需要執行他特有的 toast
function 才能顯示 toast 訊息。
所以今天我們要來挑戰看看是不是能夠做出 Antd 這種一拿就能夠用的 toast。
我們多按幾次來觀察一下他的行為,首先,我們發現他跟其他提示元件一樣都是把元件畫到比較外層的 DOM 結構上,大概是如下的結構:
<html>
<header>...</header>
<body>
<div id="root">...</div>
<div class="ant-message">
<div>...</div>
</div>
</body>
</html>
所以這個就是我們本次的目標,我們要想辦法把 toast 畫在比較上層的節點上,並且是用呼叫一個 function 的方式來做。只要能夠做到這一件事,其他的部分感覺起來就不難了。
然後我想像我未來會這樣使用這個 toast,因為提示訊息有分種類,分別有 操作成功
、一般通知訊息
、警告訊息
、錯誤訊息
。
然後訊息也會有它的內容,甚至有時候我們想要控制這個訊息顯示的持續時間。
一個簡單的 Toast 應該具備上述幾個參數就足夠了。再更進階一點,我們有看到 React-toastify
有很多的參數控制項,我們來偷看一下:
toast('? Wow so easy!', {
position: "bottom-center",
autoClose: 5000,
hideProgressBar: false,
closeOnClick: true,
pauseOnHover: true,
draggable: true,
progress: undefined,
});
以上述的參數來看,甚至他可以決定 Toast 的出現位置,從視窗的左上、右上、左下、右下...等等各種位置來出現,還有是不是能夠手動關掉 Toast,因為可能有時候 Toast 遮擋住畫面上我們正在瀏覽的訊息。
因此我們今天的目標,至少先做出一個簡單版的 Toast,其他的部分,可以依照自己的需求再修改或添加。
屬性 | 說明 | 類型 | 默認值 |
---|---|---|---|
type | 提示訊息種類 | success , info , warn , error |
|
content | 提示訊息內容 | ReactElement , string |
|
duration | 提示訊息展示時間 | 3000ms |
假設我今天要跳出一個訊息,我希望我的介面是長這樣:
message.success({ type: 'success', content: '新增成功' });
我們知道,success 是一個 function,這個 function 會幫我們把畫面畫出來。
在 React 當中,有一個方法可以幫助我們做到這件事,就是 ReactDOM.render()
:
ReactDOM.render(element, container[, callback])
所以簡單來說,我們要用 ReactDOM.render()
這個方法,想辦法讓我們的 Toast 變成這樣:
<html>
<header>...</header>
<body>
<div id="root">...</div>
<div class="toast-root">
<div class="toast-container">
<Toast />
<Toast />
<Toast />
<Toast />
<Toast />
...
</div>
</div>
</body>
</html>
以下是我的方法:
export const message = {
success: (props) => {
render(<Toast {...props} type="success" />, getContainer());
},
info: (props) => {
render(<Toast {...props} type="info" />, getContainer());
},
warn: (props) => {
render(<Toast {...props} type="warn" />, getContainer());
},
error: (props) => {
render(<Toast {...props} type="error" />, getContainer());
},
};
在 getContainer()
當中,我要想辦法製造出下面這樣的結構,並透過 document.body.appendChild(...);
把他塞進 body 下面,之後新增一個在 toast-container
下面的子節點當作 container 回傳回來給 ReactDOM.render()
就可以了。
<div class="toast-root">
<div class="toast-container">
</div>
</div>
其中,toast-container 存在的目的,是為了要幫助我們對他的 children ,也就是 Toast 做一些排版上的佈局,例如置中...等等。
const rootId = 'toast-root';
const getContainer = () => {
let toastRoot;
let toastContainer;
// 製造出 toastRoot
if (document.getElementById(rootId)) {
toastRoot = document.getElementById(rootId);
} else {
const divDOM = document.createElement('div');
divDOM.id = rootId;
document.body.appendChild(divDOM);
toastRoot = divDOM;
}
// 製造出 toastContainer,並放在 toastRoot 底下
if (toastRoot.firstChild) {
toastContainer = toastRoot.firstChild;
} else {
const divDOM = document.createElement('div');
toastRoot.appendChild(divDOM);
toastContainer = divDOM;
}
// 製造出 container,並放在 toastContainer 底下
const divDOM = document.createElement('div');
toastContainer.appendChild(divDOM);
// 調整 toastRoot 以及 toastContainer 的樣式
toastRoot.style = css`
position: absolute;
top: 0px;
left: 0px;
width: 100vw;
`;
toastContainer.style = css`
position: absolute;
top: 0px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
`;
// 把 container 回傳,成為 ReactDOM.render() 的第二個參數
return divDOM;
};
以上就是我們把 Toast 畫在外面的方法了。
再來我們來看 Toast 本體:
const Toast = ({
type,
content,
duration,
}) => {
const toastRef = useRef();
const [isVisible, setIsVisible] = useState(true);
const color = getColor(type);
useEffect(() => {
setTimeout(() => {
setIsVisible(false);
}, duration);
setTimeout(() => {
const currentDOM = toastRef.current;
const parentDOM = currentDOM.parentElement;
parentDOM.parentElement.removeChild(parentDOM);
}, duration + 200);
}, [duration]);
return (
<ToastWrapper
ref={toastRef}
$isVisible={isVisible}
>
<Icon $color={color}>{iconMap[type]}</Icon>
{content}
</ToastWrapper>
);
};
這個本體也就很簡單,就是一些樣式的排版與呈現,需要特別說明的部分是我們透過 isVisible
這個 state 來控制 Toast 的出現與消失,同時伴隨著他的動畫效果:
const topIn = keyframes`
0% {
transform: translateY(-50%);
opacity: 0;
}
100% {
transform: translateY(100%);
opacity: 1;
}
`;
const topOut = keyframes`
0% {
transform: translateY(100%);
opacity: 1;
}
100% {
transform: translateY(-50%);
opacity: 0;
}
`;
const topStyle = css`
animation: ${(props) => (props.$isVisible ? topIn : topOut)} 200ms ease-in-out forwards;
`;
const ToastWrapper = styled.div`
/* ...略 */
${topStyle}
`;
當然,如果還有餘裕的話,我們就能夠來刻各種方向出現與消失的動畫,這邊先以 top
方向為例。
那在這個 Toast 當中,依照不同類型的提示訊息,我們可以給他不同的 icon:
import SuccessIcon from '@material-ui/icons/Check';
import InfoIcon from '@material-ui/icons/InfoOutlined';
import WarnIcon from '@material-ui/icons/ReportProblemOutlined';
import ErrorIcon from '@material-ui/icons/HighlightOffOutlined';
const iconMap = {
success: <SuccessIcon />,
info: <InfoIcon />,
warn: <WarnIcon />,
error: <ErrorIcon />,
};
而不同的訊息,當然也需要對應的不同顏色:
const getColor = (type) => {
if (type === 'success') {
return '#52c41a';
}
if (type === 'info') {
return '#1890ff';
}
if (type === 'warn') {
return '#faad14';
}
if (type === 'error') {
return '#d9363e';
}
return '#1890ff';
};
這樣我們的簡單的 Toast 就完整了:
<ToastWrapper
ref={toastRef}
$isVisible={isVisible}
>
<Icon $color={color}>{iconMap[type]}</Icon>
{content}
</ToastWrapper>
最後我們來 Demo 我們的成果:
const ToastDemo = (args) => (
<ButtonGroup>
<Button variant="outlined" onClick={() => message.success({ type: 'success', content: '新增成功' })}>Success</Button>
<Button variant="outlined" onClick={() => message.info({ type: 'info', content: 'Some information' })}>Information</Button>
<Button variant="outlined" onClick={() => message.warn({ type: 'warn', content: '伺服器出了一點問題' })}>Warning</Button>
<Button variant="outlined" onClick={() => message.error({ type: 'error', content: '刪除失敗' })}>Error</Button>
</ButtonGroup>
);
Toast 元件原始碼:
Source code
Storybook:
Toast
https://www.npmjs.com/package/antd-like-message
https://github.com/bingqichen/antd-message/blob/master/src/message/index.js