默认情况,组件的 render
方法返回一个元素时,会被挂在到最近的 DOM 节点上,也就是其父节点。比如这样
class Item extends React.Component {
// ...
render() {
return (
<div>xxx</div>
);
}
}
class App extends React.Component {
// ...
render() {
return (
<div className="wrap">
<Item />
</div>
);
}
}
Item 组件会被挂载在 className
为 “wrap
” 的 div
节点上,Item 返回的内容会被渲染在 App 组件渲染的区域内。
但是有时候我们希望在父组件内使用子组件,但是在子组件的渲染内容不会出现在该父组件渲染区域内,而是出现在别的地方,甚至挂载的DOM节点并不在该父组件的子节点中。
如下图的需求:
- Button 区是一个组件,其内容根据导航的不同显示内容不同;
- Button 组件的渲染结果还受内容区中当前活跃Tab(基本信息、部署配置、权限分配)页的不同而不同
- Button 区组件和内容区组件不是父子组件关系,而是兄弟节点的关系
不使用传送门的实现
实现的关键是:每个 Tab 页都单独定义一个 Button 区域组件,通过 CSS 绝对定位定位到指定位置。
这样做可以保证 Button 区域的按钮随着内容区的Tab也切换而改变,因为它们本身就是挂载在下面的Tab页的DOM节点上的。
但是,由于是通过绝对定位将渲染的视觉位置改变,所以需要梳理好父节点及其兄弟节点间的样式关系。比如内容区不能设置 overflow: hidden;
样式;还有在 Tab 组件到 button 区之间可能有其他 position: relative
(或 absolute
)元素的影响等等。
使用传送门
React Portal 用法
ReactDOM.createPortal(child, container)
child
是被传送过去要渲染的内容,是任何可渲染的 React 子元素,例如一个元素,字符串或 fragment
。
React 不会创建一个新的 div。它只是把第一个参数的子元素渲染到“container
”中。“container
” 是一个可以在任何位置的有效 DOM 节点。就如同官方例子中所示
- 先通过
document.createElement
创建一个没有挂载在任何地方的div
元素 - 将这个
div
元素通过appendChild
方法添加到指定 DOM 节点下 - 再将这个
div
元素作为createPortal
的第二个参数,其实就是将子元素渲染进这个div
中,那么也就将想要渲染的内容渲染在指定位置了。
先看一个官方例子:
// 入口 index.html
<!DOCTYPE html>
<html>
<body>
<div id="app-root"></div>
<div id="modal-root"></div>
</body>
</html>
// 组件实现
import React from 'react';
import ReactDOM from 'react-dom';
// 获取两个DOM节点
const appRoot = document.getElementById('app-root');
const modalRoot = document.getElementById('modal-root');
// 创建一个模态组件,它是 Portal API的实现
class Modal extends React.Component {
constructor(props) {
super(props);
// 创建一个div,我们将把modal呈现到其中。因为每个模态组件都有自己的元素,
// 所以我们可以将多个模态组件呈现到模态容器中。
this.el = document.createElement('div');
}
componentDidMount() {
// 将元素附加到mount上的DOM中。我们将呈现到模态容器元素中
modalRoot.appendChild(this.el);
}
componentWillUnmount() {
// 卸载组件的时候,移除手动创建的 DOM
modalRoot.removeChild(this.el);
}
render() {
// 使用传送门将 children 渲染进元素中
return ReactDOM.createPortal(
// 任意有效的 React 子节点:JSX,字符串,数组等等
this.props.children,
// DOM 元素
this.el,
);
}
}
// Modal 组件是一个普通的 React 组件,因此我们可以在任何地方呈现它,用户不需要知道它是用门户实现的。
class App extends React.Component {
constructor(props) {
super(props);
this.state = {showModal: false};
this.handleShow = this.handleShow.bind(this);
this.handleHide = this.handleHide.bind(this);
}
handleShow() {
this.setState({showModal: true});
}
handleHide() {
this.setState({showModal: false});
}
render() {
// 点击时展示 Modal
const modal = this.state.showModal ? (
<Modal>
<div className="modal">
xxx
<button onClick={this.handleHide}>Hide modal</button>
</div>
</Modal>
) : null;
return (
<div className="app">
This div has overflow: hidden.
<button onClick={this.handleShow}>Show modal</button>
{modal}
</div>
);
}
}
ReactDOM.render(<App />, appRoot);
PS: 这里有个疑问,为什么不直接将目标DOM节点当做第二个参数传进去?
实际测试,直接传目标 DOM 节点是完全可以的(看这里,Fork的官方例子修改)。但问题是,就如同描述的那样第二个参数可以在任何位置。也就是说我们无法保证传进去的DOM节点已经被渲染,所以要手动加一些校验,防止出现“当传送时目标DOM还没有被渲染”的情况。
有了基础知识,那么大概思路就有了:
- 先搭建好 Button 区域和 Content 区域的DOM结构
- 创建一个通用的 ButtonPortal 组件,通过 props.children 接受需要渲染的内容,然后使用传送门发送并挂载到 Buttons 区域中用来占位的DOM元素上
- 在 Content 组件内有三个 Tab,每个Tab Panel都是一个单独的组件,不同的 Panel 调用 ButtonPortal 组件 ,并将渲染的按钮信息通过props传递给 ButtonPortal
思路有了,根据思路的大致代码结构也就可以写出来了(在线效果)
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import "./styles.css";
function Target(props) {
const modalRoot = document.getElementById("modal-root");
const eleRef = useRef(document.createElement("div"));
useEffect(() => {
if (modalRoot) {
modalRoot.appendChild(eleRef.current);
return () => {
if (modalRoot) {
modalRoot.removeChild(eleRef.current);
}
};
}
}, [modalRoot]);
return ReactDOM.createPortal(
<div>
hello world!
{props.children}
</div>,
eleRef.current
);
}
function View() {
const [show, setShow] = useState(false);
return (
<div>
内容将传送至上方红色区域
<button onClick={() => setShow(true)}>开启传送</button>
{show && (
<Target>
<div className="modal">
<div>
通过Portal,我们可以将内容呈现到DOM的不同部分,就像它是任何其他React子级一样。
</div>
<button onClick={() => setShow(false)}>销毁目标</button>
</div>
</Target>
)}
</div>
);
}
export default function App() {
return (
<div className="app">
<div id="modal-root"></div>
<View />
</div>
);
}
通过 Portal 进行事件冒泡
官方文档的示例
虽然 portal 可以被放置在 DOM 树中的任何地方,但是其行为和普通的 React 子节点行为一致。比如 context
功能、事件冒泡等等。
拿事件冒泡来说,(React v16 之后)在 portal 渲染的 DOM 内部触发的事件会一直冒泡到开启传送的源位置(不是实际渲染挂载的DOM位置)。比如官方文档的示例中,在 #app-root
里的 Parent 组件能够捕获到未被捕获的从兄弟节点 #modal-root
冒泡上来的事件。
为什么 React 需要传送门?
React Portal之所以叫Portal,因为做的就是和“传送门”一样的事情:render
到一个组件里面去,实际改变的是网页上另一处的DOM结构。
比如,某个组件在渲染时,在某种条件下需要显示一个对话框(Dialog),这该怎么做呢?
而 portal 的典型用例就是当父组件有 overflow: hidden
或 z-index
样式时,但需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。
React在v16之前的传送门实现方法
在v16之前,实现“传送门”,要用到两个秘而不宣的React API
unstable_renderSubtreeIntoContainer
unmountComponentAtNode
第一个 unstable_renderSubtreeIntoContainer
,都带上前缀 unstable
了,就知道并不鼓励使用,但是没办法,不用也得用,还好 React 一直没有 deprecate 这个 API,一直挺到 v16 直接支持 portal。这个API的作用就是建立“传送门”,可以把JSX代表的组件结构塞到传送门里面去,让他们在传送门的另一端渲染出来。
第二个 unmountComponentAtNode
用来清理第一个 API 的副作用,通常在 unmount
的时候调用,不调用的话会造成资源泄露的。
一个通用的Dialog组件的实现差不多是这样,注意看 renderPortal
中的注释。
import React from 'react';
import {unstable_renderSubtreeIntoContainer, unmountComponentAtNode}
from 'react-dom';
class Dialog extends React.Component {
render() {
return null;
}
componentDidMount() {
const doc = window.document;
this.node = doc.createElement('div');
doc.body.appendChild(this.node);
this.renderPortal(this.props);
}
componentDidUpdate() {
this.renderPortal(this.props);
}
componentWillUnmount() {
unmountComponentAtNode(this.node);
window.document.body.removeChild(this.node);
}
renderPortal(props) {
unstable_renderSubtreeIntoContainer(
this, //代表当前组件
<div class="dialog">
{props.children}
</div>, // 塞进传送门的JSX
this.node // 传送门另一端的DOM node
);
}
}
- 首先,
render
函数不要返回有意义的JSX
,也就说说这个组件通过正常生命周期什么都不画,要是画了,那画出来的 HTML/DOM 就直接出现在使用 Dialog 的位置了,这不是我们想要的。 - 在
componentDidMount
里面,利用原生 API 来在body
上创建一个div
,这个div
的样式绝对不会被其他元素的样式干扰。 - 然后,无论
componentDidMount
还是componentDidUpdate
,都调用一个renderPortal
来往“传送门”里塞东西。
总结,这个Dialog组件做得事情是这样:
- 它什么都不给自己画,
render
返回一个null
就够了; - 它做得事情是通过调用
renderPortal
把要画的东西画在DOM树上另一个角落。
参考
- Portals
- 传送门:React Portal ——程墨
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!