Featured image of post 手撸一个 Message 组件

手撸一个 Message 组件

基于 React 实现

这些天在重构自己的个人主页,想着完全实现一套自己的组件库,刚好碰到了 Message 组件,分享一下我的实现

组件 API 及需求分析

目前市面上绝大多数组件库如 AntdArco 等所使用的 Message 组件的调用方式基本上都是形似

1
message.success("text", 1000);

这样直接的函数调用,而不用像 Modal 组件那样需要在 JSX 中声明节点,十分方便,因此我们的实现也需要使用这样的 API 调用。

其次,Message 组件的显示形式是在页面顶部排列,新的消息框会在旧的消息框下方显示,旧的消息框会在指定时间(delay)后销毁

实现思路

我们调用 Message 组件时并不需要声明节点,因此我们需要实现动态挂载节点。

这个需求可以使用 ReactDom 为我们提供的 createRoot API 实现,方式如下:

1
2
3
4
5
6
const root = document.getElementById("root")!;
const el = document.createElement("div");
root.appendChild(el);

const container = creatRoot(el);
wrapper.render(<MessageContainer />);

在这个过程中,我们在 root 节点下新创建了一个 el 节点,并将 <MessageContainer /> 组件挂载到 el 节点下,我们将这个组件作为 Message 的容器,在其内部维护一个 Message List,使用 map 渲染所有组件,就可以基本上实现我们的需求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const MessageContainer: FC = (): ReactElement => {
  const [notices, setNotices] = useState<IMessage[]>([]);
  /* ... */
  return (
    <div className="message-container">
      {notices.map(({ text, key, type }) => (
        <Notice type={type} text={text} />
      ))}
    </div>
  );
};

然后我们需要实现在函数组件外部调用其内部的添加方法,在外部声明一个 add 函数,在函数内对其赋值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
let add: (message: IMessage) => void;

const MessageContainer: FC = (): ReactElement => {
  const [notices, setNotices] = useState<IMessage[]>([]);

  /* ... */
  add = (message: IMessage) => {
    setNotices(prevNotices => [...prevNotices, message]);
    setTimeout(() => {
      remove(message);
    }, timeout);
  };
  /* ... */

  return (
    <div className="message-container">
      {notices.map(({ text, key, type }) => (
        <Notice type={type} text={text} />
      ))}
    </div>
  );
};

这样就可以在函数外部调用 add 方法,改变其内部的状态了,当然,我们还需要能够在指定时间后移除单个消息,我们需要为每一个消息分配一个 key 值,以区分不同的消息,remove 函数如下:

1
2
3
4
const remove = (message: IMessage) => {
  const { key } = message;
  setNotices(prevNotices => prevNotices.filter(item => key !== item.key));
};

那么添加消息时,我们的调用接口就是如下所示:

1
2
let key = 0;
add({ text, key: key++, type: "success" });

如果我们还需要实现超过指定长度后自动删除前面的消息,则就可以在 MessageContainer 组件中添加一个 useEffect 来处理,实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const MessageContainer: FC = (): ReactElement => {
  const [notices, setNotices] = useState<IMessage[]>([]);

  /* ... */
  useEffect(() => {
    if (notices.length > maxCount) {
      const [firstNotice] = notices;
      remove(firstNotice);
    }
  }, [notices]);
  /* ... */

  return (
    <div className="message-container">
      {notices.map(({ text, key, type }) => (
        <Notice type={type} text={text} />
      ))}
    </div>
  );
};

最后我们使用一个类(class)来封装整个组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// index.tsx
export class Message {
  private static el = document.getElementById("message-wrapper");
  private static timeout = 3 * 1000;
  private static maxCount = 10;
  private static key = 0;
  private static add: (message: IMessage) => void;

  private static MessageContainer() {
    /* ... */
  }

  public static success(text: string) {
    Message.add({ text, key: Message.key++, type: "success" });
  }
  public static error(text: string) {
    Message.add({ text, key: Message.key++, type: "error" });
  }
  public static info(text: string) {
    Message.add({ text, key: Message.key++, type: "info" });
  }
}

这样一来,我们就可以在外部使用 Message.xxxx("some text") 来调用组件了

但是这样还不够,在第一次调用这个组件时,add 方法是没有值的,因此我们需要为其处理首次调用时的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private static add = (message: IMessage): void => {
  if (!Message.el) {
    Message.el = document.createElement("div")
    Message.el.className = "message-wrapper"
    Message.el.id = "message-wrapper"
    root.appendChild(Message.el)
    const _root = createRoot(Message.el)
    _root.render(<Message.MessageContainer />)
    setTimeout(() => Message.add(message))
  }
}

不存在节点时,先创建节点,等待容器组件渲染完成,修改完 add 方法后再调用自身,这里需要使用 setTimeout 将这一步放到宏任务队列中,使得其在渲染完成后再执行。当然你也可以用 callback 实现

相关资料: 事件循环:微任务和宏任务 > React 18 用 createRoot 替换 render - 什么是渲染回调?

这样一来就可以实现第一次调用时自动注册节点并重新执行了

关于动画的实现

我们希望节点在添加和删除时显示动画,在 Vue 中我们可以直接使用 Transition 组件,在 React 中则没有原生实现,需要使用第三方库,其中 React Transition Group 就满足了其所有基本需求,原理大致是延迟 DOM 的删除时间,并在指定的时间节点动态添加 CSS 样式,我会在接下来的文章中实现一个基础版本的 Transition 组件

关于组件的使用我不多赘述,大体实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
return (
  <div className="message-container">
    <TransitionGroup>
      {notices.map(({ text, key, type }) => (
        <CSSTransition
          classNames="message"
          timeout={150}
          unmountOnExit={true}
          appear={true}
          key={key}
        >
          <Notice type={type} text={text} />
        </CSSTransition>
      ))}
    </TransitionGroup>
  </div>
);
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* styles.css */
/* 出现前 */
.message-enter,
.message-appear {
  opacity: 0;
  transform: translateY(-50%);
}

/* 出现时 */
.message-enter-active,
.message-appear-active {
  opacity: 100%;
  transform: translateY(0);
  transition: all 150ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

/* 出现后、退出前 */
.message-enter-done,
.message-appear-done,
.message-exit {
  opacity: 100%;
  transform: translateY(0);
}

/* 退出时 */
.message-exit-active {
  opacity: 0;
  transform: translateY(-50%);
  transition: all 150ms cubic-bezier(0.68, -0.55, 0.27, 1.55);
}

这样就可以实现丝滑的弹出动画啦

演示demo

Built with Hugo
主题 StackJimmy 设计