【Day29】反馈元件 - Toast

元件介绍

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


<<:  基本面 VS 技术面

>>:  Day26【Web】TCP 安全协定:SSL/TLS

Day4 参加职训(机器学习与资料分析工程师培训班),记录学习内容(6/30-8/20)

上午: 人工智慧AIoT资料分析应用系统框架设计与实作 初步介绍网站,WWW的历史以及各个浏览器的演...

(ISC)² 道德准则

(ISC)² 道德准则仅适用於 (ISC)² 会员。垃圾邮件发送者的身份不明或匿名,这些垃圾邮件发送...

【图解演算法教学】一次搞懂「资料结构」与「演算法」到底是什麽?

Youtube连结:https://bit.ly/35x3dih 这次我们将精确定位出,在整个演算...

Springboot 简介

Springboot 简介 ...

硬体的讯号怎麽丢给软件?

预设 先要有一个开发板,可以接各种sensor。 可以先跟电脑有实体连接,这样就有指定的port可以...