基于React实现Notification

2,612 阅读6分钟

在这篇文章中,主要讲如何在不借助第三方库的情况只实现Notifaction

需求分析

参考Ant-Design的Notification,期望我们实现的组件有以下功能:

  1. 具有四种风格: info(蓝色),success(绿色),warning(橘黄色)和error(红色)
  2. notification被定位在屏幕的右上角
  3. 在添加和移除的时候都有过场动画。当某个notication被删除时,其他的应该垂直滑动
  4. 可以创建10秒后关闭的通知
  5. 能够在jsx中以<Notification color="success" />方式调用
  6. 也可以通过函数的方式进行调用例如success()

前置工作

这里使用create-react-app来创建工程,然后使用css module来写样式。 下面是具体的代码

create-react-app notify --template typescript

在ts中使用css module需要单独配置一下,先安装typescript-plugin-css-modules

yarn add -D typescript-plugin-css-modules

然后在tsconfig.json中添加以下配置

{
  "compilerOptioins": {
    "plugins": [
      {
        "name": "typescript-plugin-css-modules"
      }
    ]
  }
}

最后还需要在src/types目录下添加global.d.ts

declare module "*.module.scss" {
  const classes: {[key: string]: string}
  export default classes;
}

下面是对应的目录结构


- notify
  - Notification
    - index.module.scss
    - index.tsx
    - times.svg
  - createContainer
    - index.module.scss
    - index.tsx
  - index.ts

Notification Component

下面是核心的notifcation的代码

// notify/Notification/index.js
import React from 'react'
import cn from 'classnames'

import { ReactComponent as Times } from './times.svg'
import styles from './index.module.scss'

export enum Color {
  info = 'info',
  success = 'success',
  warning = 'warning',
  error = 'error',
}

export interface NotificatonProps {
  color?: Color;
}

const Notification: React.FC<NotificatonProps> = ({color = Color.info, children}) {
  return (
    <div className={cn([styles.notification, styles[color]])}>
      {children}
      <button className={styles.closeButton}>
        <Times height={16} />
      </button>
    </div>
  )
}
export default Notification

目前这个Notification接收两个props:

  • color: 决定当前的notification的背景颜色,有四种可选值: info、success、warning、error
  • children: 可以在Notification中渲染的React Element 下面是他的具体样式:
.notification {
  max-width: 430px;
  max-height: 200px;
  overflow: hidden;
  padding: 12px 48px 12px 12px;
  z-index: 99;
  font-weight: bold;
  position: relative;
  color: #fff;

  .closeButton {
    position: absolute;
    top: 50%;
    right: 12px;
    height: 16px;
    transform: translateY(-50%);
    background: transparent;
    padding: 0;
    border: none;
    cursor: pointer;
    color: #fff;
    outline: none;
  }

  &:not(:last-child) {
    margin-bottom: 8px;
  }

  &.info {
    background-color: #2196f3;
  }

  &.success {
    background-color: #4caf50;
  }

  &.warning {
    background-color: #ff9800;
  }

  &.error {
    background-color: #f44336;
  }
}

在DOM中渲染Notification

在业务中使用notification的时候,我们期望这个notfication不会受父元素样式的影响,所以这里使用react portals来脱离dom树,但是不脱离react树,具体可参考你真的了解React Portals吗

createContainer是用于创建notification的容器然后将其添加到body中:

// notify/createContainer/index.js
import styles from './index.module.scss'

