实现 React 组件系列 1 -- Modal

3,035

什么是浮层组件 

浮层组件是指悬浮在其他元素之上的一类组件。比如我们常见的 Modal、Tooltip、Popover、Select、AutoComplete、DatePicker 等等。 这类组件通常包含两个部分:一个是用于控制弹出层显示和隐藏的触发器(trigger),另一个是弹出层(content)。

image.png

常见误区

实现这类组件最简单的一种方法,就是将弹出层放置在 trigger 节点的旁边:

image.png

这样,我们可以通过 CSS 相对定位,轻松地控制弹出层显示的位置。但是,如果祖先节点定义了 overflow: hidden 样式,可能导致弹出层被隐藏或截断:

image.png

当同时显示多个弹出层时,还可能出现相互覆盖的问题。因为基于上面的 DOM 结构,很难管理它们在 Z 轴上的排列顺序。毕竟 z-index 不是一个绝对值,它要在父级堆叠上下文中才有意义。举个例子,有如下结构的三个元素,分别为它们设置有效的 z-index 值:

	<div role="blue">
  	<div role="red"/>
  </div>
  <div role="green"/>

image.png

从上图可以看出,虽然我们给 red 节点设置了 z-index: 999 ,但是由于它的父元素 blue 节点,与其兄弟节点 green 设置了相同的 z-index: 1 ,且 green 节点位于 blue 节点之后,因此 green 节点会覆盖整个 blue 节点,也包括它的子节点 red。

也就是说,弹出层的显示依赖于其祖先元素的 z-index 值。这样会给使用带来极大不便,因为需要随时关心弹出层所在的堆叠上下文。

为了避免上面这些问题,我们可以将弹出层添加到应用根节点之后:

Screen Shot 2019-01-05 at 10.52.57 PM.png

这样:

  • 弹出层不会受到祖先元素的样式的影响。
  • 位于应用根节点之后,创建和销毁不会影响应用主体,避免了大规模重排带来的性能开销。
  • 方便管理多个弹出层在 Z 轴上的排列顺序。因为它们处于同一堆叠上下文,如果给它们设置相同的 z-index 值,后添加的弹出层始终会覆盖前面的。即使有特殊情况,这样的结构也易于修改。

实现 Modal 组件时,也应该将 Modal 组件的弹出层添加到应用根节点之后。如何实现呢?这就需要下一小节的 Portal 组件来帮忙了。

Portal

一般来说,如果两个 React 组件是父子节点关系,那么它们对应的 DOM 也是父子节点关系。但是,如果要把弹出层组件的 DOM 添加到应用根节点之后,我们必须打破这种映射关系。因此有了 Portal 组件。Portal 意为「传送门」,就是把一个 DOM 节点,传送到另一个节点去。

Screen Shot 2019-01-05 at 3.29.14 PM.png

从上图可以看出,虽然 Modal 组件和 App 组件在 React 中仍然是父子节点关系,但是它们对应的 DOM 却不再是父子节点关系。

接下来,让我们一起实现一个简单的 Portal 组件。它接收一个 children  prop,用来指定需要传送的内容,如下所示:

<Portal>
  <div>This is content</div>
</Portal>

从 React 16.3 开始,我们可以用 createPortal 来实现 Portal 组件:

export const Portal: React.FC = ({ children }) => {
  // 创建一个 container 节点,作为 portal 的容器节点
  let containerRef = useRef<HTMLDivElement | null>(null);

  if (!containerRef.current) {
    containerRef.current = document.createElement("div");
    // 将 container 节点添加到 document.body
    document.body.appendChild(containerRef.current);
  }

  // 当组件销毁时,移除 container 节点
  useEffect(() => {
    return function cleanup() {
      if (containerRef.current) {
        document.body.removeChild(containerRef.current);
      }
    };
  }, []);

  return createPortal(children, containerRef.current);
};

到这里,一个简单的 Portal 组件就完成了。使用 Portal 组件时,我们只需要结合 useState 就能够很方便地控制它的创建和销毁:

