前端通用组件开发笔记 - Toast 轻提示、Message 全局提示

3,222 阅读8分钟

本文详解如何完整创造出功能较为齐全的 Toast 轻提示组件、Message 全局提示组件

组件开发采用 React 框架,组件功能参考 Toast 轻提示 - Ant Design Mobile全局提示 Message - Ant Design

Toast 轻提示组件

目标

通过指令式调用快速生成 Toast 提示

Toast.show('Toast 轻提示');
Toast.show({
    content: <div className={css['custom-text']}>自定义文本</div>
})
Toast.show({
    content: '成功',
    icon: 'success',
});
...

1. 规定参数及其类型、含义

type ToastOptionsT = {
  /** Toast 文本内容 */
  content: string | React.ReactNode;
  /** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
  duration?: number;
  /** Toast 图标 */
  icon?: 'success' | 'fail' | 'loading' | React.ReactNode,
  /** 图标和文字布局,仅在参数 icon 合法时有效 */
  direction?: 'row' | 'column',
  /** 是否允许背景点击,默认为 true */
  maskClickable?: boolean,
  /** Toast 消失时触发 */
  onClose?: () => void;
};

2. 内部组件 InternalToast 及其样式

最外层一张蒙层 + 灰色背景的盒子 + 盒子里面的内容

import React, { useEffect, useMemo } from 'react';

/** 内部 InternalToast 组件 */
const InternalToast: React.FC<ToastOptionsT> = (props) => {
  useEffect(() => {
    if (props.duration !== 0) {
      setTimeout(() => { // 定时关闭 Toast
        props.onClose?.(); // 关闭回调
      }, props.duration);
    }
  }, []);
   // 处理图标显示逻辑
  const ShowIcon = useMemo(() => {
    if (!props.icon || ['number', 'boolean'].includes(typeof props.icon)) {
      return null;
    }
    switch (props.icon) {
      case 'success':
        return <img className={css['toast-icon']} src={successIcon} />;
      case 'fail':
        return <img className={css['toast-icon']} src={failIcon} />;
      case 'loading':
        return <LoadingIcon className={css['toast-icon']} />;
      default:
        return typeof props.icon === 'string' ? null : <>{props.icon}</>;
    }
  }, [props]);
  return (
    <div
      className={css.mask}
      style={{ pointerEvents: props.maskClickable ? 'none' : 'auto' }}
    >
      <div
        className={classNames(css.toast, ShowIcon ? css['toastWithIcon'] : '')}
        style={{ flexDirection: props.direction }}
      >
        {/* 图标 */}
        {ShowIcon && (
          <div className={css['toast-icon-box']}>{ShowIcon}</div>
        )}
        {/* 文本 */}
        {typeof props.content === 'string' ? (
          <div className={css['toast-text']}>{props.content}</div>
        ) : (
          props.content
        )}
      </div>
    </div>
  );
};

Toast.less

