Modal组件

110 阅读4分钟

创建一个简单的modal组件,可通过组件方式和函数方式调用

核心

为什么要放在body下?

  1. 浮层组件(悬浮在其他组件上的,例如modal、message等反馈组件),如果其祖先元素有overflow:hidden ,就容易被遮盖;或者z-index层级不够大,就容易被覆盖(z-index和父元素的index也有关),所以,通常放在document.body下。
  2. 解决方法:使用createPortal(要被包裹的节点,实际在dom上包裹的节点)
  • ”传送门“:一般来讲,虚拟dom(react纤维)与dom节点的数据结构是对应的,但是悬浮就要求虚拟dom与真实dom无法一一对应:如果两个组件是父子对应关系,那么它们的dom节点也是对应的父子关系。而我们要打打破这种关系,就有了portal:将一个组件,传送到另一个节点中去(只是在dom节点中保持这种关系,但是在虚拟dom中,父子关系还是原来的父子关系)

image.png

import React, { useLayoutEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { IPortalProps } from './interface';

export const Portal: React.FC<IPortalProps> = (props) => {
  const { open, children } = props;
  const container = useMemo(() => {
    return document.createElement('div');
  }, []);
  function appendDiv() {
    if (!container.parentElement) {
      document.body.appendChild(container);
    }
  }
  function removeDiv() {
    if (container.parentElement) {
      document.body.removeChild(container);
    }
  }
  useLayoutEffect(() => {
    if (open) {
      appendDiv();
    } else {
      removeDiv();
    }
    return removeDiv;
  }, [open]);
  return createPortal(children, container);//这样就有一个Portal组件,将子组件传送到container中,但在虚拟dom中还是处于其原父组件
};

  • 使用createRoot 在浏览器的 DOM 节点中创建根节点以显示 React 组件,创建一个react的渲染节点,这个createRoot(div)返回一个节点对象,上面有两个方法,render将组件渲染到该节点上, unmounted将该节点从dom树上卸载,这个方法主要是将组件渲染到指定的dom里面(这个dom是一个外部的dom)

  • 好处:弹出层不会受到祖先元素的样式的影响

下面是一个modal组件: index.tsx


import { info } from './info';
import { default as MyModal } from './modal';

const Modal: typeof MyModal & { info: typeof info } = MyModal as any;
Modal.info = info;
export default Modal;

info.tsx

import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { IModalProps } from './interface';
import Modal from './modal';

interface Iprops extends Partial<IModalProps> {
  afterClose?: () => void;
}
const MARK = 'react-render-root';
function reactRender(
  node: React.ReactNode,
  container: {
    docu: DocumentFragment;
    [MARK]: Root | null;
  },
) {
  if (!container[MARK]) {
    container[MARK] = createRoot(container.docu);
  }
  container[MARK]?.render(node);
}

export function info(config: Iprops) {
  const container: { docu: DocumentFragment; [MARK]: null | Root } = {
    docu: document.createDocumentFragment() as DocumentFragment,
    [MARK]: null,
  };
  //代码片段中

  let timeoutId: ReturnType<typeof setTimeout>;

  function render(props: any) {
    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      const { close, children, ...rest } = props;

      reactRender(
        rest.open ? (
          <Modal
            onCancel={() => {
              close();
            }}
            onOk={() => {
              close();
            }}
            cancelText={null}
            {...rest}
          >
            {children}
          </Modal>
        ) : null,
        container,
      );
    });
  }
  let currentConfig = { ...config, open: true } as any;
  function destroy() {
    config.onCancel?.();
    container[MARK]?.unmount();
  }
  function close() {
    currentConfig = {
      ...currentConfig,
      open: false,
      close: () => {
        destroy();
      },
    };
    render(currentConfig);
  }
  currentConfig.close = close;

  render(currentConfig);
  return {
    close,
  };
}


modal.tsx

import React, { FC, useEffect, useMemo, useRef } from 'react';

import { IModalProps } from './interface';
import { Portal } from './portal';

import './index.scss';

