用React Portals在React中构建模态

1,746 阅读11分钟

模板对于快速引起用户的注意非常有用。它们可以用来收集用户信息,提供更新,或鼓励用户采取行动。一项对20亿个弹出式广告的研究显示,表现最好的10%有超过9%的转换率。

然而,我认为可以这样说,模态的建立需要一些耐心。要跟踪所有的Z-index值、层和DOM层次结构并不容易。这种困难也延伸到了其他需要在顶层渲染的元素,如覆盖或工具提示。

在React应用程序中,一个组件或元素作为最近的父节点的一个子节点被装入DOM中。从上到下,标准的层级结构如下。root node => parent nodes => child nodes => leaf nodes

如果父节点有溢出隐藏属性或者在更高的层有元素,那么子节点就不能出现在顶层,而被限制在父节点的可见区域。我们可以尝试设置一个非常高的z-index值来把子节点带到顶层,但这个策略可能很繁琐,而且不一定成功。

这就是React Portals出现的地方。React Portals为一个元素提供了在默认层次结构之外渲染的能力,而不影响组件之间的父子关系。

在这篇文章中,我们将演示如何使用React Portals在React中建立一个模态。本文所使用的方法也可以应用于构建工具提示、全页面顶层侧边栏、全局搜索overalls或隐藏的溢出式父容器内的下拉框。

那么,废话不多说,让我们开始这个魔术吧......

开始吧

让我们先用Create React App模板或你自己的React应用设置来创建一个新的React应用。

# using yarn
yarn create react-app react-portal-overlay
# using npx
npx create-react-app react-portal-overlay

接下来,切换到app目录并启动React应用。

# cd into app directory
cd react-portal-overlay
# start using yarn
yarn start
# start using npm
npm run start

组件概述

我们将创建两个组件,并在模板中已经可用的app组件中渲染它们。

但首先,这里有一些重要的定义。

  • ReactPortal: 一个包装组件,在默认的层次结构之外,在提供的容器中创建一个Portal并渲染内容
  • Modal: 一个基本的模态组件,它的JSX内容要用ReactPortal
  • App (任何组件):我们将使用Modal 组件并保持其活动状态(打开或关闭)的位置

创建React Portal

可以使用react-dom 中的createPortal来创建一个React Portal。它需要两个参数。

  1. content :任何有效的可渲染React元素
  2. containerElement :一个有效的DOM元素,我们可以将其附加到该元素上。content
ReactDOM.createPortal(content, containerElement);

我们将在src/components 目录下创建一个新的组件,ReactPortal.js ,并添加这个片段。

// src/components/ReactPortal.js
import { createPortal } from 'react-dom';

function ReactPortal({ children, wrapperId }) {
return createPortal(children, document.getElementById(wrapperId));
}
export default ReactPortal;

ReactPortal 组件接受wrapperId 属性,它是一个DOM元素的ID。我们用这段代码找到一个具有所提供的ID的元素,并将其作为门户网站的containerElement

值得注意的是,createPortal() 函数不会为我们创建containerElement 。该函数希望containerElement 已经在DOM中可用。这就是为什么我们必须自己添加它,以使门户在该元素中呈现内容。

我们可以定制ReactPortal 组件,如果在DOM中找不到这样的元素,就用提供的ID创建一个元素。

首先,我们添加一个辅助函数,用给定的ID创建一个空的div ,将其追加到正文中,并返回该元素。

function createWrapperAndAppendToBody(wrapperId) {
const wrapperElement = document.createElement('div');
wrapperElement.setAttribute("id", wrapperId);
document.body.appendChild(wrapperElement);
return wrapperElement;
}

接下来,让我们更新ReactPortal 组件以使用createWrapperAndAppendToBody 辅助方法。

// Also, set a default value for wrapperId prop if none provided
function ReactPortal({ children, wrapperId = "react-portal-wrapper" }) {
let element = document.getElementById(wrapperId);
// if element is not found with wrapperId,
// create and append to body
if (!element) {
element = createWrapperAndAppendToBody(wrapperId);
}

return createPortal(children, element);
}

