Toast

400 阅读2分钟

前言

移动端反馈组件一般会有Toast Dialog ActionSheet Popup 等。Toast 是一个轻提示, 对操作结果的轻量级反馈,无需用户操作即可自行消失。

在自己动手实现之前一直以为 Toast 组件是基于 Popup 组件实现的。在实现过程中发现不太合适。 因为 Popup 是基于 ReactDOM.createPortal 实现的。
如果是纯组件调用的方式是可以用 createPortal 这种方式去实现的, 例如:

<Toast>
   提示哦
</Toast>

但是如果是需要简洁的方法调用则需要用ReactDOM.render去实现更合适,所以 类似于Toast.info(), Dialog.confirm() 这种形式使用 ReactDOM.render 去实现。 例如:

Toast.info('提示哦')

ReactDOM.createPortalReactDOM.render 差别是 createPortal 是增量添加而 render 是替换,所以使用场景是有一定的区别的。

ReactDOM.createPortal(<Component />, container); // 会对 container  appendChild
ReactDOM.render(<Component />, container); // 会对 container replaceChild

效果

toast.gif

实现

我们先分析下 Toast 有哪些东西,从UI展现形式就一个黑色透明的小块,支持纯文本和图标文本的方式展示,里面包含图标文本,一般位于视窗居中展示,当然也可以自己控制位置。然后再添加一些其他的属性比如duration,vertical, placement之类的。所以html结构比较简单,

<div className={classNamePro}>
    {iconElement && <div className="toast__icon">{iconElement}</div>}
    <div className="toast__content">{content}</div>
</div>

调用方式:

// 1 入参为字符串
Toast.info('ABC');

// 2 入参为配置项
Toast.info({
    icon: 'loading',
    content: '加载中...',
    // ...other config
});

// 提供别名调用
Toast.loading('加载中...');
Toast.error('失败');
Toast.success('成功');
Toast.warn('警告');

实现方式一般都是先写好组件,然后再给组件添加对应的方法。简单实现如下:

import * as ReactDOM from 'react-dom/client';
import { CSSTransition } from 'react-transition-group';
import React, { ReactNode, useRef } from 'react';

import classNames from 'classnames';

import './toast.scss';

const withContent = (props: string | object): object => typeof props === 'string' ? { content: props } : props;

/**
 * Toast Component Properties.
 */
export interface IToastProps {
  /** className */
  className?: string;
  /** 展示方向 */
  direction?: 'horizontal' | 'vertical';
  /** 显隐 */
  visible?: boolean;
  /** 图标 */
  icon?: 'error' | 'warn' | 'loading' | 'success' | 'string' | ReactNode;
  /** 文本内容 */
  content?: ReactNode;
  /** 隐藏之后的回调 */
  onExited?: () => void;
}

/**
 * Toast Methods Properties.
 */
export type ToastMethodsProps =
  | (Omit<IToastProps, 'visible'> & {
      duration?: number | null | undefined;
    })
  | string;

export interface IToastInstance {
  destory: () => void;
  update: (props: ToastMethodsProps) => void;
}

// 模拟 icon
const defaultIcons = {
    error: '❎',
    warn: '⚠️',
    loading: '😇',
    success: '🎉',
};

/**
 * Toast.
 */
const Toast = (props: IToastProps) => {
    const {
        icon,
        content,
        className,
        visible,
        direction = 'vertical',
        onExited,
    } = props;
    const nodeRef = useRef(null);

    const classNamePro = classNames('toast', `toast--${direction}`, className);

    const iconElement = defaultIcons[icon] || icon;

    return (
        <CSSTransition
            in={visible}
            timeout={300}
            nodeRef={nodeRef}
            classNames="zoom"
            appear={true}
            unmountOnExit
            onExited={onExited}
        >
            <div className={classNamePro} ref={nodeRef}>
                {iconElement && <div className="toast__icon">{iconElement}</div>}
                <div className="toast__content">{content}</div>
            </div>
        </CSSTransition>
    );
};

const container = document.createElement('div');
container.classList.add('toast-wrapper');
document.body.appendChild(container);

let instance: IToastInstance | null = null;
let timer;
let root;