function PortalDemo() {
  const [visible, setVisible] = useState(false);
  return (
    <>
      <button onClick={() => setVisible(prevVisible => !prevVisible)}>Open Portal</button>
      {visible && (
        <Portal>
          <div>This is content</div>
        </Portal>
      )}
    </>
  );
}

Portal 组件是所有浮层组件的基础。有了 Portal 组件之后,实现 Modal 组件就变得很容易了。

Modal

Modal 组件通常由「遮罩层」和「内容」两个部分组成:

Screen Shot 2019-01-05 at 12.02.40 PM.png

在实现 Modal 组件之前,让我们先来分析一下 Modal 组件需要的功能点:

  1. 将整个 Modal(包括 Overlay 和 Content)传送到应用根节点之后
  2. 遮罩背景层
  3. Modal 内容层

以上三个功能点,可以通过三个组件分别完成:

<Modal>
  <ModalOverlay />
  <ModalContent>
    <div>This is a simple modal</div>
	</ModalContent>
</Modal>

我们先来实现 Modal 组件:

const modalStyles = {
  position: "absolute",
  top: 0,
  left: 0,
  right: 0,
  bottom: 0,
  zIndex: 1000,
};

export const Modal: React.FC = ({ children }) => (
  <Portal>
    <div style={modalStyles}>{children}</div>
  </Portal>
);

因为 Modal 需要覆盖整个视窗,所以我们一般会给 Modal 设置一个相对较大的 z-index 值。

当然,如果应用中某些节点的 z-index 高于 Modal,并且和 Modal 处于同一堆叠层上下文(根堆叠上下文 html),那么它仍然会覆盖在 Modal 之上。不过,我们可以给应用根节点设置 CSS position(非 static),并且不超过 Modal 的 z-index 值。这样应用内节点的 z-index 就会受制于根节点,而不会覆盖 Modal。

但如果你不想给应用根节点设置 position,最好的方法是: 集中管理 z-index 值,并制定规范。比如:

const zIndex = {
  mobileStepper: 1000,
  appBar: 1100,
  drawer: 1200,
  modal: 1300,
  snackbar: 1400,
  tooltip: 1500,
};

接下来,我们来实现 ModalOverlay 组件。一般情况下,ModalOverlay 是一个带背景色的全屏遮罩层,点击它可以关闭 Modal。因此,Modal 组件只需要接受一个 onClick  prop 即可:

interface IModalOverlayProps {
  onClick?: MouseEventHandler;
}

const modalOverlayStyles = {
  position: "absolute",
  top: 0,
  left: 0,
  bottom: 0,
  right: 0,
  background: "rgba(0,0,0,0.65)",
};

export const ModalOverlay = ({ onClick }: IModalOverlayProps) => <div onClick={onClick} style={modalOverlayStyles} />;

至于 ModalContent 组件,我们只需要为它添加一些样式即可。这里就不再赘述了。 最后,我们只需要组合这几个组件,便可以轻松实现一个简单的 Modal,如下所示:

export function ModalDemo() {
  const [isOpen, open, close] = useToggle();

  return (
    <div>
      <Button onClick={open}>Open Modal</Button>
      {isOpen && (
        <Modal>
          <ModalOverlay onClick={close} />
          <ModalContent>
            <div>This is a simple modal</div>
          </ModalContent>
        </Modal>
      )}
    </div>
  );
}

小结

这一节中,我们介绍了什么是浮层组件,以及开发这类组件容易出现的问题。掌握了设计浮层组件的原则与方法:统一弹出层 DOM 的添加位置。这样可以:

  • 规避父元素样式的影响
  • 在创建和销毁弹出层时,避免影响应用主体
  • 方便 z-index 的设置与管理

同时,为了改变 React 节点树与 DOM 树的映射关系,我们实现了 Portal 组件。 最后,我们又基于 Portal 实现了 Modal 组件。大家可以继续扩展 Modal 组件,应用不同的样式,创建出各种各样的模态框。比如警告框(Alert), 对话框(Dialog)等等。