Skip to main content

React Portal

· 7 min read
Kimi Gao
Fullstack & AI

什么是 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 显示或者不显示。

这种方法看起来还凑合着,但是无疑增加了代码之间的耦合,维护成本是可想而知的。而且如果我们把 Dialog 做成一个通用组件,希望里面的内容完全定制,这就更加麻烦了,看如下代码:

<div>
<p>this is p</p>
{needDialog ? (
<Dialog>
<header>Any Header</header>
<section>Any content</section>
</Dialog>
) : null}
</div>

如果以上代码想写成 signal 的方式传递,要传递的东西会变得更加复杂,也缺乏灵活性。

tip

当我们既希望在组件的 JSX 中选择使用 Dialog ,把 Dialog 用得像一个普通组件一样,但是又希望 Dialog 内容显示在另一个地方,就需要 Portal 上场了。

React v16 中的 Portal

Portal 就是建立一个“传送门”,让像 Dialog 这样的组件在 v-dom 和其他组件没有任何差异,但是渲染的真实 dom 却像经过传送门一样出现在另一个地方。

直接挂在 body

先看一种最简单的也是比较常用的场景,将 Dialog 直接挂在 body 下面,如下图所示:

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 下面:

挂在其它节点

有时候我们需要将 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);
}
caution

createPortal 没有调用的话,那么 this.container 只是一个空 div。

通过 Portal 进行事件冒泡

v16 之前的 React Portal 实现方法,有一个小小的缺陷,就是 Portal 是单向的,内容通过 Portal 传到另一个出口,在那个出口 DOM 上发生的事件是不会冒泡传送回进入那一端的。

也就是说,这样的代码:

<div onClick={handleDialogClick}>
<Dialog>What ever shit</Dialog>
</div>

在 Dialog 画出的内容上点击, handleDialogClick 是不会被触发的。当然,这只是一个小小的缺陷,大部分场景下事件不传过来也没什么大问题。

tip

在 v16 中,通过 Portal 渲染出去的 DOM,事件是会传送门的入口端冒泡出来的,上面的 handleDialogClick 也就会被调用到了。Run in CodePen

参考资料

  1. React 官方文档(中文):Portals
  2. 传送门:React Portal,作者:程墨 Morgan