什么是浮层组件
浮层组件是指悬浮在其他元素之上的一类组件。比如我们常见的 Modal、Tooltip、Popover、Select、AutoComplete、DatePicker 等等。 这类组件通常包含两个部分:一个是用于控制弹出层显示和隐藏的触发器(trigger),另一个是弹出层(content)。
常见误区
实现这类组件最简单的一种方法,就是将弹出层放置在 trigger 节点的旁边:
这样,我们可以通过 CSS 相对定位,轻松地控制弹出层显示的位置。但是,如果祖先节点定义了 overflow: hidden
样式,可能导致弹出层被隐藏或截断:
当同时显示多个弹出层时,还可能出现相互覆盖的问题。因为基于上面的 DOM 结构,很难管理它们在 Z 轴上的排列顺序。毕竟 z-index 不是一个绝对值,它要在父级堆叠上下文中才有意义。举个例子,有如下结构的三个元素,分别为它们设置有效的 z-index 值:
<div role="blue">
<div role="red"/>
</div>
<div role="green"/>
从上图可以看出,虽然我们给 red 节点设置了 z-index: 999
,但是由于它的父元素 blue 节点,与其兄弟节点 green 设置了相同的 z-index: 1
,且 green 节点位于 blue 节点之后,因此 green 节点会覆盖整个 blue 节点,也包括它的子节点 red。
也就是说,弹出层的显示依赖于其祖先元素的 z-index 值。这样会给使用带来极大不便,因为需要随时关心弹出层所在的堆叠上下文。
为了避免上面这些问题,我们可以将弹出层添加到应用根节点之后:
这样:
- 弹出层不会受到祖先元素的样式的影响。
- 位于应用根节点之后,创建和销毁不会影响应用主体,避免了大规模重排带来的性能开销。
- 方便管理多个弹出层在 Z 轴上的排列顺序。因为它们处于同一堆叠上下文,如果给它们设置相同的
z-index
值,后添加的弹出层始终会覆盖前面的。即使有特殊情况,这样的结构也易于修改。
实现 Modal 组件时,也应该将 Modal 组件的弹出层添加到应用根节点之后。如何实现呢?这就需要下一小节的 Portal 组件来帮忙了。
Portal
一般来说,如果两个 React 组件是父子节点关系,那么它们对应的 DOM 也是父子节点关系。但是,如果要把弹出层组件的 DOM 添加到应用根节点之后,我们必须打破这种映射关系。因此有了 Portal 组件。Portal 意为「传送门」,就是把一个 DOM 节点,传送到另一个节点去。
从上图可以看出,虽然 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 组件通常由「遮罩层」和「内容」两个部分组成:
在实现 Modal 组件之前,让我们先来分析一下 Modal 组件需要的功能点:
- 将整个 Modal(包括 Overlay 和 Content)传送到应用根节点之后
- 遮罩背景层
- 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)等等。