likes
comments
collection
share

【组件封装】使用React框架手搓一个Message

作者站长头像
站长
· 阅读数 3

1. 思路

2. 实现过程

1. 先想好可以传递给message的参数,定义一个interface

一般来说message组件都有type、duration、content、closeable等等参数,以下这些参数基本可以满足日常使用

export interface MessageOptions {
  /**
   * 内容
   *
   * @default ''
   */
  content?: ReactNode;
  /**
   * 类型
   *
   * @default success
   */
  type?: MessageType;
  /**
   * 唯一标识
   */
  key?: MessageKey;
  /**
   * 可关闭
   *
   * @default false
   */
  closable?: boolean;
  /**
   * 关闭时间,0不关闭
   *
   * @default 3000
   */
  duration?: number;
  /**
   * 隐藏icon
   *
   * @default false
   */
  hideIcon?: boolean;
  /**
   * 是否单例
   *
   * @description
   * 适合频繁更新message的场景,保持在一个message实例中去更新内容
   * 如果更新失败,会自动去执行新增message的逻辑
   * 配合key一起使用
   *
   * @default false
   */
  isSingleton?: boolean;
  /**
   * hover是否停止定时器
   *
   * @default true
   */
  isHoverStop?: boolean;
}

2. Message组件,用于每个Message的UI

这个组件是渲染单个Message的UI,在这个组件里,需要处理:UI布局、Hover事件的监听、点击close按钮的监听

实现如下:

interface MessageProps {
  options: MessageOptions;
  closeCallback?: (key: MessageKey) => void;
  hoverCallback?: (key: MessageKey, type: 'enter' | 'leave') => void;
}

const Message = memo((props: MessageProps) => {
  const { content, type, key, closable, hideIcon, isHoverStop } = props.options;
  const { closeCallback, hoverCallback } = props;

  const container = useRef<HTMLDivElement>(null);
  useHover(container, {
    onEnter: () => {
      isHoverStop && hoverCallback?.(key, 'enter');
    },
    onLeave: () => {
      isHoverStop && hoverCallback?.(key, 'leave');
    },
  });

  const renderIcon = (type: MessageType) => {
    const icons = {
      info: 'icon-info-fill text-blue-500',
      success: 'icon-success-fill text-green-500',
      error: 'icon-error-fill text-red-500',
      warning: 'icon-warning text-yellow-500',
      loading: 'icon-loading text-blue-500',
    };
    const iconCls = icons[type];
    return <i className={`iconfont ${iconCls} text-24px`} />;
  };

  return (
    <div
      ref={container}
      className="border-default box-border inline-block rounded-[8px] border border-solid bg-[#fff] px-4 py-3 shadow-lg"
    >
      <div className="flex items-center">
        {!hideIcon && (
          <div
            className={classNames(['mr-2 h-6', { 'animate-spin': type === 'loading' }])}
          >
            {renderIcon(type as MessageType)}
          </div>
        )}
        <div className="text-secondary text-16 whitespace-nowrap leading-[24px]">
          {content}
        </div>
        {closable && (
          <span
            className="ml-2 flex cursor-pointer"
            onClick={() => {
              closeCallback?.(key);
            }}
          >
            <i className="iconfont icon-close leading-[24px] text-stone-500" />
          </span>
        )}
      </div>
    </div>
  );
});

export default Message;

closeCallback和hoverCallback这两个事件是MessageContainer传进来的,后续再处理这两个事件,先写好放着,同时用memo包裹了这个组件,避免不必要的渲染

3. MessageContainer组件,用来渲染所有的Message

在这个组件中,要使用useState去定义一个messageList,要为MessageStore添加更新UI的方法(即把setMessageList传进MessageStore中),MessageStore中的messages数组作为messageList的初始值,同时 要写一下hoverCallback和closeCallback。

message的动画方面,我使用的是framer-motion这个库,这是个用于React框架的动画库,功能强大。

代码如下:

