骚年,来一起聊聊React Portals吧

4,846 阅读5分钟

在v16的React中,出现了一个新的特性Portals。当我第一眼看到Portals这个特性的时候,并没有领略到这玩意有啥特殊的。不过近期在处理业务上的一个需求时,让我意识到,Portals真的是非常有意思。

场景复现

先还原一下产品需求吧。

需求分析

在这个功能模块中,A组件控制第一级tabs的展示,B组件控制第二级tabs的展示,C组件负责展示当前激活的tab内容;点击组件C中的标题列表的某一项(如左图所示),即在这个模块中展开右图的列表详情D。且该详情D会在此模块中撑满宽高显示。

实现思路

组件C的大概结构用下列代码模拟下。


class C extends React.Component {
    constructor(props) {
        super(props)
        this.state = { visible: false }
    }
    handleClick = () => {
        this.setState({ visible: false })
    }

    render() {
        return (
            <div>
                <Others onClick={this.handleClick} />
                {this.state.visible && <D />}
            </div>
        )
    }
}

通过点击Othors中的某一个标题(模拟代码,就不要纠结完整实现了),去修改C组件内state中的visible布尔值,进而决定D组件的显隐。

既然需求说的是占满宽高100%显示,那么我就很愉快的将组件D的css样式写成如下这般:

.d {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    z-index: 10;
}

cmd+s保存之后,回到浏览器看看效果,发现确实实现了需求,交互上也和设计稿一致。

貌似我前面说的这么多好像没什么意义。但是当我喝完一瓶冰镇的肥仔快乐水后,突然意识到这样的代码肯定是会出bug的。

bug现场

在上面的css代码中,第一行position: absolute就是隐患所在。

我们都知道应用position: absolute的元素是相对于最近的非 static 定位祖先元素来进行偏移的。在本例中,D组件的最近的祖先是C。虽然目前在C组件中我们没有使用诸如position: relative之类的css,但是某天若我们需要在C组件中使用position: relative来进行元素定位时,D组件的宽高就只能撑满C组件。(如下图所示)

作为一名前端,100%还原UI可以说是作为前端er的尊严。我们不可能去跟产品说:“你以后不能再往C组件再加定位元素了,否则会影响原来的功能。”

所以我们现在抽象一下,在这个需求中我们是希望当点击C组件中的某一标题时,将D组件传送到A组件下面去,再利用position: absolute使D组件撑满A组件

听起来好像我们需要一个传送门,当D组件穿过这个门出来后,就到达了A组件。

React世界的传送门--Portals

Portals provide a first-class way to render children into a DOM node that exists outside the DOM hierarchy of the parent component.

Portals提供了一种非常棒的方法允许你将子节点渲染到父组件以外的DOM节点

其实在没有深入这个特性之前,我的脑子里一直都不知道该找一个什么词去翻译Portals,而现在真真切切觉得译作“传送门”真的是精髓。

Portals是什么

我们来简单了解下Portals:

ReactDOM.createPortal(child, container)

第一个参数是一个可渲染的React子元素,第二个参数是个DOM元素。

代码改造

那么现在就让我们使用Portals来改造我们的代码。

首先在我们需要获取A组件的DOM元素,通过给组件A添加一个id,后续根据document.getElementById('component-a')获取A的DOM引用:

<div id="component-a">
    {/* ... 组件A的代码*/}
</div>

组件A的css需要加上一段position: relative,以确保后面的组件D是相对于组件A进行绝对定位的。

然后创建一个应用Portals的组件:

import * as React from 'react'
import { createPortal } from 'react-dom'

import './index.scss'
import { ComponentExt } from '@utils/reactExt'

export interface PortalsContainerProps {}

class PortalsContainer extends ComponentExt<PortalsContainerProps> {
    el: HTMLDivElement = null
    constructor(props: PortalsContainerProps) {
        super(props)
        const containers = document.getElementById('component-a')
        this.el = document.createElement('div')
        containers.appendChild(this.el)
    }
    componentWillUnmount() {
        document.getElementById('component-a').removeChild(this.el)
    }
    render() {
        return createPortal(<div className="portals-container">{this.props.children}</div>, this.el)
    }
}
export default PortalsContainer

css部分

.portals-container {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    z-index: 10;
}

最后将D组件作为PortalsContainer的Children传进去就可以了:

import PortalsContainer from './PortalsContainer'

...

<PortalsContainer>
    {/* ... 组件D的代码*/}    
</PortalsContainer>

现在我们可以看看最终通过传送门优化后的代码在DOM中的结构:

然后我们就会发现非常神奇的事情。组件D明明是组件C的子元素,但是现在它的DOM结构却是直接通过Portals插入到组件A的下面。是不是就像是React Portals为我们开启了一个传送门,让我们的组件D直接穿越到组件A的DOM结构中。

这样一来,无论以后组件C加不加定位元素,我们的组件D都是直接相对于整个模块组件A进行定位的。

发散

当我领略到Portals这个传送门特性时,发现诸如模态弹窗(Modal),全局提示(Message),文字提示(Tootip)之类的常用UI组件都能应用这个特性。倍儿爽!

就比如说,在ant-design的Popover气泡卡片组件中,就有应用到Portals。

我们可以看看Popover这个组件在React中的组件结构:

箭头处所示,就是Portals在Trigger中的应用。而最中间的Content组件,才是我们卡片中内容真正存在的地方。

ps: 各位要是对这种弹框类的组件有兴趣,非常建议去看看rc-trigger的源码。

结语

React对Portals的支持,非常好地解决了我在业务中遇到的问题,不必去考虑一些非常hack的方法。故写篇博文记叙下这么个过程。

回顾自己从第一次看到Portals到后面深入实践的这么一个过程,感觉很多时候对于业务场景边界条件要多做探索。说不定还能收获一些让自己受用的知识。而不能仅仅是为了实现需求、为了赶进度而不做考虑,故而写出存在漏洞隐患的代码。