.mask {
  width: 100vw;
  height: 100vh;
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  .toast {
    color: #fff; // 自定义 content 时也要默认字体颜色为白色
    border-radius: 10px;
    background-color: rgba(0, 0, 0, 0.7);
    padding: 20px 30px;
    min-width: 200px; // 设置最小宽度
    max-width: 60vw; // 设置最大宽度
    display: flex;
    justify-content: center;
    align-items: center;
    &-text {
      font-size: 24px;
    }
    &-icon-box {
      margin: 10px 20px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    &-icon {
      width: 50px;
      height: 50px;
    }
  }
  .toastWithIcon {
    padding: 30px;
  }
}

如何通过调用 Toast.show 方法将 InternalToast 组件渲染出来,这是重点步骤

3. 通过 show 方法渲染 InternalToast 组件

show 函数代码

const show = (options: string | ToastOptionsT) => {
  let ops = options as ToastOptionsT;
  if (typeof options === 'string') {
    ops = {
      content: options, // 直接传入字符串时,将其转换为 `content`
    }
  }
  const close = renderToastInBody(
    <InternalToast
      {...{
        ...ops,
        onClose: () => {
          close(); // 通过 renderToastInBody 函数返回的 close 方法关闭 Toast
          ops.onClose?.(); // 关闭回调
        },
      }}
    />
  );
};

renderToastInBody 函数代码

import { createRoot } from 'react-dom/client';
const animationTime = 300; // 动画时间,单位(ms)
const appearAnimation = [ { opacity: 0 }, { opacity: 1 } ]; // 淡入动画效果
const disappearAnimation  = [ { opacity: 1 }, { opacity: 0 } ]; // 淡出动画效果

const renderToastInBody = function (component: JSX.Element) {
  // 创建一个 div
  const nextToastDiv = document.createElement('div');
  nextToastDiv.id = 'toast-box';
  // 如果之前存在已渲染的 Toast 组件,则使用 replaceChild 进行替换
  const presentToastDiv = document.getElementById('toast-box');
  if (presentToastDiv && document.body.contains(presentToastDiv)) {
    document.body.replaceChild(nextToastDiv, presentToastDiv);
  }
  // 否则使用 appendChild 添加在 body 里面
  else {
    document.body.appendChild(nextToastDiv);
    nextToastDiv.animate(appearAnimation , animationTime);
  }
  // 通过 createRoot 将 React 组件渲染到浏览器 DOM 节点里面
  const root = createRoot(nextToastDiv);
  root.render(component);
  return () => {
    nextToastDiv.animate(disappearAnimation , animationTime).onfinish = function () {
      root.unmount();
      // 容错处理
      if (document.body.contains(nextToastDiv)) document.body.removeChild(nextToastDiv);
    };
  };
}

renderToastInBody 函数是如何将 InternalToast 组件渲染出来的?

是采用官方提供的 API createRoot 方法传入一个浏览器 DOM 节点 nextToastDiv 后得到一个 root 对象,通过这个对象的 render 方法传入一个 React 组件即可将其渲染到这个 DOM 节点里面(官方文档:createRoot – React

值得注意的是:在 React18 中使用 createRoot 代替 render

// React18 以前的写法
import { render } from 'react-dom'; 
import App from 'App'; 
const container = document.getElementById('app'); 
render(<App />, container);

// 新的写法
import { createRoot } from 'react-dom/client';
import App from 'App'; 
const container = document.getElementById('app'); 
const root = createRoot(container); 
root.render(<App />); 

renderToastInBody 函数返回一个函数,用来关闭 Toast,其中调用 root.unmount 销毁 DOM 节点 nextToastDiv 里的 React 组件,然后再调用 removeChild 方法将该 DOM 节点移除

4. 通过 clear 方法关闭当前 Toast

clear 函数代码

/** 关闭当前显示中的 Toast */
const clear = () => {
  removeToastInBody();
}
/** 移除当前的 Toast 组件 */
const removeToastInBody = function () {
  const presentToastDiv = document.getElementById('toast-box');
  if (presentToastDiv && document.body.contains(presentToastDiv)) {
    presentToastDiv.animate(disappearAnimation, animationTime).onfinish = function () {
      document.body.removeChild(presentToastDiv);
    };
  }
}

5. 通过 config 方法进行全局配置

config 函数代码

/** 全局配置 */
const configOptions = {};
/** 全局配置 */
const config = (options: Pick<ToastOptionsT, 'duration' | 'icon' | 'maskClickable' | 'onClose'>) => {
  Object.assign(configOptions, options);
}

这样在使用 Toast.show 之前调用 Toast.config 方法进行全局配置,可以减少重复配置的代码

6. 默认配置

/** 默认配置 */
const defaultOptions = {
  duration: 2000,
  maskClickable: true,
  direction: 'column',
}

需要在 show 函数里添加逻辑:将默认配置和全局配置合并到传入的 option 当中

ops = mergeOptions(defaultOptions, configOptions, ops);
/** 合并配置参数 */
export function mergeOptions(...options: any) {
  let res: any = {};
  options.forEach((option: any) => {
    res = Object.assign(res, option);
  })
  return res;
}

值得注意的是:Object.assign 传入对象的顺序,参考 MDN 官方文档 Object.assign() - JavaScript | MDN

如果目标对象与源对象具有相同的 key,则目标对象中的属性将被源对象中的属性覆盖,后面的源对象的属性将类似地覆盖前面的源对象的属性

所以是外部传入的 option 覆盖 configOptions 覆盖 defaultOptions

7. 导出 Toast 对象

Toast.tsx 最终导出一个对象,里面包含 show、clear、config 函数

/** Toast 轻提示,只支持指令式调用 */
const Toast: {
  show: typeof show;
  clear: typeof clear;
  config: typeof config;
} = { show, clear, config };

export default Toast;

这样在外部使用 Toast 组件时就很直观了:

image.png

使用

自此,一个功能较为齐全的 Toast 组件已经完成,使用示例:

import React, { useEffect } from 'react';
import Toast from '@/components/toast/toast';

const Demo: React.FC<{}> = (props) => {
  useEffect(() => {
    Toast.config({
      duration: 3000,
      maskClickable: false,
    })
  }, [])
  return (
    <div className={css.demo}>
      <div className={css.demoBlock}>
        <div className={css.title}>Toast 轻提示</div>
        <div className={css.main}>
          <button onClick={() => {
            Toast.show('Toast 轻提示')
          }}>轻提示</button>
          <button onClick={() => {
            Toast.show({
              content: <div className={css['custom-text']}>自定义文本</div>
            })
          }}>自定义文本</button>
          <br />
          <button onClick={() => {
            Toast.show({
              content: '成功',
              icon: 'success',
            })
          }}>成功</button>
          <button onClick={() => {
            Toast.show({
              content: '加载中',
              icon: 'loading',
              duration: 0,
            })
          }}>加载中</button>
          <button onClick={() => {
            Toast.show({
              content: '上传中',
              icon: <img src={uploadIcon} className={css['upload-icon']} />,
            })
          }}>自定义图标</button>
          <br />
          <button onClick={() => {
            Toast.show({
              content: '请耐心等待',
              maskClickable: false,
            })
          }}>阻止背景点击</button>
          <br />
          <button onClick={() => {
            Toast.clear();
          }}>清除当前 Toast</button>
          <br />
          <button onClick={() => {
            Toast.show({
              content: '加载中',
              icon: 'loading',
              duration: 0,
            })
            setTimeout(() => {
              const rdm = Math.random();
              if (rdm >= 0.2) {
                Toast.show({
                  content: '成功',
                  icon: 'success',
                })
              } else {
                Toast.show({
                  content: '失败',
                  icon: 'fail',
                })
              }
            }, 1000);
          }}>模拟网络请求</button>
          <br />
          <button onClick={() => {
            Toast.show({
              content: '可打开控制台查看',
              onClose: () => { console.log('Toast 关闭'); }
            })
          }}>关闭时触发回调函数</button>
        </div>
      </div>
    </div>
  );
};

Message 全局提示组件

Message 组件的实现与 Toast 组件有一定的相似之处,最大差别在于:需要维护一个 messageList 消息队列来管理消息

目标

Message.show('Message 全局提示');
Message.show({
    content: <div className={css['custom-text']}>自定义文本</div>
})
Message.show({
    content: '成功',
    icon: 'success',
});
...

1. 规定参数及其类型、含义

type MessageOptionsT = {
  /** Message 文本内容 */
  content: string | React.ReactNode;
  /** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
  duration?: number;
  /** Message 图标 */
  icon?: 'success' | 'fail' | 'loading' | React.ReactNode;
  /** 当前提示的唯一标志 */
  key?: string | number;
  /** Message 消失时触发 */
  onClose?: () => void;
};

2. 内部组件 InternalMessage 及其样式

import React, { useEffect, useMemo, useSyncExternalStore } from 'react';

/** 内部 InternalMessage 组件 */
const InternalMessage: React.FC<{}> = () => {
  // 订阅 messageList 的变化,具体实现后面会补充
  const messageList = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return (
    <div
      id='message-box-mask'
      className={css.mask}
    >
      {messageList.map((item, index) => (
        <MessageNotice key={index} id={item.id} options={item.options} />
      ))}
    </div>
  );
};
/** Message 列表中每条消息的类型 */
type MessageItemT = {
  id: string; // 每条的唯一标识
  options: MessageOptionsT; // 每条消息的配置
}

/** 单条消息组件 */
const MessageNotice: React.FC<MessageItemT> = ({ id, options }) => {
  useEffect(() => {
    if (options.duration !== 0) {
      setTimeout(() => { // 定时关闭
        removeDomByID(id); // 根据指定 id 移除指定消息
      }, options.duration);
    }
  }, []);
  const ShowIcon = useMemo(() => {
    if (!options.icon || ['number', 'boolean'].includes(typeof options.icon)) {
      return null;
    }
    switch (options.icon) {
      case 'success':
        return <img className={css['message-icon']} src={successIcon} />;
      case 'fail':
        return <img className={css['message-icon']} src={failIcon} />;
      case 'loading':
        return <LoadingIcon className={css['message-icon']} />;
      default:
        return typeof options.icon === 'string' ? null : <>{options.icon}</>;
    }
  }, [options]);
  return (
    <div
      id={`message-${id}`}
      className={css.message}
    >
      {ShowIcon && (
        <div className={css['message-icon-box']}>{ShowIcon}</div>
      )}
      {typeof options.content === 'string' ? (
        <div className={css['message-text']}>{options.content}</div>
      ) : (
        options.content
      )}
    </div>
  );
};

Message.less

.mask {
  width: 100vw;
  height: 100vh;
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 80px 0; // 顶部和底部留空
  pointer-events: none; // 允许穿透点击蒙层
  .message {
    border-radius: 10px;
    background-color: rgba(0, 0, 0, 0.7);
    padding: 20px 30px;
    min-width: 200px; // 设置最小宽度
    max-width: 60vw; // 设置最大宽度
    color: #fff; // 自定义 content 时也要默认字体颜色为白色
    display: flex;
    justify-content: center;
    align-items: center;
    animation: move 300ms; // 消息关闭时的动画
    margin-bottom: 20px;
    &-text {
      font-size: 24px;
    }
    &-icon-box {
      margin-right: 20px;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    &-icon {
      width: 40px;
      height: 40px;
    }
  }
}
@keyframes move {
  from {
    opacity: 0.5;
    transform: translate(0, -100%);
  }
  to {
    opacity: 1;
    transform: translate(0, 0);
  }
}

如何通过调用 Message.show 方法去更新消息队列,这是重点也是难点

3. 通过 show 方法更新消息队列

难点: 由于我们是通过 show 函数去新增消息的,要想从视图层面上新增消息,就需要通知函数组件 InternalMessagemessageList 队列发生了变化

思路: 可以在函数组件外部维护一个 store、一个数组 messageList,通过 store 去管理 messageList,而在函数组件里面订阅这个 store。当 messageList 发生变化时由 store 去通知函数组件

针对这个思路选择采用官方提供的 useSyncExternalStore – React 钩子

useSyncExternalStore 是一个可以订阅外部 store 的 react hook,需要传入两个参数:

  • subscribe 函数:接受一个回调函数作为参数并将其订阅到这个 store(放到 store 里面)。当 store 发生变化时,这个回调函数会执行。另外 subscribe 函数需要返回一个可以取消订阅的函数;
  • getSnapshot 函数:返回函数组件(此例子为 InternalMessage)所订阅的 store 里的数据(此例子为 messageList)的一个快照。当这个 store 没有改变的时候,重复调用 getSnapshot 必须返回同样的值。如果 store 发生变化并且返回值不同(判断依据:用 Object.is() 做比较),React 就会重新渲染这个函数组件

先自定义一个外部的 store

/** Message 列表 */
let messageList: MessageItemT[] = [];

/** 监听器列表 */
let listeners: (() => void)[] = [];

/** Message 列表的状态管理 */
const store = {
  // 新增一条消息
  addMessage(item: MessageItemT) {
    messageList = [...messageList, item]; // 注意不能直接 push,需要重新赋值才能被监听到变化
    emitChange();
  },
  // 关闭一条消息
  deleteMessage(id: string) {
    messageList.find((m) => m.id === id)?.options.onClose?.(); // 消息关闭回调
    messageList = messageList.filter((m) => m.id !== id); // 根据 id 删除指定消息
    emitChange();
  },
  // 订阅函数
  subscribe(listener: () => void) {
    listeners = [...listeners, listener];
    return () => {
      listeners = listeners.filter(l => l !== listener); // 取消订阅函数
    };
  },
  // 直接返回 messageList
  getSnapshot() {
    return messageList;
  }
};
// 通知变化
function emitChange() {
  for (let listener of listeners) {
    listener();
  }
}

show 函数代码

const show = (options: string | MessageOptionsT) => {
  let ops = options as MessageOptionsT;
  if (typeof options === 'string') {
    ops = {
      content: options,
    }
  }
  // 判断 body 下是否含有放置 Message 列表的容器
  const dom = document.getElementById('message-box');
  if (!dom) {
    createMessageBox(ops);
  }
  // 消息队列新增一条消息
  store.addMessage({
    id: String(ops.key ?? '') || getUuiD(4), // id 的值为传入的 key 值或随机生成的字符串
    options: ops,
  });
};
/** 创建一个放置 Message 列表的容器 */
const createMessageBox = function (option: MessageOptionsT) {
  const $div = document.createElement('div');
  $div.id = 'message-box';
  document.body.appendChild($div);
  const root = ReactDOMClient.createRoot($div);
  root.render(
    <InternalMessage />
  );
};
/**
 * 生成一个用不重复的ID
 * @param { Number } randomLength
 */
export function getUuiD(randomLength: number) {
  return Number(Math.random().toString().slice(2) + Date.now())
    .toString(36)
    .slice(0, randomLength);
}

4. 通过 clear 方法关闭消息

clear 函数代码

/** 
 * 关闭消息
 * - 不传 key 关闭所有消息
 * - 传 key 关闭指定一条消息
 */
const clear = (key?: string) => {
  if (key) {
    removeDomByID(key);
    return;
  }
  messageList.forEach((message) => {
    removeDomByID(message.id);
  })
}
/** 根据 id 移除指定 message 的 DOM 节点 */
const removeDomByID = function (id: string) {
  const msgDom = document.getElementById(`message-${id}`);
  if (!msgDom) return;
  const msgDomHeight = msgDom.offsetHeight;
  // 关闭消息动画
  const disappearAnimation = [
    { opacity: 1, },
    { opacity: 0, marginTop: `${-msgDomHeight}px`, marginBottom: 0 }
  ];
  msgDom.animate(disappearAnimation, 300).onfinish = function () {
    store.deleteMessage(id);
  }
};

5. 通过 config 方法进行全局配置

config 函数代码

/** 消息的全局配置项类型 */
type MessageGlobalOptionsT = {
  /** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
  duration?: number;
  /** 最大显示数, 超过限制时,最早的消息会被自动关闭 */
  maxCount?: number;
  /** Message 消失时触发 */
  onClose?: () => void;
};
/** 全局配置 */
const globalOptions: MessageGlobalOptionsT = {};
/** 
 * 全局配置
 * |  属性  |  类型  |  说明  |  默认值  |
 * | :----: | :----: | :----: | :----: |
 * | duration | number | 提示持续时间(ms),为 0 则不会自动关闭 | 2000 |
 * | maxCount | number | 最大显示数, 超过限制时,最早的消息会被自动关闭 | - |
 * | onClose | () => void | Message 消失时触发 | - |
 */
const config = (options: MessageGlobalOptionsT) => {
  Object.assign(globalOptions, options);
}

为实现【超过限制时,最早的消息会被自动关闭】功能需要在 show 函数里添加:

// 超过最大显示数限制时,队头消息会被自动关闭。
if (globalOptions.maxCount && globalOptions.maxCount > 0) {
  if (messageList.length + 1 > globalOptions.maxCount) {
    removeDomByID(messageList[0].id);
  }
}

这样在使用 Message.show 之前调用 Message.config 方法进行全局配置,可以减少重复配置的代码

6. 默认配置

/** 默认配置 */
const defaultOptions: Pick<MessageOptionsT, 'duration' | 'onClose'> = {
  duration: 2000,
  onClose: undefined,
}

所以在 config 函数里,需要加这样的逻辑:全局配置会替换默认配置

if (options.duration !== undefined) {
  defaultOptions.duration = options.duration;
}
if (options.onClose !== undefined) {
  defaultOptions.onClose = options.onClose;
}

而在 show 函数里,需要将默认配置合并到传入的 ops 当中

ops = mergeOptions(defaultOptions, ops);

7. 导出 Message 对象

Message.tsx 最终导出一个对象,里面包含 show、clear、config 函数

/** Message 全局提示,只支持指令式调用 */
const Message: {
  show: typeof show;
  clear: typeof clear;
  config: typeof config;
} = { show, clear, config };

export default Message;

使用

自此,一个功能较为齐全的 Message 组件已经完成,使用示例:

import Message from '@/components/message/message';

const Demo: React.FC<{}> = (props) => {
  useEffect(() => {
    Message.config({
      duration: 3000,
      maxCount: 5,
    })
  }, [])
  return (
    <div className={css.demo}>
      <div className={css.demoBlock}>
        <div className={css.title}>Message 全局提示</div>
        <div className={css.main}>
          <button onClick={() => {
            Message.show('Message 全局提示')
          }}>全局提示</button>
          <button onClick={() => {
            Message.show({
              content: <div className={css['custom-text']}>自定义文本</div>,
            })
          }}>自定义文本</button>
          <br />
          <button onClick={() => {
            Message.show({
              content: '成功',
              icon: 'success',
            })
          }}>成功</button>
          <button onClick={() => {
            Message.show({
              content: '失败',
              icon: 'fail',
            })
          }}>失败</button>
          <button onClick={() => {
            Message.show({
              content: '加载中',
              icon: 'loading',
              duration: 0
            })
          }}>加载中</button>
          <button onClick={() => {
            Message.show({
              content: '上传中',
              icon: <img src={uploadIcon} className={css['upload-icon-small']} />,
            })
          }}>自定义图标</button>
          <br />
          <button onClick={() => {
            Message.show({
              content: '不会消失的 Message',
              duration: 0
            });
          }}>不会消失的 Message</button>
          <button onClick={() => {
            Message.clear();
          }}>清除所有 Message</button>
          <br />
          <button onClick={() => {
            Message.show({
              content: '可打开控制台查看',
              onClose: () => { console.log('Message 关闭'); }
            })
          }}>关闭时触发回调函数</button>
        </div>
      </div>
    </div>
  );
};

小 Tips

写组件注释

采用 Markdown 语法写组件注释,可以让组件使用者更加清晰地了解参数及其含义

/** 
 * options 配置项说明
 * |  属性  |  类型  |  说明  |  默认值  |
 * | :----: | :----: | :----: | :----: |
 * | content | string、React.ReactNode | Toast 文本内容 | - |
 * | duration | number | 提示持续时间(ms),为 0 则不会自动关闭 | 2000 |
 * | icon | `success`、`fail`、`loading`、React.ReactNode | Toast 图标 | - |
 * | direction | `row`、`column` | 图标和文字布局,仅在参数 `icon` 合法时有效 | `column` |
 * | maskClickable | boolean | 是否允许背景点击 | true |
 * | onClose | () => void | Toast 消失时触发 | - |
 */

鼠标 hover 在 show 方法上面时就可以看到完整的注释:

image.png

写类型注释

这样写类型注释,可以让组件使用者快速地了解参数的含义

type ToastOptionsT = {
  /** Toast 文本内容 */
  content: string | React.ReactNode;
  /** 提示持续时间(ms),默认 2000,若为 0 则不会自动关闭 */
  duration?: number;
  /** Toast 图标 */
  icon?: 'success' | 'fail' | 'loading' | React.ReactNode,
  /** 图标和文字布局,仅在参数 icon 合法时有效 */
  direction?: 'row' | 'column',
  /** 是否允许背景点击,默认为 true */
  maskClickable?: boolean,
  /** Toast 消失时触发 */
  onClose?: () => void;
};

鼠标 hover 在 duration 参数上面时就可以看到完整的注释:

image.png

参考文章