【Message 全局提示】基于 React 实现 Message 组件

简介: 使用 ReactDOM.createRoot、React.forwardRef、React.useImperativeHandle 实现 Message 组件。使用 Web Crypto API 生成符合密码学要求的安全的随机 ID。

需求

全局展示操作反馈信息。

  • 可提供成功、警告和错误等反馈信息。
  • 顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。

我们希望通过如下方式调用组件:

import Message from '@/components/Message';
// or
import { Message } from '@/components'

message.open({ type: 'info', content: 'Hello!' })

设计

总体设计

向外暴露 open 方法,使用者在第一次调用 open 方法时初始化容器,open 方法接收消息类型(type),根据是否已完成初始化:

  • 未完成初始化:

    • 创建容器
    • 根据消息类型派发延时任务(必须先完成初始化,任务才可以执行,所以要等到下一次 eventLoop)
  • 已完成初始化

    • 根据消息类型派发即时任务
const message = {
  open: ({ type = 'info', content }) => {
    if (初始化完成) {
      // 派发即时任务
    } else {
      // 未完成初始化
      // 创建容器
      // 派发延时任务
    }
  },
  info: null, // 具体的实现会在初始化的过程中填充
  error: null,
};

export default message;

容器设计(React.createRoot 的使用)

使用 React.createRoot() 来创建容器,此方法需要一个真实的DOM节点,在这个节点下创建一个 React root ,这个 React root 提供一个 render 方法,用于渲染 React Component。

const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;

// 创建真实 DOM
function createContainer() {}

// 创建 React root
function renderMessageRoot() {
  // 获取到真实 DOM
  const container = createContainer();
  if (!containerRoot) {
    // 创建 React root
    containerRoot = ReactDOM.createRoot(container);
    // 渲染消息组件
    containerRoot.render(<消息组件 />);
  }
}

消息组件设计(forwardRef 与 useImperativeHandle 的使用)

使用React.forwardRefReact.useImperativeHandle 将消息管理与增删消息的实现相分离。

最外层的消息管理组件被渲染在 React root 下,所以进行初始化的过程中,会填充向外暴露的 message 对象上的info、error等方法的具体实现。而这些方法又是下层容器组件通过 useImperativeHandle 向管理器组件提供的。具体见伪代码:

const MessageContainer = React.forwardRef((props, ref) => {
  const { messageList, setMessageList } = props;

  useImperativeHandle(ref, () => {
    return {
      info: text => {
        // 插入一条消息
      },
      error: text => {
        // 插入一条消息
      },
    };
  });

  return (
    <>
      // 遍历消息列表,渲染
    </>
  );
});

function MessageManager() {
  const [messageList, setMessageList] = useState([]);
  
  const msgRef = useRef();

  useEffect(() => {
    message.current = msgRef.current;
    message.info = msgRef.current.info;
    message.error = msgRef.current.error;
  }, []);

  return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}

架构设计

// 创建真实 DOM 容器
function createContainer() {}

// 创建 React root 容器
function renderMessageRoot() {}

// 单条消息组件
const MessageItem = () => {};

// 消息容器组件
const MessageContainer = React.forwardRef((props, ref) => {
  const { messageList, setMessageList } = props;

  useImperativeHandle(ref, () => {
    return {
      info: text => {},
      error: text => {},
    };
  });

  return // 遍历消息列表,渲染;
});

// 消息管理组件
function MessageManager() {
  const [messageList, setMessageList] = useState([]);

  const msgRef = useRef();

  useEffect(() => {
    message.current = msgRef.current;
    message.info = msgRef.current.info;
    message.error = msgRef.current.error;
  }, []);

  return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}

const message = {
  open: ({ type = 'info', content }) => {}, // 向外暴露 open 方法
  info: null,
  error: null
};

export default message;

实现

创建容器

const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;

// 创建真实 DOM
function createContainer() {
  // 尝试根据 ID 获取真实DOM节点
  let container = document.getElementById(MESSAGE_CONTAINER_ID);
  if (!container) {
    // 如果没有获取到,就创建一个,并插入 body 中
    container = document.createElement('div');
    container.setAttribute('id', MESSAGE_CONTAINER_ID);
    document.body.appendChild(container);
  }
  return container;
}