这个方法有一个限制。如果wrapperId 属性发生变化,ReactPortal 组件将无法处理最新的属性值。为了解决这个问题,我们需要将任何依赖于wrapperId 的逻辑转移到另一个操作或副作用。

处理一个动态wrapperId

React HooksuseLayoutEffectuseEffect 实现了类似的结果,但用法略有不同。一个快速的经验法则是,如果效果需要同步,而且在DOM上有任何直接的突变,那么就使用useLayoutEffect 。由于这种情况相当罕见,useEffect 通常是最好的选择。useEffect 异步运行。

在这种情况下,我们直接突变了DOM,并希望在DOM被重新绘制之前同步运行效果,所以使用useLayoutEffect Hook更有意义。

首先,让我们把查找元素和创建逻辑移到useLayoutEffect Hook中,把wrapperId 作为依赖关系。接下来,我们将把element 设为状态。当wrapperId 变化时,该组件将相应地更新。

import { useState, useLayoutEffect } from 'react';
// ...

function ReactPortal({ children, wrapperId = "react-portal-wrapper" }) {
const [wrapperElement, setWrapperElement] = useState(null);

useLayoutEffect(() => {
let element = document.getElementById(wrapperId);
// if element is not found with wrapperId or wrapperId is not provided,
// create and append to body
if (!element) {
element = createWrapperAndAppendToBody(wrapperId);
}
setWrapperElement(element);
}, [wrapperId]);

// wrapperElement state will be null on very first render.
if (wrapperElement === null) return null;

return createPortal(children, wrapperElement);
}

现在,我们需要处理清理工作。

处理效果清理

我们直接对DOM进行突变,并在没有找到元素的情况下向主体追加一个空的div 。因此,我们需要确保当ReactPortal 组件被卸载时,动态添加的空div 被从DOM中删除。另外,我们必须避免在清理过程中删除任何现有的元素。

让我们添加一个systemCreated 标志,当createWrapperAndAppendToBody 被调用时,将其设置为true 。如果systemCreatedtrue ,我们将从DOM中删除该元素。更新后的useLayoutEffect 将看起来像这样。

// ...
useLayoutEffect(() => {
let element = document.getElementById(wrapperId);
let systemCreated = false;
// if element is not found with wrapperId or wrapperId is not provided,
// create and append to body
if (!element) {
systemCreated = true;
element = createWrapperAndAppendToBody(wrapperId);
}
setWrapperElement(element);

return () => {
// delete the programatically created element
if (systemCreated && element.parentNode) {
element.parentNode.removeChild(element);
}
}
}, [wrapperId]);
// ...

我们已经创建了门户,并将其定制为故障安全的。接下来,让我们创建一个简单的模态组件,并使用React Portal渲染它。

构建一个演示模态

为了构建模态组件,我们首先在src/components 下创建一个新的目录:Modal ,并添加两个新文件:Modal.jsmodalStyles.css

这个模态组件接受几个属性。

  • isOpen :一个布尔标志,代表模态的状态(打开或关闭),由渲染模态的父组件控制。
  • handleClose :一个方法,通过点击关闭按钮或任何触发关闭的动作来调用。

只有当isOpen ,模态组件才会渲染内容truefalse模态组件将在return null ,因为我们不希望在关闭模态时将其保留在DOM中。

// src/components/Modal/Modal.js
import "./modalStyles.css";

function Modal({ children, isOpen, handleClose }) {
if (!isOpen) return null;

return (
<div className="modal">
<button onClick={handleClose} className="close-btn">
Close
</button>
<div className="modal-content">{children}</div>
</div>
);
}
export default Modal;

演示模态的样式

现在,让我们为模态添加一些样式。

