Concis组件库封装—— Notification通知提醒框

977 阅读7分钟

您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

演示

提醒框是一个很常用的交互组件,效果如下:

在这里插入图片描述 触发某事件后出现提示信息达到反馈效果,与前文Message效果类似。

组件库文档

组件库文档页面如下:

在这里插入图片描述 在这里插入图片描述 在这里插入图片描述 文档中可以看到,组件封装中提供了不同位置、不同类型、自定义操作按钮、自定义样式的支持,让组件更加灵活,同时提供了如下的API能力:

在这里插入图片描述

调用方式 & 封装思路

代码如下:

Notification.info({
  title: 'Notification',
  content: 'this is a Notification!',
  duration: 3000,
});

因为是暴露给用户一个函数,因此组件的设计其实也是在监听到调用时,转入到组件内部自定义函数进行调用:

Notification.info = (props: string | NotificationProps<string>) => {
  return addInstance('info', props);
};
Notification.success = (props: string | NotificationProps<string>) => {
  return addInstance('success', props);
};
Notification.error = (props: string | NotificationProps<string>) => {
  return addInstance('error', props);
};
Notification.normal = (props: string | NotificationProps<string>) => {
  return addInstance('normal', props);
};
Notification.warning = (props: string | NotificationProps<string>) => {
  return addInstance('warning', props);
};
Notification.loading = (props: string | NotificationProps<string>) => {
  return addInstance('loading', props);
};

export default Notification;

可以看到,其实就是暴露出去了6个函数,再看一下addInstance函数吧:

//添加消息窗口
function addInstance(
  type: 'info' | 'success' | 'warning' | 'error' | 'normal' | 'loading',
  props: string | NotificationProps<string>,
) {
  let style: CSSProperties = {},
    duration: number = 3000,
    title: string = '',
    content: string = '',
    position: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' = 'topRight',
    clearable: boolean = false,
    showFooter: boolean = false,
    footerBtnVal: footerBtnVal = {
      enter: 'OK',
      exit: 'Cancel',
    },
    doneCallback: Function | undefined;
  if (typeof props === 'object') {
    title = props.title;
    style = props.style || {};
    duration = props.duration || 3000;
    content = props.content as string;
    doneCallback = props.doneCallback;
    if (!props.position) {
      position = 'topRight';
    } else {
      position = props.position;
    }
    clearable = props.clearable ? props.clearable : false;
    showFooter = props.showFooter ? props.showFooter : false;
    if (props.footerBtnVal) {
      footerBtnVal = props.footerBtnVal as footerBtnVal;
    }
  } else if (typeof props === 'string') {
    content = props;
  }
  const div = document.createElement('div');
  const messageBoxId = String(Math.floor(Math.random() * 1000));
  div.setAttribute('class', `${position}-${messageBoxId}`);
  if (container) {
    container.appendChild(div);
  } else {
    container = document.createElement('div');
    container.setAttribute('class', 'notification-container');
    document.body.appendChild(container);
    container.appendChild(div);
  }
  setTimeout(() => {
    if (Array.prototype.slice.call(container?.childNodes).includes(div)) {
      changeHeight(Array.prototype.slice.call(container?.childNodes), position);
      container?.removeChild(div);
      if (position === 'topLeft') {
        topLeftMessageNum--;
      } else if (position === 'topRight') {
        topRightMessageNum--;
      } else if (position === 'bottomLeft') {
        bottomLeftMessageNum--;
      } else if (position === 'bottomRight') {
        bottomRightMessageNum--;
      }
    }
  }, duration + 200);
  //挂载组件
  ReactDOM.render(
    <Notification
      title={title}
      style={style}
      content={content}
      type={type}
      duration={duration}
      position={position}
      clearable={clearable}
      showFooter={showFooter}
      footerBtnVal={footerBtnVal}
      doneCallback={doneCallback}
      messageBoxId={messageBoxId}
    />,
    div,
  );
}

函数功能分两步:

  1. 收集传入对象参数,作为props备用,处理初始化值;
  2. 创建一个全局dom,用来存放所有的通知提醒框,插入到这个容器中,并赋予一个独特的id,以便于后期删除;

在最后,ReactDOM.render挂载了这个组件,做到这一步,其实点击后的交互就出来了。

Notification组件代码如下:

