React Portal
什么是 Portal?
Portal 中文即“传送门”的意思,来看 一张电影剧照,不用多说,你应该能猜出来是什么意思:
官方定义:Portals 提供了一种很好的将子节点渲染到父组件以外的 DOM 节点的方式。
ReactDOM.createPortal(child, container);
第一个参数 child
是任何可渲染的 React 子元素,例如一个元素,字符串或 fragment
。第二个参数 container
则是一个真实的 DOM 元素。
为什么需要 Portal?
React Portal 之所以叫 Portal,因为做的就是和“传送门”一样的事情:在 JSX 中 render
到一个组件里面去,实际改变的是网页上另一处的 DOM 结构。
在 React 的世界中,一切都是组件,用组件可以表示一切界面中发生的逻辑,不过,有些特例处理起来还比较麻烦,比如某个组件在渲染时,在某种条件下需要显示一个 Dialog,这该怎么做呢?
最直观的做法,就是直接在 JSX 中把 Dialog 画出来,像下面代码的样子:
<div>
<p>this is p</p>
{needDialog ? <Dialog /> : null}
</div>
我们写的 Dialog 组件是这样渲染的话,那么 Dialog 最终渲染产生的 DOM 将会出现在 JSX 相应定义的地方:
<div>
<p>this is p</p>
<div class="dialog">Dialog Content</div>
</div>
这样写会有什么问题呢?对于 Dialog ,从用户感知角度,应该是一个独立的组件,通常应该显示在屏幕的最中间,现在 Dialog 被包在其他组件中,要用 CSS 的 position
属性控制 Dialog 位置,就要求从 Dialog 往上一直到 body
没有其他 position: relative
的元素干扰,这有点难为作为通用组件的 Dialog,毕竟谁管得住所有组件不用 position
。另外 Dialog 的样式,因为包在其他元素中,各种样式纠缠,CSS 样式会变得难以维护。
这样写 Dialog 组件虽然能满足基本需求,但局限很多,维护代价也比较高,有没有其他办法?
有一个其他办法,就是在 React 组件树的最顶层留一个元素专属于 Dialog,然后通过 Redux 或 Context 或其他什么通讯方式给这个 Dialog 发送信号,让 Dialog 显示或者不显示。
![](https://cosmos-x.oss-cn-hangzhou.aliyuncs.com/yjyy0B.png)
这种方法看起来还凑合着,但是无疑增加了代码之间的耦合,维护成本是可想而知的。而且如果我们把 Dialog 做成一个通用组件,希望里面的内容完全定制,这就更加麻烦了,看如下代码:
<div>
<p>this is p</p>
{needDialog ? (
<Dialog>
<header>Any Header</header>
<section>Any content</section>
</Dialog>
) : null}
</div>
如果以上代码想写成 signal
的方式传递,要传递的东西会变得更加复杂,也缺乏灵活性。
当我们既希望在组件的 JSX 中选择使用 Dialog ,把 Dialog 用得像一个普通组件一样,但是又希望 Dialog 内容显示在另一个地方,就需要 Portal 上场了。
React v16 中的 Portal
Portal 就是建立一个“传送门”,让像 Dialog 这样的组件在 v-dom 和其他组件没有任何差异,但是渲染的真实 dom 却像经过传送门一样出现在另一个地方。
直接挂在 body
先看一种最简单的也是比较常用的场景,将 Dialog 直接挂在 body 下面,如下图所示:
![](https://cosmos-x.oss-cn-hangzhou.aliyuncs.com/0F52fx.png)
Dialog 代码如下:
import React from 'react';
import { createPortal } from 'react-dom';
class Dialog extends React.Component {
constructor(props) {
super(props);
this.container = document.createElement('div');
document.body.appendChild(this.container);
}
componentWillUnmount() {
document.body.removeChild(this.container);
}
render() {
return createPortal(
<div class="dialog">{this.props.children}</div>, //塞进传送门的JSX
this.container //传送门的另一端真实 DOM container
);
}
}
假设在 Parent
组件中是这样进行调用的:
render() {
return (
<div onClick={this.handleClick}>
<p>this is p</p>
<Dialog>
<div className="dialog">
<button>Click</button>
</div>
</Dialog>
</div>
);
}
那么实际渲染出来的 DOM 大概如下图所示,不难发现在 p 之后定义的 Dialog
实际被传送到了 body 下面:
![](https://cosmos-x.oss-cn-hangzhou.aliyuncs.com/MIG83K.png)
挂在其它节点
有时候我们需要将 portal 创建的节点挂在其它节点,比如挂在父元素下面:
<div>
<div><MyComponent /></div>
<div id="portal-container">
</div>
MyComponent render()
实现:
<div>
<div>...</div>
{this.props.show && createPortal(<div>{this.props.children}</div>, this.container)}
</div>
这个时候就不能在 MyComponent 的 constructor
里面调用类似 document.body.appendChild
这样的代码,因为父节点的真实 dom 还没有挂载,是取不到 <div id="portal-container">
的,此时我们可以使用 componentDidMount
:
constructor (props) {
this.container = document.createElement('div');
}
componentDidMount () {
document.getElementById('portal-container').appendChild(this.container);
}
componentWillUnmount () {
document.getElementById('portal-container').removeChild(this.container);
}
createPortal
没有调用的话,那么 this.container
只是一个空 div。
通过 Portal 进行事件冒泡
v16 之前的 React Portal 实现方法,有一个小小的缺陷,就是 Portal 是单向的,内容通过 Portal 传到另一个出口,在那个出口 DOM 上发生的事件是不会冒泡传送回进入那一端的。
也就是说,这样的代码:
<div onClick={handleDialogClick}>
<Dialog>What ever shit</Dialog>
</div>
在 Dialog 画出的内容上点击, handleDialogClick
是不会被触发的。当然,这只是一个小小的缺陷,大部分场景下事件不传过来也没什么大问题。
在 v16 中,通过 Portal 渲染出去的 DOM,事件是会传送门的入口端冒泡出来的,上面的 handleDialogClick
也就会被调用到了。Run in CodePen