// 创建 React root
function renderMessageRoot() {
  const container = createContainer();
  if (!containerRoot) {
    containerRoot = ReactDOM.createRoot(container);
    containerRoot.render(<消息组件 />);
  }
}

const message = {
  open: ({ type = 'info', content }) => {
    if (containerRoot) {
      message[type](content);
    } else {
      renderMessageRoot();
      nextTick(() => {
        message[type](content);
      });
    }
  },
};

export default message;

消息组件

const MessageItem = ({ children }) => {
  return (
    <div className="messageItem">
      {children}
    </div>
  );
};

const MessageContainer = React.forwardRef((props, ref) => {
  const { messageList, setMessageList } = props;

  useImperativeHandle(ref, () => {
    return {
      info: text => {
        setMessageList(list => [...list, text]);
      },
      error: text => {
        setMessageList(list => [...list, text]);
      },
    };
  });

  return (
    <>
      {messageList.map((msg, index) => (
        <MessageItem key={index}>
          <span>{msg.text}</span>
        </MessageItem>
      ))}
    </>
  );
});

function MessageManager() {
  const [messageList, setMessageList] = useState([]);

  const msgRef = useRef();

  useEffect(() => {
    message.current = msgRef.current;
    message.info = msgRef.current.info;
    message.error = msgRef.current.error;
  }, []);

  return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}

自动移除消息

const MessageItem = ({ children, onRemove }) => {
  useEffect(() => {
    let timer = null;
    timer = setTimeout(() => {
      onRemove()
    }, 2000);

    return () => {
      clearTimeout(timer);
    };
  }, []);

  return (
    <div className="messageItem">
      {children}
    </div>
  );
};

const MessageContainer = React.forwardRef((props, ref) => {
  // ...

  return (
    <>
      {messageList.map((msg, index) => (
        <MessageItem key={index} onRemove={() => setMessageList(list => list.filter((item, i) => i !== index))}>
          <span>{msg.text}</span>
        </MessageItem>
      ))}
    </>
  );
});

单条消息唯一ID

在循环渲染和移除消息的时候,最好为单条消息加上唯一ID,此时我们使用Web Crypto API生成符合密码学要求的安全的随机 ID。

function uuid() {
  const uuid = window.crypto.getRandomValues(new Uint8Array(8));
  return uuid.toString().split(',').join('');
}

改造组件:

const MessageContainer = React.forwardRef((props, ref) => {
  const { messageList, setMessageList } = props;

  useImperativeHandle(ref, () => {
    return {
      info: text => {
        const id = uuid();
        setMessageList(list => [...list, { id, text }]);
      },
      error: text => {
        const id = uuid();
        setMessageList(list => [...list, { id, text }]);
      },
    };
  });

  return (
    <>
      {messageList.map(msg => (
        <MessageItem key={msg.id} onRemove={() => setMessageList(list => list.filter(item => item.id !== msg.id))}>
          <span>{msg.text}</span>
        </MessageItem>
      ))}
    </>
  );
});

动画

为消息弹出、消息加上动画:

.messageItem {
  width: max-content;
  background-color: white;
  margin: 0.5em auto;
  text-align: center;
  padding: 9px 12px;
  border-radius: 8px;
  box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%);
  position: relative;
  opacity: 0;
  display: flex;
  align-items: center;
}
.messageItem-appear {
  opacity: 0;
  animation-name: messageItemMoveIn;
  animation-duration: 0.3s;
  animation-play-state: 'paused';
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
  animation-fill-mode: forwards;
}
.messageItem-disappear {
  opacity: 1;
  animation-name: messageItemMoveOut;
  animation-duration: 0.3s;
  animation-play-state: 'paused';
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
  animation-fill-mode: forwards;
}