const MessageContainer = () => {
  const [messageList, setMessageList] = useState(messageStore.getMessages());

  // 关闭按钮的回调
  const closeCallback = useMemoizedFn((key: MessageKey) => {
    messageStore.removeMessage(key);
  });

  // hover的回调
  const hoverCallback = useMemoizedFn((key: MessageKey, type: 'enter' | 'leave') => {
    const message = messageStore.getMessage(key);
    if (message?.duration === 0) return;
    type === 'enter' ? messageStore.removeTimeout(key) : messageStore.addTimeout(key);
  });

  // 为MessageStore添加更新Ui的方法
  useEffect(() => {
    const unSubscribe = messageStore.subscribe((newMessages) => {
      setMessageList([...newMessages]);
    });
    return unSubscribe;
  }, []);

  return (
    <ul className="components-message-container absolute left-[50%] top-0 flex -translate-x-1/2 transform flex-col items-center gap-y-6">
      <AnimatePresence>
        {messageList.map((message) => (
          <motion.li
            key={message.key}
            animate={{ opacity: 1, y: 24 }}
            exit={{ opacity: 0, y: 0 }}
            transition={{ ease: 'linear', duration: 0.2 }}
            className="list-none"
          >
            <Message
              options={message}
              closeCallback={closeCallback}
              hoverCallback={hoverCallback}
            />
          </motion.li>
        ))}
      </AnimatePresence>
    </ul>
  );
};

代码里面的messageStore是从store文件里导入的MessageStore的单例

4. 定义MessageStore,实现各种操作,如add、remove、update等

class MessageStore {
  private messages: MessageOptions[] = [];
  private cb: (messages: MessageOptions[]) => void = () => {
    throw new Error('subscribe function is not defined');
  };
  private timeouts: MessageTimeout[] = [];

  static instance = new MessageStore();

  constructor() {
    if (!MessageStore.instance) {
      MessageStore.instance = this;
    }
    return MessageStore.instance;
  }

  addMessage(baseMessage: MessageOptions) {
    const defaultMessage: MessageOptions = {
      type: 'success',
      key: uniqueId(),
      closable: false,
      duration: 3000,
      hideIcon: false,
      isSingleton: false,
      isHoverStop: true,
    };
    const message = mergeProps<MessageOptions>(
      baseMessage,
      defaultMessage
    ) as Required<MessageOptions>;

    if (message.duration < 0) {
      message.duration = defaultMessage.duration as number;
    }
    if (message.duration > 0) {
      const timerId = setTimeout(() => {
        this.removeMessage(message.key);
      }, message.duration);
      this.timeouts.push({ key: message.key, timerId, ms: message.duration });
    }

    this.messages.push(message);
    this.cb(this.messages);
  }

  removeMessage(key: MessageKey) {
    const message = this.getMessage(key);
    if (message) {
      this.messages = this.messages.filter((mes) => mes.key !== key);
      this.removeTimeout(key);
      this.cb(this.messages);
    }
  }

  updateMessage(baseMessage: MessageOptions) {
    const message = this.getMessage(baseMessage.key);
    if (message) {
      const newMessage = mergeProps(baseMessage, message);
      this.messages = this.messages.map((mes) =>
        mes.key === baseMessage.key ? newMessage : mes
      );

      // delete old timeout
      this.removeTimeout(baseMessage.key);
      // add new timeout
      this.addTimeout(baseMessage.key);

      this.cb(this.messages);
    }
  }

  clear() {
    !!this.timeouts.length &&
      this.timeouts.forEach((timeout) => clearTimeout(timeout.timerId));
    this.timeouts = [];
    this.messages = [];
  }

  destory() {
    this.clear();
    this.cb(this.messages);
  }

  subscribe(callback: (messages: MessageOptions[]) => void) {
    this.cb = callback;
    return this.clear;
  }

  getMessage(key: MessageKey) {
    return this.messages.find((mes) => mes.key === key);
  }

  getMessages() {
    return this.messages;
  }

  getTimeout(key: MessageKey) {
    return this.timeouts.find((timeout) => timeout.key === key);
  }

