React造轮子系列--仿Antd的Message组件(Typescript+Hooks)

1,680 阅读4分钟

基于Typescript和Hooks写法实现模拟一个Message组件

先捋清楚思路

  1. 调用消息组件,将一个容器挂载到body元素中,并且拉高z-index保证能出现在元素顶层
  2. 消息组件有默认的消失时间,支持自定义传入,停留时间到达后销毁组件,并删除挂载的容器 确定了逻辑后直接上手干!定义一个组件文件夹专门存放message组件代码

image.png

下面代码中都有注释供阅读

Message核心组件

代码中有个MessageProps类型定义了组件可以接收的props,其中mode为模式,如(成功,失败,警告,提示),content为内容,可以传入文本或者React元素,componentId为组件挂载的容器,传入的作用为在销毁组件时能找到目标容器并执行相对应的清扫动作

import React, {FC, useEffect, useRef} from 'react';
import "./message.less"
import classes from "../utils/classes";
import ReactDOM from "react-dom";
import {messageContainer} from "./bootstrap"
import Icon from "../icon/icon"

export interface MessageProps {
  duration?: number; // 持续时间,不传默认3秒
  mode: "success" | "error" | "info" | "warning"; // 消息模式,区别为icon不同
  content: string | React.ReactElement; // 消息内容
  className?: string; // 自定义样式名
  style?: React.CSSProperties; // 自定义style
  componentId: string; // 容器id,通过id生成器自增生成
};

// FC是FunctionComponent,可以传入Props的类型,Typescript便能自动推导props
const Message: FC<MessageProps> = (props) => {
  const {componentId, duration, content, mode} = props; // 从props中解构方便使用
  const container = useRef<HTMLDivElement>(null); // 通过ref拿到组件最外层的div
  useEffect(() => {
    // 组件挂载后添加一个定时器,作用为卸载组件,删除挂载容器
    const stId = setTimeout(() => {
        // 在入口文件有一个Map存储这id和容器的映射关系
        const wrapperEle = messageContainer.get(componentId);
        // 利用unmoutComponentAtNode方法手动卸载组件
        ReactDOM.unmountComponentAtNode(container.current!.parentElement as HTMLDivElement);
        // 把从map中取出的挂载容器从文档中删除,因为可以确保wrapperEle不会空,所以可以加!类型问题
        wrapperEle!.remove();
        // 从文档删除还不够,记得map也要记得清理
        messageContainer.delete(componentId);
      },
      // 这里设置消息组件存在时间,如果传入数字则X1000,单位为毫秒,否则默认存在3秒
      (typeof duration === 'number') ? duration * 1000 : 3000);
    return () => {
      // 组件卸载时带走垃圾:)
      if (stId) {
        window.clearTimeout(stId);
      }
    };
  }, []);

  return (
    <div className={classes("lm-message-container")}
         ref={container}>
      // classes类名工具函数后面有代码
      <div className={classes("lm-message")}>
        // 引入了自造的Icon组件,实现很简单,封装iconfont的使用即可
        <Icon className={"icon"} name={mode}/>
        <div className={'content'}>{content}</div>
      </div>
    </div>);
};

// 导出组件
export default Message;

classes类名处理函数的定义

// 不限个数的参数最终用空格拼接返回一个字符串
function classes(...names: (string | undefined)[]) {
  return names.filter(Boolean).join(" ");
}

export default classes;

Message的启动入口,真正调用的方法

import Message, {MessageProps} from "./message";
import ReactDOM from "react-dom";
import React from "react";

// id自增
let i = 1;
const idGenerator = () => {
  return `lemon-message-no-${i++}`;
};

// 组件id与容器的映射关系
export const messageContainer = new Map<string, HTMLDivElement>();

// 不同类型的使用方法最终都会调用此方法,通过typescript提供的Omit帮助类型方法可以剔除不需要的属性
export const createMessage = (options: Omit<MessageProps, "mode" | "componentId">, mode: MessageProps["mode"]) => {
  const element = document.createElement("div"); // 创建容器
  const componentId = idGenerator(); // 生成id
  messageContainer.set(componentId, element); // 存到map中,卸载时会用到
  const instance = <Message {...{...options, componentId, mode}}/>; // 组件实例
  document.body.appendChild(element); // 添加到document中
  ReactDOM.render(instance, element); // 渲染组件
};