@keyframes messageItemMoveIn {
  from {
    opacity: 0;
    transform: translateY(-100%);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes messageItemMoveOut {
  from {
    opacity: 1;
  }

  to {
    max-height: 0;
    padding-top: 0;
    padding-bottom: 0;
    margin-top: 0;
    margin-bottom: 0;
    opacity: 0;
  }
}

改造单条消息组件:

const MessageItem = ({ children, onRemove }) => {
  const messageItemRef = useRef();
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    let timer = null;
    timer = setTimeout(() => {
      if (messageItemRef.current) {
        messageItemRef.current.addEventListener('animationend', onRemove, {
          once: true,
        });
      }
      setIsVisible(false);
    }, 2000);

    return () => {
      clearTimeout(timer);
    };
  }, []);

  return (
    <div ref={messageItemRef} className={`messageItem ${isVisible ? 'messageItem-appear' : 'messageItem-disappear'}`}>
      {children}
    </div>
  );
};

完整代码

React Component:

// index.jsx
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import ReactDOM from 'react-dom/client';
import { nextTick, uuid } from './utils';
import './index.css';

const MESSAGE_CONTAINER_ID = 'message_container';
let containerRoot = null;

function createContainer() {
  let container = document.getElementById(MESSAGE_CONTAINER_ID);
  if (!container) {
    container = document.createElement('div');
    container.setAttribute('id', MESSAGE_CONTAINER_ID);
    document.body.appendChild(container);
  }
  return container;
}

function renderMessageRoot() {
  const container = createContainer();
  if (!containerRoot) {
    containerRoot = ReactDOM.createRoot(container);
    containerRoot.render(<MessageManager />);
  }
}

const MessageItem = ({ children, onRemove }) => {
  const messageItemRef = useRef();
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    let timer = null;
    timer = setTimeout(() => {
      if (messageItemRef.current) {
        messageItemRef.current.addEventListener('animationend', onRemove, {
          once: true,
        });
      }
      setIsVisible(false);
    }, 2000);

    return () => {
      clearTimeout(timer);
    };
  }, []);

  return (
    <div ref={messageItemRef} className={`messageItem ${isVisible ? 'messageItem-appear' : 'messageItem-disappear'}`}>
      {children}
    </div>
  );
};

const MessageContainer = React.forwardRef((props, ref) => {
  const { messageList, setMessageList } = props;

  useImperativeHandle(ref, () => {
    return {
      info: text => {
        const id = uuid();
        setMessageList(list => [...list, { id, text }]);
      },
      error: text => {
        const id = uuid();
        setMessageList(list => [...list, { id, text }]);
      },
    };
  });

  return (
    <>
      {messageList.map(msg => (
        <MessageItem key={msg.id} onRemove={() => setMessageList(list => list.filter(item => item.id !== msg.id))}>
          <span className="text-blue messageItem-icon">
            <svg
              viewBox="64 64 896 896"
              focusable="false"
              data-icon="info-circle"
              width="1em"
              height="1em"
              fill="currentColor"
              aria-hidden="true">
              <path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z"></path>
            </svg>
          </span>
          <span>{msg.text}</span>
        </MessageItem>
      ))}
    </>
  );
});

function MessageManager() {
  const [messageList, setMessageList] = useState([]);

  const msgRef = useRef();

  useEffect(() => {
    message.current = msgRef.current;
    message.info = msgRef.current.info;
    message.error = msgRef.current.error;
  }, []);

  return <MessageContainer ref={msgRef} messageList={messageList} setMessageList={setMessageList} />;
}

const message = {
  current: null,
  open: ({ type = 'info', content }) => {
    if (containerRoot) {
      message[type](content);
    } else {
      renderMessageRoot();
      nextTick(() => {
        message[type](content);
      });
    }
  },
};

export default message;

CSS:

#message_container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999999;
  margin: 0;
  padding: 0;
  font-size: 14px;
  min-height: 1px;
  background-color: transparent;
  display: flex;
  justify-content: center;
  flex-direction: column;
  pointer-events: none;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
    'Helvetica Neue', sans-serif;
}