/* src/components/Modal/modalStyles.css */
.modal {
position: fixed;
inset: 0; /* inset sets all 4 values (top right bottom left) much like how we set padding, margin etc., */
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s ease-in-out;
overflow: hidden;
z-index: 999;
padding: 40px 20px 20px;
}

.modal-content {
width: 70%;
height: 70%;
background-color: #282c34;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
}

这段代码将使模态占据整个视口,并在垂直和水平方向上将.modal-content 中心对齐。

用escape键关闭模态

模态可以通过点击Close ,触发handleClose 来关闭。让我们也添加通过按转义键来关闭模态的功能。为了达到这个目的,我们将附加useEffect keydown事件监听器。我们将在效果清理时移除事件监听器。

在一个按键事件中,如果Escape 键被按下,我们将调用handleClose

// src/components/Modal/Modal.js
import { useEffect } from "react";
import "./modalStyles.css";

function Modal({ children, isOpen, handleClose }) {
useEffect(() => {
const closeOnEscapeKey = e => e.key === "Escape" ? handleClose() : null;
document.body.addEventListener("keydown", closeOnEscapeKey);
return () => {
document.body.removeEventListener("keydown", closeOnEscapeKey);
};
}, [handleClose]);

if (!isOpen) return null;

return (
<div className="modal">
<button onClick={handleClose} className="close-btn">
Close
</button>
<div className="modal-content">{children}</div>
</div>
);
};

export default Modal;

我们的模态组件现在已经准备就绪,可以开始行动了

摆脱默认的DOM层次结构

让我们在一个应用程序中渲染演示的Modal 组件。

为了控制模态的打开和关闭行为,我们将用useState Hook初始化状态isOpen ,并将其设置为默认的false 。接下来,我们将添加一个按钮点击,button onClick ,将isOpen 状态设置为true ,并打开模态。

现在,我们将把isOpenhandleClose 作为属性发送给Modal 组件。handleClose 属性只是一个回调方法,它将isOpen 状态设置为false ,以便关闭模态。

// src/App.js
import { useState } from "react";
import logo from "./logo.svg";
import Modal from "./components/Modal/Modal";
import "./App.css";

function App() {
const [isOpen, setIsOpen] = useState(false);

return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<button onClick={() => setIsOpen(true)}>
Click to Open Modal
</button>

<Modal handleClose={() => setIsOpen(false)} isOpen={isOpen}>
This is Modal Content!
</Modal>
</header>
</div>
);
}

export default App;

模态可以通过点击Click to Open Modal按钮来打开。模态可以通过按转义键或点击关闭按钮来关闭。无论哪种操作都会触发handleClose 方法并关闭模态。

如果我们看一下DOM树,我们就会发现,根据默认的DOM层次结构,modal 被渲染为header 的一个子节点。

Modal Without React Portals

构建的模态没有ReactPortal

让我们用ReactPortal 来包装模态的返回JSX,这样模态就会在DOM层次结构之外和所提供的容器元素中被呈现出来。一个动态的容器被附加在DOM内的主体的最后一个孩子身上。

Modal 组件的最新返回方法应该是这样的。

// src/components/Modal/Modal.js
import ReactPortal from "../ReactPortal";
// ...

function Modal({ children, isOpen, handleClose }) {
// ...

return (
<ReactPortal wrapperId="react-portal-modal-container">
<div className="modal">
// ...
</div>
</ReactPortal>
);
}
// ...

由于我们没有添加一个具有react-portal-modal-container id的容器,一个空的div 将以这个id被创建,然后它将被附加到body上。Modal 组件将在这个新创建的容器中被渲染,在默认的 DOM 层次结构之外。只有生成的HTML和DOM树被改变。

React组件的头和Modal 组件之间的父子关系保持不变。

Modal With React Portals

ReactPortal 构建的模态。

如下图所示,我们的演示模态渲染正确,但其用户界面的打开和关闭感觉过于瞬时。

Modal Without CSSTransition

不使用CSSTransition 构建的模态。

应用过渡CSSTransition

