【Message 消息提示】:使用 React.createRoot() 实现 Message 组件

这篇文章将会带你从 0 到 1 用 React 完成一个 Message 消息提示组件,知识点包括React.createRoot() 这个 API 的使用,以及如何使用原生 JS 来生成 uuid;实际项目开发中,改变一个元素的样式经常是通过动态切换 class 类名,所以,我还会带你封装一个自定义 Hooks useCssClassManager 来优雅的实现组件样式的切换,其余还有很多技术细节,请耐心往下看吧😎

明确需求

Message 消息提示,常见于弹出一个消息提示告知用户,比如,用户登录的时候,如果遇到网络异常,最好的交互方式就是弹出一个 Message 提示,告诉用户当前无法登陆的原因。这个组件对于前端开发者来说应该都不陌生,相信大部分人都用过 Element UI,其中的 Message 组件就很经典
image.png

Message 可以分为不同的类型,用来表达不同的状态,比如“操作成功”就是一个和谐轻松的绿框框,而“操作失败”就弹一个醒目刺眼的红框框。

Message 通常不用固定的写在你的模版或 JSX 中,一般都是提供一个函数,例如Message.success('register successful'),随用随调,这就意味着使用 Message 需要手动挂载 React 组件到 DOM 中。

我们的目标是实现一个 Message 组件,用户每调用一次就能够在屏幕上弹出一个文字提示框,3s 之后自动消失,为了美观,可以给 Message 加上动画效果;同时为了不留下冗余的 DOM 节点,需要在 Message 小时之后自动移除其 DOM 节点。

我们最终要完成的 API 调用形式是 Message.[type]('xxxx') ,这里直接仿照了 Element UI 的 API 形式;提供四种不同样式的 Message ,分别是:info 普通消息success 成功消息warn 警示消息error 错误消息,通过 type 属性来定义,下面是 Message对象的结构:

interface propsMessage {
  info: Function;
  warn: Function;
  error: Function;
  success: Function;
}

Message 对象提供的这 4 个函数分别渲染出不同类型的消息,所以,我们接下来定义 Function 的入参和返回值:

// 即上面代码中的 Function 类型
// message 是用户自定义的消息内容
type messageInput = (message: string) => void;

interface propsMessage {
  info: messageInput;
  warn: messageInput;
  error: messageInput;
  success: messageInput;
}

现在,我们有了一个 API Message.success('hello world'),调用后屏幕上就会弹出消息提示,接下来的重点是对 Message 的 4 个子方法的逻辑实现。

说到在 React 中手动生成 DOM 节点,首先想到的就是 ReactDOM.render() 这个 API ,首先来回顾一下官方文档中的定义:
image.png
其函数签名为render(element, container[, callback]),我们只需要用到前两个参数就够了。element可以是任何 DOM 节点,也可以是 React 节点,而 container 是一个父容器节点,本文中的父容器就是document.bodyReactDOM.render()可以将 element 作为自节点渲染到 container 中。

下面来说个重要的事情,假如你经过一顿操作,使用 ReactDOM.render() 实现了最终效果,那你的 Message 在未来的 React 新版本中将不再可用 ☹️,为啥呢?因为控制台有一个报错
image.png
上面说“ReactDOM.render is no longer supported in React 18. Use createRoot instead”,意思就是 React 18 不再支持这个 API 了,在未来版本中将会被废弃,请使用createRoot 来替代。我就是傻夫夫的用 React.render 实现了核心逻辑,然后发现这个报错,哭笑不得 🥲,没办法,未来将废弃的 API 肯定不能再用啦。

本文就不叙述之前用 render 实现的细节啦,直接使用新的 React.createRoot() 。现在,我们来梳理一下具体的步骤:

  1. 根据传入的 message 以及所调用方法的 type ,准备好一个 Message 组件
  2. 手动渲染这个组件到 body 节点下
  3. 3s 后自动清除这个 DOM 节点