export default function createContainer() {
  export default function createContainer(): Element {
  const portaId = "notifyContainer";

  let element = document.querySelector(`#${portaId}`)

  if (element) {
    return element;
  }

  element = document.createElement('div');
  element.setAttribute('id', portaId);
  element.className = styles.container;
  document.body.appendChild(element);
  return element;
}

下面是container的样式

.container {
  position: fixed;
  top: 16px;
  right: 16px;
}

然后修改notification组件,把它渲染在我们创建的容器中

const container = createContainer();

const Notification: React.FC<NotificatonProps> = ({color = Color.info, children}) => {
  return createPortal(
    <div className={cn([styles.notification, styles[color]])}>
      {children}
      <button className={styles.closeButton}>
        <Times height={16} />
      </button>
    </div>,
    container
  )
}

Demo

在编写demo之前,把Notification和类型定义暴露出去,具体如下:

export Notification, { Color } from './Notification'

接下来写一个demo来看各种模式的notificaiton

import React, { useState } from 'react';
import { Notification, Color } from './notify';
import './App.css';

interface NoteInterface {
  id: number;
  color?: Color;
}

function App() {
  const [notifications, setNotifications] = useState<NoteInterface[]>([]);
  const createNotification = (color: Color) => {
    setNotifications([
      ...notifications,
      {
        color,
        id: notifications.length,
      },
    ]);
  };

  return (
    <div className="App">
      <h1>Notification Demo</h1>
      <button onClick={() => createNotification(Color.info)}>Info</button>
      <button onClick={() => createNotification(Color.success)}>Success</button>
      <button onClick={() => createNotification(Color.warning)}>Warning</button>
      <button onClick={() => createNotification(Color.error)}>Error</button>
      {notifications.map(({ id, color }) => {
        <Notification key={id} color={color} />;
      })}
    </div>
  );
}

export default App;

在demo中展示了四种不同主题的notification

关闭Notification

接下来给notification的关闭按钮添加事件用于主动关闭notification:

interface NotificatonProps {
  color?: Color,
  onDelete: Function,
}

const Notification: React.FC<NotificatonProps> = ({ color = Color.info, children, onDelete }) =>
  createPortal(
    <div className={cn([styles.notification, styles[color]])}>
      {children}
      <button onClick={() => onDelete()} className={styles.closeButton}>
        <Times height={16} />
      </button>
    </div>,
    container
  );

export default Notification;

然后在App.tsx中添加onDelete函数用于关闭notification:

function App() {
  const [notifications, setNotifications] = useState<NoteInterface[]>([]);
  const createNotification = (color: Color) => {
    setNotifications([
      ...notifications,
      {
        color,
        id: notifications.length,
      },
    ]);
  };

  const deleteNotification = (id: number) =>
    setNotifications(notifications.filter(notification => notification.id !== id));

  return (
    <div className="App">
      <h1>Notification Demo</h1>
      <button onClick={() => createNotification(Color.info)}>Info</button>
      <button onClick={() => createNotification(Color.success)}>Success</button>
      <button onClick={() => createNotification(Color.warning)}>Warning</button>
      <button onClick={() => createNotification(Color.error)}>Error</button>
      {notifications.map(({ id, color }) => (
        <Notification onDelete={() => deleteNotification(id)} key={id} color={color}>
          This is Notification
        </Notification>
      ))}
    </div>
  );
}

添加渐入渐出动画

在上面notification无论是添加还是删除,动效都非常僵硬。下面添加渐入渐出动画,让这个添加删除过程更加平滑提供用户体验。

在添加动画中,让组件位置从translateX(100%)移动到translateX(0%)

下面是使用keyframes的动画代码:

// notify/Notification/index.module.scss
@keyframes slideIn {
  from {
    transform: translateX(100%)
  }
  to {
    transform: translateX(0)
  }
}

.notification {
  &.slideIn {
    animation-name: slideIn;
    animation-duration: 0.3s;
    animation-timing-function: ease-in-out;
  }
}

移除动画有点棘手,因为如果在关闭的时候如果立即删除DOM,那么在transition是不会生效的。所以当点击删除的时候,添加300ms的延迟,元素从translateX(0%)到translateX(150%)

下面是移除动画的效果css

// notify/Notification/index.module.scss
.notification {
  transition: transform .3s ease-out;

  &.slideOut {
    transform: translateX(150%);
    flex: 0;
  }
}

为了实现关闭的阶段,在组件中我们需要添加一个状态值isClosing,默认是false。当我们点击关闭按钮的时候,把isClosing为true,动画结束以后再调用onDelete函数

需要注意在没有关闭的阶段的时候我们只能使用slideIn动画,当在关闭的时候使用slideOut动画

const Notification: React.FC<NotificatonProps> = ({ color = Color.info, autoClose = false, children, onDelete }) => {
  const [isClosing, setIsClosing] = useState(false);
  useEffect(() => {
    if (isClosing) {
      const timerId = setTimeout(() => setIsClosing(true), timeToDelete);
      return (): void => {
        clearTimeout(timerId);
      };
    }
  }, [isClosing, onDelete]);
  return createPortal(
    <div
      className={cn([
        styles.notification,
        styles[color],
        {
          [styles.slideIn]: !isClosing,
          [styles.slideOut]: isClosing,
        },
      ])}
    >
      {children}
      <button type="button" onClick={(): void => setIsClosing(true)} className={styles.closeButton}>
        <Times height={16} />
      </button>
    </div>,
    container
  );
};

移除动画

当一个notification被移除的时候,下一个的notification需要移动到被删除的notification的位置

为了让这个过程更加顺畅,在关闭阶段给组件添加一个容器用于让收缩更加顺畅

const Notification: React.FC<NotificatonProps> = ({ color = Color.info, autoClose = false, children, onDelete }) => {
  const [isClosing, setIsClosing] = useState(false);
  useEffect(() => {
    if (isClosing) {
      const timerId = setTimeout(() => setIsClosing(true), timeToDelete);
      return () => {
        clearTimeout(timerId);
      };
    }
  }, [isClosing, onDelete]);
  return createPortal(
    <div
      className={cn([
        styles.container,
        {
          [styles.shrink]: isClosing,
        },
      ])}
    >
      <div
        className={cn([
          styles.notification,
          styles[color],
          {
            [styles.slideIn]: !isClosing,
            [styles.slideOut]: isClosing,
          },
        ])}
      >
        {children}
        <button type="button" onClick={(): void => setIsClosing(true)} className={styles.closeButton}>
          <Times height={16} />
        </button>
      </div>
    </div>,
    container
  );
};

这个容器默认max-height为200px然后在移除阶段自动收缩为0px。然后在不同容器之间添加一定的距离:

.container {
  overflow: hidden;
  max-height: 200px;
  transition: max-height .3s ease-out;

  &:not(:last-child) {
    margin-bottom: 8px;
  }

  &.shrink {
    max-height: 0;
  }
}

添加自动关闭功能

下面添加autoClose的props,使用useEffect监听autoClose的变化,当值发生变化时,10秒更改isClosing为true

const timeToClose = 10 * 1000;
 useEffect(() => {
    if (autoClose) {
      const timerId = setTimeout(() => setIsClosing(true), timeToClose);
      return () => {
        clearTimeout(timerId);
      };
    }
  }, [autoClose]);

然后在demo测试一下autoClose

function App() {
  const [notifications, setNotifications] = React.useState([]);

  const createNotification = (color) =>
    setNotifications([...notifications, { color, id: notifications.length }]);

  const deleteNotification = (id) =>
    setNotifications(
      notifications.filter((notification) => notification.id !== id)
    );

  return (
    <div className="App">
      <h1>Notification Demo</h1>
      <button onClick={() => createNotification(Color.info)}>Info</button>
      <button onClick={() => createNotification(Color.success)}>Success</button>
      <button onClick={() => createNotification(Color.warning)}>Warning</button>
      <button onClick={() => createNotification(Color.error)}>Error</button>
      {notifications.map(({ id, color }) => (
        <Notification
          key={id}
          onDelete={() => deleteNotification(id)}
          color={color}
          autoClose={true}
        >
          This is a notification!
        </Notification>
      ))}
    </div>
  );
}

函数方式调用notification

下面添加函数方式调用notificaiton,例如success()或者error()

实现这个效果还是需要把组件渲染在DOM中,但是需要对Notification进行二次封装。

下面创建NotificationsManager来满足这个需求

import React, { useEffect } from 'react';

import Notification, { NotificatonProps } from './notification';

interface Props {
  setNotify(fn: (params: NotificatonProps) => void): void;
}

export default function NotificationsManager(props: Props) {
  const { setNotify } = props;
  const [notifications, setNotifications] = React.useState([]);

  const createNotification = ({ color, autoClose, children }): void => {
    setNotifications(prevNotifications => [
      ...prevNotifications,
      {
        children,
        color,
        autoClose,
        id: prevNotifications.length,
      },
    ]);
  };

  useEffect(() => {
    setNotify(({ color, autoClose, children }) => createNotification({ color, autoClose, children }));
  }, [setNotify]);

  const deleteNotification = (id: number): void => {
    const filteredNotifications = notifications.filter((_, index) => id !== index, []);
    setNotifications(filteredNotifications);
  };

  return (
    <template>
      {notifications.map(({ id, ...props }, index) => (
        <Notification key={id} onDelete={(): void => deleteNotification(index)} {...props} />
      ))}
    </template>
  );
}

在上述代码中,我们使用了notifications数组用来管理notification,遍历数组notifications生成组件。添加和删除实际上都是对这个数组进行操作。

这个函数接收一个函数setNotify,这个函数入参是一个函数这个函数用来添加notification组件入如createNotification。在命令式调用方式notification的时候,只要把这个暴露出去,就可以实现功能了。

下面是具体的代码

// notify/index.tsx

import React from 'react';
import ReactDOM from 'react-dom';

import NotificationsManager from './NotificationsManager';
import Notification, { Color } from './notification';
import createContainer from './createContainer';

const containerElement = createContainer();

let notify;

ReactDOM.render(
  <NotificationsManager
    setNotify={(notifyFn): void => {
      notify = notifyFn;
    }}
  />,
  containerElement
);

export { Notification, Color };

export function info(children, autoClose): () => void {
  return notify({
    color: Color.info,
    children,
    autoClose,
  });
}
// ...

调用notification就可以用以下方式:

info('message', true)

欢迎关注「前端好好学」,前端学习不迷路或加微信 ssdwbobo,一起交流学习