const Modal: FC<Partial<IModalProps>> = (props) => {
  const {
    open = true,
    mask = true,
    title = '提示',
    cancelText = '取消',
    okText = '确定',
    closable = true,
    footer,
    onOk = () => {},
    onCancel = () => {},
    maskClosable = true,
    children,
    width,
    classNames,
  } = props;
  const dom = useRef(null);

  const myFooter = useMemo(() => {
    if (footer === null) return null;
    if (footer === undefined)
      return (
        <div className="modal_footer">
          {cancelText === null ? null : typeof cancelText === 'string' ? (
            <div className="modal_cancel modal_button_bx" onClick={onCancel}>
              {cancelText}
            </div>
          ) : (
            cancelText
          )}
          {okText === null ? null : typeof okText === 'string' ? (
            <div className="modal_ok modal_button_bx" onClick={onOk}>
              {okText}
            </div>
          ) : (
            okText
          )}
        </div>
      );
    return footer;
  }, [footer, cancelText, okText]);

  const myTitle = useMemo(() => {
    if (title === null) return null;
    if (typeof title === 'string') {
      return (
        <div className="modal_header">
          <div className="modal_title">
            <span>{title}</span>
            <span className=" close"></span>
          </div>
          {closable && (
            <div className="modal_close close" onClick={onCancel}></div>
          )}
        </div>
      );
    }

    return title;
  }, [title, closable, mask, maskClosable]);
  const maskOnClick = useMemo(() => {
    return mask && maskClosable
      ? (e: any) => {
          const isClickWrapper = dom.current === e?.target;
          if (isClickWrapper) {
            onCancel(e);
          }
        }
      : () => {};
  }, [mask, mask, onCancel]);

  const wrapperClassName = (mask ? 'mask' : '') + ' zh_modal ' + (classNames||'');
  const w =
    width !== undefined
      ? isNaN(Number(width))
        ? width
        : width + 'px'
      : undefined;

  return (
    <Portal open={open}>
      <div className={wrapperClassName} onClick={maskOnClick} ref={dom}>
        <div className="modal_wrapper" style={{ width: w }}>
          {/* 头部 */}
          {myTitle}
          <div className="modal_content">{children}</div>
          {myFooter}
        </div>
      </div>
    </Portal>
  );
};

export default Modal;


portal.tsx

import React, { useLayoutEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { IPortalProps } from './interface';

export const Portal: React.FC<IPortalProps> = (props) => {
  const { open, children } = props;
  const container = useMemo(() => {
    return document.createElement('div');
  }, []);
  function appendDiv() {
    if (!container.parentElement) {
      document.body.appendChild(container);
    }
  }
  function removeDiv() {
    if (container.parentElement) {
      document.body.removeChild(container);
    }
  }
  useLayoutEffect(() => {
    if (open) {
      appendDiv();
    } else {
      removeDiv();
    }
    return removeDiv;
  }, [open]);

  return createPortal(children, container);
};



index.scss

.zh_modal {
	position: fixed;
	top: 0;
	bottom: 0;
	left: 0;
	right: 0;
	font-size: 16px;
	font-family: PingFang SC, PingFang SC-Regular;
	text-align: center;
	color: #333b56;
	z-index: 9999;

	&.mask {
		background-color: rgba(0, 0, 0, 0.45);
	}

	.modal_wrapper {
		border-radius: 8px;
		position: absolute;
		background-color: white;
		top: 25%;
		left: 50%;
		transform: translate(-50%,-50%);
		width: 500px;
		box-shadow: 0px 6px 18px 0px rgba(0,0,0,0.13);
		.modal_header {
			width: 100%;
			background: #f7f8fa;
			border-radius: 8px 0px 0px 8px;
			display: flex;
			padding: 17px 0;
			position: relative;

			.modal_title {
				font-weight: Medium;

				flex-grow: 1;
				height: 22px;
				line-height: 1;
				display: flex;
				justify-content: center;
				align-items: center;
			}

			.modal_close {
				position: absolute;
				right: 0;
				top: 50%;
        transform: translateY(-50%);
				width: 11px;
				height: 11px;
				overflow: hidden;
				cursor: pointer;

				&:before,
				&::after {
					content: '';
					position: absolute;
					right: 0;
					top: 0;
					right: 0;
					left: calc(50% - 1px);
					height: 100%;
					width: 1px;
					background: #333b56;
				}

				&:before {
					transform: rotate(-45deg);
				}

				&:after {
					transform: rotate(45deg);
				}
			}

			.close {
				width: 11px;
				margin-right: 27px;
			}
		}

		.modal_content {
			padding-top: 28px;
		}

		.modal_footer {
			display: flex;
			padding: 20px 0 30px 0;
			justify-content: center;

			.modal_cancel {
				background: transparent;
				border: 1px solid #ebebeb;
				color: #333b56;
				cursor: pointer;
			}

			.modal_ok {
				background: #d1a366;
				color: #ffffff;
				cursor: pointer;
			}

			.modal_button_bx {
				padding: 9px 66px;
				border-radius: 100px;

				&:nth-child(2) {
					margin-left: 20px;
				}
			}
		}

	}
}