  removeTimeout(key: MessageKey) {
    const timeout = this.getTimeout(key);
    if (timeout) {
      clearTimeout(timeout.timerId);
      this.timeouts = this.timeouts.filter((timeout) => timeout.key !== key);
    }
  }

  addTimeout(key: MessageKey) {
    const message = this.getMessage(key);
    if (message?.duration === 0) return;
    const timerId = setTimeout(() => {
      this.removeMessage(key);
    }, message?.duration);
    this.timeouts.push({ key, timerId, ms: message?.duration as number });
  }
}

export const messageStore = new MessageStore();

我在这个类里处理了props的默认值,所以不用在Message里处理,传到Message里的props一定是有值的

这个类里维护了四个变量,解释一下这几个变量:

  • messages: 存放message实例
  • cb: UI层的更新函数
  • timeouts: 存放每个message实例对应的定时器,因为message被创建之后,会在n秒后进行销毁,类型定义如下:
export interface MessageTimeout {
  /**
   * key
   */
  key: MessageKey;
  /**
   * 定时器id
   */
  timerId: number;
  /**
   * 关闭时间
   */
  ms: number;
}
  • instance:MessageStore实例

总之,流程就是:调用MessageStore里的方法进行数据操作 => 调用cb => UI更新

  1. 暴露给外部使用的API 单独开一个文件message.ts, 暴露了几个api,分别是open api 和 别名api, 如下:
const message: MessageApi = {
  success: (content) => {
    messageStore.addMessage({ content, type: 'success' });
  },
  info: (content) => {
    messageStore.addMessage({ content, type: 'info' });
  },
  warning: (content) => {
    messageStore.addMessage({ content, type: 'warning' });
  },
  error: (content) => {
    messageStore.addMessage({ content, type: 'error' });
  },
  loading: (content) => {
    messageStore.addMessage({ content, type: 'loading' });
  },
  open: (option) => {
    if (option?.isSingleton) {
      const message = messageStore.getMessage(option?.key);
      if (message) {
        messageStore.updateMessage(option);
        return;
      }
    }
    messageStore.addMessage(option);
  },
  destoryAll: () => {
    messageStore.destory();
  },
};

export default message;

类型MessageApi定义如下:

export interface MessageApi {
  success: (content: ReactNode) => void;
  info: (content: ReactNode) => void;
  error: (content: ReactNode) => void;
  warning: (content: ReactNode) => void;
  loading: (content: ReactNode) => void;
  open: (option: MessageOptions) => void;
  destoryAll: () => void;
}

这个messageStore跟MessageContainer里面的是同一个store,都是从store文件里导入的,

别名api暴露的parameters是比较简洁的,只有一个content,调用示例:

message.success('This is a message!')

open api 就暴露了所有的props,调用示例:

  message.open({
      content: `这是m1, date: ${Date.now()}`,
      isSingleton: true,
      key: 'm1',
});

5. render

现在暴露给外面使用的api也写完了,如果直接调用message.success('xxx),理论上来说看不到任何效果, 因为MessageContainer还没有渲染到Render Tree中,只要把MessageContainer放在任何一个地方,例如App.tsx中,如:

function App() {
  return <div>
    {/* ohter components... */}
    <MessageContainer />
  </div>
}

这样就能看到效果了

但是这样做不是很好,因为有点麻烦。

搞一个root,来render, 写个render方法,如下:

const render = () => {
  const containerEle = document.createElement('div');
  containerEle.id = 'components-message-wrapper';
  document.body.appendChild(containerEle);
  const root = createRoot(containerEle);
  root.render(<MessageContainer />);
};

render();

这段代码我放在了MessageContainer组件里,因为没有地方需要导入MessageContainer.tsx这个组件,这段代码也不会执行,所以在message.ts(定义暴露给外部使用的api的文件)手动引入一下,如下:

import './ui/MessageContainer';

6. 整理一下目录结构,如下:

【组件封装】使用React框架手搓一个Message