.messageItem {
  width: max-content;
  background-color: white;
  margin: 0.5em auto;
  text-align: center;
  padding: 9px 12px;
  border-radius: 8px;
  box-shadow: 0 6px 16px 0 rgb(0 0 0 / 8%), 0 3px 6px -4px rgb(0 0 0 / 12%), 0 9px 28px 8px rgb(0 0 0 / 5%);
  position: relative;
  opacity: 0;
  display: flex;
  align-items: center;
}
.messageItem-icon {
  margin-inline-end: 8px;
}

.messageItem-appear {
  opacity: 0;
  animation-name: messageItemMoveIn;
  animation-duration: 0.3s;
  animation-play-state: 'paused';
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
  animation-fill-mode: forwards;
}
.messageItem-disappear {
  opacity: 1;
  animation-name: messageItemMoveOut;
  animation-duration: 0.3s;
  animation-play-state: 'paused';
  animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
  animation-fill-mode: forwards;
}

@keyframes messageItemMoveIn {
  from {
    opacity: 0;
    transform: translateY(-100%);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes messageItemMoveOut {
  from {
    opacity: 1;
  }

  to {
    max-height: 0;
    padding-top: 0;
    padding-bottom: 0;
    margin-top: 0;
    margin-bottom: 0;
    opacity: 0;
  }
}

用到的工具方法:

export function nextTick(callback) {
  let timerFunc;
  if (typeof Promise !== 'undefined') {
    const p = Promise.resolve();
    timerFunc = () => {
      p.then(callback);
    };
  } else if (
    typeof MutationObserver !== 'undefined' &&
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  ) {
    let counter = 1;
    const observer = new MutationObserver(callback);
    const textNode = document.createTextNode(String(counter));
    observer.observe(textNode, { characterData: true });
    timerFunc = () => {
      counter = (counter + 1) % 2;
      textNode.data = String(counter); // 数据更新
    };
  } else if (typeof setImmediate !== 'undefined') {
    timerFunc = () => {
      setImmediate(callback);
    };
  } else {
    timerFunc = () => {
      setTimeout(callback, 0);
    };
  }
  timerFunc();
}

export function uuid() {
  const uuid = window.crypto.getRandomValues(new Uint8Array(8));
  return uuid.toString().split(',').join('');
}

总结

知识点总结:

  • ReactDOM.createRoot
  • React.forwardRef
  • React.useImperativeHandle
  • Web Crypto API
  • CSS animate

快动手试试把组件跑起来吧~

相关文章
|
6月前
|
设计模式 前端开发 数据可视化
【第4期】一文了解React UI 组件库
【第4期】一文了解React UI 组件库
345 0
|
6月前
|
存储 前端开发 JavaScript
【第34期】一文学会React组件传值
【第34期】一文学会React组件传值
71 0
|
6月前
|
前端开发
【第31期】一文学会用React Hooks组件编写组件
【第31期】一文学会用React Hooks组件编写组件
73 0
|
6月前
|
存储 前端开发 JavaScript
【第29期】一文学会用React类组件编写组件
【第29期】一文学会用React类组件编写组件
71 0
|
6月前
|
前端开发 开发者
【第26期】一文读懂React组件编写方式
【第26期】一文读懂React组件编写方式
59 0
|
6月前
|
资源调度 前端开发 JavaScript
React 的antd-mobile 组件库,嵌套路由
React 的antd-mobile 组件库,嵌套路由
116 0
|
6月前
|
存储 前端开发 中间件
React组件间的通信
React组件间的通信
50 1
|
6月前
|
前端开发 JavaScript API
React组件生命周期
React组件生命周期
114 1
|
6月前
|
存储 前端开发 JavaScript
探索 React Hooks 的世界:如何构建出色的组件(下)
探索 React Hooks 的世界:如何构建出色的组件(下)
探索 React Hooks 的世界:如何构建出色的组件(下)
|
6月前
|
缓存 前端开发 API
探索 React Hooks 的世界:如何构建出色的组件(上)
探索 React Hooks 的世界:如何构建出色的组件(上)
探索 React Hooks 的世界:如何构建出色的组件(上)