实现 Message 组件

首先是准备一个 Message 组件,这是渲染到页面上的 DOM ,内容和结构其实很简单;因为Message 这个变量名已经被占用,作为最终的导出对象的名称,所以这里我们给真正的 Message 组件命名为BaseMessage

// 根据 type 和 message 返回不同样式类型的 UI
function BaseMessage(props) {
  const { type, message } = props;

  return (
    <p
      ref={refMessage}
      className="wdu-message"
    >
      {message}
    </p>
  );
}

样式切换

前面说到,有四种不同类型的 Message 需要实现,所以我们可以定义 4 种不同的 css 类,然后动态的应用到组件上

// less 代码

.wdu-raw-plain {
    color: #7e7e7e;
    background-color: #efefef;
    border: 2px solid #bababa;
}

.wdu-raw-danger {
    color: #db1a1a;
    background-color: #ffbdbd;
    border: 2px solid #eb5757;
}

.wdu-raw-success {
    color: #0d8a0d;
    background-color: #c9f3c9;
    border: 2px solid #50b250;
}

.wdu-raw-warn {
    color: #9f9f00;
    background-color: #ffe89c;
    border: 2px solid #dddd00;
}

.wdu-message {
    &__info {
        .wdu-raw-plain();
    }

    &__error {
        color: white;
        .wdu-raw-danger();
    }

    &__success {
        color: white;
        .wdu-raw-success();
    }

    &__warning {
        .wdu-raw-warn();
    }
}

function BaseMessage(props: any) {
  const { type, message} = props;

  return (
    <p
      ref={refMessage}
      className={`wdu-message wdu-message__${type}`}
    >
      {message}
    </p>
  );
}

这样我们就能够根据传入的type来实现不同样式的 Message 了

显示与隐藏

为了效果美观,我们来给 Message 显示与隐藏都加上 CSS 动画

.wdu-message {
    &__visible {
        opacity: 0;
        animation: visible 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) 0s 1 forwards;
    }

    &__hidden {
        opacity: 1;
        animation: hidden 0.5s cubic-bezier(0.250, 0.460, 0.450, 0.940) 0s 1 forwards;
    }
}

@keyframes visible {
    from {
        opacity: 0;
        top: -30px;
    }

    to {
        opacity: 1;
        top: 0;
    }
}

@keyframes hidden {
    from {
        opacity: 1;
        top: 0;
    }

    to {
        opacity: 0;
        top: -30px;
    }
}

然后,定义一个状态visible,控制组件的显示与隐藏,使用 useEffect 监听visible的变化,动态应用我们编写好的动画即可

function BaseMessage(props: any) {
  const { type, message} = props;

  const refMessage = useRef<any>();

  const classMap = {
    base: "",
    visible: "wdu-message__visible",
    hidden: "wdu-message__hidden",
  };

  const { addClassName, classList } = useCssClassManager(classMap);

  useEffect(() => {
    visible ? addClassName("visible") : addClassName("hidden");;
  }, [visible]);

  return (
    <p
      ref={refMessage}
      className={`wdu-message wdu-message__${type} ${classList}`}
    >
      {message}
    </p>
  );
}

上面的代码突然变复杂了,不过不急,我们一段段来看

const classMap = {
    base: "",
    visible: "wdu-message__visible",
    hidden: "wdu-message__hidden",
  };

const { addClassName, classList } = useCssClassManager(classMap);

classMap是一个类名集合,其中包含组件样式的三种状态,即“初始”、“显示”、“隐藏”,通过类名来表示

为了避免在标签上加太多表达式以及写出面条式的代码,这里封装了一个 hooks useCssClassManager,它可以动态地管理我们所提供的 classMap

interface cssClass {
  base: string; // 初始状态的样式类名
  [key: string]: string;
}

