React Portal传送门 - 将子节点渲染到存在于父组件以外的 DOM 节点

1,637 阅读7分钟

React Portal 是一种将子节点渲染到其父组件以外的 DOM 节点的方案。 -- React 文档

默认情况,组件的 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)

该方法定义在 react-dom 上而不是 react 中

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 冒泡上来的事件。


以下部分内容来自程墨Morgan传送门:React Portal(侵删)

为什么 React 需要传送门?

React Portal之所以叫Portal,因为做的就是和“传送门”一样的事情:render 到一个组件里面去,实际改变的是网页上另一处的DOM结构。

比如,某个组件在渲染时,在某种条件下需要显示一个对话框(Dialog),这该怎么做呢? 而 portal 的典型用例就是当父组件有 overflow: hiddenz-index 样式时,但需要子组件能够在视觉上“跳出”其容器。例如,对话框、悬浮卡以及提示框。

React在v16之前的传送门实现方法

在v16之前,实现“传送门”,要用到两个秘而不宣的React API

  • unstable_renderSubtreeIntoContainer
  • unmountComponentAtNode

第一个 unstable_renderSubtreeIntoContainer,都带上前缀 unstable 了,就知道并不鼓励使用,但是没办法,不用也得用,还好 React 一直没有 deprecate 这个 API,一直挺到 v16 直接支持 portal。这个API的作用就是建立“传送门”,可以把JSX代表的组件结构塞到传送门里面去,让他们在传送门的另一端渲染出来。

第二个 unmountComponentAtNode 用来清理第一个 API 的副作用,通常在 unmount 的时候调用,不调用的话会造成资源泄露的。

一个通用的Dialog组件的实现差不多是这样,注意看 renderPortal 中的注释。

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

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
    );
  }
}
  1. 首先,render 函数不要返回有意义的 JSX,也就说说这个组件通过正常生命周期什么都不画,要是画了,那画出来的 HTML/DOM 就直接出现在使用 Dialog 的位置了,这不是我们想要的。
  2. componentDidMount 里面,利用原生 API 来在 body 上创建一个 div,这个 div 的样式绝对不会被其他元素的样式干扰。
  3. 然后,无论 componentDidMount 还是 componentDidUpdate,都调用一个 renderPortal 来往“传送门”里塞东西。

总结,这个Dialog组件做得事情是这样:

  • 它什么都不给自己画,render 返回一个 null 就够了;
  • 它做得事情是通过调用 renderPortal 把要画的东西画在DOM树上另一个角落。

参考