Toast.info = (p: ToastMethodsProps) => {
    const lastProps = withContent(p);
    if (!root) {
        root = ReactDOM.createRoot(container);
    }
    const showToast = (props) => {
        if (timer) clearTimeout(timer);
        props = withContent(props);
        const { duration = 3000 } = props;
        root.render(<Toast {...{ ...props, visible: true }} />);
        if (duration) {
            timer = setTimeout(() => {
                    hideToast();
            }, duration);
        }
    };

    const hideToast = () => {
        root.render(
            <Toast
                {...{ ...lastProps, visible: false }}
                onExited={() => {
                    // Delay to unmount to avoid React 18 sync warning
                    Promise.resolve().then(() => {
                        root.unmount();
                        root = null;
                        instance = null;
                    });
                }}
            />
        );
    };

    showToast(lastProps);

    instance = {
        destory: hideToast,
        update: showToast,
    };

    return instance;
};

Toast.loading = (props: ToastMethodsProps) => Toast.info({ icon: 'loading', ...withContent(props) });
Toast.error = (props: ToastMethodsProps) => Toast.info({ icon: 'error', ...withContent(props) });
Toast.warn = (props: ToastMethodsProps) => Toast.info({ icon: 'warn', ...withContent(props) });
Toast.success = (props: ToastMethodsProps) => Toast.info({ icon: 'success', ...withContent(props) });

Toast.defaultProps = {
    direction: 'vertical',
    visible: true,
};

export default Toast;

样式

.toast {
  position: fixed;
  top: 50%;
  left: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 6px 10px;
  color: #fff;
  background: rgb(0 0 0 / 70%);
  border-radius: 4px;
  transform: translate(-50%, -50%);

  &--horizontal {
    flex-direction: row;
  }

  &--vertical {
    flex-direction: column;
  }
}

.zoom-enter {
  transform: translate(-50%, -50%) scale(0.8);
  opacity: 0;
}

.zoom-enter-active {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  transition: 300ms all linear;
}

.zoom-exit {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
}

.zoom-exit-active {
  transform: translate(-50%, -50%) scale(0.8);
  opacity: 0;
  transition: 300ms all linear;
}

测试

import { Button, Toast } from 'thor';
import React, { useState } from 'react';

/**
 * Demo1
 */
const Demo1 = (props) => {
    const { ...restProps } = props;
    const [visible, setVisible] = useState(false);
    return (
        <div {...restProps}>
            <Button
                type="primary"
                onClick={() => {
                    Toast.info('纯文字哦');
                }}
            >文字</Button>
            <Button
                type="primary"
                onClick={() => {
                    Toast.error('错误哦');
                }}
            >错误提示❎</Button>
            <Button
                type="primary"
                onClick={() => {
                    Toast.warn('警告哦');
                }}
            >警告提示⚠️</Button>
            <Button
                type="primary"
                onClick={() => {
                    Toast.success('成功哦');
                }}
            >成功提示</Button>

            <Button
                type="primary"
                onClick={() => {
                    Toast.success({
                        direction: 'horizontal',
                        content: '水平展示哦'
                    });
                }}
            >水平 horizontal</Button>
            <Button
                type="primary"
                onClick={() => {
                    Toast.success({
                        icon: '🤡',
                        content: '自定义icon😯'
                    });
                }}
            >自定义icon</Button>
            <Button
                type="primary"
                onClick={() => {
                    let n = 0;
                    let timer:any = null;

                    const instance =  Toast.success(`${n}`);

                    timer = setInterval(() => {
                        n++;
                        instance.update({ icon: 'success', content: `${n}` });
                        if (n===4) {
                            instance.destory();
                            timer && clearInterval(timer);
                        }
                    }, 1000);

                }}
            >更新</Button>
            <Button
                type="primary"
                onClick={() => {
                    Toast.success({ content: '不隐藏哦', duration: 0 });
                }}
            >不隐藏</Button>
            <Button
                onClick={() => {
                    setVisible(true);
                }}
            >显示</Button>
            <Button
                onClick={() => {
                    setVisible(false);
                }}
            >隐藏</Button>
            <Toast visible={visible} content="手动测试哦"></Toast>
        </div>
    );
};

export default Demo1;

结语

🎉 🎉 大功告成 🎉 🎉,这样初版就完成了。当然还有很多细节的功能没有实现,这里只是提供一种思路,可以根据自己的反馈组件的功能去逐步的打磨完善Toast组件。