function useCssClassManager(cssClassMap: cssClass) {
  const [classMap, setClassMap] = useState<cssClass>({
    base: cssClassMap.base,
  });

  // 动态类名列表
  const [classList, setClassList] = useState("");

  // 移除指定类名
  const removeClassName = (classKey: string) => {
    setClassMap((prev) => {
      const template = { ...prev };
      delete template[classKey];
      return template;
    });
  };

  // 添加指定类名
  const addClassName = (classKey: string) => {
    setClassMap((prev) => ({ ...prev, [classKey]: cssClassMap[classKey] }));
  };

  useEffect(() => {
    setClassList(Object.values(classMap).join(" "));
  }, [classMap]);

  return {
    removeClassName,
    addClassName,
    classList,
  };
}

到这里,我们就实现了一个基本的 Message 组件了,但是还有很多工作要做。

手动渲染组件

第二步,手动渲染组件到 body 元素下。这里有一个问题:如何渲染多个 Message ?

我们的需求是:每调用一次 Message 方法就要渲染出一个消息框;大家应该都知道,在 React 中UI = component(state),我首先想到的就是用队列来存储每条 Message 要用到的数据,也就是state;假如我们给队列新增 5 个数据项,那么页面上就会渲染 5 个 Message,然后我们删掉队列中的某个数据项,那么页面上相应地也要移除那一个 Message——核心思想就是通过数据的增删来实现响应式的更新 UI

为了实现更清晰的逻辑,可以将数据和 UI 分开来处理。

管理数据项

我们来实现addMessage方法,用于向队列中添加每条消息的数据项:

interface messageOption {
  type: messageType;
  message: string;
}

interface messageQueueItem extends messageOption {
  id: string;
}

const MESSAGE_QUEUE: Array<messageQueueItem> = [];

function addMessage(params: messageOption) {
  const id = uuid();
  
  MESSAGE_QUEUE.push({ ...params,id });
}

MESSAGE_QUEUE队列中的每条数据都由type、message组成,为了区分不同的消息,通过uuid这个方法来生成一个独一无二的id然后赋予每条数据。

github 有一个非常好用的 id 生成工具——nanoid,它非常的短小精悍,推荐在实际开发中使用
image.png

但此处我不想引入外部依赖🧐,所以我们来手动实现一个uuid函数,我首先想到的方法是:用正则表达式加上随机数来生成,但再细一想,这个实现起来巨麻烦,你要考虑会不会重复的情况,以及怎样编写合理的正则表达式;所以,还是去 MDN 查一波文档,看看有没有什么 API 能方便的实现这个功能,让我可以偷偷懒 😛。

还真让我找到了
image.png

简单来说,Web Crypto API 用于生成随机密码,常用加密货币领域,看到“随机”和“密码”两个字你想到了什么?没错,这不就是我们需要的功能嘛!

window.crypto.getRandomValues可以获取符合密码学要求的安全的随机值,传入参数的数组被随机值填充,语法是cryptoObj.getRandomValues(typedArray)typedArray 就是Javascript 中的类型数组,如果你玩儿过 Web 3D ,那你一定对类型数组滚瓜烂熟,它可以用来装载字节流直接传输给 GPU 进行图形计算。限于篇幅,这里不做详细介绍,你可以直接去 MDN 了解它的用法,我们只需要将new Unit8Array(length) 作为参数传递给getRandomValues即可得到 crypto 生成的随机字符串,将它用作 Message 数据项的 id

下面是 uuid 方法的实现,关于要生成的随机数的长度,8位无符号整型(Uint8Array)已经足够

function uuid() {
  const uuid = window.crypto.getRandomValues(new Uint8Array(8));

  return uuid.toString().split(",").join("");
}

// uuid(8)  "1914612525112710383190"

uuid(8)将会返回一个最长 24 位的随机数值,这几乎满足我们的要求了。

接下来以同样的方式实现删除数据项的方法removeMessage,非常简单