为了调整模态的打开和关闭的过渡,我们可以在关闭Modal 组件时删除return null 。我们可以通过CSS控制模态的可见性,使用opacitytransform 属性以及一个有条件添加的类,show/hide

这个show/hide 类可以用来设置或重置可见性,并使用过渡属性对打开和关闭进行动画处理。这样做效果很好,只是模态即使在关闭后仍然留在DOM中。

我们也可以将display 属性设置为none ,但这与return null 的结果相同。这两个属性都会立即从DOM中移除元素,而不需要等待过渡或动画完成。这就是[CSSTransition] 组件的用武之地了。

通过将要过渡的元素包裹[CSSTransition] 组件中,并将unmountOnExit 属性设置为true ,过渡就会运行,一旦过渡完成,该元素就会从 DOM 中删除。

首先,我们安装react-transition-group 依赖关系。

# using yarn
yarn add react-transition-group
# using npm
npm install react-transition-group

接下来,我们导入CSSTransition 组件,用它来包裹模态返回JSX中ReactPortal 下的一切。

该组件的触发、持续时间和样式都可以通过设置CSSTransition 属性来控制。

  • in: 触发进入或退出状态的布尔标志
  • timeout :每个状态(进入、退出等)的转换时间。
  • unmountOnExit: 退出后解除组件的挂载
  • classNames :每个状态(进入、退出等)的类名都会有后缀,以便于控制CSS的定制
  • nodeRef: 对需要转换的DOM元素的React引用(在本例中,是Modal 组件的根div 元素)

一个ref ,可以使用useRef Hook来创建。这个值被传递给CSSTransition'snodeRef 属性。它作为一个ref 属性附加到Modal'的根div ,以连接CSSTransition 组件和需要过渡的元素。

// src/components/Modal/Modal.js
import { useEffect, useRef } from "react";
import { CSSTransition } from "react-transition-group";
// ...

function Modal({ children, isOpen, handleClose }) {
const nodeRef = useRef(null);
// ...

// if (!isOpen) return null; <-- Make sure to remove this line.

return (
<ReactPortal wrapperId="react-portal-modal-container">
<CSSTransition
in={isOpen}
timeout={{ entry: 0, exit: 300 }}
unmountOnExit
classNames="modal"
nodeRef={nodeRef}
>
<div className="modal" ref={nodeRef}>
// ...
</div>
</CSSTransition>
<ReactPortal wrapperId="react-portal-modal-container">
);
}
// ....

接下来,让我们为CSSTransition 组件添加的状态前缀类,modal-enter-donemodal-exit ,添加一些过渡样式。

.modal {
...
opacity: 0;
pointer-events: none;
transform: scale(0.4);
}

.modal-enter-done {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
.modal-exit {
opacity: 0;
transform: scale(0.4);
}

...

现在,演示模态的用户界面的打开和关闭显得更加平滑,这是在不影响DOM负载的情况下实现的。

Modal With CSSTransition

CSSTransition 构建的模态。

总结

在这篇文章中,我们用一个React Portal模态的例子展示了React Portals的功能。然而,React Portals的应用并不仅限于模态或覆盖。我们还可以利用React Portals在包装层的一切之上渲染一个组件。

通过用ReactPortal 来包装组件的JSX或组件本身,我们可以跳过默认的DOM层次行为,在任何组件上获得React Portals的好处。

import ReactPortal from "./path/to/ReactPortal";

function AnyComponent() {
return (
<ReactPortal wrapperId="dedicated-container-id-if-any">
{/* compontents JSX to render */}
</ReactPortal>
);
}

这就是目前的全部内容!你可以在这个GitHub repo中找到本文的最终组件和样式,并在这里访问最终的[ReactPortal] 和模态组件的操作

谢谢你的阅读。我希望你觉得这篇文章对你有帮助。请与其他人分享,他们可能会发现它的好处。Ciao!

The postBuilding a modal in React with React Portalsappeared first onLogRocket Blog.