最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • React Portal传送门 - 将子节点渲染到存在于父组件以外的 DOM 节点

    正文概述 掘金(思无时)   2020-11-30   998

    默认情况,组件的 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节点并不在该父组件的子节点中。

    如下图的需求:

    React Portal传送门 - 将子节点渲染到存在于父组件以外的 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: 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 中的注释。

    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树上另一个角落。

    参考

    • Portals
    • 传送门:React Portal ——程墨

    起源地下载网 » React Portal传送门 - 将子节点渲染到存在于父组件以外的 DOM 节点

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元