function removeMessage(id: string) {
  // 根据 id 找到元素,然后移除这个元素
  const position = MESSAGE_QUEUE.findIndex((item) => item.id === id);
  MESSAGE_QUEUE.splice(position, 1);
  renderMessage([...MESSAGE_QUEUE]);
}

创建容器

现在,我们已经完成数据项的准备工作,可以实现增删,接下来就是要把数据队列中的每一项传入BaseMessage组件,然后渲染出多个BaseMessage组件即可。

使用 React.createRoot()将组件挂载到页面上,先来看下这个新增 API 的用法
image.png

createRoot接受container参数作为父容器,然后返回这个父容器的引用root,接下来,使用root.render来将element渲染到父容器中,注意,它会完全替换掉父容器中的内容,也就是说,如果你将document.body作为父容器,然后调用root.render(element),那么body 中的所有内容将被替换为element这一个元素,这是不行的。

所以我们还需要在**body**下新增一个子元素,作为挂载 Message 的父容器,而不能直接以body 作为父容器;实现方法createContainer来创建容器:

const CONTAINER_ID = "wdu-message__container";

function createContainer() {
  // 检查页面上是否已存在容器
  let container = document.getElementById(CONTAINER_ID);

  // 创建一个容器,添加到 body 上
  if (!container) {
    container = document.createElement("div");
    container.setAttribute("id", CONTAINER_ID);
    document.body.appendChild(container);
  }
  
  return container;
}

渲染组件

挂载 Message 的容器已经准备好,接下来实现方法renderMessage用来渲染组件:

import { createRoot } from "react-dom/client";

function renderMessage(messageQueue: Array<any>) {
  // 获取容器
  const container = createContainer();

  // 准备 Message 组件
  const MessageComponents = messageQueue.map((props) => {
    return <BaseMessage {...props} key={props.id} />;
  });

// 挂载组件
  const containerRoot = createRoot(container);
  containerRoot.render(MessageComponents);
}

自动移除组件

最后一步很简单,我们只需要在 Message 组件挂载后,创建一个setTimeout 定时器,3s 后在数据队列中删掉当前消息的数据项,然后再次调用renderMessage 重新渲染视图即可。

这一部分的逻辑由组件自己管理,具体实现如下:

function BaseMessage(props: any) {
  const { type, message, id } = props;

  // 省略一些非重点逻辑
  // ....

  // 组件 DOM 节点
  const refMessage = useRef<any>();

  // 处理消息的隐藏
  const clear = () => removeMessage(id);
  const handleHidden = () => {
    if (refMessage.current) {
      // 在隐藏动画结束后,移除当前组件的数据项
      refMessage.current.addEventListener("animationend", clear, {
        once: true,
      });
    }
    addClassName("hidden");
  };

  // 在组件挂载后,应用显示动画,3s 后执行组件隐藏的逻辑
  useEffect(() => {
    addClassName("visible");

    setTimeout(() => {
      handleHidden();
    }, 3000);
  }, []);

  return (
    <p
      ref={refMessage}
      className={`wdu-message wdu-message__${type} ${classList}`}
    >
      {message}
    </p>
  );
}

成果检验

到此,我们基本上已经完成了全部的代码实现,先给大家看一下最终效果
Message.gif
戳这里去👉演示页面

下面贴一下核心代码:

  • 类型声明
type messageType = "info" | "warning" | "error" | "success";

type messageInput = (message: string) => void;

interface messageOption {
  type: messageType;
  message: string;
}

interface messageQueueItem extends messageOption {
  id: string;
}

interface propsMessage {
  info: messageInput;
  warn: messageInput;
  error: messageInput;
  success: messageInput;
}

export type { propsMessage, messageOption, messageQueueItem };

  • 自定义 Hooks
interface cssClass {
  base: string; // 初始状态的样式类名
  [key: string]: string;
}

