在v16的React中,出现了一个新的特性Portals。当我第一眼看到Portals这个特性的时候,并没有领略到这玩意有啥特殊的。不过近期在处理业务上的一个需求时,让我意识到,Portals真的是非常有意思。
场景复现
先还原一下产品需求吧。

需求分析
在这个功能模块中,A组件控制第一级tabs的展示,B组件控制第二级tabs的展示,C组件负责展示当前激活的tab内容;点击组件C中的标题列表的某一项(如左图所示),即在这个模块中展开右图的列表详情D。且该详情D会在此模块中撑满宽高显示。
实现思路
组件C的大概结构用下列代码模拟下。
class C extends React.Component {
constructor(props) {
super(props)
this.state = { visible: false }
}
handleClick = () => {
this.setState({ visible: false })
}
render() {
return (
<div>
<Others onClick={this.handleClick} />
{this.state.visible && <D />}
</div>
)
}
}
通过点击Othors中的某一个标题(模拟代码,就不要纠结完整实现了),去修改C组件内state中的visible布尔值,进而决定D组件的显隐。
既然需求说的是占满宽高100%显示,那么我就很愉快的将组件D的css样式写成如下这般:
.d {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 10;
}
cmd+s
保存之后,回到浏览器看看效果,发现确实实现了需求,交互上也和设计稿一致。
貌似我前面说的这么多好像没什么意义。但是当我喝完一瓶冰镇的肥仔快乐水后,突然意识到这样的代码肯定是会出bug的。
bug现场
在上面的css代码中,第一行position: absolute
就是隐患所在。
我们都知道应用position: absolute
的元素是相对于最近的非 static 定位祖先元素来进行偏移的。在本例中,D组件的最近的祖先是C。虽然目前在C组件中我们没有使用诸如position: relative
之类的css,但是某天若我们需要在C组件中使用position: relative
来进行元素定位时,D组件的宽高就只能撑满C组件。(如下图所示)

作为一名前端,100%还原UI可以说是作为前端er的尊严。我们不可能去跟产品说:“你以后不能再往C组件再加定位元素了,否则会影响原来的功能。”
所以我们现在抽象一下,在这个需求中我们是希望当点击C组件中的某一标题时,将D组件传送到A组件下面去,再利用position: absolute
使D组件撑满A组件。
听起来好像我们需要一个传送门,当D组件穿过这个门出来后,就到达了A组件。
React世界的传送门--Portals
Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.
Portals提供了一种非常棒的方法允许你将子节点渲染到父组件以外的DOM节点
其实在没有深入这个特性之前,我的脑子里一直都不知道该找一个什么词去翻译Portals,而现在真真切切觉得译作“传送门”真的是精髓。
Portals是什么
我们来简单了解下Portals:
ReactDOM.createPortal(child, container)
第一个参数是一个可渲染的React子元素,第二个参数是个DOM元素。
代码改造
那么现在就让我们使用Portals来改造我们的代码。
首先在我们需要获取A组件的DOM元素,通过给组件A添加一个id,后续根据document.getElementById('component-a')
获取A的DOM引用:
<div id="component-a">
{/* ... 组件A的代码*/}
</div>
组件A的css需要加上一段position: relative
,以确保后面的组件D是相对于组件A进行绝对定位的。
然后创建一个应用Portals的组件:
import * as React from 'react'
import { createPortal } from 'react-dom'
import './index.scss'
import { ComponentExt } from '@utils/reactExt'
export interface PortalsContainerProps {}
class PortalsContainer extends ComponentExt<PortalsContainerProps> {
el: HTMLDivElement = null
constructor(props: PortalsContainerProps) {
super(props)
const containers = document.getElementById('component-a')
this.el = document.createElement('div')
containers.appendChild(this.el)
}
componentWillUnmount() {
document.getElementById('component-a').removeChild(this.el)
}
render() {
return createPortal(<div className="portals-container">{this.props.children}</div>, this.el)
}
}
export default PortalsContainer
css部分
.portals-container {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 10;
}
最后将D组件作为PortalsContainer的Children传进去就可以了:
import PortalsContainer from './PortalsContainer'
...
<PortalsContainer>
{/* ... 组件D的代码*/}
</PortalsContainer>
现在我们可以看看最终通过传送门优化后的代码在DOM中的结构:

然后我们就会发现非常神奇的事情。组件D明明是组件C的子元素,但是现在它的DOM结构却是直接通过Portals插入到组件A的下面。是不是就像是React Portals为我们开启了一个传送门,让我们的组件D直接穿越到组件A的DOM结构中。
这样一来,无论以后组件C加不加定位元素,我们的组件D都是直接相对于整个模块组件A进行定位的。
发散
当我领略到Portals这个传送门特性时,发现诸如模态弹窗(Modal),全局提示(Message),文字提示(Tootip)之类的常用UI组件都能应用这个特性。倍儿爽!
就比如说,在ant-design的Popover气泡卡片组件中,就有应用到Portals。
我们可以看看Popover这个组件在React中的组件结构:

箭头处所示,就是Portals在Trigger中的应用。而最中间的Content组件,才是我们卡片中内容真正存在的地方。
ps: 各位要是对这种弹框类的组件有兴趣,非常建议去看看rc-trigger的源码。
结语
React对Portals的支持,非常好地解决了我在业务中遇到的问题,不必去考虑一些非常hack的方法。故写篇博文记叙下这么个过程。
回顾自己从第一次看到Portals到后面深入实践的这么一个过程,感觉很多时候对于业务场景边界条件要多做探索。说不定还能收获一些让自己受用的知识。而不能仅仅是为了实现需求、为了赶进度而不做考虑,故而写出存在漏洞隐患的代码。