const Notification = (props: NotificationProps<string>) => {
  const {
    style,
    title,
    content,
    type,
    duration,
    position,
    clearable,
    showFooter,
    footerBtnVal,
    doneCallback,
    messageBoxId,
  } = props;
  const [opac, setOpac] = useState(1);
  const messageDom = useRef<any>(null);

  useEffect(() => {
    if (position === 'topLeft') {
      topLeftMessageNum++;
    } else if (position === 'topRight') {
      topRightMessageNum++;
    } else if (position === 'bottomLeft') {
      bottomLeftMessageNum++;
    } else if (position === 'bottomRight') {
      bottomRightMessageNum++;
    }
    setTimeout(() => {
      (messageDom.current as HTMLElement).style.transition = '0.2s linear';
      (messageDom.current as HTMLElement).style.animation = 'none';
    }, 500);
    setTimeout(() => {
      setOpac(0);
    }, duration);
  }, []);

  useEffect(() => {
    let transform;
    if (position?.startsWith('top')) {
      transform = 'top';
    } else {
      transform = 'bottom';
    }
    let defaultHeight = 0;
    let avaHeight;
    if (position === 'topLeft' && topLeftMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (topLeftMessageNum - 1);
      avaHeight = topLeftMessageNum;
    } else if (position === 'topRight' && topRightMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (topRightMessageNum - 1);
      avaHeight = topRightMessageNum;
    } else if (position === 'bottomLeft' && bottomLeftMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (bottomLeftMessageNum - 1);
      avaHeight = bottomLeftMessageNum;
    } else if (position === 'bottomRight' && bottomRightMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (bottomRightMessageNum - 1);
      avaHeight = bottomRightMessageNum;
    }
    (messageDom?.current as HTMLElement).style[transform as 'top' | 'bottom'] =
      (avaHeight as number) * 30 + defaultHeight + 'px';
  }, [topLeftMessageNum, topRightMessageNum, bottomLeftMessageNum, bottomRightMessageNum]);

  const messageIcon = useMemo(() => {
    if (type === 'info') {
      return <ExclamationCircleFilled style={{ color: '#1890ff', fontSize: '24px' }} />;
    } else if (type === 'error') {
      return <CloseCircleFilled style={{ color: '#f53f3f', fontSize: '24px' }} />;
    } else if (type === 'normal') {
      return <></>;
    } else if (type === 'success') {
      return <CheckCircleFilled style={{ color: '#19b42a', fontSize: '24px' }} />;
    } else if (type === 'warning') {
      return <ExclamationCircleFilled style={{ color: '#fa7d00', fontSize: '24px' }} />;
    } else if (type === 'loading') {
      return <LoadingOutlined style={{ color: '#1890ff', fontSize: '24px' }} />;
    }
  }, [type]);

  const messageXtransform = useMemo(() => {
    //提示框水平位置,居左/居右
    if (position?.includes('Left')) {
      return {
        left: '20px',
      };
    } else {
      return {
        right: '20px',
      };
    }
  }, [position]);
  const closeMessage = () => {
    //close按钮关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(1);
    });
  };
  const enter = () => {
    //确认关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(2);
    });
  };
  const exit = () => {
    //取消关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(3);
    });
  };

  return (
    <div
      className="notifica-container"
      style={{ opacity: opac, ...messageXtransform, ...style }}
      ref={messageDom}
    >
      <div className="title">
        <div className="title-left">
          {messageIcon}
          <span className="title-content">{title}</span>
        </div>
        {clearable && <CloseOutlined className="close-icon" onClick={closeMessage} />}
      </div>
      <div className="notification-content">{content}</div>
      {showFooter && (
        <div className="notification-footer">
          <div></div>
          <div>
            <Button type="text" height={30} handleClick={enter}>
              {(footerBtnVal as footerBtnVal).exit}
            </Button>
            <Button type="primary" height={30} style={{ marginLeft: '15px' }} handleClick={exit}>
              {(footerBtnVal as footerBtnVal).enter}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
};

组件中的代码主要做了一些样式设计,可以看到组件中共有三个事件:

  • enter
  • exit
  • closeMessage

在其中,都调用了move方法,而move方法并不在组件中,原因很简单,move其实像所有组件的父亲一样,用于全局控制组件销毁后页面上dom节点的删除,看一下move方法:

//移除窗口
function remove(id: string, position: string, callback: Function) {
  const container = document.querySelector('.notification-container');
  const children = Array.prototype.slice.call(container?.childNodes);
  for (let key in children) {
    if (children[key].getAttribute('class') === `${position}-${id}`) {
      const removeDom = children[key];
      console.log(removeDom.childNodes);
      removeDom.childNodes[0].style.opacity = 0;
      setTimeout(() => {
        container?.removeChild(removeDom);
      }, 50);

      if (position === 'topLeft') {
        topLeftMessageNum--;
      } else if (position === 'topRight') {
        topRightMessageNum--;
      } else if (position === 'bottomLeft') {
        bottomLeftMessageNum--;
      } else if (position === 'bottomRight') {
        bottomRightMessageNum--;
      }
      changeHeight(children.slice(Number(key)), position);
      callback();
    }
  }
}

根据我们最早挂载的id唯一标识,去查找这样标识的dom节点,删除,并把对应位置的计数减1之后调用changeHeight方法重排手动删除元素下的元素位置(让他们顶上去,不留空隙)

changeHeight方法如下:

//重排节点下窗口高度
function changeHeight(children: Array<HTMLElement>, position: any) {
  const transform = position.startsWith('top') ? 'top' : 'bottom';
  for (let key in children) {
    const child = children[key].childNodes[0] as HTMLElement;
    if (children[key].getAttribute('class')?.startsWith(transform)) {
      const domHeight = document.querySelector('.notifica-container')?.clientHeight;
      child.style[transform] =
        Number(child.style[transform].split('p')[0]) - 30 - (domHeight as number) + 'px';
    }
  }
}

组件完整源码

import React, { useState, useEffect, useMemo, useRef, CSSProperties } from 'react';
import ReactDOM from 'react-dom';
import { NotificationProps, footerBtnVal } from './interface';
import Button from '../Button';
import './index.module.less';
import {
  ExclamationCircleFilled,
  CheckCircleFilled,
  CloseCircleFilled,
  LoadingOutlined,
  CloseOutlined,
} from '@ant-design/icons';

let container: HTMLDivElement | null;
let topLeftMessageNum: number = 0;
let topRightMessageNum: number = 0;
let bottomLeftMessageNum: number = 0;
let bottomRightMessageNum: number = 0;

//添加消息窗口
function addInstance(
  type: 'info' | 'success' | 'warning' | 'error' | 'normal' | 'loading',
  props: string | NotificationProps<string>,
) {
  let style: CSSProperties = {},
    duration: number = 3000,
    title: string = '',
    content: string = '',
    position: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' = 'topRight',
    clearable: boolean = false,
    showFooter: boolean = false,
    footerBtnVal: footerBtnVal = {
      enter: 'OK',
      exit: 'Cancel',
    },
    doneCallback: Function | undefined;
  if (typeof props === 'object') {
    title = props.title;
    style = props.style || {};
    duration = props.duration || 3000;
    content = props.content as string;
    doneCallback = props.doneCallback;
    if (!props.position) {
      position = 'topRight';
    } else {
      position = props.position;
    }
    clearable = props.clearable ? props.clearable : false;
    showFooter = props.showFooter ? props.showFooter : false;
    if (props.footerBtnVal) {
      footerBtnVal = props.footerBtnVal as footerBtnVal;
    }
  } else if (typeof props === 'string') {
    content = props;
  }
  const div = document.createElement('div');
  const messageBoxId = String(Math.floor(Math.random() * 1000));
  div.setAttribute('class', `${position}-${messageBoxId}`);
  if (container) {
    container.appendChild(div);
  } else {
    container = document.createElement('div');
    container.setAttribute('class', 'notification-container');
    document.body.appendChild(container);
    container.appendChild(div);
  }
  setTimeout(() => {
    if (Array.prototype.slice.call(container?.childNodes).includes(div)) {
      changeHeight(Array.prototype.slice.call(container?.childNodes), position);
      container?.removeChild(div);
      if (position === 'topLeft') {
        topLeftMessageNum--;
      } else if (position === 'topRight') {
        topRightMessageNum--;
      } else if (position === 'bottomLeft') {
        bottomLeftMessageNum--;
      } else if (position === 'bottomRight') {
        bottomRightMessageNum--;
      }
    }
  }, duration + 200);
  //挂载组件
  ReactDOM.render(
    <Notification
      title={title}
      style={style}
      content={content}
      type={type}
      duration={duration}
      position={position}
      clearable={clearable}
      showFooter={showFooter}
      footerBtnVal={footerBtnVal}
      doneCallback={doneCallback}
      messageBoxId={messageBoxId}
    />,
    div,
  );
}
//移除窗口
function remove(id: string, position: string, callback: Function) {
  const container = document.querySelector('.notification-container');
  const children = Array.prototype.slice.call(container?.childNodes);
  for (let key in children) {
    if (children[key].getAttribute('class') === `${position}-${id}`) {
      const removeDom = children[key];
      console.log(removeDom.childNodes);
      removeDom.childNodes[0].style.opacity = 0;
      setTimeout(() => {
        container?.removeChild(removeDom);
      }, 50);

      if (position === 'topLeft') {
        topLeftMessageNum--;
      } else if (position === 'topRight') {
        topRightMessageNum--;
      } else if (position === 'bottomLeft') {
        bottomLeftMessageNum--;
      } else if (position === 'bottomRight') {
        bottomRightMessageNum--;
      }
      changeHeight(children.slice(Number(key)), position);
      callback();
    }
  }
}
//重排节点下窗口高度
function changeHeight(children: Array<HTMLElement>, position: any) {
  const transform = position.startsWith('top') ? 'top' : 'bottom';
  for (let key in children) {
    const child = children[key].childNodes[0] as HTMLElement;
    if (children[key].getAttribute('class')?.startsWith(transform)) {
      const domHeight = document.querySelector('.notifica-container')?.clientHeight;
      child.style[transform] =
        Number(child.style[transform].split('p')[0]) - 30 - (domHeight as number) + 'px';
    }
  }
}
const Notification = (props: NotificationProps<string>) => {
  const {
    style,
    title,
    content,
    type,
    duration,
    position,
    clearable,
    showFooter,
    footerBtnVal,
    doneCallback,
    messageBoxId,
  } = props;
  const [opac, setOpac] = useState(1);
  const messageDom = useRef<any>(null);

  useEffect(() => {
    if (position === 'topLeft') {
      topLeftMessageNum++;
    } else if (position === 'topRight') {
      topRightMessageNum++;
    } else if (position === 'bottomLeft') {
      bottomLeftMessageNum++;
    } else if (position === 'bottomRight') {
      bottomRightMessageNum++;
    }
    setTimeout(() => {
      (messageDom.current as HTMLElement).style.transition = '0.2s linear';
      (messageDom.current as HTMLElement).style.animation = 'none';
    }, 500);
    setTimeout(() => {
      setOpac(0);
    }, duration);
  }, []);

  useEffect(() => {
    let transform;
    if (position?.startsWith('top')) {
      transform = 'top';
    } else {
      transform = 'bottom';
    }
    let defaultHeight = 0;
    let avaHeight;
    if (position === 'topLeft' && topLeftMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (topLeftMessageNum - 1);
      avaHeight = topLeftMessageNum;
    } else if (position === 'topRight' && topRightMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (topRightMessageNum - 1);
      avaHeight = topRightMessageNum;
    } else if (position === 'bottomLeft' && bottomLeftMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (bottomLeftMessageNum - 1);
      avaHeight = bottomLeftMessageNum;
    } else if (position === 'bottomRight' && bottomRightMessageNum >= 1) {
      defaultHeight = messageDom.current.clientHeight * (bottomRightMessageNum - 1);
      avaHeight = bottomRightMessageNum;
    }
    (messageDom?.current as HTMLElement).style[transform as 'top' | 'bottom'] =
      (avaHeight as number) * 30 + defaultHeight + 'px';
  }, [topLeftMessageNum, topRightMessageNum, bottomLeftMessageNum, bottomRightMessageNum]);

  const messageIcon = useMemo(() => {
    if (type === 'info') {
      return <ExclamationCircleFilled style={{ color: '#1890ff', fontSize: '24px' }} />;
    } else if (type === 'error') {
      return <CloseCircleFilled style={{ color: '#f53f3f', fontSize: '24px' }} />;
    } else if (type === 'normal') {
      return <></>;
    } else if (type === 'success') {
      return <CheckCircleFilled style={{ color: '#19b42a', fontSize: '24px' }} />;
    } else if (type === 'warning') {
      return <ExclamationCircleFilled style={{ color: '#fa7d00', fontSize: '24px' }} />;
    } else if (type === 'loading') {
      return <LoadingOutlined style={{ color: '#1890ff', fontSize: '24px' }} />;
    }
  }, [type]);

  const messageXtransform = useMemo(() => {
    //提示框水平位置,居左/居右
    if (position?.includes('Left')) {
      return {
        left: '20px',
      };
    } else {
      return {
        right: '20px',
      };
    }
  }, [position]);
  const closeMessage = () => {
    //close按钮关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(1);
    });
  };
  const enter = () => {
    //确认关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(2);
    });
  };
  const exit = () => {
    //取消关闭
    remove(messageBoxId as string, position as string, () => {
      doneCallback && doneCallback(3);
    });
  };

  return (
    <div
      className="notifica-container"
      style={{ opacity: opac, ...messageXtransform, ...style }}
      ref={messageDom}
    >
      <div className="title">
        <div className="title-left">
          {messageIcon}
          <span className="title-content">{title}</span>
        </div>
        {clearable && <CloseOutlined className="close-icon" onClick={closeMessage} />}
      </div>
      <div className="notification-content">{content}</div>
      {showFooter && (
        <div className="notification-footer">
          <div></div>
          <div>
            <Button type="text" height={30} handleClick={enter}>
              {(footerBtnVal as footerBtnVal).exit}
            </Button>
            <Button type="primary" height={30} style={{ marginLeft: '15px' }} handleClick={exit}>
              {(footerBtnVal as footerBtnVal).enter}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
};

Notification.info = (props: string | NotificationProps<string>) => {
  return addInstance('info', props);
};
Notification.success = (props: string | NotificationProps<string>) => {
  return addInstance('success', props);
};
Notification.error = (props: string | NotificationProps<string>) => {
  return addInstance('error', props);
};
Notification.normal = (props: string | NotificationProps<string>) => {
  return addInstance('normal', props);
};
Notification.warning = (props: string | NotificationProps<string>) => {
  return addInstance('warning', props);
};
Notification.loading = (props: string | NotificationProps<string>) => {
  return addInstance('loading', props);
};

export default Notification;

组件测试

组件jest测试代码如下:

import React from 'react';
import Notification from '../../Notification/index';
import Enzyme from '../setup';
import mountTest from '../mountTest';

const { mount } = Enzyme;

describe('Notification', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.runAllTimers();
  });

  it('test base Notification show correctly', () => {
    //基础测试
    Notification.info({
      title: 'Notification',
      content: 'test',
      duration: 3000,
    });
    expect(document.querySelectorAll('.notification-container')).toHaveLength(1);
    expect(document.querySelectorAll('.notifica-container .title-content')[0].innerHTML).toBe(
      'Notification',
    );
    expect(
      document.querySelectorAll('.notifica-container .notification-content')[0].innerHTML,
    ).toBe('test');
  });

  it('test click five nums Notification show num correctly', () => {
    //测试多次渲染
    for (let i = 0; i < 5; i++) {
      Notification.info({
        title: 'Notification',
        content: 'test',
        duration: 3000,
      });
    }
    expect(document.querySelectorAll('.notification-container')[0].childNodes.length).toBe(5);
  });

  it('test four transform Notification show correctly', async () => {
    //测试不同方向
    const transforms = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'];
    for (let i = 0; i < transforms.length; i++) {
      const transform = transforms[i] as 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
      Notification.info({
        title: 'Notification',
        content: 'test',
        duration: 3000,
        position: transform,
      });
      setTimeout(() => {
        const dom = document.querySelectorAll('.notifica-container')[i];
        switch (i) {
          case 0:
            expect(
              dom.getAttribute('style')?.includes('top') &&
                dom.getAttribute('style')?.includes('left'),
            ).toBe(true);
            break;
          case 1:
            expect(
              dom.getAttribute('style')?.includes('top') &&
                dom.getAttribute('style')?.includes('right'),
            ).toBe(true);
            break;
          case 2:
            expect(
              dom.getAttribute('style')?.includes('bottom') &&
                dom.getAttribute('style')?.includes('left'),
            ).toBe(true);
            break;
          case 3:
            expect(
              dom.getAttribute('style')?.includes('bottom') &&
                dom.getAttribute('style')?.includes('right'),
            ).toBe(true);
            break;
        }
      }, 500);
    }
  });

  it('test footer Notification show correctly', () => {
    //测试自定义按钮
    const mockFn = jest.fn();
    Notification.info({
      title: 'Notification',
      content: 'test',
      duration: 10000,
      clearable: true,
      showFooter: true,
      footerBtnVal: {
        enter: '确认',
        exit: '取消',
      },
      doneCallback: mockFn,
    });
    expect(document.querySelector('.notifica-container .title')?.childNodes.length).toBe(2);
    expect(document.querySelector('.notifica-container .notification-footer')).toBeDefined();
    expect(
      document.querySelector('.notifica-container .notification-footer .text')?.innerHTML,
    ).toBe('取消');
    expect(
      document.querySelector('.notifica-container .notification-footer .primary')?.innerHTML,
    ).toBe('确认');
  });

  it('test setting style Notification correctly', () => {
    //测试自定义样式
    Notification.info({
      title: 'Notification',
      content: 'test',
      duration: 3000,
      style: { width: '500px', fontSize: '15px' },
    });
    expect(
      document
        .querySelector('.notifica-container')
        ?.getAttribute('style')
        ?.includes('width: 500px, font-size: 15px'),
    );
  });
});

主要测试了文档中所有的功能,文档地址在下面~~

组件库地址

开源不易,欢迎学习和体验,喜欢请多多支持,有问题请留言。