function useCssClassManager(cssClassMap: cssClass) {
  const [classMap, setClassMap] = useState<cssClass>({
    base: cssClassMap.base,
  });

  // 动态类名列表
  const [classList, setClassList] = useState("");

  // 移除指定类名
  const removeClassName = (classKey: string) => {
    setClassMap((prev) => {
      const template = { ...prev };
      delete template[classKey];
      return template;
    });
  };

  // 添加指定类名
  const addClassName = (classKey: string) => {
    setClassMap((prev) => ({ ...prev, [classKey]: cssClassMap[classKey] }));
  };

  useEffect(() => {
    setClassList(Object.values(classMap).join(" "));
  }, [classMap]);

  return {
    removeClassName,
    addClassName,
    classList,
  };
}
  • 工具函数
function uuid() {
  const uuid = window.crypto.getRandomValues(new Uint8Array(8));

  return uuid.toString().split(",").join("");
}
  • 组件实现
import { useEffect, useRef } from "react";
import { propsMessage, messageOption, messageQueueItem } from "./type";
import { createRoot } from "react-dom/client";
import { uuid } from "@util";
import { useCssClassManager } from "@base/hooks";

import "./message.less";

const CONTAINER_ID = "wdu-message__container";
const MESSAGE_QUEUE: Array<messageQueueItem> = [];

// 创建容器
function createContainer() {
  let container = document.getElementById(CONTAINER_ID);
  if (!container) {
    container = document.createElement("div");
    container.setAttribute("id", CONTAINER_ID);
    document.body.appendChild(container);
  }
  return container;
}

// 新增消息
function addMessage(params: messageOption) {
  const id = uuid(8);
  MESSAGE_QUEUE.push({ ...params, id });
  renderMessage([...MESSAGE_QUEUE]);
}

// 移除消息
function removeMessage(id: string) {
  const position = MESSAGE_QUEUE.findIndex((item) => item.id === id);
  MESSAGE_QUEUE.splice(position, 1);
  renderMessage([...MESSAGE_QUEUE]);
}

// Message 组件
function BaseMessage(props: any) {
  const { type, message, id } = props;

  const refMessage = useRef<any>();

  const classMap = {
    base: "",
    visible: "wdu-message__visible",
    hidden: "wdu-message__hidden",
  };

  const { addClassName, classList } = useCssClassManager(classMap);

  const clear = () => removeMessage(id);
  const handleHidden = () => {
    if (refMessage.current) {
      refMessage.current.addEventListener("animationend", clear, {
        once: true,
      });
    }
    addClassName("hidden");
  };

  useEffect(() => {
    addClassName("visible");

    setTimeout(() => {
      handleHidden();
    }, 3000);
  }, []);

  return (
    <p
      ref={refMessage}
      className={`wdu-message wdu-message__${type} ${classList}`}
    >
      {message}
    </p>
  );
}

// 组件渲染
let containerRoot: any;
function renderMessage(messageQueue: Array<any>) {
  const container = createContainer();
  if (!containerRoot) {
    containerRoot = createRoot(container);
  }

  const MessageComponents = messageQueue.map((props) => {
    return <BaseMessage {...props} key={props.id} />;
  });

  containerRoot.render(MessageComponents);
}

// 对外导出的 api
const Message: propsMessage = {
  info: (message: string) => addMessage({ type: "info", message }),
  warn: (message: string) => addMessage({ type: "warning", message }),
  error: (message: string) => addMessage({ type: "error", message }),
  success: (message: string) => addMessage({ type: "success", message }),
};

export default Message;

总结

本篇文章向大家介绍了如果一步步地编写一个 Message 消息提示组件,涉及到的知识点或技巧方法有:

  • React.createRoot 的使用
  • React 自定义 hooks 的封装
  • window.crypto API 的简单使用
  • 动态切换类名的实现
  • 如何清晰的分离逻辑,写出单一职责的方法

这是我个人前端项目 wood-ui-react 中的子组件,完整代码请访问 wood-ui-react ,如果觉得不错,可以 start 支持下哦 😜

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容