一次笔试的惨痛教训 - 使用React Portal封装Modal
最近行情比较差,在BOSS投了很多简历,给回复的没有几个。今天难得抓住了一次机会,店匠科技的武汉分部收了我的简历,不过要通过了笔试才能面试,笔试的题目是:实现一个Modal组件。我心想:这不是简简单单吗,写个蒙层和对话框,用绝对定位让组件渲染的DOM脱离文档流就行了。结果一提交代码就给面试方拒绝了,连面试的机会都没有,可谓惨不忍睹。
于是我开始思索,问题究竟出在哪里呢?于是我上网冲浪看了下别人的实现,发现别人都使用了ReactDOM.createPortal。说实话,我之前真没见过这个,这还是React v16就有的特性,看完之后的一瞬间我觉得自己特菜。
菜归菜,知识短板要补齐,于是就有了这片文章。
认知
在使用一个工具和方法之前,我们肯定要先探究下它是个啥、它为了啥场景而诞生等问题,这样有利于我们更好的去使用。
React的Portal是一种机制,通过这种方式来允许我们将子组件渲染到父组件之外的DOM节点中。如:
import React from 'react';
import ReactDOM from 'react-dom';
class MyComponent extends React.Component {
render() {
return ReactDOM.createPortal(
<div>
<h1>Hello World!</h1>
</div>,
document.body
);
}
}
ReactDOM.render(<MyComponent />, document.getElementById('root'));在页面渲染这个组件以后,我们会发现,真实的DOM节点并没有包裹在root里面,而是在body内与root同级。Portal就像传送门一样,我们可以自由地将内部的组件渲染到DOM的任意一个指定的角落。
为什么使用Portal
再回到一开始提的需求:实现一个Modal。为啥要使用Portal呢,有如下几个理由:
Modal组件通常需要覆盖整个页面或页面中的某个部分,因此需要在页面的最上层进行渲染,而不受父组件的限制。Modal组件通常是一种对话框形式的 UI 组件,需要与用户进行交互,例如获取用户输入或展示信息等,如果在父组件的上下文中渲染,可能会受到其他组件的干扰或影响交互体验。Modal组件通常需要具有固定的位置和尺寸,如果在父组件的上下文中渲染,可能会受到父组件 CSS 样式的影响,导致无法实现固定位置和尺寸的效果。
不过我之前是不知道的,所以也就没用,估计就是因为这个笔试给刷了😭😭😭。
顺带提下Portal比较常用的场景:
刚刚提到的
Modal:一些组件需要渲染在屏幕的中央或者覆盖在其他组件之上,这时候使用Portal可以将这些组件渲染到DOM树的body节点下,从而实现这些效果。面包屑导航:面包屑导航通常需要在页面的顶部展示,而且需要在组件之间传递信息,使用
Portal可以在任意位置渲染面包屑导航并传递信息。多级菜单:多级菜单需要在不同的层级之间渲染,使用
Portal可以将菜单渲染到body节点下的菜单容器中,从而实现菜单的层级渲染。
实战:用Portal实现一个Modal
从哪里跌倒就从哪里爬起,下面简单实现一个Modal,一定要把不会的东西整明白了。
在组件设计上,我把蒙层Mask单独抽出来做了一个原子组件。当然,抽出来之后无论是做Modal还是做Drawer,都可以复用。代码也很简单:
src/components/Mask/index.tsx
import { useMemo } from 'react'
import './index.css'
type IProps = {
/** 显示/隐藏蒙层 */
show: boolean
/** 点击蒙层触发的回调 */
onClick: () => void
}
export default ({ show, onClick }: IProps) => {
const maskContainerExtraStyle = useMemo(() => ({ display: show ? 'block' : 'none' }), [show])
return (
<div className="mask-container" style={maskContainerExtraStyle} onClick={() => onClick && onClick()}></div>
)
}src/components/Mask/index.css:
.mask-container {
position: fixed;
z-index: 99;
background-color: rgb(0 0 0 / 39%);
width: 100vw;
height: 100vh;
top:0;
left:0;
}利用position:fixed配合vw、vh,保证把浏览器窗口区域完整遮住。
之后就是Modal的设计了,参考了下Antd Modal的API,我把一些比较常用的属性整了进来,可以看看下面组件Props的type定义:
type IProps = {
/** modal宽度(默认400) */
width?: number | string;
/** 右上角是否显示关闭按钮 */
closable?: boolean;
/** 关闭Modal时销毁内部组件实例 */
destroyOnClose?: boolean;
/** 显示/隐藏Modal */
show?: boolean;
/** 关闭的回调 */
onCancel?: () => void;
/** 点击蒙层是否允许关闭 */
maskClosable?: boolean;
/** Modal标题 */
title?: string | React.ReactNode;
/** Modal内容节点 */
children?: React.ReactNode;
}之后就是照着写功能了,其实也没什么好说的:
src/components/Modal/index.tsx:
import React, { useCallback, useMemo } from "react";
import Mask from "../Mask"
import ReactDOM from 'react-dom'
import "./index.css"
type IProps = {
/** modal宽度(默认400) */
width?: number | string;
/** 右上角是否显示关闭按钮 */
closable?: boolean;
/** 关闭Modal时销毁内部组件实例 */
destroyOnClose?: boolean;
/** 显示/隐藏Modal */
show?: boolean;
/** 关闭的回调 */
onCancel?: () => void;
/** 点击蒙层是否允许关闭 */
maskClosable?: boolean;
/** Modal标题 */
title?: string | React.ReactNode;
/** Modal内容节点 */
children?: React.ReactNode;
}
type HeaderProps = {
/** Modal标题 */
title?: string | React.ReactNode;
/** 显示隐藏关闭按钮 */
showCloseBtn?: boolean,
/** 关闭的回调 */
onCancel?: () => void;
}
const Header = ({
title,
showCloseBtn,
onCancel
}: HeaderProps) => {
const modalHeaderExtraStyle = useMemo(() => ({ justifyContent: showCloseBtn ? 'space-between' : 'left' }), [showCloseBtn])
return (
<div className="modal-header" style={modalHeaderExtraStyle}>
<div className="modal-header-title">{title}</div>
{
showCloseBtn && <div className="modal-header-close-btn" onClick={() => onCancel && onCancel()}>x</div>
}
</div>
)
}
export default React.memo<IProps>(({ width = 400, destroyOnClose = false, children, show = true, onCancel, maskClosable = false, title, closable = true }) => {
/** 点击蒙层关闭的逻辑 */
const handleMaskClick = useCallback(() => {
if (!maskClosable) return
if (onCancel) onCancel()
}, [
maskClosable, onCancel
])
const modalContainerExtraStyle = useMemo(() => ({ height: show ? '100vh' : 0 }), [show])
const modalCardExtraStyle = useMemo(() => ({ width, display: show ? 'block' : 'none' }), [show, width])
if (!destroyOnClose || show) return ReactDOM.createPortal(
<div className="modal-container" style={modalContainerExtraStyle}>
<Mask show={show} onClick={() => handleMaskClick()} />
<div className="modal-card" style={modalCardExtraStyle} >
<Header onCancel={() => onCancel && onCancel()} title={title} showCloseBtn={closable} />
{children}
</div>
</div>, document.body
)
return <></>
})src/components/Modal/index.css:
.modal-container {
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top:0;
}
.modal-card {
min-height: 200px;
background-color: white;
z-index: 99;
box-shadow: 1px 1px 1px grey;
border-radius: 5px;
padding: 20px;
}
.modal-header {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
user-select: none;
}
.modal-header-title {
width: 90%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.modal-header-close-btn {
cursor: pointer;
}其中值得留意的有以下几个点:
因为页面中有且仅会存在一个
body标签,所以把Portal的目标设置在了body下。实现
destroyOnClose功能意味着关闭Modal会销毁内容组件实例,所以组建最终渲染写了个分支条件:如果destroyOnClose开启的情况下,show为false时渲染一个空的Fragment,实际就相当于内部组件实例销毁了。由于组件是用绝对定位做的,在非
destoryOnClose的情形下是存在真实DOM占位的情况,所以此时用如下的方式避免对页面元素的遮挡:当show为false时,将卡片的display设置为none,将外层容器高度设置为0。当然,这绝非最佳实践,因为对样式的操作涉及到部分重绘和重排,有很多优化的空间。但是:

使用
Portal仅仅是在DOM层面渲染到组件外部,在其他方面的性质(React层面)以及层级关系与一般组件的使用没啥区别。
本部分的完整代码可见:https://github.com/kirazZ1/react-modal-demo。
小结
React的Portal算是一个我们很少会去接触到的功能,因为一般的小公司压根就没有自己封装组件库的需求,或者直接是拿别人开源的组件库去二次封装。这回笔试撞上了,虽然有点难受,但也算是个人的一个收获。