// 剔除不需要的属性
type EmbedUtilsProps = Omit<MessageProps, "mode" | "componentId">;

// 下面4个最终调用的方法
export const success = (options: EmbedUtilsProps) => {
  createMessage(options, "success");
};

export const error = (options: EmbedUtilsProps) => {
  createMessage(options, "error");
};

export const warning = (options: EmbedUtilsProps) => {
  createMessage(options, "warning");
};

export const info = (options: EmbedUtilsProps) => {
  createMessage(options, "info");
};

export default {
  success, error, warning, info
};

样式文件,这里用的是less语法,如果用别的自行替换即可

.lm-message-container {
  position: fixed;
  left: 50%;
  z-index: 999;
  top: 20px;
  transform: translateX(-50%);

  .lm-message {
    display: flex;
    align-items: center;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    border-radius: 4px;
    font-size: 14px;
    background: white;
    animation: slide-up 500ms;
    padding: 10px 16px;
    pointer-events: all;

    .icon {
      width: 1.2em;
      height: 1.2em;
    }

    .content {
      margin-left: 8px;
    }
  }
}

//让消息出现时带点动画,不至于太生硬
@keyframes slide-up {
  0% {
    opacity: 0;
    transform: translateY(-100%);
  }
  100% {
    opacity: 1;
    transform: translateY(0%);
  }
}

使用方式

在组件中使用message提供的函数

import * as React from "react";
import ReactDOM from "react-dom";
import message from "./message/bootstrap";
import Button from "./button/button";

const App = () => {
  return (
    <>
      <Button onClick={() => {
        message.success({content: "删除成功"});
      }} type={"primary"}>success类型</Button>
      <hr/>
      <Button onClick={() => {
        message.error({content: "删除失败"});
      }}>error类型</Button>
      <hr/>
      <Button onClick={() => {
        message.warning({content: "警告警告"});
      }} type={"danger"}>warning类型</Button>
      <hr/>
      <Button onClick={() => {
        message.info({content: "很好很好"});
      }} type={"ghost"}>info类型</Button>
    </>
  );
};

ReactDOM.render(<App/>, document.querySelector("#root"));

其中Button组件也是仿Antd模拟的产物,代码也顺带贴出来,比较简单自行阅读

import React, {FC, HTMLAttributes, PropsWithChildren} from 'react';
import "./index.less"
import classes from "../utils/classes";

type Props = {
  type?: "primary" | "ghost" | "danger" | "link"
} & HTMLAttributes<HTMLButtonElement>

const Button: FC<PropsWithChildren<Props>> = (props) => {
  const {children, type, ...rest} = props;

  return <button {...rest} className={classes("lm-button", type)}>{children}</button>;
}

export default Button;

下面是Button组件的样式代码

@base-primary-color: #448ef7;
@danger-primary-color: #ec5b56;

.lm-button {
  border: 1px solid #d9d9d9;
  display: inline-block;
  border-radius: 4px;
  outline: none;
  background: none;
  padding: 0 14px;
  height: 30px;
  line-height: 30px;
  transition: all .3s;

  &:hover {
    cursor: pointer;
    border-color: @base-primary-color;
    color: @base-primary-color;
  }

  &.primary {
    background: @base-primary-color;
    color: white;

    &:hover {
      background: red;
      background: #40a9ff;
      border-color: #40a9ff;
    }
  }

  &.danger {
    background: @danger-primary-color;
    color: white;

    &:hover {
      background: #ee8079;
      border-color: #ee8079;
    }
  }

  &.ghost {
    border-style: dashed;
  }

  &.link{
    border: none;
    background: none;
  }
}

最后看看实现的效果吧

Filmage-2022-06-08_233712.gif 希望对你有帮助,完 :)